Penetrazione e Sicurezza in JavaScript

Penetrazione e Sicurezza in JavaScript

Scritto da Francesco Di Donato 7 ottobre 2021 6 minuti di lettura

Questo post è stato ispirato da questo articolo di Thomas Hunter II 🔗.

Indice

Sarai sia il lupo che la pecora. Ho creato la funzione qui sotto in modo che avesse tutto il necessario per imparare le tecniche di attacco e le relative difese:

  1. Sondaggio e Doppio Getter (Probing & Double Getter)
  2. Corruzione del Prototipo (Prototype Bribing)
  3. Illusione Primitiva (Primitive Illusion)

La funzione è Connector, che riceve un oggetto di configurazione options. Questo deve contenere una proprietà chiamata address che deve essere uguale a uno di quelli elencati in validAddresses, altrimenti viene lanciata un’eccezione.

Una volta stabilita la connessione con uno degli address validi, l’istanza fornisce il metodo transfer per spostare un certo amount passato come input, che non deve superare il valore 500.

function Connector(options) {
  const validAddresses = ['partner-account', 'investments', 'mutual']

  if (!options.address || typeof options.address !== 'string') _err1()

  if (!validAddresses.includes(options.address)) _err2(options, validAddresses)

  console.info(`Connessione all'indirizzo [${options.address}] stabilita`)

  return {
    transfer,
  }

  function transfer(amount) {
    if (!amount || amount <= 0) _err3()

    if (amount > 500) _err4()

    console.info(
      `Trasferito un importo di [${amount}] all'indirizzo [${options.address}]`
    )
  }
}

Non concentrarti sulle funzioni _err. Non sono importanti qui.

L’ happy path è il seguente:

const c = Connector({ address: 'investments' })
// Connessione all'indirizzo [investments] stabilita

c.transfer(300)
// Trasferito un importo di [300] all'indirizzo [investments]

Sondaggio e Doppio Getter

ATTACCO

Supponi di essere un utente malintenzionato dello script. Vuoi inviare una somma di denaro a un address non incluso in validAddresses.

Un attacco frontale è ovviamente bloccato.

Connector({ address: 'malicious' })
// L'indirizzo malicious non è valido. Quelli validi sono: partner-account, investments, mutual

Ricorda, mentre impersoni l’hacker non sei a conoscenza dell’implementazione del codice!

È possibile inviare un address valido e contare il numero di volte in cui vi si accede. In questo modo puoi capire quando è il momento giusto per - ZAC! - trasformarlo nell’indirizzo malicious!

Costruisci una sonda:

let i = 0
const probe = {
  get address() {
    console.count('sonda')
    return 'investments'
  },
}

const c = Connector(probe)
// sonda: 1
// sonda: 2
// sonda: 3
// sonda: 4
// Connessione all'indirizzo [investments] stabilita

c.transfer(300)
// sonda: 5

È chiaro. Basta cambiare la quinta lettura di address; la sua validità è verificata nelle quattro letture precedenti. È possibile farlo usando la tecnica del Doppio Getter.

let i = 0
const doubleGetter = {
  get address() {
    if (++i === 5) return 'malicious'
    return 'investments'
  },
}

const c = Connector(doubleGetter)
// Connessione all'indirizzo [investments] stabilita

c.transfer(300)
// Trasferito un importo di [300] all'indirizzo [malicious]

Grazie a questa tecnica hai effettivamente bypassato le guardie della fase di inizializzazione.

DIFESA

Il problema è che si accede ripetutamente ad address. Anche due volte sarebbero troppe. Ma se fosse solo una, i Doppi Getter non potrebbero ingannare le guardie.

Per accedere ad address una sola volta, basta copiarlo in una variabile. Poiché è una string, è un primitivo - la nuova variabile è una copia separata, senza il getter.

In ES6 puoi usare il destructuring:

function Connector({ address }) { ... }

Esegui la sonda e vedrai che effettivamente suona una sola volta. La minaccia del Doppio Getter è neutralizzata.


Corruzione del Prototipo

ATTACCO

Devi trovare un modo per infiltrarti nel codice. Ma hanno alzato le mura - abbiamo bisogno di un infiltrato, qualcuno dall’interno che per un momento, solo un momento, finga di non vedere.

La funzione includes è il tuo uomo. Corromperla è semplice:

const includesBackup = Array.prototype.includes

// corrompila...
Array.prototype.includes = () => true

const c = Connector({ address: 'malicious' })
// Connessione all'indirizzo [malicious] stabilita

// ...e subito dopo tutto torna alla normalità
Array.prototype.includes = includesBackup

c.transfer(300)
// Trasferito un importo di [300] all'indirizzo [malicious]

Solo durante la fase di inizializzazione, includes restituirà true indiscriminatamente. La guardia validAddresses.includes(address) è di fatto accecata e l’indirizzo malicious può entrare arrogantemente dalla porta principale.

DIFESA

Si tira su un muro intorno a Connector, ovvero un block scope. All’interno di questo, vuoi avere la tua copia di Array.prototype.includes che non sia corruttibile dall’esterno e usare solo quella.

{
  const safeIncludes = Array.prototype.includes

  function Connector({ address }) {
    const validAddresses = ['partner-account', 'investments', 'mutual']

    ...

    const isValidAddress = safeIncludes.bind(validAddresses)
    if (!isValidAddress(address)) _err2(address, validAddresses)

    ...
  }

  global.Connector = Connector // window se nel browser
}

Lo stesso trucco che abbiamo usato prima questa volta non funzionerà e verrà lanciato l’errore _err2.

ATTACCO

Con un po’ di astuzia è possibile corrompere il supervisore di includes. Si tratta di bind. Consiglio di tenere una copia della funzione corrotta per rimettere le cose a posto non appena commesso il reato.

const includesBackup = Array.prototype.includes
const bindBackup = Function.prototype.bind

Array.prototype.includes = () => true
Function.prototype.bind = () => () => true

const c = Connector({ address: 'malicious' })
// Connessione all'indirizzo [malicious] stabilita

Array.prototype.includes = includesBackup
Function.prototype.bind = bindBackup

c.transfer(300)
// Trasferito un importo di [300] all'indirizzo [malicious]

Ancora una volta, sei riuscito a eludere le guardie.


Illusione Primitiva

L’istanza di Connector fornisce il metodo transfer. Questo richiede l’argomento amount che è un numero e, affinché il trasferimento vada a buon fine, non deve superare il valore 500. Supponiamo che io sia già riuscito a stabilire un contatto con un address a mia scelta. A questo punto voglio trasferire un importo superiore a quello consentito.

// Connector#transfer
function transfer(amount) {
  if (!amount || amount <= 0) _err3()

  if (amount > 500) _err4()

  console.info(
    `Trasferito un importo di [${amount}] all'indirizzo [${options.address}]`
  )
}

La tecnica dell’Illusione Primitiva ottiene un effetto simile al Doppio Getter ma in altri modi. Un limite della tecnica del DG è infatti quello di essere applicabile solo a variabili passate per riferimento. Prova a implementarla per un primitivo - Number per esempio.

Trovo più funzionale modificare Number.prototype.valueOf 🔗. Questo è un metodo che probabilmente non avrai mai bisogno di chiamare direttamente. JavaScript stesso lo invoca quando ha bisogno di recuperare il valore primitivo di un oggetto (in questo caso, un Number). L’intuizione è più immediata con un esempio:

Number.prototype.valueOf = () => {
  console.count('sonda')
  return this
}

this nel caso di Number rappresenta lo stesso numero passato nel costruttore.

Probabilmente l’hai riconosciuta, è una sonda. Provi diverse operazioni su un’istanza di Number:

const number = new Number(42)

console.log(number)
// [Number: 42]

console.log(+number)
// sonda: 1
// 42

console.log(number > 0)
// sonda: 2
// true

Come intuisci al volo, il metodo valueOf viene invocato quando ci si aspetta un valore primitivo - come nel caso di un’operazione matematica. A questo punto non resta che inserire la sonda nel metodo transfer.

c.transfer(number)
// sonda: 1
// sonda: 2
// Trasferito un importo di [42] all'indirizzo [hacker-address]

I due log della sonda corrispondono precisamente a amount <= 0 e amount > 500. A questo punto ti rendi conto che non hai bisogno di scambiare il valore con un altro a un certo punto - devi solo restituire un valore che soddisfi le condizioni di cui sopra quando valueOf viene chiamato.

Number.prototype.valueOf = () => 1
const number = new Number(100000)

c.transfer(number)
// Trasferito un importo di [100000] all'indirizzo [hacker-address]

Ancora una volta, sei riuscito a ottenere quello che volevi.