We all heard about insecure deserialization vulnerability and saw many real-world cases in Java, PHP, and other languages.
But, we rarely hear about this vulnerability in JavaScript. I think it’s because the built-in serialization/deserialization function JSON.parse and JSON.stringify are only for basic data structures like string, number, array and object.
Class and function are not supported, so there is no way to run malicious code during deserialization.
What if we implement our deserialization logic and support class and function? What could possibly go wrong?
GoogleCTF 2022 has a web challenge called “HORKOS,” which shows us the way.
Overview
Before digging into the vulnerability in the challenge, we need to know how it works first.
This challenge is like a shopping website:
After selecting what you want and pressing the “CHECKOUT” button, a request will be sent to POST /order with a JSON string.
Here is what the JSON looks like when I add one tomato to my shopping cart:
if (!req.body.cart) { res.writeHead(400, {'Content-Type': 'text/html'}); returnawait res.end("bad request") }
let orders = []; let cart = req.body.cart; let vm = new VM({sandbox: {orders, cart}});
let result = await vm.run(script);
orders = new Buffer.from(JSON.stringify(orders)).toString('base64');
let url = '/order#' + orders; bot.visit(CHALL_URL + url);
res.redirect(url); });
Our input, req.body.cart is pass to a VM and run sendOrder(cart, orders).
After sendOrder, the orders array will be updated and sent to /order as the parameter. Then, the user will be redirected to the order page, and a bot will also visit the page.
There is a escpaeHtml function to do the sanitization, it encodes all < if < is in the input.
Also, we can see that almost all variables are escaped before rendering to the page, it seems that we have no chance to do something bad?
Not exactly, if you look very carefully.
In function renderLines, this line is different:
1
<p>${escapeHtml(c.key).toString()}</p>
Why? Because all the other places are escape(something.toString()), cast the input to string then escape, but the one above cast to string “after” escaped.
If you are familiar with JavaScript, besides String.prototype.includes, there is another function with the same name: Array.prototype.includes.
String.prototype.includes checks if the target is in the string while Array.prototype.includes checks if the target is in the array.
For example, ['<p>hello</p>'].includes('<') is false because there no '<' element in the array.
In other words, if c.key is an array, we can bypass the check and rendering <, which caused XSS.
Now, we have already finished the second half of the challenge. All we need to do is to find the solution for the first half: “how do we make c.key an array?”
Source code - generating order data
As I mentioned earlier, the order data is generated by sendOrder function, our goal is to find the vulnerability in its implementation and manipulate the order data.
In Driver.sendOrder, the driver is assigned to the order, and pickle.dumps(order) is pushed to this.orders, which returns to the user and shows on the /order page in the end.
The first thing I noticed is that I can create a function if the type is Function, because globalThis['Function'] is a function constructor.
If I can find a way to run the function, I can get an RCE in the sandbox and manipulate the orders. But I can’t find one at the moment.
The second thing I tried is to let key equals to __proto__, so that I can control obj.__proto__.__proto__ which is Object.prototype.__proto__, the prototype of Object.prototype.
But this does not work because it’s not allowed. You will get an error like this:
TypeError: Immutable prototype object ‘#<Object>’ cannot have their prototype set
The third thing I came up with is “prototype confusion”, look at this part:
pickle.loads always returns an object, so obj[key] is an object. But, if the type is pickledString, its prototype will be String.prototype.
So, we can have a weird object whose prototype is String. We messed up the prototype! But, unfortunately, it’s useless in this challenge.
After playing around with the pickle function for hours and finding nothing useful, I decided to take a step back.
The essence of insecure deserialization
The most suspicious part of the challenge is the pickle function, which is responsible for deserializing data. So, I assumed it’s a challenge about insecure deserialization.
What is the essence of insecure deserialization? Or put it in another way, what makes deserialization “insecure”?
My answer is: “unexpected object” and “magic function”.
For example, when we do the deserialization in the application, it usually is to load our data. The reason why deserialization is a vulnerability is that it can be exploited by loading “unexpected object”, like common gadgets in popular libraries.
Also, the “magic function” is important in PHP, like __wakeup, __destruct or __toString and so on. Those magic functions can help the attacker to find the gadget.
Back to the challenge, it’s written in JavaScript, what are the magic functions in JavaScript?
toString
valueOf
toJSON
So, based on this new mindset, I rechecked the code to see if I could find somewhere interesting.
Although none of the functions has been called on our deserialized object, I did find an interesting place:
Look at the sendOrder function, it’s an async function and it returns this.order.orderId. It means that if this.order.orderId is a Promise, it will be resolved, even without await.
Promise.then is another magic function.
1 2 3 4 5 6 7 8 9
asyncfunctiontest() { const p = newPromise(resolve => { console.log('hello') resolve() }) return p }
test()
Paste it to the browser console and run, you will see hello printed in the console.
It’s easy to build a serialized Promise, we only need a then function:
let cart = JSON.stringify( [{"key":"0","type":"pickledShoppingCart","value":[{"key":"items","type":"pickledObject","value":[{"key":"Tomato","type":"pickledItem","value":[{"key":"price","type":"Number","value":10},{"key":"quantity","type":"String","value":"1"}]},{"key":"Pickle","type":"pickledItem","value":[{"key":"price","type":"Number","value":8},{"key":"quantity","type":"String","value":"0"}]},{"key":"Pineapple","type":"pickledItem","value":[{"key":"price","type":"Number","value":44},{"key":"quantity","type":"String","value":"0"}]}]},{"key":"address","type":"pickledAddress","value":[{"key":"street","type":"String","value":"1"},{"key":"number","type":"Number","value":0},{"key":"zip","type":"Number","value":0}]},{"key":"shoppingCartId","type":"pickledPromise","value":[{"key":"then","type":"Function","value":"globalThis.orders.push(JSON.parse('"+payload+"'));arguments[0]();"}]}]}] );
let vm = new VM({sandbox: {orders, cart, console}}); try { let result = await vm.run(script); } catch(err){ console.log('err', err) } console.log('orders') console.log(orders)
console.log(encodeURIComponent(cart))
} main()
Conclusion
This challenge shows us how a simple deserialization function can be abused by crafting a Promise with a malicious then function.
You can return anything in an async function, but if you return a Promise, it will be resolved first as per the MDN documentation.
Thanks Pew for solving the second part and other team members for the great teamwork.