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
<!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) {} }
デバッガーを妨害する処理。
Chromeのデベロッパーツールを表示した状態で動かすと1000回中断される。
a
変数を使用しているのがポイント。(後でこの罠に苦しめられる。)function hで、
x
のハッシュを計算している。これがハマりポイントの1つ目。
実は、引数のх
ではなく、関数のx
を指している。引数のx
は、ASCIIのx
ではなくキリル文字のх
であり別物。 つまり、関数のx
の実装コードのハッシュを計算していることになる。デバッガーを妨害する処理。
Chromeのデベロッパーツールを表示した状態で動かすとconsole.log('debug', source);
が終わらない。
3-1でsource
のtoString
をオーバーライドしているため、3-2のconsole.log
にsource
を渡すと、関数c
が呼ばれる。しかし、関数c
に渡すsource
は正規表現オブジェクトであるため、a.length
がundefined
になり無限ループになり、これが原因。 なお、★4のwith(source)
配下なら、正規表現オブジェクトのsource
の、source
プロパティの文字列を渡すことになるためa.length
が有限の値になる。source
のsource
プロパティと★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) {} }
直前で、変数
a
を1000までインクリメントする処理をコメントアウトしているため、a
を1000に設定。
ここがハマりポイント2つ目。変数a
のスコープ、関数c
と同じだったのね・・・。関数
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_}
フラグゲット。