概要

なんか3月末にCTFやるらしいので、適当に問題作ってみたよってのと、その解説。
ブラウザのJSからWebAssemblyの関数をごちゃごちゃするので、Web問ですね。
とりあえずここに公開してます (この記事執筆時点(2020/3/8 2:32)。今後変わるかも。)
https://test.kogcoder.com/ctf_problems/sugi/index.html

見た目

ページを開くと、こんな感じ。

間違ったフラグだとこうなって…

正しいフラグだとこうなる。(ネタバレ(flagバレ?)防止のため塗りつぶし)

(適当にスクショ撮ってたらなんか横幅でかくなっちゃったんだけど表示サイズって指定できないのかこれ)

ログインシステムみたいな見た目にしてもよかったんだけど、そのためだけにユーザー名の欄つけるのもなぁってのと遷移先ないしなぁ、ってので考えるの面倒になったのでやめた。まぁ実際にCTFに出すならそこら辺は変えるかもね

どうやって作ったのか

もともとは、Rustの勉強してたらWebAssemblyやってみよう!のコーナーが出てきたのでそれで作ろうとしたんだけど、なんかいろいろwrapされてて無駄な要素が大きくなってしまうのでやめた。んで、代わりの手段としてTypeScriptからコンパイルできるAssemblyScriptってのを見つけたので、それでWebAssemblyのファイルを作成して、それを読み込むコードも最初はやり方がよくわからず、調べながらなんとか作った。

解説するよ

ここからは、やってみけどわからんクソ問過ぎるだろこれ、って人向けの解説。
まぁHTMLはどうでもいいので、読み込まれてるindex.jsを見ていきましょう

var str2ptr, ptr2str;
var importObject = {
    wasm:{
        congratulations: s => {
            document.getElementById('congratulations').innerHTML = ptr2str(s);
        },
        status: arg => {
            const text=['Checking', 'NG', 'OK'], className=['checking', 'ng', 'ok'];
            document.getElementById('progress').innerText = text[arg];
            document.getElementById('progress').className = className[arg];
        }
    },
    env: {
        abort(msg, file, line, column) {
            if(line===1&&column===1) console.error('Too Many recurcive call');
            else console.error("abort at :" + line + ":" + column);
        }
    }
    };

fetch('webassembly.wasm').then(response =>
    response.arrayBuffer()
).then(bytes =>{
    loader.instantiate(bytes, importObject).then(({exports}) => {
        str2ptr = exports.__newString; ptr2str = exports.__getString; //Functions to convert data between string in js and pointer in wasm
        document.getElementById('input').addEventListener('keypress', e => {
            if(e.code === 'Enter'){
                document.getElementById('congratulations').innerHTML = '';  
                exports.check(str2ptr(document.getElementById('input').value));
            }
        });
        document.getElementById('button').addEventListener('click', ()=>{
            document.getElementById('congratulations').innerHTML = '';  
            exports.check(str2ptr(document.getElementById('input').value));
        });
    });
});

一番上の方ではwasmの読み込み時にインポートさせる引数を定義して
下の方でwasmファイルをfetchしたら上で定義したやつを渡してインスタンス化して、ユーザーの操作に対応するイベントハンドラを設定

って感じの流れ。inputタグ(入力欄)に入力された文字列とflagの比較はWebAssemblyの中でやってて、デコンパイルしても読むのはなかなかつらい。いや、スラスラ読める人がこの世に存在する可能性は否定しないが。
あとflagもちょっと面倒なマスク処理をして保存してるのでstringsコマンドとか使っても見えないし、テキスト形式にデコンパイルされたwatファイルの処理を追うのもつらいと思う。(Cにデコンパイルするツールで検証しようとしたらセグフォしちゃって変換できなかった…)

というわけで、JSからどうにか攻めるしかない。
ここで気になってほしいのが、importObject.wasm.statusとして定義された関数。
wasmから呼び出される関数で、与えられた引数に応じて、さっきのスクショで赤とか緑になっていた四角の表示内容を変えている。
引数に0を与えられるとCheckingになるらしいけどそんなのあったかな…なんか途中で呼ばれるっぽいけど…CSS見ると青背景らしいけど…とまぁそんなことを考えつ、どんな引数でどれだけ呼ばれてるのか調べるために、この関数の中でconsole.log(arg)してみる。とは言ってもこの関数はもう書き換えられないので、こんなコードを開発者ツールのConsoleで実行する。

void function(){
    var str2ptr, ptr2str;
    var importObject = {
        wasm:{
            congratulations: s => {
                document.getElementById('congratulations').innerHTML = ptr2str(s);
            },
            status: arg => {
                console.log(arg);
                const text=['Checking', 'NG', 'OK'], className=['checking', 'ng', 'ok'];
                document.getElementById('progress').innerText = text[arg];
                document.getElementById('progress').className = className[arg];
            }
        },
        env: {
            abort(msg, file, line, column) {
                if(line===1&&column===1) console.error('Too Many recurcive call');
                else console.error("abort at :" + line + ":" + column);
            }
        }
        };

    fetch('webassembly.wasm').then(response =>
        response.arrayBuffer()
    ).then(bytes =>{
        loader.instantiate(bytes, importObject).then(({exports}) => {
            str2ptr = exports.__newString; ptr2str = exports.__getString;
            window.test = s => {
                document.getElementById('input').value = s;
                exports.check(str2ptr(s));
            };
        });
    });
}();

index.js からそのままコピペしたコードを名前空間を分離するため匿名関数に入れてその関数を実行するようにした。先程の関数の先頭にconsole.log(arg)を追加したのと、下の方のコードも変えた。CheckボタンのクリックおよびinputタグでのEnterキーは既に定義されたものと被っているのでどっちも実行されてしまいわかりにくいので、windowオブジェクトを経由してグローバルスコープにtestという関数を定義した。これにより、Consoleでtest(入力文字列)とすることにより、細工を施したstatus関数を使用してwasmのcheck関数を呼び出せる。

そして、これ↓がその実行結果。

試しに a を渡してみると、status(0)は実行されずに、status(1)だけが呼ばれている。aaaaでも同じだけど、kは0が1回、(ry あとは画像を見てね
ここからわかることは…先頭から数えて合ってる文字の分だけstatus(0)が呼ばれるということ。ってことは、status(0)が文字数分呼ばれたらそこまでの文字は全部合ってるということがわかるから、それを利用して1文字ずつ確定させていけばflagの文字数Nに対してO(N)で探索できるよ、やったね!
ちなみにCheckingが見えないのは、すぐにOKまたはNGに書き換えられてしまうため。次のイベントループに移らないうちに書き換えてしまうと、その途中の状態は画面に反映されない。つまり、Checkingにする機能は、ループの回数を把握させるためだけに存在する…

という訳で、こんなコードを実行してみる。

void function(){
    let f;
    var str2ptr, ptr2str;
    let charList = [];
    for(let c='a'; c!='z'; c=String.fromCharCode(c.charCodeAt(0)+1)) charList.push(c);
    for(let c='A'; c!='Z'; c=String.fromCharCode(c.charCodeAt(0)+1)) charList.push(c);
    for(let i=0; i<9; i++) charList.push(i.toString());
    charList.push('_');
    charList.push('{');
    charList.push('}');
    let s = charList[0], cnt=0;
    var importObject = {
        wasm:{
            congratulations: s => {
                document.getElementById('congratulations').innerHTML = ptr2str(s);
            },
            status: arg => {
                if(!arg) cnt++;
                else if(arg===1){
                    if(cnt === s.length){
                        document.getElementById('input').value = s;
                        s += charList[0];
                    } else {
                        if(s[s.length-1]==charList[charList.length-1]) return alert('探索失敗\n' + s);
                        else s = s.substr(0,s.length-1) + charList[charList.indexOf(s[s.length-1])+1];
                    }
                    cnt=0;
                    f();
                } else if(arg===2){
                    document.getElementById('progress').innerText = 'OK';
                    document.getElementById('progress').className = 'ok';
                    document.getElementById('input').value = s;
                    console.log(s);
                }
            }
        },
        env: {
            abort(msg, file, line, column) {
                if(line===1&&column===1) console.error('Too Many recurcive call');
                else console.error("Unknown Error in wasm at :" + line + ":" + column);
            }
        }
    };    

    fetch('webassembly.wasm').then(response =>
        response.arrayBuffer()
    ).then(bytes =>{
        loader.instantiate(bytes, importObject).then(({exports}) => {
            str2ptr = exports.__newString; ptr2str = exports.__getString;
            f = ()=>exports.check(str2ptr(s));
            f();
        });
    });
}();

charListという配列に探索対象の文字をぶち込んで、探索に使用する文字列sの初期値はcharList[0]の1文字。status(0)の回数を数えるcntも宣言してる。status関数は後で解説するとして、一旦一番下の読み込み部分を。さっきのtest関数はfに名前を変えて、statusから呼び出すために上の方で宣言して、ここで代入してる。Consoleからは呼び出さないからグローバルじゃなくていいので、window.はしてないよ。

さて、本題のstatus関数。

argが0だったら

cntをインクリメントするだけ。

1だったら

まずはcntを見てsの長さと等しければ、最後の文字まで合ってるということなので、その内容をinputタグに反映させて(見た目のわかりやすさのため)、次の文字の探索に移るためにsの末尾にcharList[0]を追加。cntsの長さよりも短かったらその文字は違うので、最後の文字を置き換える。jsの文字列はインデックス記法で1文字だけ代入するのができないので、こういうのはちょっと面倒。で、このときもしその文字がcharListの最後だったら、その場所に入る文字が見つからないので画面に表示して終了。うまくいけば実行されない部分。上記のif文の中でsの書き換えが終わったので、cntを0に戻して次の探索を行うf()を実行。

2だったら

これはsが正しいflagだった場合なので、sをコンソールに出力して、オマケでページの表示も変えて、終了。

で、これを実行すると…?(スクショを撮ったあと少しコードを変えたので、行数はずれている)

途中まではflagが求まったけど、最後まで行かずにエラーになってしまった(※2)。再帰呼び出ししすぎだよ!って怒られてる(※3)。再帰呼び出し?した覚えないけど?
3つの関数を跨ぐ呼び出しなのでわかりにくいが、再帰呼び出しになっている。
まずfが wasmのcheckを呼んで、checkstatusを呼んで、statusfを呼んで、その繰り返し。呼ばれた関数が終わるまで呼んだ元の関数も終わらないので、最初の方の呼び出しはいつまでも終わらず、どんどんスタックのメモリ使用量が増えて、限界に達するとランタイムエラーになってしまう。ちなみに一度これが起きると、それ以降はwasmを新しく読み込まないと何やってもmemory access out of boundsっていう別のエラーが出て何もできないんだけど、満杯になったスタックが開放されてないのかな?謎。ページを再読込しよう。

※2 メモリの使用量に起因する問題だしflagの長さを短くしたら問題なく実行できたので、もしかしたら環境依存?16GB RAM、Windows 10、VivaldiとChromeとFirefoxで実行して同じ結果だったよ。
※3 jsを見ればわかるように、1行目のエラーは俺が書いたコードが吐いてるやつだけどね…意味不明のエラーで躓くと可哀想だなと思ってヒント要素。実際のコンテストではこれ消してもいいかも?あとエラー発生箇所だけで判断してるので、再帰のスタックオーバーフロー以外にも同じエラーになる原因があるかも?
ちなみにこのエラー、flagをwasmの元になるTypeScriptにハードコードしてるときは発生しなくて、maskで求めるように変えてから起きるようになった…これも謎。check関数は一切いじってないけど。

じゃあどうするか。statusが終わってからfが実行されるように書き換える。そうすればfが終わってからfが呼ばれるので、再帰によるスタックオーバーフローは起きない。
そう、jsは素晴らしい(宣伝)ので、そんなことができる関数が用意されている。 setTimeout関数だ。f()setTimeout(f, 0)に書き換えれば、statusが終わって次のイベントループに移ってから実行される。イベントループ?なにそれライブイベントでフラフープすんの?って人は、「JavaScript イベントループ」ってググればいろいろ出てくるので、はい。ちなみにNode.jsなら、setImmediate(f)って書ける便利な関数もある。ブラウザでもポリフィルで使えるようにしてるサイトもある。
という訳で、1行しか変えてないので貼るのもだるいんだけど、これを実行する。

void function(){
    let f;
    var str2ptr, ptr2str;
    let charList = [];
    for(let c='a'; c!='z'; c=String.fromCharCode(c.charCodeAt(0)+1)) charList.push(c);
    for(let c='A'; c!='Z'; c=String.fromCharCode(c.charCodeAt(0)+1)) charList.push(c);
    for(let i=0; i<9; i++) charList.push(i.toString());
    charList.push('_');
    charList.push('{');
    charList.push('}');
    let s = charList[0], cnt=0;
    var importObject = {
        wasm:{
            congratulations: s => {
                document.getElementById('congratulations').innerHTML = ptr2str(s);
            },
            status: arg => {
                if(!arg) cnt++;
                else if(arg===1){
                    if(cnt === s.length){
                        document.getElementById('input').value = s;
                        s += charList[0];
                    } else {
                        if(s[s.length-1]==charList[charList.length-1]) return alert('探索失敗\n' + s);
                        else s = s.substr(0,s.length-1) + charList[charList.indexOf(s[s.length-1])+1];
                    }
                    cnt=0;
                    setTimeout(f, 0);
                } else if(arg===2){
                    document.getElementById('progress').innerText = 'OK';
                    document.getElementById('progress').className = 'ok';
                    document.getElementById('input').value = s;
                    console.log(s);
                }
            }
        },
        env: {
            abort(msg, file, line, column) {
                if(line===1&&column===1) console.error('Too Many recurcive call');
                else console.error("Unknown Error in wasm at :" + line + ":" + column);
            }
        }
    };    

    fetch('webassembly.wasm').then(response =>
        response.arrayBuffer()
    ).then(bytes =>{
        loader.instantiate(bytes, importObject).then(({exports}) => {
            str2ptr = exports.__newString; ptr2str = exports.__getString;
            f = ()=>exports.check(str2ptr(s));
            f();
        });
    });
}();

一回ごとに次のイベントループまで待つ関係でさっきのやつよりかなり遅くなるが、数秒でフラグが求まる。(なんかFirefoxだと15秒近くかかる)

ちなみに、Congratulationsから始まるメッセージは、statusのすぐ上にいるけど今まで一度も触れられていない可哀想な関数、congratulationsがwasmから呼び出されてHTMLを受け取って表示している。
という訳で、flagはkogcoderCTF{wasm_1s_bl4ckb0x_f0r_hum4n}。最後のJSは https://test.kogcoder.com/ctf_problems/sugi/solve.js に置いてある。