こんとろーるしーこんとろーるぶい

週末にカチャカチャッターン!したことを貼り付けていくブログ

Google CTF 2018 - JS SAFE 2.0

問題文

You stumbled upon someone's "JS Safe" on the web. It's a simple HTML file that can store secrets in the browser's localStorage. This means that you won't be able to extract any secret from it (the secrets are on the computer of the owner), but it looks like it was hand-crafted to work only with the password of the owner...

添付ファイル:js_safe_2.html

f:id:graneed:20180624164749p:plain

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>JS safe v2.0 - the leading localStorage based safe solution with military grade JS anti-debug technology</title>
<!--
Advertisement:
Looking for a hand-crafted, browser based virtual safe to store your most
interesting secrets? Look no further, you have found it. You can order your own
by sending a mail to js_safe@example.com. When ordering, please specify the
password you'd like to use to open and close the safe. We'll hand craft a
unique safe just for you, that only works with your password of choice.
-->
<style>
body {
  text-align: center;
}
input {
  font-size: 200%;
  margin-top: 5em;
  text-align: center;
  width: 26em;
}
#result {
  margin-top: 8em;
  font-size: 300%;
  font-family: monospace;
  font-weight: bold;
}
body.granted>#result::before {
  content: "Access Granted";
  color: green;
}
body.denied>#result::before {
  content: "Access Denied";
  color: red;
}
#content {
  display: none;
}
body.granted #content {
  display: initial;
}
.wrap {
  display: inline-block;
  margin-top: 50px;
  perspective: 800px;
  perspective-origin: 50% 100px;
}
.cube {
  position: relative;
  width: 200px;
  transform-style: preserve-3d;
}
.back {
  transform: translateZ(-100px) rotateY(180deg);
}
.right {
  transform: rotateY(-270deg) translateX(100px);
  transform-origin: top right;
}
.left {
  transform: rotateY(270deg) translateX(-100px);
  transform-origin: center left;
}
.top {
  transform: rotateX(-90deg) translateY(-100px);
  transform-origin: top center;
}
.bottom {
  transform: rotateX(90deg) translateY(100px);
  transform-origin: bottom center;
}
.front {
  transform: translateZ(100px);
}
@keyframes spin {
  from { transform: rotateY(0); }
  to { transform: rotateY(360deg); }
}
.cube {
  animation: spin 20s infinite linear;
}
.cube div {
  position: absolute;
  width: 200px;
  height: 200px;
  background: rgba(0, 0, 0, 0.51);
  box-shadow: inset 0 0 60px white;
  font-size: 20px;
  text-align: center;
  line-height: 200px;
  color: rgba(0,0,0,0.5);
  font-family: sans-serif;
  text-transform: uppercase;
}
</style>
<script>
function x(х){ord=Function.prototype.call.bind(''.charCodeAt);chr=String.fromCharCode;str=String;function h(s){for(i=0;i!=s.length;i++){a=((typeof a=='undefined'?1:a)+ord(str(s[i])))%65521;b=((typeof b=='undefined'?0:b)+a)%65521}return chr(b>>8)+chr(b&0xFF)+chr(a>>8)+chr(a&0xFF)}function c(a,b,c){for(i=0;i!=a.length;i++)c=(c||'')+chr(ord(str(a[i]))^ord(str(b[i%b.length])));return c}for(a=0;a!=1000;a++)debugger;x=h(str(x));source=/Ӈ#7ùª9¨M¤À.áÔ¥6¦¨¹.ÿÓÂ.Ö£JºÓ¹WþÊmãÖÚG¤¢dÈ9&òªћ#³­1᧨/;source.toString=function(){return c(source,x)};try{console.log('debug',source);with(source)return eval('eval(c(source,x))')}catch(e){}}
</script>
<script>
function open_safe() {
  keyhole.disabled = true;
  password = /^CTF{([0-9a-zA-Z_@!?-]+)}$/.exec(keyhole.value);
  if (!password || !x(password[1])) return document.body.className = 'denied';
  document.body.className = 'granted';
  password = Array.from(password[1]).map(c => c.charCodeAt());
  encrypted = JSON.parse(localStorage.content || '');
  content.value = encrypted.map((c,i) => c ^ password[i % password.length]).map(String.fromCharCode).join('')
}
function save() {
  plaintext = Array.from(content.value).map(c => c.charCodeAt());
  localStorage.content = JSON.stringify(plaintext.map((c,i) => c ^ password[i % password.length]));
}
</script>
</head>
<body>
<div>
  <input id="keyhole" autofocus onchange="open_safe()" placeholder="🔑">
</div>
<div class="wrap">
  <div class="cube">
    <div class="front"></div>
    <div class="back"></div>
    <div class="top"></div>
    <div class="bottom"></div>
    <div class="left"></div>
    <div class="right"></div>
  </div>
</div>
<div id="result">
</div>
<div>
  <input id="content" onchange="save()">
</div>
</body>
</html>

writeup

テキストボックスにCTF{password}の形式でパスワードを入力してフォーカスアウトするとfunction xが呼ばれる。 返り値が真となる文字列がフラグのようだ。

Stage1

function xにインデント整形をかけると以下のコードになる。

function x(х) {
    ord = Function.prototype.call.bind(''.charCodeAt);
    chr = String.fromCharCode;
    str = String;
    function h(s) {
        for (i = 0; i != s.length; i++) {
            a = ((typeof a == 'undefined' ? 1 : a) + ord(str(s[i]))) % 65521;
            b = ((typeof b == 'undefined' ? 0 : b) + a) % 65521
        }
        return chr(b >> 8) + chr(b & 0xFF) + chr(a >> 8) + chr(a & 0xFF)
    }
    function c(a, b, c) {
        for (i = 0; i != a.length; i++)
            c = (c || '') + chr(ord(str(a[i])) ^ ord(str(b[i % b.length])));
        return c
    }
    for (a = 0; a != 1000; a++) ★1
        debugger ;
    x = h(str(x)); ★2
    source = /Ӈ#7ùª9¨M¤À.áÔ¥6¦¨¹.ÿÓÂ.Ö£JºÓ¹WþÊmãÖÚG¤¢dÈ9&òªћ#³­1᧨/;
    source.toString = function() { ★3-1
        return c(source, x)
    }
    ;
    try {
        console.log('debug', source); ★3-2
        with (source)
            return eval('eval(c(source,x))')★4
    } catch (e) {}
}
  1. デバッガーを妨害する処理。
    Chromeデベロッパーツールを表示した状態で動かすと1000回中断される。
    a変数を使用しているのがポイント。(後でこの罠に苦しめられる。)

  2. function hで、xのハッシュを計算している。これがハマりポイントの1つ目。
    実は、引数のхではなく、関数のxを指している。引数のxは、ASCIIのxではなくキリル文字хであり別物。 つまり、関数のxの実装コードのハッシュを計算していることになる。

  3. デバッガーを妨害する処理。
    Chromeデベロッパーツールを表示した状態で動かすとconsole.log('debug', source);が終わらない。
    3-1でsourcetoStringをオーバーライドしているため、3-2のconsole.logsourceを渡すと、関数cが呼ばれる。しかし、関数cに渡すsource正規表現オブジェクトであるため、a.lengthundefinedになり無限ループになり、これが原因。 なお、★4のwith(source)配下なら、正規表現オブジェクトのsourceの、sourceプロパティの文字列を渡すことになるためa.lengthが有限の値になる。

  4. sourcesourceプロパティと★2で計算したハッシュのXORをとって復号する処理。

★2は関数xの実装コードのハッシュを計算しているため、インデント整形したりデバッガー妨害処理をコメントアウトしたりすると、ハッシュ値が変わる。
よって、関数xをコピーして関数yを作成し、デバッガー妨害処理等を外す。

function y(х) {
    ord = Function.prototype.call.bind(''.charCodeAt);
    chr = String.fromCharCode;
    str = String;
    function h(s) {
        for (i = 0; i != s.length; i++) {
            a = ((typeof a == 'undefined' ? 1 : a) + ord(str(s[i]))) % 65521;
            b = ((typeof b == 'undefined' ? 0 : b) + a) % 65521
        }
        return chr(b >> 8) + chr(b & 0xFF) + chr(a >> 8) + chr(a & 0xFF)
    }
    function c(a, b, c) {
        for (i = 0; i != a.length; i++)
            c = (c || '') + chr(ord(str(a[i])) ^ ord(str(b[i % b.length])));
        return c
    }
    //for (a = 0; a != 1000; a++)
    //    debugger ;
    a = 1000 ★1
    x = h(str(x)); ★2
    source = /Ӈ#7ùª9¨M¤À.áÔ¥6¦¨¹.ÿÓÂ.Ö£JºÓ¹WþÊmãÖÚG¤¢dÈ9&òªћ#³­1᧨/;
    //source.toString = function() {
    //    return c(source, x)
    //}
    ;
    try {
        //console.log('debug', source);
        with (source){
            console.log('debug', "c(source,x)=" + c(source,x));
            return eval('eval(c(source,hash_x))')
        }
    } catch (e) {}
}
  1. 直前で、変数aを1000までインクリメントする処理をコメントアウトしているため、aを1000に設定。
    ここがハマりポイント2つ目。変数aのスコープ、関数cと同じだったのね・・・。

  2. 関数xの実装コードをハッシュ計算。

ブラウザで開いて、CTF{AAAA}を入力してフォーカスアウトすると、コンソールに以下の表示。
debug c(source,x)=х==c('¢×&Ê´cʯ¬$¶³´}ÍÈ´T©Ð8ͳÍ|Ô÷aÈÐÝ&¨þJ',h(х))//᧢

Stage2

х==c('¢×&Ê´cʯ¬$¶³´}ÍÈ´T©Ð8ͳÍ|Ô÷aÈÐÝ&¨þJ',h(х))が成立するхを探せば良さそうだ。

¢×&Ê´cʯ¬$¶³´}ÍÈ´T©Ð8ͳÍ|Ô÷aÈÐÝ&¨þJхハッシュ値を関数cに渡して復号した文字列」と一致するхを探すため、bruteforceする。

h(х)は4バイトであることがわかっているため、хではなくh(х)をbruteforceの対象とする。フラグ文字列らしきものが復号できたら、その文字列のハッシュを計算して突き合わせ確認する。復号途中にフラグ文字列(0-9a-zA-Z_@!?-)以外が出現した場合は中断させる。また、分散実行できるように、画面で開始位置と終了位置を入力可能なようにした。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <script>
            ord = Function.prototype.call.bind(''.charCodeAt);
            chr = String.fromCharCode;
            str = String;

            source = /¢×&Ê´cʯ¬$¶³´}ÍÈ´T©Ð8ͳÍ|Ô÷aÈÐÝ&¨þJ/;

            function h(s) {
                a = 2714; // str(x)を引数にしてh関数を実行した直後のaの値
                b = 33310; // str(x)を引数にしてh関数を実行した直後のbの値
                for (i = 0; i != s.length; i++) {
                    a = ((typeof a == 'undefined' ? 1 : a) + ord(str(s[i]))) % 65521;
                    b = ((typeof b == 'undefined' ? 0 : b) + a) % 65521
                }
                return chr(b >> 8) + chr(b & 0xFF) + chr(a >> 8) + chr(a & 0xFF)
            }

            function c(a, b, c) {
                for (i = 0; i != a.length; i++) {
                    tmp_ord = ord(str(a[i])) ^ ord(str(b[i % b.length]));
                    if ((33  == tmp_ord)|| // !
                        (45  == tmp_ord)|| // -
                        (48  <= tmp_ord && tmp_ord <=  57)|| // 0-9
                        (63  <= tmp_ord && tmp_ord <=  90)|| // ?,@,A-Z
                        (95  == tmp_ord)|| // _
                        (97  <= tmp_ord && tmp_ord <=  122) // a-z
                       ) {
                    } else {
                        return
                    }
                    tmp_chr = chr(tmp_ord);
                    c = (c || '') + tmp_chr;
                }
                return c
            }

            function solve(x) {
                decrypted = c(source.source, x);
                if (typeof decrypted === "undefined") {
                    return
                }

                //console.log("debug", "decrypted=" + decrypted);
                if (x == h(decrypted)){
                    console.log("debug", "decrypted=" + decrypted);
                    console.log("debug", "x=" + x.split("").map(function(e){return ord(e)}).join(","));
                    return decrypted;
                }else{
                    return
                }
            }

            function brute() {
                var start = document.getElementById("start").value;
                var end = document.getElementById("end").value;
                for (var a = start; a < end; a++) {
                    var a_1 = chr(a >> 8);
                    var a_2 = chr(a & 0xFF);
                    if (a % 1000 == 0)
                        console.log("debug", "a=" + a);

                    for (var b = 0; b < 65521; b++) {
                        result = solve(chr(b >> 8) + chr(b & 0xFF) + a_1 + a_2);
                        if (typeof result === "undefined") {
                            continue
                        } else {
                            console.log("[ans]flag=CTF{" + result + "}");
                            return;
                        }
                    }
                }
            }
        </script>
    </head>
    <body>
        <input type="text" id="start" value="0">
        <input type="text" id="end" value="65521">
        <input type="button" onclick="brute()" value="start">
    </body>
</html>

実行するとコンソールに以下の表示。

debug a=0
debug a=1000
debug a=2000
debug a=3000
debug a=4000
debug a=5000
debug decrypted=_N3x7-v3R51ON-h45-AnTI-4NTi-ant1-D3bUg_
debug x=253,153,21,249
[ans]flag=CTF{_N3x7-v3R51ON-h45-AnTI-4NTi-ant1-D3bUg_}

フラグゲット。