fr33f0r4ll

自分用雑記

pwntools 使い方

pwntools 使い方

忘れないようにメモする。 公式のDocsとか、関数のdescriptionが優秀なのでそっちを読んだ方が正確だと思う。 でも日本語じゃないと読むのに時間がかかってしまうので日本語でメモする。

基本

基本的な機能の使い方。 プログラムへの入出力。

from pwn import *

# プログラムを実行するprocessを作る
# cwdキーワードで現在のワーキングディレクトリが変更できる
p = process('test_program')
# p = remote('127.0.0.1', 12345) # 127.0.0.1の12345ポートに接続する、APIが同じなのでそっくりそのまま同じように動作する

ret = p.recv() # test_programの出力をEOFまでを受けとる
ret = p.recvline() # 改行までを受けとる、改行が送られないとここで止まるので注意
ret = p.recvline(timeout=0.01) # recv系はtimeoutを設定できる、単位は秒。ハングするのが嫌なら設定しておくといい
ret = p.recvuntil('some output') # 引数の文字列までを受けとる。

# ログとして出力する。context.log_levelに値を設定することで、debug, infoなど出力するログも操作できるので便利
log.info(ret) 

payload = ''
payload += p32(64) # 数値をリトルエンディアンで32ビット長の文字列として変換する。
# これなら'@\x00\x00\x00'になる。
# ほかにもp64なら64ビット長に変換する。
# 逆変換はu32()、符号無し整数としてデコードしてくれる。
payload += 'some shellcodes'

# test_programにshellcodeを送る。文字列ならなんでも。
p.send(shellcode) # 末尾になにもなし、多分EOFが付く
p.sendline(shellcode) # これなら末尾に改行が付く

# 実行すると入出力を直接表示、送信するようになる。
# 相手側でシェルを奪ったときに起動する
p.interactive()

基本的にはこれらを使う。他にも便利機能がたくさんあるけど、これだけでもpwnはできると思う。 人によっては他のコマンドの方を好むかもしれない。

実際の流れとしては、processremoteでプログラムに接続し、sendrecvで送受信を行なって、シェルを取ったらinteractiveで直接操作する、というような感じになる

場合によってはローカル環境でプログラムをエクスプロイトするときもremoteを使う。 プログラムがポートにバインドされてフォークするようなときとか。

ELFの解析

elfのシンボルの位置なんかの解析はIDAとかobjdumpとかradare2を使ってすませることもあるが、pwntoolsからでも解析ができる。 ちょっと関数のアドレスとかpltやgotのアドレスを使いたいときにはハードコーディングしなくて済むのがいい。 ROP関連の機能にも使える。

from pwn import *

elf = ELF('program') # 解析、ログにセキュリティ機構などの解析結果も表示される。
# 多分pwntoolsに付いてくるchecksecと同じ出力
# libcなどの共有ライブラリも解析できて、その場合はオフセットが分かる

elf.plt['printf'] # plt領域にある関数のアドレスを辞書型で保持してる。
# ハードコーディングせずにすむのでこっちの方が好き。
# ただし間違ってることがあるっぽい?理解が足りなくて勘違いしてるだけかもしれない
# 心配ならobjdumpとかreadelfとかでも調べよう

elf.got['printf'] # 同じようにgot領域も調べられる

elf.symbols['local_variable'] # stripされてなかったりするとシンボルのアドレスも参照できる

elf.bss() # セクションのアドレスなんかもある

他にも色々な情報が参照できるので最初に解析用のスクリプト作っておくのもいいかもしれない。 dir()でメンバの名前見ればだいたい何の情報か想像も付くので忘れても安心。

context

contextはconfigみたいな感じに解析のための情報を渡すことができる。

from pwn import *

context.arch = 'amd64' # 解析するプログラムのアーキテクチャを設定できる、初期値は多分i386
context.log_level = 'debug' # ログのレベルを設定できる。普段は多分infoぐらいが表示される
# pwntoolsのバグっぽいのに遭遇したらdebugにして見てみよう

context(arch='amd64') # 関数もある

ちょっと変わったバイナリを解析するときは、一度こっちの設定を確認して、適切かどうか見るといいかもしれない。 シェルコードの生成とか、デフォルトの挙動に影響するので間違った設定になっていると思った通りに動かなかったりするのでお行儀良く最初に設定しておこう。

shellcode

shellstormでいいけど、pwntoolsにも簡単なものはあるので楽になるかもしれない。 自分で書くという選択肢はない。

from pwn import *

asm(shellcraft.sh()) # shellcraft.sh()はシェルを開くアセンブリコードを返す、asm()はそれをアセンブルする

# アセンブリコードを文字列として渡すとアセンブルしてくれるので、必要に応じて書くこともできる
asm('''
xor ebx, ebx
lea eax, [ebx + 4]
mov ecx, esp
lea edx, [ebx + 0xff]
int 0x80
''')

asmを使えば自作shellcodeを使うこともできるので、shellcodeを書く練習にももってこい。 shellcraftのサブモジュールにはアーキテクチャやOSに対応したモジュールがあり、様々シェルコードが収録されているみたいなので一度探してみると役に立つかもしれない。

書式文字列攻撃

書式文字列攻撃のための機能もある。 攻撃を自動でやるようにする使い方もできるらしいが、関数のセッティングが面倒そうだったので、ここではペイロードを作成する機能の紹介だけにする。 基本的には書き込みたい値と書き込みたいアドレスを指定すると、書式文字列攻撃で書き込みをするペイロードを返すといった感じ。

こんな感じ。

from pwn import *

p = process('target_program')

trg = ELF('target_program')
trg_addr = trg.symbols['target_symbol']
# trg_addrに0x12345678と書き込む
writes = {trg_addr : 0x12345678}
offset = 11 # printfの引数があるオフセット、書式文字列攻撃ができるなら多分リークもできる
payload = fmtstr_payload(offset, writes, numbwritten=0)

p.send(payload)

offsetには書式文字列バグのある箇所で、入力した文字列が出現する位置を指定する。 適当に%xとか入力して調べる必要がある。

numbwrittenは既に書き込んだ文字数を指定する、デフォルトで0なのでこの例では本当はいらない。 書式文字列攻撃は、printfのこれまで出力したバイト数を変数へ書き込むパラメータを利用して、任意のアドレスへの書き込みをする。 なのでこれまでの出力数がないと正しくペイロードを組み立てることができない。

writes{書き込みたいアドレス : 書き込みたい値}という形式で書き込んでいく値などを指定する。

fmtstr_payloadにそれらを渡すとペイロードを作成して返すようになっている。

rop

ropチェインを組み立てることができる機能がある。 elfの解析結果を使えば自動的に呼び出しのフレームを作成したりできるので便利。

from pwn import *

elf = ELF('test_program')
rop = ROP(elf)

# 直接値をスタックに積む
rop.raw(0)

# 関数などの呼び出しをする、シンボルがないと無理?
# 引数も渡せる
rop.call('read', [0, 4, 10])

# 横着なcall
rop.write(1, 4, 10)

# ropガジェットを勝手に探してきてスタックフレームの調節をしてくれるらしいので気にせず呼び出せる

# 本当にちゃんと出来てるか知りたいときはダンプすることもできる
# シンボルなどがあるときはその内容まで表示してくれるので分かりやすい
print(rop.dump())

# ropガジェットの検索もできる
# 返り値はGadgetクラスで直接rop.raw()に渡したりできる、むしろ直接渡した方が情報が表示されるので良い
rop.find_gadget(['pop eax', 'ret']) # pop eax; ret

# ガジェット一覧
rop.gadgets()

# ropチェインの取得、文字列として
rop.chain()
str(rop)

callがとても便利そう。ガジェットの検索はrp++とかの専用ツールほどフレキシブルな検索はやってくれないっぽいのでそういうのはやっぱり自分で探す必要がある(残念)。

debug

自分の環境では動かなかったが、gdbデバッグもできるらしい。

from pwn import *

context.terminal = ['terminator', '-e']
p = process('program')
gdb.attach(p)
gdb.debug('program')

追記 どうも元のプログラムでプロセスIDを直接引数としてgdbに渡してるのが原因っぽい。 gdbはプロセスにアタッチするときは-pオプションに渡さないとデバッグできない。 そのことでIssueも立ってたし、聞いてみたけど問題として認識してないっぽい?

constants

エクスプロイトを書くときは様々な定数値、例えばシステムコール番号とか標準入出力のディスクリプタの番号とか、をよく使う。 でも全部憶えるのはまず無理なのでその都度調べるのだけど、pwntoolsには(pwntools自身使うこともあって)そのような定数値を調べられる機能がある。 一々ブラウザやman開いてスクリプトにメモするのも面倒なので積極的に使いたい機能の1つ。

from pwn import *

# 名前で調べる、ちなみに`execve`のシステムコール番号が返る
constants.eval('SYS_execve')

# 直接定数値をロード
constants.SYS_execve

# 他にもシグナル番号などもある
constants.SIGKILL

# どんな定数値があるかの一覧
help(constants)

定数値は良く分からなくなったりするので地味に便利。 スクリプト中でマジックナンバーになって意味が分からなくなったりしないし。 contextの設定に従ってアーキテクチャに対して適切な値を返すので、設定してから使わないと変な値になったりする。

その他

pwntoolsはライブラリとしてだけではなく、コマンドラインから使うこともできる。 pwn --helpで使い方が表示される。 checksecとかちょっとしたアセンブラとか便利な機能も多いので一度覗いてみるといい。 定数値検索のできるconstgrepがとても便利。

おわり

使ったことのある機能はこれぐらい。 他にも色々便利そうな、書式文字列攻撃用のモジュールだったりflag管理用のモジュールだったりがあるので、公式を見ておくとワクワクできる。 使い方は分かりません。使われてない機能だとバグっぽいのもしばしばあったりするけれど、そういうときは他のコマンドで補おう。