SECCON Beginners CTF 2021
welcome
チームでの参加でしたがこれは取りたかったので(少し前にあったDEF CON CTF 2021 QUALSで一問たりとも解けずに終わってしまったので)開始5分前から正確な時計を横においた上でDiscordとスコアサーバー開いて張り付いていました。
Crypto
simple_RSA
eが小さいのでgmpy2.irootを使うだけでflagが得られました。
Logical_SEESAW
プログラムを見てみるとflagとkeyを用意して、flagとkeyのandもしくはflagそのままを1/2の確率で取得し、それを16回行うものでした。
flagが1の場合andを取っても出力は必ず1となり、flagが0の場合はkeyが1でかつandを取った場合は1となり、それ以外は0となります
この事から16個の列を全て比較し、一つでも0が含まれていたら0、全て1なら1としてやればflagが取り出せると予想し、以下のコードを書きました
//cipherにoutputの中身を代入
let flag_long = "2".repeat(cipher[0].length).split("");
for(let i = 0; i < cipher[0].length; i++) {
let one = false;
for(let j = 0; j < 16; j++){
if(cipher[j][i] === "1") {
one = true;
}
}
if(one){
flag_long[i] = "1"
} else {
flag_long[i] = "0"
}
}
flag_long.join("")
これで得られた物をPythonでlong_to_bytes(0B出力)とすることでflagを得ることができました
reversing
only_read
retdecやGhidraにかけたところフラグらしき物が見えたのでつなぎ合わせてflagを得ることができました。
please_not_trace_me
トレースを行うとflagのdecryptを行わないようになっていたので、Ghidraで開いて確認しました。
case 8:
fwrite("prease not trace me...\n",1,0x17,stderr);
exit(1);
exitする部分を見つけたのでここに分岐してくる処理を探し、
case 5:
if (local_50 == 6) {
local_30 = 0x12;
}
else {
local_30 = 8;
}
break;
とあったのでこの8を代入する部分に0x12を代入してやればtraceの検知を回避できそうです この部分の処理は
48 c7 45 d8 08 00 00 00
であったので
48 c7 45 d8 12 00 00 00
と書き換えてtraceの検知を無効化し、gdbで実行を行いました。
この時点でflag decryptedと表示されるようになり、メモリ内に保持していると思われる状態にできたので、メモリを既に分かっているctf4bを利 用し検索し、flagを得ました。
pwnable
rewriter
pwnはさっぱり分からず避けていましたが、これは直接アドレスを入力するだけで解けるように見えたので、win関数のアドレスをバイナリから探し、それをret addrへ入力することでflagを得ることができました。
gdbでp &win
web
osoba
?pageでディレクトリトラバーサルができそうだったので
https://osoba.quals.beginners.seccon.jp/?page=../flag
Werewolf
配られたapp.pyを確認し、player.roleが'WEREWOLF'であればflagを出力することが分かりました。
また、Playerへは受け取ったデータを全てプロパティへ代入していることも分かりました。
しかし、roleは__roleとプライベートに設定されており、そのままではアクセスできません。
Pythonのプライベートの変数へ外部からアクセスする方法を探し、_Player__roleでアクセスできることが分かったので_Player__role=WEREWOLFとリクエストを投げてflagを得ました。
check_url
127.0.0.1からのアクセスに対してflagを返却するので、SSRFを利用してlocalhostへアクセスさせれば良いことが分かりますが、
preg_replace("/[^a-zA-Z0-9\/:]+/u", "👻", $url); //Super sanitizing
この制限と、localhostを含む場合には処理を拒むという部分を回避する必要があり、localhostの別の表記を探し、
https://qiita.com/naka_kyon/items/88478be20b300e757fc0
これを入力すると取得できました。
json
最初にipの制限を回避する必要があり、ginが使われていたので
https://github.com/gin-gonic/gin/blob/7313b8fddc61fa4df9788abfda4fab53a83dfead/context_test.go#L1409
ソースコードを確認し、X-Appengine-Remote-Addr,X-Real-IP,X-Forwarded-Forを試し、X-Forwarded-Forに内部IPアドレスを指定すれば回避できることを確認しました。
その上でapiへidが2であるjsonを投げればflagが得られるものでしたが、その前にあるbffサーバーでidが2である場合はapiサーバーにアクセスさせないとい う処理がありました。
そこでJSONのパース部分を確認してみると、bffではjson.Unmarshalでjsonをパースしているにも関わらず、api側ではbuger/jsonparserを利用してidを取り出していました。
jsonparserはプロパティ名を指定して取得していたのでidを2つ含めればjsonparserの場合は最初に定義したものが取得されそうだと考え、そうであればjson.Unmarshalは最後に定義したものを利用するのでは無いかと予想し、(もしだめならライブラリの処理を確認しようと考えながら)
{"id": 2, "id": 0}
というjsonを作成してリクエストしたところflagを得ることができました。
cant_use_db
$20000の残高でNoodles($10000)を2つ、Soup($20000)を一つ購入する必要がありました。
app.pyの中では購入処理の後にsleepを入れ、それから残高を減らす処理があり、購入のみ通してしまえば良いと考え、
$.ajax({type: 'POST', timeout: 200, url: '/buy_soup'});$.ajax({type: 'POST', timeout: 200, url: '/buy_noodles'});$.ajax({type: 'POST', timeout: 200, url: '/buy_noodles'})
をDeveloper Toolのconsoleから実行してflagを入手しました。
意図としてはtmieoutさせれば残高を減らさずに済むのでは無いかと考えていたのですが、実際には並列に実行していたことで通っていたようです。
magic
恐らく一番時間がかかりました。(ある事に気付かなかったというより忘れていて6時間程度)
まず、サイトの挙動を確認し、配られたファイルを読み以下の事を確認しました。
- magiclinkのtokenはhtmlescapeがされている(`のエスケープが抜けているが他はエスケープされてしまうのでDom base xssの為に用意された物ではなくそれはmemoで行いそう、この部分はcrawl.jsに任意のmemo表示するためだけに使う?) (こう考えてしまったことが誤りの原因、この時実際に誤ったtoken指定してアクセスしていればもっと早く気付けた)
- memoはhtmlescapeがなされていない(ここからXSSができそう)
- magiclinkを利用することで操作なしにログインが可能(reportページからcrawl.jsへ渡せばmemoが表示されるページへアクセスさせることができそう)
- CSPが設定されていて(self)、inline含めてjavascriptの実行は難しい、styleもinlineすら不可(これをどうにか回避する必要がありそう)
以上からflagを取得するまでの流れを予想し、
- 任意のmemoを埋め込んだアカウントのmagiclinkをrepotから送信しcrawl.jsにアクセスさせる。
- crawl.jsはflagをmemoFieldに入力した後h1をクリックしてonchangeを発火させ、static/index.jsによってlocalStorageに保存された状態で任意のmemoを埋め込んだページにアクセスする
- static/index.jsによってlocalStorageの内容がmemoFieldに入力される
- 埋め込んだmemoによってXSSでsaveMemoをクリックさせる
- memoにflagが保存されるのでログインして確認
このために、まずCSPのBypass方法を検索し、cspにselfが指定されている時に利用可能なJSONPやCDNのライブラリを介した方法や、ファイルアップロードを利用した物が見つかりましたが、どれも使えないと判断しました。(ここでmagiclinkに気づけば良かったのですが)
scriptタグに/を指定してやれば任意のhtmlを埋め込んだトップページを取得させることはできますが、先頭が<で固定されていて、エラーとなってしまうので使え無さそうでした。
次に特にcspの指定されていないiframeやimgを介して外部リソースを読み込ませる方法を思いつき、数時間試していましたが全て失敗しました
iframe内にscriptを配置することでJavaScriptの実行そのものは可能でしたが、クロスドメインとなってしまうため、parentへのアクセスが制限されていました。
iframeからparentへクロスドメインでアクセスする方法を探すも何もなく、
今度はiframe内に更にiframeを設置してみるなど試行錯誤を続けていました。
iframeから離れて、scriptタグをどうにか使えないか考えている時に(dataとか色々試した後)、やはり同一originから取得するしか無いのでは、出力される余計な物をコメントアウトすれば動作するのでは無いか、という結論に至り、再度用意されているプログラムを確認するとmagiclinkのtokenに誤った物が入力されている際の出力はhtmlタグを含まず、先頭にtokenが上記の不完全なhtmlescapeを通して表示されている事に気付きました。
ここからは簡単で、以下のコードをmemoへ保存し、reportからmagiclinkを送信し、再度アクセスすることでflagを入手しました。
<script src="https://magic.quals.beginners.seccon.jp/magic?token=setTimeout(function(){document.getElementById(`saveMemo`).click();},500);//"></script>
<>&"'はエスケープされてしまうため、()=>ではなくfunction()
"saveMemo"や'saveMemo'ではなくsaveMemo
とする必要があります
ctf4b{w0w_y0ur_skil1ful_3xploi7_c0de_1s_lik3_4_ma6ic_7rick}
misc
git-leak
gitだったのでblobを見れば答えがあるだろうと判断しましたが、削除されたcommitを探すのが面倒で削除されたcommitを探せる方法を検索し、最終的に以下のコマンドでflagの入手を行いました
git reflog
git checkout 7387982
Mail_Address_Validator
ReDosを行う問題であると判断できたので、ReDosを行う方法を探していたのですが、手元で試している時に大文字を入力すると処理時間が跳ね上がる事に気付き、
a@AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.A
で通ってしまいました。
何故通ったのか未だに分かりません。
fly
magicで意味のわからない見落としをしなければ一番時間かかりました。
中身がGame.exeとData.wolfがあり、Wolf RPG Editorで作成されたゲームであることが分かりました。
Wolf RPG EditorはDXライブラリを利用したソフトで、Data.wolfはDXArchiveを利用したものだろうと予想ができました。
DXArchiveの古いバージョンには脆弱性があり、パスワードを知らなくても解凍可能だったのでそれを試しましたが、その方法では開けませんでした。
エディタを確認してみたところ、最近のバージョンでこの脆弱性の対応をされたバージョンのDXArchiveを利用するようになったようでした。
そこで、バイナリかメモリ内からパスワードを探して解凍する問題だと判断し、
バイナリを確認しましたが、探すには時間がかかりそうに見えたのでメモリの中身を探し、データファイルのパス名を保持するアドレスの近くにパスワードらしき文字列が複数存在することを見つけました。
これを用いて解凍を行い、エディタで開く事でflagを得ることができました。
depixelization
PIXの三文字を重ねた上で縮小してflagを画像として出力していたので、0-9a-z_の画像を用意して比較すれば良いと考えました。
(画像検索自体は過去に数回書いていて、すぐに書けそうだった事もあり。これとか)
https://github.com/maa123/NearImageSearch
pixelization.pyを書き換えたプログラムで画像を用意し、その画像を元に以下のプログラムで検索を行いました。
const Jimp = require('jimp');
const zeroFill = s => {
return `000${s}`.slice(-3);
}
const compare = (a,b) => {
var count = 0;
var i = 0;
while(i<a.length){
if(a[i] !== b[i]){
count++;
if(count>2) return 3;
}
i++;
}
return count;
}
const strs = "0123456789abcdefghijklmnopqrstuvwxyz_";
const base = [];
let flag = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".split("");
(async()=>{
for(let i = 0; i < strs.length; i++) {
const h = (await Jimp.read(`img/base-${zeroFill(i+1)}.png`)).resize(16, 16).greyscale().hash(2);
base[i] = h;
}
for(let i = 0; i < 31; i++) {
const h = (await Jimp.read(`img/flag-${zeroFill(i+1)}.png`)).resize(16, 16).greyscale().hash(2);
for(let j = 0; j < base.length; j++) {
if(compare(h, base[j]) === 0){
if(flag[i] !== "A"){
console.log(flag[i], strs[j]);
}
flag[i] = strs[j];
}
}
}
console.log(flag.join(""));
})();
このプログラムでは完全一致ではなくdHashを用いて類似画像を検索するためのものを流用したのでnとhの判断ができず。
ctf4b{1f_y0u_p1x_y0u_c4n_d3p1x}
ctf4b{1f_y0u_p1x_y0u_c4h_d3p1x}
の二通りになってしまいましたが、内容から前者の可能性が高いと判断して前者を送信し、正解でした。(コードから分かる通りについては位置が確定できるため処理を行いませんでした)