🐈

CakeCTF 2022 Writeup CakeGEAR [web, warmup]

2022/09/07に公開

CakeCTFとは

yoshiking氏, theoremoon氏, ptr-yudai氏らが主催しているJeopardy形式のCTFです。
Jeopardy形式では、独立した様々なジャンルの問題が出題されます。

図1 CakeCTFのトップ画面
図1 CakeCTFのトップ画面

難易度は初心者から中級者向けらしいのですが、実際に参加したところ、自分のようなCTF始めたての初心者には難しめかなと感じました。
ちなみに、自分は制限時間内に1問も解くことができませんでした。悲しい、、

CakeGEAR [web, warmup]

PHPの脆弱性を利用してフラグを取得する問題です。丸一日この問題に費やしました。

問題文

図2 CakeGEARの問題文
図2 CakeGEARの問題文

ログインポータルへのリンクと、サーバ構築時のDockerfile、PHPファイルが配られます。
以下、その詳細です。

図3 ログインポータルの画面
図3 ログインポータルの画面

Dockerfile
FROM php:8-apache

RUN echo "FakeCTF{neko}" > /flag.txt
RUN chmod 444 /flag.txt

RUN apt update

WORKDIR /var/www/html

ADD index.php ./
ADD admin.php ./
RUN chown -R root:www-data ./
RUN chmod -R 440           ./
RUN chmod    550           ./
index.php
<?php
session_start();
$_SESSION = array();
define('ADMIN_PASSWORD', 'f365691b6e7d8bc4e043ff1b75dc660708c1040e');

/* Router login API */
$req = @json_decode(file_get_contents("php://input"));
if (isset($req->username) && isset($req->password)) {
    if ($req->username === 'godmode'
        && !in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'])) {
        /* Debug mode is not allowed from outside the router */
        $req->username = 'nobody';
    }

    switch ($req->username) {
        case 'godmode':
            /* No password is required in god mode */
            $_SESSION['login'] = true;
            $_SESSION['admin'] = true;
            break;

        case 'admin':
            /* Secret password is required in admin mode */
            if (sha1($req->password) === ADMIN_PASSWORD) {
                $_SESSION['login'] = true;
                $_SESSION['admin'] = true;
            }
            break;

        case 'guest':
            /* Guest mode (low privilege) */
            if ($req->password === 'guest') {
                $_SESSION['login'] = true;
                $_SESSION['admin'] = false;
            }
            break;
    }

    /* Return response */
    if (isset($_SESSION['login']) && $_SESSION['login'] === true) {
        echo json_encode(array('status'=>'success'));
        exit;
    } else {
        echo json_encode(array('status'=>'error'));
        exit;
    }
}
?>
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>login - CAKEGEAR</title>
        <style>input { margin: 0.5em; }</style>
    </head>
    <body style="text-align: center;">
        <h1>CakeWiFi Login</h1>
        <div>
            <label>Username</label>
            <input type="text" id="username" required>
            <br>
            <label>Password</label>
            <input type="text" id="password" required>
            <br>
            <button onclick="login()">Login</button>
            <p style="color: red;" id="error-msg"></p>
        </div>
        <script>
         function login() {
             let error = document.getElementById('error-msg');
             let username = document.getElementById('username').value;
             let password = document.getElementById('password').value;
             let xhr = new XMLHttpRequest();
             xhr.addEventListener('load', function() {
                 let res = JSON.parse(this.response);
                 if (res.status === 'success') {
                     window.location.href = "/admin.php";
                 } else {
                     error.innerHTML = "Invalid credential";
                 }
             }, false);
             xhr.withCredentials = true;
             xhr.open('post', '/');
             xhr.send(JSON.stringify({ username, password }));
         }
        </script>
    </body>
</html>
admin.php
<?php
session_start();
if (empty($_SESSION['login']) || $_SESSION['login'] !== true) {
    header("Location: /index.php");
    exit;
}

if ($_SESSION['admin'] === true) {
    $mode = 'admin';
    $flag = file_get_contents("/flag.txt");
} else {
    $mode = 'guest';
    $flag = "***** Access Denied *****";
}
?>
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>control panel - CAKEGEAR</title>
        <style>table, td { margin: auto; border: 1px solid #000; }</style>
    </head>
    <body style="text-align: center;">
        <h1>Router Control Panel</h1>
        <table><tbody>
            <tr><td><b>Status</b></td><td>UP</td></tr>
            <tr><td><b>Router IP</b></td><td>192.168.1.1</td></tr>
            <tr><td><b>Your IP</b></td><td>192.168.1.7</td></tr>
            <tr><td><b>Access Mode</b></td><td><?= $mode ?></td></tr>
            <tr><td><b>FLAG</b></td><td><?= $flag ?></td></tr>
        </tbody></table>
    </body>
</html>

自分で試したこと

まず始めに、ざっくりコードを読むところから始めました。
コードからは、全体的に以下のような流れで処理を進めていることが分かります。

  1. ユーザ名とパスワードを入力
  2. 入力内容をJSON形式に変換してXMLHttpRequestオブジェクトとしてサーバに送信
  3. サーバ側でJSON文字列をデコードして認証情報を取り出す
  4. 認証情報をもとに$_SESSION['login'], $_SESSION['admin']を設定
  5. $_SESSION['login'], $_SESSION['admin']が両方trueであるユーザのみにフラグを表示

どうにかしてセッション変数を設定しなければいけないようです。
コードを見た感じ、入力のサニタイジングが行われていなかったので、インジェクション系の攻撃が利用できるかもしれません。
そこで、以下の入力を渡してみました。

入力
(ユーザ名)
null)||('a'=='a')){$_SESSION['login']=true;$_SESSION['admin']=true;}echo json_encode(array('status'=>'success'));exit;//

(パスワード)
入力なし

この入力がサーバに到達したとき、最初にindex.phpの以下の部分に入力が渡されます。

"index.php" から一部抜粋
if (isset($req->username) && isset($req->password)) {
    if ($req->username === 'godmode'
        && !in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'])) {
        /* Debug mode is not allowed from outside the router */
        $req->username = 'nobody';
    }
    // 以下略

$req->usernameにユーザ名が入るので、今回の入力だと下のコードに置き換わるはずです。(たぶん)

if (isset(null) || ('a' == 'a')){
    $_SESSION['login'] = true;
    $_SESSION['admin'] = true;
}
echo json_encode(array('status'=>'success'));
exit;
//) && isset($req->password)) {
    if ($req->username === 'godmode'
        && !in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'])) {
        /* Debug mode is not allowed from outside the router */
        $req->username = 'nobody';
    }
    // 以下略

isset(null)は偽になりますが、('a' == 'a')で必ず真となるためif文が通って、セッション変数 "login" と "admin" が true に設定されます。
そして、echo json_encode(array('status'=>'success'))の部分でステータスにsuccessを設定し、admin.phpに遷移させて認証をクリアさせます。
その後は、exitでスクリプトを終了し、それ以降のコードを実行しないようにしています。
(正直ここら辺のコードは自信無いです。間違ったことを書いていたら教えてください。)

結果は失敗でした。

他にもコードを変えたり、HTTPリクエストとレスポンスを観察したりしましたが、解決策が思いつかず時間切れになりました。

解法

CTFイベント終了後もずっと格闘していましたが、結局分からなかったので他の方のwriteupを参考にしました。(https://www.youtube.com/watch?v=-U53PFwi93M&t=2540s)
https://www.youtube.com/watch?v=-U53PFwi93M&t=2540s

どうやら、PHPのswitch文を利用するようです。

PHPの比較演算子には=====がありますが、switch文は==を使っており、緩やかな比較を行います。
そのため、厳密に同じ値でなくても、比較判定が真となる場合があります。
具体的には、文字列と論理型(true)の組み合わせのときです。
例えば、"abc"==trueのような条件式は真となります。なので、今回の入力にtrueを渡すと全ての条件式が真となり、switch文全てのケースにマッチします。
このとき、switch文で全てにマッチする場合、どのケースが実行されるかですが、一番最初のケースが実行されるらしいです。
これらの性質を利用すると、switch文で一番最初にあるgodmodeでログインして、フラグを取得することができます。

それでは、入力にtrueを入れてみましょう。

入力
(ユーザ名)
true

(パスワード)
入力なし

ここで一つ注意点があります。入力した値が送信されるとき、入力は文字列として解釈されているため、以下のように一度リクエストをキャッチして、ダブルクォーテーションを外して論理型の値として認識させてあげましょう。
図4 ダブルクォーテーションを外す前と外す後の画像
図4 ダブルクォーテーションを外して論理型として認識させる

最後に、止めていたリクエストを送信して終了です。

図5 フラグゲット画像
図5 フラグゲット!

無事、フラグを取得できました。

感想

想像していたよりも数倍難しかったです。実際に問題を解いてみて、自分の知識不足をひしひしと感じました。それから、phpのswitch文の仕様には全然気づけませんでした。気づいた人すごい、、というかこの問題がwarmupって、、難しい。
今回のCakeCTF2022は惨敗でしたが、自分の実力を把握できたうえ、解いていてすごく楽しかったので、有意義な時間を過ごすことができました。またこれからも色々writeup書いていく予定です。

Discussion