Una serie di errori di progettazione nell’app di ATM Milano permetteva a chiunque di accedere ai dati e ai biglietti degli utenti semplicemente conoscendone l’indirizzo e-mail. Nel frattempo, alcune apparenti misure di sicurezza rendevano le vulnerabilità meno facili da individuare e da sfruttare.
Un po’ di contesto
L’app di ATM, disponibile sia per Android1 sia per iOS2, fornisce diverse funzionalità utili per chi usufruisce del servizio di trasporti pubblici di Milano. Per esempio consente di cercare le fermate dei mezzi e di visualizzare il tempo di attesa stimato; ricevere notifiche sulla situazione del traffico; calcolare un itinerario tra due punti di interesse. Ma consente anche di acquistare biglietti utilizzabili su tutta la rete dei trasporti milanese pagando via SMS, con carta di credito o tramite PayPal.
I motivi che hanno portato la mia attenzione su quest’app sono piuttosto semplici. Essendo un cliente di ATM, volevo estrapolare e documentare l’API per usufruire delle stesse funzioni dell’app su piattaforme diverse da quelle previste. Tanto per dare un’idea, mi sarebbe piaciuto visualizzare in un widget sul desktop del mio computer il tempo di attesa del tram che passa fuori da casa mia.
Analisi del traffico
Come sempre il mio reverse engineering comincia configurando un proxy HTTPS sullo smartphone su cui è installata l’app per dare una sbirciata al traffico. Spesso in questo modo si ottengono tutte le informazioni che si desiderano e non serve addentrarsi ulteriormente nel funzionamento dell’app. Sembrava proprio questo il caso: considerando che l’app non dava nemmeno segni della presenza di certificate pinning3, contavo di finire il mio lavoro in pochi minuti.
Le richieste prodotte durante il login nell’area riservata dell’app, da cui è possibile acquistare i biglietti, hanno invece attirato la mia attenzione più del previsto. Vediamole insieme.
Richiesta di login
Innanzitutto l’app invia le credenziali al backend, come ci si aspetterebbe.
POST /v2/it/Membership/ValidateUser HTTP/1.1
ContentType: application/json;charset=utf-8
Timestamp: Tue, 12 Jun 2018 19:17:42 GMT
User-Agent: Android/6.0.1 (Nexus 5) it.atm.appmobile/3.4
Authentication: ATMApp:5rWm4ttLe4NrB/LbglTFz6+DaQrJUwcHMN2901Caz+s=
Content-Type: application/json; charset=utf-8
Content-Length: 60
Host: atm-be.sg.engitel.com
Connection: Keep-Alive
Accept-Encoding: gzip
{
"username": "[email protected]",
"password": "miapassword"
}
Il server risponde:
HTTP/1.1 200 OK
Cache-Control: no-cache
Pragma: no-cache
Content-Length: 1
Content-Type: application/json; charset=utf-8
Expires: -1
Server: Microsoft-IIS/8.5
Request-Context: appId=cid-v1:815dcd44-dd4b-45ce-b0e0-cc8a66a1f0c5
X-AspNet-Version: 4.0.30319
X-Powered-By: ASP.NET
Date: Tue, 12 Jun 2018 19:17:43 GMT
0
Fin qui non c’è quasi nulla di strano. Dico “quasi” perché nonostante a prima vista sembri tutto in ordine, i lettori più attenti si accorgeranno che nella risposta del server manca qualcosa.
Qualcuno potrebbe anche chiedersi cosa sia quel singolo 0
che troviamo nel corpo della risposta. Possiamo scoprirlo andando per tentativi e ripetendo il login dall’app con credenziali diverse.
Ecco il risultato:
0
è il valore che viene restituito quando il login è completato con successo (come nel caso sopra).1
viene restituito quando l’username inserito non esiste.4
viene restituito quando l’username inserito esiste ma la password non è corretta.
Il fatto di distinguere il caso in cui un username non esista da quello in cui la password non sia corretta costituisce una scelta progettuale abbastanza curiosa, che sicuramente avrà i suoi motivi. Il fatto che si tratti di una caratteristica intenzionale è confermato dall’interfaccia grafica dell’app, che mostra due messaggi di errore diversi nei due casi. Ma andiamo avanti.
Richiesta del wallet
Ora che siamo loggati l’app chiede al server il nostro wallet, ovvero la lista dei biglietti che abbiamo acquistato.
GET /v2/it/ticketing/wallet HTTP/1.1
ContentType: application/json;charset=utf-8
Timestamp: Tue, 12 Jun 2018 19:17:43 GMT
User-Agent: Android/6.0.1 (Nexus 5) it.atm.appmobile/3.4
Authentication: [email protected]:IlwR5JE3QqBG0WUF8GaergNFC1K9kjZSZZXeFUDdK5U=
Host: atm-be.sg.engitel.com
Connection: Keep-Alive
Accept-Encoding: gzip
Nel momento in cui ho registrato questa richiesta il mio portafoglio conteneva solo un biglietto urbano non ancora utilizzato.
HTTP/1.1 200 OK
Cache-Control: no-cache
Pragma: no-cache
Content-Length: 951
Content-Type: application/json; charset=utf-8
Expires: -1
Server: Microsoft-IIS/8.5
Request-Context: appId=cid-v1:815dcd44-dd4b-45ce-b0e0-cc8a66a1f0c5
X-AspNet-Version: 4.0.30319
X-Powered-By: ASP.NET
Date: Tue, 12 Jun 2018 19:43:44 GMT
[
{
"MobileTicketId": "PXSL9GUUH",
"ValidationTimeStamp": "1997-01-01T00:00:00",
"ExpirationTimeStamp": null,
"QrCodeData": null,
"ValidationGuid": null,
"Description_ENG": null,
"Description_IT": null,
"Duration": 90,
"Price": 1.5,
"TariffId": 2095,
"MaxValidationsAllowed": 1,
"Description": "Biglietto Singolo Urbano",
"Instruction": "Biglietto valido per un viaggio della durata di 90 minuti sia sui mezzi ATM che sulle tratte urbane della rete ferroviaria di Trenord, Passante Ferroviario compreso. Convalida sempre prima di accedere ai mezzi. Usa il QR code per entrare in metropolitana dai tornelli segnalati, oppure stampa il biglietto ai distributori automatici usando il PNR. Biglietto valido per una sola corsa in metropolitana. Per accedere ai tratti urbani di Trenord è sempre necessario stampare il titolo di viaggio. Non valido per RHO Fieramilano",
"DurationDescription": "90'",
"Duration_IT": null,
"Duration_ENG": null,
"Instruction_IT": null,
"Instruction_ENG": null
}
]
Un header, un sospetto, una vulnerabilità
Se finora non avete notato alcuna mancanza, e se menzionare i cookie ancora non vi fa suonare un campanello d’allarme, riguardate le richieste chiedendovi come venga mantenuta una sessione tra le varie richieste stateless. Oppure, più esplicitamente: come fa il server ad assicurarsi che gli utenti possano richiedere solo il proprio wallet?
Considerando che in nessuna richiesta c’è traccia di un cookie o di qualche altro tipo di token che identifichi una sessione, l’unica parvenza di sicurezza sembra essere nell’header Authentication
che l’app invia a ogni richiesta.
È facile vedere che l’header è costituito da due parti separate da due punti (:
). La prima parte contiene l’username dell’utente loggato – oppure ATMApp
se l’utente non si è ancora loggato, come si vede nella richiesta di login. La seconda parte contiene dei dati codificati in base64 che cambiano a ogni richiesta.
Pur non conoscendo il significato dei dati nella seconda parte dell’header, una domanda sorge spontanea: cosa succede se modifichiamo la richiesta (per esempio usando gli strumenti del proxy) sostituendo il nostro username con quello di qualcun altro?
Ho deciso di provarci. Ho spiegato a un mio collega costa stavo facendo; gli ho chiesto di comprare un biglietto con il suo account e mi sono fatto dare l’indirizzo e-mail con cui era registrato. Ho configurato il proxy per fare un rewrite dell’header Authorization
e ho ripetuto la richiesta del wallet sostituendo il mio indirizzo e-mail con il suo.
Incredibilmente, il server ha eseguito senza lamentarsi e mi ha restituito il wallet del mio collega, con il biglietto appena acquistato. Notate il MobileTicketId
(o PNR4) del biglietto diverso dal mio.
HTTP/1.1 200 OK
Cache-Control: no-cache
Pragma: no-cache
Content-Length: 951
Content-Type: application/json; charset=utf-8
Expires: -1
Server: Microsoft-IIS/8.5
Request-Context: appId=cid-v1:815dcd44-dd4b-45ce-b0e0-cc8a66a1f0c5
X-AspNet-Version: 4.0.30319
X-Powered-By: ASP.NET
Date: Tue, 12 Jun 2018 20:32:12 GMT
[
{
"MobileTicketId": "E57C41F19",
"ValidationTimeStamp": "1997-01-01T00:00:00",
"ExpirationTimeStamp": null,
"QrCodeData": null,
"ValidationGuid": null,
"Description_ENG": null,
"Description_IT": null,
"Duration": 90,
"Price": 1.5,
"TariffId": 2095,
"MaxValidationsAllowed": 1,
"Description": "Biglietto Singolo Urbano",
"Instruction": "Biglietto valido per un viaggio della durata di 90 minuti sia sui mezzi ATM che sulle tratte urbane della rete ferroviaria di Trenord, Passante Ferroviario compreso. Convalida sempre prima di accedere ai mezzi. Usa il QR code per entrare in metropolitana dai tornelli segnalati, oppure stampa il biglietto ai distributori automatici usando il PNR. Biglietto valido per una sola corsa in metropolitana. Per accedere ai tratti urbani di Trenord è sempre necessario stampare il titolo di viaggio. Non valido per RHO Fieramilano",
"DurationDescription": "90'",
"Duration_IT": null,
"Duration_ENG": null,
"Instruction_IT": null,
"Instruction_ENG": null
}
]
A questo punto ho deciso che la cosa andava approfondita studiando anche il significato della seconda parte dell’header Authorization
.
Garanzie di integrità?
Innanzitutto mi sono accorto che la lunghezza dei dati è di 256 bit, fissa, cosa che mi ha fatto subito pensare a un hash SHA-256. Poi, notando che tra gli header inviati dall’app c’è anche un Timestamp
, ho riconosciuto il pattern dell’autenticazione tramite HMAC5. Semplificando, alcune parti critiche della richiesta vengono concatenate con il timestamp attuale e con una chiave segreta condivisa tra client e server. Un hash del risultato viene quindi inviato al server insieme con la richiesta.
Il server, prima di elaborarla, può concatenare gli stessi elementi e calcolare lo stesso hash. Se l’hash corrisponde a quello inviato dal client, l’integrità dei dati coinvolti è garantita. Se modifichiamo le parti critiche della richiesta il server otterrà un hash diverso da quello fornito dal client, rileverà la manomissione e rifiuterà la richiesta. Il timestamp entra in gioco per evitare che una richiesta possa essere ripetuta a distanza di un certo numero di minuti (o secondi).
Purtroppo non esiste uno standard che definisca le parti critiche della richiesta da includere nel calcolo dell’HMAC. Per ora sappiamo solo che l’username dell’utente che ha effettuato l’accesso sicuramente non è incluso, altrimenti il tentativo di ottenere i biglietti del mio collega non sarebbe andato a buon fine. Per indagare più in profondità dobbiamo decompilare l’applicazione.
Dentro l’app
Estrarre un’app per smartphone, e in particolare un’app per Android, è semplice quanto decomprimere un file zip. Normalmente userei un tool come jadx6 per automatizzare l’estrazione del file apk e la decompilazione dell’eseguibile, ottenendo una buona approssimazione del codice sorgente dell’app in Java.
In questo caso però una rapida occhiata al contenuto del file apk rivela qualcosa di interessante:
it.atm.appmobile.apk
├── assemblies
│ ├── ATM.dll
│ ├── Microsoft.AppCenter.dll
│ ├── Microsoft.CSharp.dll
│ ├── Mono.Android.dll
│ ├── Mono.Security.dll
│ ├── Xamarin.Android.Arch.Core.Common.dll
│ ├── Xamarin.Android.Support.v4.dll
...
Cosa ci fanno dei file DLL in un’app per Android? La spiegazione è semplice: l’app è realizzata con Xamarin7, un framework di Microsoft che consente di realizzare app cross-platform scritte in C#. Questo significa che i file DLL che abbiamo trovato contengono l’intera logica dell’applicazione, mentre l’eseguibile dell’app si limita a costruire l’ambiente di runtime di Microsoft .NET.
Questa, in realtà, è un’ottima notizia. Gli eseguibili e le librerie prodotte con Microsoft .NET sono notoriamente facili da decompilare, e il codice sorgente che si ottiene spesso è talmente fedele all’originale da poter essere ricompilato. Inoltre esiste una vasta gamma di software tra cui scegliere, da dotPeek8 a dnSpy9. È sufficiente importare tutti i DLL e il decompilatore fa il resto, sfornando codice estremamente leggibile e fedele all’originale.
Niente più segreti
Curiosando tra i sorgenti ricostruiti, troviamo facilmente il codice che cercavamo.
Ecco la funzione che concatena i parametri della richiesta da includere nel calcolo dell’HMAC. I commenti sono stati aggiunti da me.
public string Autentication {
get {
return string.Format("{0}\n{1}\n{2}\n{3}", new object[4] {
(object) this.Method, // Il metodo della richiesta
(object) this.DateUTC, // Data e ora attuali
(object) this.UriAction.ToLower(), // Il path della richiesta
(object) this.Parameters // La query string
});
}
}
E qui c’è la funzione che costruisce l’hash, prendendo come parametri la chiave “segreta” condivisa tra client e server e la stringa prodotta con la funzione sopra:
public static string ComputeHash(string hashedPassword, string message) {
UTF8Encoding utF8Encoding = new UTF8Encoding();
HMACSHA256 hmacshA256 = new HMACSHA256(utF8Encoding.GetBytes(hashedPassword.ToUpper()))
str = Convert.ToBase64String(hmacshA256.ComputeHash(utF8Encoding.GetBytes(message)));
return str;
}
Ho messo “segreta” tra virgolette perché la chiave che prende parte al calcolo dell’hash è hard-codata nei sorgenti dell’applicazione.
private const string AuthenticationHeaderName = "Authentication";
private const string TimestampHeaderName = "Timestamp";
private const string ConsumerKey = "ATMApp";
private const string ConsumerSecret = "jn2ic5az"; // Segreto condiviso
Vorrei sottolineare che in ogni caso non c’è nessun modo di salvare una chiave segreta lato client in modo sicuro. Hard-codarla non è una scelta sbagliata di per sé; anzi, metterla altrove o cercare di offuscarla non avrebbe reso l’app più sicura. Avrebbe solo reso più difficile il mio lavoro.
Con queste informazioni anche l’unico mistero che rimaneva è risolto. Siamo effettivamente di fronte a un HMAC-SHA256 e tra l’altro ora conosciamo tutti gli ingredienti necessari per generare l’hash a partire da una richiesta qualsiasi. Questo significa che possiamo costruire nuove richieste personalizzandone ogni loro parte (mentre prima l’HMAC sconosciuto ci impediva di smanettare con il percorso o con la query string). A prova di ciò ho realizzato un proof of concept che troverete in fondo al post.
A questo punto possiamo tirare le somme sulle vulnerabilità trovate.
Le vulnerabilità
L’unico meccanismo di sicurezza è un HMAC
Come abbiamo visto l’app non tiene traccia di una sessione con il server usando i cookie, come si è soliti fare; implementa invece una sola funzione di sicurezza basata su un “segreto” condiviso. Dato che non è possibile custodire un segreto lato client in maniera sicura, anche l’integrità che un HMAC dovrebbe fornire non è garantita. Un utente malintenzionato potrebbe estrarre la chiave segreta e usarla per firmare richieste personalizzate, proprio come abbiamo fatto noi.
L’implementazione dell’HMAC non è sufficientemente sicura
I parametri della richiesta che prendono parte alla generazione dell’HMAC sono:
- metodo della richiesta (
GET
,POST
, ecc) - timestamp attuale
- path della richiesta (per esempio
/v2/it/ticketing/wallet
) - query string
Siccome il nome dell’utente eventualmente autenticato non è tra questi parametri, anche se la prima vulnerabilità non esistesse sarebbe comunque possibile alterare le richieste per ottenere i dati e i biglietti di altri utenti, altra cosa che abbiamo già fatto.
Dato che con i biglietti il server restituisce anche i relativi PNR, sarebbe possibile per un malintenzionato “riscuotere” i biglietti di un altro utente semplicemente digitandone il codice in un distributore automatico. Allo stesso modo sarebbe possibile abusare dell’API per convalidare il biglietto e ottenere direttamente il codice QR da usare ai tornelli.
Si potrebbe aggiungere anche il fatto che nemmeno il corpo delle richieste POST partecipa alla generazione dell’HMAC, lasciando anche altri tipi di richieste senza un adeguato livello di protezione.
La soluzione
Le vulnerabilità delineate qui sono state risolte da ATM grazie all’uso di JWT10. Nelle versioni dell’app attualmente disponibili, l’header Authentication
per gli utenti che hanno effettuato l’accesso non contiene più l’username in chiaro accostato all’HMAC. Invece, l’username è codificato in un token firmato.
GET /v3/it/ticketing/wallet HTTP/1.1
Host: atm-be.sg.engitel.com
Connection: keep-alive
Authentication: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImphY29wby5qQGVzZW1waW8uY29tIiwiaXNzIjoiQVRNQkUiLCJhdWQiOiJBVE1BcHAiLCJleHAiOjE1OTk5OTA4MzUsIm5iZiI6MTU5NzM5ODgzNX0=.lLAe3C6UJY3RvNlBrJH3zb5ra70JoILcx_LbeI2bivQ:RmbFP5ZQ5vOwX24DehXTb5iyGRMVQfFr/ueeVRD4jOs=
Accept: */*
ContentType: application/json;charset=utf-8
User-Agent: iOS/14.0 (iPhone) it.atm.iATMMilano/7.4.1
Timestamp: Mon, 17 Aug 2020 13:39:39 GMT
Accept-Language: it-it
Accept-Encoding: gzip, deflate, br
Non entrerò nei dettagli delle specifiche di JWT in questa sede. Mi limiterò a notare che il token è costituito da tre parti separate da un punto, e che ogni parte è codificata in base64. La prima parte contiene informazioni sull’algoritmo usato per la firma:
{
"typ": "JWT",
"alg": "HS256"
}
Mentre la seconda contiene l’username e alcune informazioni che determinano la validità del token:
{
"username": "[email protected]",
"iss": "ATMBE",
"aud": "ATMApp",
"exp": 1599990835,
"nbf": 1597398835
}
La terza parte è invece una firma delle prime due; in questo caso si tratta ancora una volta di HMAC-SHA256. Ma allora cos’è cambiato rispetto a prima? Perché questo approccio dovrebbe essere più sicuro? La risposta risiede nel fatto che è il server a restituire il token firmato nel momento in cui il client esegue il login.
Il client non ha quindi alcuna conoscenza del segreto usato per la firma; non si tratta più di un segreto condiviso, ma di un segreto noto solo lato server. Diventa così impossibile estrapolarlo e usarlo per firmare richieste personalizzate.
Anche senza introdurre l’uso dei cookie, mantenendo quindi un API completamente stateless, è stata introdotta una contromisura efficace per impedire agli utenti di impersonare qualcun altro. L’HMAC nella seconda parte dell’header Authorization
è stato mantenuto, ma ora non ha più la pretesa di proteggere da questo tipo di abusi.
Timeline della responsible disclosure
- 27 maggio 2018: scopro le vulnerabilità e invio una segnalazione ad ATM.
- 29 maggio 2018: vengo contattato da ATM; illustro i problemi da sottoporre agli sviluppatori dell’applicazione.
- 14 giugno 2018: invio ad ATM un proof of concept che dimostra la possibilità di “rubare” i biglietti agli altri utenti.
- 11 luglio 2018: incontro i dirigenti della divisione Sistemi Informativi di ATM presso la loro sede per discutere ulteriormente delle vulnerabilità.
- Durante l’autunno 2018 ATM rilascia un aggiornamento dell’applicazione che risolve le vulnerabilità trovate. Gli endpoint vulnerabili lato server vengono però lasciati in esecuzione per retrocompatibilità con le vecchie versioni dell’app.
- Durante l’estate 2020 gli endpoint vulnerabili vengono rimossi.
- 11 agosto 2020: accerto che le vulnerabilità non sono più presenti e che il proof of concept che avevo realizzato non è più funzionante.
- 18 agosto 2020: pubblico questo post.
Il codice del proof of concept
Insieme a questo post rendo disponibile su GitHub il codice sorgente del proof of concept che ho realizzato per ATM. Si tratta di una singola pagina in PHP che permetteva di sfruttare le vulnerabilità trovate per mostrare la lista dei biglietti di un determinato utente conoscendone solo l’indirizzo e-mail. Chiaramente ora che le vulnerabilità sono state risolte, la pagina non è più funzionante. Ritengo però che possa essere interessante per chiunque voglia curiosare in maniera un po’ più ravvicinata.
-
https://play.google.com/store/apps/details?id=it.atm.appmobile ↩︎
-
https://apps.apple.com/it/app/atm-milano-official-app/id415637297 ↩︎
-
Il certificate pinning, o public key pinning, aggiunge un blando livello di protezione impedendo a eventuali proxy di decodificare le richieste effettuate al backend. Dettagli ↩︎
-
Passenger Name Record, costituisce il codice univoco che identifica un determinato titolo di viaggio sulla rete di trasporti. ↩︎