2018/12/03

Google CTF Beginners Quest JS SAFE

自力で解けなかったためHacking Livestream #57: Google CTF 2018 Beginners QuestCTFtime.org / Google Capture The Flag 2018 (Quals) / Beginner’s Quest - JS Safe 1.0 / Writeupを参考にした。

Attachmentとしてjs_safe_1.htmlが用意されており、ブラウザで開くとテキストボックスが表示される。適当な文字列を入力するとAccess Deniedと表示される。

<script>
const alg = { name: 'AES-CBC', iv: Uint8Array.from([211,42,178,197,55,212,108,85,255,21,132,210,209,137,37,24])};
const secret = Uint8Array.from([26,151,171,117,143,168,228,24,197,212,192,15,242,175,113,59,102,57,120,172,50,64,201,73,39,92,100,64,172,223,46,189,65,120,223,15,34,96,132,7,53,63,227,157,15,37,126,106]);
async function open_safe() {
  keyhole.disabled = true; /* keyholeは表示されているテキストボックス */
  password = /^CTF{([0-9a-zA-Z_@!?-]+)}$/.exec(keyhole.value);
  if (!password || !(await x(password[1]))) return document.body.className = 'denied';
  document.body.className = 'granted';
  const pwHash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password[1]));
  const key = await crypto.subtle.importKey('raw', pwHash, alg, false, ['decrypt']);
  content.value = new TextDecoder("utf-8").decode(await crypto.subtle.decrypt(alg, key, secret))
}
</script>

ソースを読むと、テキストボックスに入力された文字列が正規表現/^CTF{([0-9a-zA-Z_@!?-]+)}$/にマッチする場合に{}の中の文字列をfunction xの引数として渡し、その結果がtrueであれば何らかのバイナリ列をデコードしている。

<script>
async function x(password) {
    // TODO: check if they can just use Google to get the password once they understand how this works.
    var code = 'icffjcifkciilckfmckincmfockkpcofqcoircqfscoktcsfucsivcufwcooxcwfycwiAcyfBcwkCcBfDcBiEcDfFcwoGcFfHcFiIcHfJcFkKcJfLcJiMcLfNcwwOcNNPcOOQcPORcQNScRkTcSiUcONVcUoWcOwXcWkYcVkЀcYiЁcЀfЂcQoЃcЂkЄcЃfЅcPNІcЅwЇcІoЈcЇiЉcЈfЊcPkЋcЊiЌcІiЍcЌfЎcWoЏcЎkАcЏiБcІkВcБfГcNkДcГfЕcЇkЖcЕiЗcЖfИcRwЙcИoКcЙkЛcUkМcЛiНcМfОcИkПcОiРcПfСcUwТcСiУcQkФcУiХcЃiЦcQwЧcЦoШcЧkЩcШiЪcЩfЫcRiЬcЫfЭcКiЮcЭfЯcСoаcЯiбcГiвcЙiгcRoдcгkеcдiжdТaзcЛfиdзaжcжийcСkкdйaжcжклcйfмdлaжcжмнdТaжcжноdЀaжcжопdNaжcжпрcUiсcрfтdсaуdЁaтcтутcтофcТfхdфaтcтхтcтктcтнтcтмцdсaтcтцтcтктcтутcтнчaaтшdЯaщcйiъcщfыdъaьcжыэcVfюdэaьcьюьcьояdЛaьcьяьcьуьcьыѐчшьёѐшшђcOfѓdђaѓcѓнѓcѓнєcUfѕdєaѓcѓѕіcЯfїdіaѓcѓїјaёѓљaaтњcжшћcЎiќcћfѝdќaњcњѝњcњeўcЏfџdўaњcњџѠdАaњcњѠњcњшњcњѝњcњfњcњџѡљшњѢaaтѣcжшѣcѣѝѣcѣeѣcѣџѤcЯkѥdѤaѣcѣѥѣcѣшѣcѣѝѣcѣfѣcѣџѦѢшѣѧcцнѧcѧїѨdСaѧcѧѨѧcѧкѧcѧуѩaёѧѪcхмѫdрaѪcѪѫѪcѪкѬdYaѪcѪѬѪcѪиѭaѩѪѮcяюѯdНaѮcѮѯѮcѮиѮcѮхѮcѮкѰaѭѮѱdVaѲcхѱѲcѲѕѳcNoѴcѳkѵcѴfѶdѵaѲcѲѶѲcѲiѲcѲlѲcѲmѷјѲgѸјѭѷѹbѰѸѺcXfѻdѺaѻcѻюѻcѻоѻcѻкѻcѻoѼdђaѻcѻѼѻcѻнѻcѻнѻcѻѕѻcѻїѽaёѻѾѽѹшѿceeҀceeҁcee҂ceeѿaѾeҀјѿT҂ѡҀшҁјh҂hѦҁшѿaѾfҀјѿV҂ѡҀшҁјh҂hѦҁшѿaѾiҀјѿU҂ѡҀшҁјh҂hѦҁшѿaѾjҀјѿX҂ѡҀшҁјh҂hѦҁшѿaѾkҀјѿЁ҂ѡҀшҁјh҂hѦҁшѿaѾlҀјѿF҂ѡҀшҁјh҂hѦҁшѿaѾmҀјѿЄ҂ѡҀшҁјh҂hѦҁшѿaѾnҀјѿЉ҂ѡҀшҁјh҂hѦҁшѿaѾoҀјѿЄ҂ѡҀшҁјh҂hѦҁшѿaѾpҀјѿЋ҂ѡҀшҁјh҂hѦҁшѿaѾqҀјѿЍ҂ѡҀшҁјh҂hѦҁшѿaѾrҀјѿА҂ѡҀшҁјh҂hѦҁшѿaѾsҀјѿF҂ѡҀшҁјh҂hѦҁшѿaѾtҀјѿВ҂ѡҀшҁјh҂hѦҁшѿaѾuҀјѿД҂ѡҀшҁјh҂hѦҁшѿaѾvҀјѿЗ҂ѡҀшҁјh҂hѦҁшѿaѾwҀјѿК҂ѡҀшҁјh҂hѦҁшѿaѾxҀјѿН҂ѡҀшҁјh҂hѦҁшѿaѾyҀјѿР҂ѡҀшҁјh҂hѦҁшѿaѾAҀјѿТ҂ѡҀшҁјh҂hѦҁшѿaѾBҀјѿФ҂ѡҀшҁјh҂hѦҁшѿaѾCҀјѿW҂ѡҀшҁјh҂hѦҁшѿaѾDҀјѿХ҂ѡҀшҁјh҂hѦҁшѿaѾEҀјѿЪ҂ѡҀшҁјh҂hѦҁшѿaѾFҀјѿЬ҂ѡҀшҁјh҂hѦҁшѿaѾGҀјѿЮ҂ѡҀшҁјh҂hѦҁшѿaѾHҀјѿа҂ѡҀшҁјh҂hѦҁшѿaѾIҀјѿe҂ѡҀшҁјh҂hѦҁшѿaѾJҀјѿб҂ѡҀшҁјh҂hѦҁшѿaѾKҀјѿв҂ѡҀшҁјh҂hѦҁшѿaѾLҀјѿK҂ѡҀшҁјh҂hѦҁшѿaѾMҀјѿе҂ѡҀшҁјh҂hѦҁшѐceeёceeѓceeјceeљceeњceeѡceeѢceeѣceeѦceeѧceeѩceeѪceeѭceeѮceeѰceeѲceeѷceeѸceeѹceeѻceeѽceeѾceeҀceeҁceeжceeтceeчceeьcee'
    var env = {
        a: (x,y) => x[y],
        b: (x,y) => Function.constructor.apply.apply(x, y),
        c: (x,y) => x+y,
        d: (x) => String.fromCharCode(x),
        e: 0,
        f: 1,
        g: new TextEncoder().encode(password),
        h: 0,
    };
    for (var i = 0; i < code.length; i += 4) {
        var [lhs, fn, arg1, arg2] = code.substr(i, 4);
        try {
            env[lhs] = env[fn](env[arg1], env[arg2]);
        } catch(e) {
            env[lhs] = new env[fn](env[arg1], env[arg2]);
        }
        if (env[lhs] instanceof Promise) env[lhs] = await env[lhs];
    }
    return !env.h;
}
</script>

function xでは文字列codeを4文字ずつ読み取り、その内容に応じて処理を行っている。この関数でtrueを返すには最終的にenv.hの値がnull, undefined, 0, “(空文字列), falseのいずれかである必要がある。

    for (var i = 0; i < code.length; i += 4) {
        var [lhs, fn, arg1, arg2] = code.substr(i, 4);
        console.log('-------------------------', i, env['h']);
        console.log(lhs, "=", env[fn], "(", env[arg1], ",", env[arg2], ")");
        console.log(env);

envの変化を追うためにconsole.logでプリントデバッグを行う。ざっと眺めると、しばらくは何らかの文字列の生成を行っているが、i=876から引数passwordのsha-256 ハッシュ値を取っているらしき処理が確認できる。

その後はまたしばらく文字列の生成が続き、i = 940で先程生成したハッシュ値のUint8Array()をenv['Ѿ']に代入している。

i = 980で初めてenv.hの値が変化するため直前の処理に注目する。デバッグのため追加でarg1とarg2を出力している。

i = 960で先程代入したUint8Array()のindex 0をenv['ѿ']に代入し、i = 968でenv['ѿ']env['T'](= 230)とのxorを取りenv['҂']に代入している。i = 976でenv['h']env['҂']とのorを取った値をenv['h']に代入している。

つまり、少なくともi = 960からi = 976までの処理を行いenv.hを0にするためには、function xの引数passwordのハッシュ値を取ったUint8Array()のindex 0が230となる必要がある。

i = 980以降の処理を見ると、Uint8Array()のindex 1以降について同様の処理を行っている。つまりxorを取っている箇所の結果が全て0になればよいと考えられる。

    var nums = [];
    for (var i = 0; i < code.length; i += 4) {
        var [lhs, fn, arg1, arg2] = code.substr(i, 4);
        if(i >= 960 ) {
            console.log('-------------------------', i, env['h']);
            console.log(lhs, "=", env[fn], "(", env[arg1], ",", env[arg2], ")");
            console.log(env);
            console.log('arg1:', arg1, ' arg2:', arg2);
            if(lhs == '҂') { nums.push(env[arg1][1]) }
        }
        try {
            env[lhs] = env[fn](env[arg1], env[arg2]);
        } catch(e) {
            env[lhs] = new env[fn](env[arg1], env[arg2]);
        }
        if (env[lhs] instanceof Promise) env[lhs] = await env[lhs];
        if(i == 884){ console.log(env[lhs]) }
    }
    console.log(nums);
    return !env.h;

以上のようにしてi >= 960についてxorを取られている値を出力。結果は[230, 104, 96, 84, 111, 24, 205, 187, 205, 134, 179, 94, 24, 181, 37, 191, 252, 103, 247, 114, 198, 80, 206, 223, 227, 255, 122, 0, 38, 250, 29, 238]

CTF{}の中身のsha-256のハッシュ値が以上の結果と一致していればxorを取った結果が全て0になり、最終的なenv.hが0となる。

import binascii
nums = [230, 104, 96, 84, 111, 24, 205, 187, 205, 134, 179, 94, 24, 181, 37, 191, 252, 103, 247, 114, 198, 80, 206, 223, 227, 255, 122, 0, 38, 250, 29, 238]
print(bytearray(nums).hex())

ハッシュ値をバイト列から文字列に直すためのPython Script。結果はe66860546f18cdbbcd86b35e18b525bffc67f772c650cedfe3ff7a0026fa1dee

これがハッシュ値となる文字列はHash Encryption and Reverse Decryption等のサービスを使って求める。得られた値をCTF{}の中身としたものがフラグである。

Chromeを使用したプリントデバッグでconsole.log(env)とすると意図しない出力1となる。(これに引っかかり自力で解けなかった)


  1. 【Chrome】参照型変数を console.log/dir した時の挙動が怪しい|もっこりJavaScript|ANALOGIC(アナロジック)