Analiza pe 3D Secure protocol de la Banca Transilvania (Varianta cu OTP ACS2)

De prin 1 ianuarie anul acesta cei de la Banca Transilvania au mai adaugat un pas de security la 3D Secure pentru platile online ce folosesc cod de OTP. Pasul consta in alegerea unei parole unice ce va fi folosita pentru tranzactiile viitoare.

Daca tot eram prin preajma am zis sa stau putin sa ma uit ce se intampla defapt in spatele acelui popup care apare de fiecare data cand folosim un card online si cum suntem protejati in cazul in care ne pierdem cardul, ori cineva ne preia datele de pe card si incearca sa isi plateasca abonamentul de Netflix. Prima cu pierderea e rezolvabila ca se poate bloca rapid cardul, dar in cazul in care cineva a copiat datele inclusiv CVV-ul, are destul timp la dispozitie sa incerce sa incerce modalitati prin care sa realizeze tranzactii.

Vreau in urmatoarele randuri sa prezint ceea ce ofera cei de la BT pe partea de securitate in cadrul protocolui 3D Secure. Cu lucrurile bune cat si cu cele rele privite din perspectiva mea. Am luat ca exemplu un cont neconectat la nicio aplicatie, ce foloseste doar coduri otp primite pe telefon si extra pasul cu parola unica(introdus de la inceputul anului).

Ecranul 3D Secure de la BT arata cam asa, in cadrul primului pas:

*Aceasta e varianta de 3D secure API apelata prin /acs2 de la BT. Din cate am reusit sa imi dau seama exista varianta de API /acs care foloseste un singur pas folosita de unele companii.

Prea multe detalii nu se pot prelua din acest ecran deoarece ei au mascat numarul de telefon lasand doar ultimele 3 cifre, care pot ajuta doar detinatorul. Numarul de card nu stiu de ce e mascat pentru ca a fost introdus inainte de a ajunge aici. Mai avem un input unde trebuie introdus codul primit pe telefon, butonul pentru continuarea spre pasul 2(cel care a fost introdus nou), si butonul pentru retrimiterea codului.

Privind acest ecran multe lucruri nu se pot face, ori ai telefonul ori nu-l ai. Dar aici din punct de vedere al securitatii, lucrurile principale sunt:

  1. cel mai important e sa existe un interval de timp in care sa se poata introduce codul, iar posibilitatea de retrimitere sa fie dezactivata
  2. exista un anumit threshold pentru coduri succesive gresite, in sensul in care dupa x incercari ceva sa se intample cu aceasta pagina incat sa nu mai poata fi introduse altele(bruteforcing)
  3. sa nu se dezvaluie numarul de cifre necesare, adica sa nu existe niciun declansator legat de acel input.
  4. posibilitatea de retrimitere a codului sa fie limitata de acelasi threshold folosit pentru incercari succesive gresite, ori de alt threshold

Daca le luam pe rand concluziile sunt asa:

  • Intervalul de timp nu pare sa existe(front end side) ceea ce dupa parerea mea este o mare bresa de securitate deoarece ofera timp pretios unui atacator sa preia pagina in cazul in care spre exemplu detinatorul isi lasa laptopul deschis
function countdown(time) {

        var seconds = time;
        function secondPassed() {
                seconds--;
                var minutes = parseInt(seconds / 60);
                if (minutes < 10) {
                        minutes = "0" + parseInt(minutes);
                }
                var remainingSeconds = parseInt(seconds % 60);
                if (remainingSeconds < 10) {
                        remainingSeconds = "0" + parseInt(remainingSeconds);
                }
                if (parseInt(seconds) <= 0) {
                        clearInterval(countdownTimer);
                        document.getElementById("submitPasswordButton").click();
                } else {
                        document.getElementById('countdownMin').value = minutes;
                        document.getElementById('countdownSec').value = remainingSeconds;
                }
        }
        var countdownTimer = setInterval(function () {
                secondPassed();
        }, 1000);
        };

countDownTimer face un callback pe functia secondPassed cu un time interval definit de 1000s -> 10minute. Practic pe branchul de else ar trebui sa fie afisat un element/loader cand seconds >=0

Cu toate ca in spatele acelei pagini ceva ar trebui sa ruleze un countDown insa pe elementul submitPasswordButton pe care ar trebui declansata o actiune cand secundele ajung sub 0, nu pare sa existe acel element nicarieri in pagina. Exista un submitButton care e continuarea spre pasul doi, fara nicio legatura. Totusi, intervalul e cumva definit undeva la ~10minute pentru ca dupa aceea la submit se inchide fortat screen-ul la urmatoare request-uri.

Dar problema e ca logica nu e buna pe linia:

document.getElementById('countdownMin').value = minutes;
elementul nu e definit nicaieri
  • Pe partea de threshold de coduri invalide exista definita o functie ce dupa 5 incercari trece butonul de submit pe disabled, altfel il lasa enabled.
window.onload = function() {
            
                    var btn = document.getElementById('resendButton');
                    var max = 5;
                    btn.value = 'Vei putea retrimite parola peste ' + max + 's.';
                    var timerId = setInterval(function() {
                        btn.value = btn.value.replace(/(\d+)/g, --max)
                    }, 1000);

                    setTimeout(function() {
                        clearInterval(timerId);
                        btn.value = 'Retrimite parola';
                        btn.disabled = false;
                    }, max*1000);
                    
        };

Daca disecam putin ideea din spatele acestei functii ce se executa la incarcarea paginii observam o valoare max definita pe 5 si o variabila timerId care apare cu valoarea –max timp de 1 secunda pe elementul resendButton. Dupa care se executa setTimeout ce reseteaza intervalul definit mai sus in timerId si pune pe element alt text, schimbandu-i si proprietatea.

Ce se primeste la a 6-a incercare e dezactivarea butonului de resend. Dar attempts-urile sunt definite undeva pe server.

window.onload = function() {
            
                    var btn = document.getElementById('resendButton');
                    btn.disabled = true;
                
        };

Ceea ce am mai observat interesant este ca dupa 5 attempt-uri ori la timeout acel card nu mai are acces la are attempt-uri gresite in primul pas, va primi intotdeauna redirect inapoi din gateway-ul cu plata ce primeste ca response pe /otp un template interesant:

<noscript>
    <div>Click the "SUBMIT" button to continue.</div>
</noscript>

<div id="mainContainer" style="width: 100%; vertical-align: middle; text-align: center;">
    <div id="formContainer" class="formContainer" style="margin: 0 auto;">
        <form id="autoSubmitForm" name="autoSubmitForm" method="post" action="https://www.secure7gw.ro/tdsv2/notify/"
              enctype="application/x-www-form-urlencoded">
            <input type="hidden" name="threeDSSessionData" value="NjI3MTkwOTktMERGODM3OUJCRDA0OTI2Mw|https://www.secure11gw.ro/portal/cgi-bin/reply.php?ttype=is171|https://172.20.1.171/cgi-bin/cgi_link"/>
            <input id="cres" type="hidden" name="cres" value="eyJ0aHJlZURTU2VydmVyVHJhbnNJRCI6Ijg4ZWE2MzVhLTYzY2YtNDQzYy05NWE0LTA4ZTRlNDVmOTEwOCIsImFjc1RyYW5zSUQiOiJkNmRkM2Y5OS02MjQ2LTQzN2ItYTkwZC03YzY2MzQ2MDM5ZmYiLCJjaGFsbGVuZ2VDb21wbGV0aW9uSW5kIjoiWSIsIm1lc3NhZ2VUeXBlIjoiQ1JlcyIsIm1lc3NhZ2VWZXJzaW9uIjoiMi4xLjAiLCJ0cmFuc1N0YXR1cyI6Ik4ifQ=="/>
            <noscript>
                <div id="submitButtonContainer" class="submitButtonContainer">
                    <input id="submitButton" name="submitButton" class="submitButton" type="submit" value="SUBMIT"/>
                </div>
            </noscript>
            <br/>
        </form>
    </div>
</div>

<script type="text/javascript">
    document.getElementById('autoSubmitForm').submit();
</script>

Un button ce se executa prin script cu submit si trimite informatiile de pe cres.value() spre procesator, ce invalideaza plata. E o cheie JWT care ascunde informatiile direct in header, ceea ce nu e foarte ok pentru ca se poate forja cu alte date.

{
  "threeDSServerTransID": "88ea635a-63cf-443c-95a4-08e4e45f9108",
  "acsTransID": "d6dd3f99-6246-437b-a90d-7c66346039ff",
  "challengeCompletionInd": "Y",
  "messageType": "CRes",
  "messageVersion": "2.1.0",
  "transStatus": "N"
}

Ideea asta nu e buna deoarece in momentul in care un card atinge maximum de incercari gresite pe cod el trebuie sa fie blocat ori detinatorul sa fie anuntat. Nu e destul doar sa se faca trigger la un redirect si la un cod corect sa il lase in step 2. Codurile inca se pot forja si dupa 5, ele atat ca activeaza un redirect de care am mentionat mai sus, iar cand se nimereste un cod bun se face request la pasul 2.

Tot un aspect ce merita mentionat aici este ca in urma acestui research s-a urmarit si daca exista un interval de timp dincolo de acest redirect atunci cand pe acel numar de card s-a atins thresholdul. Adica daca exista cumva exista o resetare in spate. El in urmatoarele incercari de a valida tranzactia va declansa un redirect de care am mentionat un paragraf mai sus, iar ce s-a observat este ca aceasta logica de blocare dureaza undeva la ~24h. Dupa intervalul acesta counterul se reseteaza, reinitializand numarul de incercari de gresite pe acel card la starea initiala, 0.

  • Numarul de cifre necesare sunt dezvaluite pentru ca atunci cand length-ul de pe elementul passwordEdit atinge 5, submitButton devine enabled. Ceea ce inseamna ca atatea sunt necesare si dupa cum am analizat mai multe retrimiteri niciodata nu vin mai putin de 5, ar fi fost faina smecheria sa nu fie strict 5 ci regula sa fie <=5 ori ca sa nu fie o legatura intre input si submitButton care sa dea de gol marimea vectorului.
function passwordChanged() {
		var pwdEdit = document.getElementById('passwordEdit');
		var submitBtn = document.getElementById('submitButton');
		if (pwdEdit.value.length == 5)
			submitBtn.disabled=false;
		else
			submitBtn.disabled=true;
	}
  • Posibilitatea de retrimitere a codului e limitata la 5 incercari, la fel ca si pentru coduri invalide. Nu exista nicio legatura care sa il reseteze pe unul dintre ele, ex: incercare gresita de cod + reset si sa se reseteze cumva incercarile gresite pe cod. Ceea ce ar fi fost o posibilitate de bresa deoarece daca resend-ul reseta threshold-ul de coduri ar fi insemnat ca puteau fi introduse 20 de coduri. Mergand pana la 4 gresite, resetand, mergand pana la 4 gresite si tot asa de 5 ori pana cand cel de resend se bloca.

Bun, acum legat de ce se intampla la diferite actiuni din aceasta pagina.

Se face un POST cu toate detaliile introduse in pagina de confirmare a cardului, numar, CVC, data expirare spre secure11gw.ro/portal/cgi-bin/ccprocess.php . Asta e gateway-ul folosit de aceasta platforma de plata.

CARD: xxxx xxxx xxxx xxxx
CardLength: 19
EXP: xx
EXP_YEAR: xx
CVC2: xxx
CVC2_RC: 1
display_suma: xxxx
AMOUNT: xxxx
CURRENCY: RON
MERCH_NAME: XIxxx xxxxxx
RCDID: xxxxxxx
ORDER: xxxxxx
secunde: 9
TERMINAL: 6xxxxxx
TIMESTAMP: 20210214153953
FORM_ID: 57be4b3b5033e5ba286bc9c96a749xxxxxxxxx

Primul request facut in pagina dupa validarea datelor de pe card e cu un POST pe endpointul:

https://ecclients.btrl.ro/acs2/acs/api/3ds2/creqbrw
creq: eyJtZXNzYWdlVHlwZSI6IkNSZXEiLCJtZXNzYWdlVmVyc2lvbiI6IjIuMS4wIiwidGhyZWVEU1NlcnZlclRyYW5zSUQiOiJlZjk2NDYzYS1mOTg3LTRjYjMtYjc5MC03ZWY4ODM0YjdhMDkiLCJhY3NUcmFuc0lEIjoiMWJkZDZmMjMtZTQ2YS00ZTU0LWJkN2UtODA5N2U1NzlkOTlkIiwiY2hhbGxlbmdlV2luZG93U2l6ZSI6IjA1In0
threeDSSessionData: MzA5NzIyMTMtMTc4QzJBMzVEOEYyOTg2Ng|https://plati.xxxx.ro/tds2/api/v2/reply?ttype=is171|https://3ds-primary.wirecard.com/cgi-bin/cgi_link
TermUrl: v2

Ce raspunde cu un template in care e si un important element ascuns:

<input id="transactionId" type="hidden" name="transactionId" value="ef96463a-f987-4cb3-b790-7ef8834b7a09"/>

La o retrimitere a codului se declanseaza un request POST pe

https://ecclients.btrl.ro/acs2/acs/api/3ds2/challenge/resend

Cu un form data ce cuprinde transactionId si actiunea:

transactionId: 25232f54-efc7-400c-9103-8a27c980ebfd
resendButton: Retrimite+codul+SMS

Am tot stat sa ma gandesc de unde e preluat acest transactionId si mi-a picat fisa ca ar trebui sa fie chiar in pagina, iar asta s-ar dovedit adevarat(prezentat mai sus). E prezent sub forma de tip ascuns chiar sub passwordEdit si submitButton.

Acest transactionId se genereaza la intrarea in pagina si ramane acolo indiferent daca se cere un alt cod, ori daca codul introdus e gresit. Cumva ar fi fost interesant sa existe o rotatie a acestui id ori la coduri gresite ori cand se retrimite codul. Ce e important de mentionat e ca rotatia se creeaza la un refresh, se genereaza un alt transactionId. Din cate am observat e posibil un singur refresh pe pagina, nu stiu daca e voit sau exista ceva legat de asta deoarece refresh-urile au fost testate sub acel posibil interval de timp.

Dupa acel interval de care am povestit mai sus pentru un cod valid/invalid atunci cand submitButton e accesat se face un request pe urmatorul endpoint care declanseaza si incheierea sesiunii:

https://ecclients.btrl.ro/acs2/acs/api/3ds2/challenge/otp
transactionId: 25232f54-efc7-400c-9103-8a27c980ebfd
passwordEdit: 56754
submitButton: Continua spre pasul 2

Bypass pe validarea codului din primul pas -> pasul doi

Ce m-a frapat in acest screen este faptul ca am reusit sa gasesc o modalitate de a face bypass la codul primit pe acel numar de telefon. Orice vector cu dimensiunea de 5 cifre va reusi sa treaca peste acest prim pas, intrand in al doilea screen(introdus de BT destul de recent), inainte tin minte ca era doar un pas.

Mi se pare o bresa super importanta in sistemul lor si trebuie discutata intai cu ei. Practic pasul intai e acolo doar asa sa ofere un strat in plus de securitate, care defapt e praf in ochi. S-ar putea sa fie pusa ca o plasa acolo si totusi sa aiba o logica in spate, insa din punctul meu de vedere a parut o greseala, iar faptul ca acel element cu o cheie atasata nu a fost generat in cazul unui cod invalid pare sa fie o mare coincidenta (s-a rezolvat in timpul research-ului)

Concluzia momentan este ca primul screen din 3D Secure poate fi trecut fara ca atacatorul sa aiba acces la telefonul victimei pe aceasta varianta ACS2. *Momentan pare ca functioneaza doar pentru cei ce inca nu au activat o parola unica. Pentru ca in acel screen dupa ce primul pas a fost bypassed, nu exista optiunea de a selecta o parola pentru acel card. S-au incercat diferite moduri de bruteforce pe codul din acest pas, au fost cautate elemente care ar fi putut fi manipulate diferit fata de situatia in care foloseam un cod valid, dar fara succes, codul din acest al doilea pas e validat corespunzator, iar pentru invalide se face redirect pe primul pas, incrementand contorul de incercari.

screen-ul doi dupa bypass fara modalitate de a seta o parola pentru un cont ce nu are nimic pre existent

Ceea ce vedem mai sus e structura paginii din pasul doi fara a folosi un cod valid in pasul 1. Se poate observa ca aici nu exista nici un element pentru ‘Seteaza acum parola’, ori vreo cheie cu care sa se faca viitoare actiuni.

Partea de jos e tratarea pasului doi in momentul in care un cod valid a fost introdus in pasul 1, unde se poate observa ca exista o cheie in spatele acelui buton cu textul ‘Seteaza parola unica’:

<div class="links">
	<div style="display: none;text-align: center">
<b>Nu ai inca o parola unica? Seteaza acum parola pentru a confirma platile online:</b>
	</div>
	<br>
        <a class="button button_secondary" href="" target="_blank" style="display: none;text-align: center;text-decoration: none;font-size: 14px;"></a>
	<a class="button button_flat" href="" target="_blank" style="display: none;text-align: center; margin-top: -30px;"></a>
       	<a class="button button_flat" href="" target="_blank" style="display: none;text-align: center;padding-top: 10px;margin-bottom: 20px;"></a>
    </div>
screen-ul doi dupa un cod valid
<a class="button button_secondary" href="https://parolastatica.btrl.ro/auth/realms/external/login-actions/reset-credentials?key=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI0ZFdYbTZzSXZhR1FrRHdseWM2MUkzU2RYdUltdEd0RFZ2QWl4dDdQeWJvIn0.eyJleHAiOjE2MTE4NjkwMDUsImlhdCI6MTYxMTg2ODQwNSwianRpIjoiMGQ2OGQxNDctZDI4MS00YjNmLWFmODQtMTI3Y2IwZjk0OWNmIiwiaXNzIjoiaHR0cHM6Ly9wYXJvbGFzdGF0aWNhLmJ0cmwucm8vYXV0aC9yZWFsbXMvZXh0ZXJuYWwiLCJhdWQiOiJodHRwczovL3Bhcm9sYXN0YXRpY2EuYnRybC5yby9hdXRoL3JlYWxtcy9leHRlcm5hbCIsInN1YiI6ImJmN2IxMGY5LTU1ZTktNGI0Zi1iZWRkLWEwODI5NmEyMDA2ZCIsInR5cCI6InNldC1jcmVkZW50aWFscyIsImF6cCI6ImFjcy1tdGxzLXNlcnZpY2UtYWNjb3VudCIsIm5vbmNlIjoiMGQ2OGQxNDctZDI4MS00YjNmLWFmODQtMTI3Y2IwZjk0OWNmIiwiYXNpZCI6ImRmMjYzZjU4LThmMmEtNGY2Mi05MjBkLWE2NDEyMDE0MDVlMS5RMjJwTzF2Ny1uay41NWQ5OTg3NC03ZjBhLTQyMzEtYmM2ZS1lM2ZkZTQ5MmE1MzAiLCJhc2lkIjoiZGYyNjNmNTgtOGYyYS00ZjYyLTkyMGQtYTY0MTIwMTQwNWUxLlEyMnBPMXY3LW5rLjU1ZDk5ODc0LTdmMGEtNDIzMS1iYzZlLWUzZmRlNDkyYTUzMCJ9.gzqn444HTww8KCAYbA_OuJlRjbmCstV9mt1z8IuCf59ZZvLqRCV9nE7yoHJHhD-Koc43iigrSdMGPgOwv39LEPu1WN-so2mZbdGBb4lc8NNOx41ovCRRUeRr0Q1N56x4fWhuhohYN49HJRLLeEeu4K4JIpmxy5I1aZmD38uawB4QmhwoAwBU_G1lJeu1o4k_gWjFtczNnOYEhJ97mjgMV9dOyWKmEOoa8f6RPlGHpf1177CUN-KiRj77HUvxYyof7M0kMc4WBKmyodIpVpyo5j67RmStK0qB5O3SRpc4TO3fC7L_vrwlkVWRV3V6KOd5Eo9krFpHi6ouX4UFyMtiYg&execution=763218ec-e28f-4af4-aff8-87adbbd88e54&client_id=acs-mtls-service-account&tab_id=Q22pO1v7-nk" target="_blank" style="display: block;text-align: center;text-decoration: none;font-size: 14px;">Seteaza parola unica</a>

Dupa cum ziceam, varianta de element de mai sus e prezenta cand pasul 1 fost validat(otp-ul a fost valid in primul pas). Despre carnatul ala afisat acolo o sa povestesc mai jos. Cheia nu se genereaza decat la un cod valid.

Din nou repet ca e frapant pentru mine de ce nu exista absolut nicio validare pe acel cod din primul pas(varianta acs2). Iar ce se intampla in pasul doi cand unu e bypassed e un noroc chior. Pentru ca in cazul in care elementul ala prin care se seteaza o parola noua ar fi fost afisat insemna ca oricine punea mana pe card, realiza ca pasul 1 e o gluma si seta o parola unica pe tranzactiile online ce folosesc 3d secure cu otp. Ori la fel de bine putea si pasul doi sa fie o gluma, dar doar pentru cardurile fara parole setate. Pare foarte ‘din greseala’ tratata toata situatia.

Pasul doi pentru un card fara o parola unica setata si Cheia pentru setarea parolei unice

/auth/realms/external/login-actions/reset-credentials?key=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI0ZFdYbTZzSXZhR1FrRHdseWM2MUkzU2RYdUltdEd0RFZ2QWl4dDdQeWJvIn0.eyJleHAiOjE2MTE4NjkwMDUsImlhdCI6MTYxMTg2ODQwNSwianRpIjoiMGQ2OGQxNDctZDI4MS00YjNmLWFmODQtMTI3Y2IwZjk0OWNmIiwiaXNzIjoiaHR0cHM6Ly9wYXJvbGFzdGF0aWNhLmJ0cmwucm8vYXV0aC9yZWFsbXMvZXh0ZXJuYWwiLCJhdWQiOiJodHRwczovL3Bhcm9sYXN0YXRpY2EuYnRybC5yby9hdXRoL3JlYWxtcy9leHRlcm5hbCIsInN1YiI6ImJmN2IxMGY5LTU1ZTktNGI0Zi1iZWRkLWEwODI5NmEyMDA2ZCIsInR5cCI6InNldC1jcmVkZW50aWFscyIsImF6cCI6ImFjcy1tdGxzLXNlcnZpY2UtYWNjb3VudCIsIm5vbmNlIjoiMGQ2OGQxNDctZDI4MS00YjNmLWFmODQtMTI3Y2IwZjk0OWNmIiwiYXNpZCI6ImRmMjYzZjU4LThmMmEtNGY2Mi05MjBkLWE2NDEyMDE0MDVlMS5RMjJwTzF2Ny1uay41NWQ5OTg3NC03ZjBhLTQyMzEtYmM2ZS1lM2ZkZTQ5MmE1MzAiLCJhc2lkIjoiZGYyNjNmNTgtOGYyYS00ZjYyLTkyMGQtYTY0MTIwMTQwNWUxLlEyMnBPMXY3LW5rLjU1ZDk5ODc0LTdmMGEtNDIzMS1iYzZlLWUzZmRlNDkyYTUzMCJ9.gzqn444HTww8KCAYbA_OuJlRjbmCstV9mt1z8IuCf59ZZvLqRCV9nE7yoHJHhD-Koc43iigrSdMGPgOwv39LEPu1WN-so2mZbdGBb4lc8NNOx41ovCRRUeRr0Q1N56x4fWhuhohYN49HJRLLeEeu4K4JIpmxy5I1aZmD38uawB4QmhwoAwBU_G1lJeu1o4k_gWjFtczNnOYEhJ97mjgMV9dOyWKmEOoa8f6RPlGHpf1177CUN-KiRj77HUvxYyof7M0kMc4WBKmyodIpVpyo5j67RmStK0qB5O3SRpc4TO3fC7L_vrwlkVWRV3V6KOd5Eo9krFpHi6ouX4UFyMtiYg&execution=763218ec-e28f-4af4-aff8-87adbbd88e54&client_id=acs-mtls-service-account&tab_id=Q22pO1v7-nk

Cheia aia lunga ce se vede acolo se transmite ca parametru pe /auth/realms/external/login-actions/reset-credentials si e un token JWT folosit pentru a parsa informatii cum ar fi: data la care a fost generata cheia, subjectul care probabil e id-ul detinatorului cardului. Semnatura JWT-ului pare a fi RSASHA256. In rest pe acel link mai e expus un client_id prin care se obtin autorizarile, un tab_id legat de sesiunea curenta si un realm name. Foarte important e ca are si un atribut ‘exp’ pentru a indica intervalul in care e valid JWT-ul. In mod contrar, cu un JWT interceptat care nu are o data de expirare si care nu a fost consumat(actiunile sunt inca pe teava) se poate seta o parola pentru acel card.

{
  "exp": 1611874938,
  "iat": 1611874338,
  "jti": "e7715ea8-e90c-4cdc-8f06-c735673d9c4c",
  "iss": "https://parolastatica.btrl.ro/auth/realms/external",
  "aud": "https://parolastatica.btrl.ro/auth/realms/external",
  "sub": "bf7b10f9-55e9-xyxy-xyxy-a08296a2002d",
  "typ": "set-credentials",
  "azp": "acs-mtls-service-account",
  "nonce": "e7715ea8-e90c-4cdc-8f06-c735673d9c4c",
  "asid": "6792a987-a62a-4844-94e7-00d4c2eceb0b.v9ztMY2f1jw.55d99874-7f0a-4231-bc6e-e3fde492a530"
}

Ceea ce ma surprinde cumva, dar e si logic avand in vedere functionalitatea Keycloak din spate e ca pe un anumit atribut din cheie exista o legatura cu detinatorul cardului. Si acel atribut e de fiecare data acelasi, pe acelasi card. Nu stiu daca prin o altfel de implementare ar fi putut lega userul de card. In spate ma gandesc ca e o lista de useri, la care or fi legat atributele cu numar de card, data expirarii, cvc de un id generat random de keycloak. Nu e chiar ceva major, insa ce e important de mentionat e ca se printr-o simpla decodare de jwt se poate extrage id-ul posesorului acelui card. Pe de alta parte nu stiu daca ati vazut pana acum, dar cei de la Banca Transilvania au un domeniu ciudatel ‘btrl’ prin care fac lucruri. Ei asta nu e o intamplare, sau cel putin asta sper, zic asta pentru ca aici pe json-ul de mai sus avem un doua proprietati ‘iss’-cine a emis aceasta cheie si ‘aud’-destinatia acestei chei. Pentru cineva care gaseste cheile aceastea din intamplare printr-un mail compromis si e cu rea intentie, o sa incerce sa le interpreze, iar la prima vedere pare ceva basic, dar la o simpla cautare isi va da seama ca e un redirect spre BT. Aici nu mi se pare buna abordarea, puteau alege un alt domeniu si sa construiasca un server de autorizare, erau constienti ca asta va aparea in multe locuri. Au incercat ei sa se ascunda sub ceva fara insemnatate la prima vedere, dar nu e destul.

De alte lucruri in acest jwt desfasurat sincer nu ma prea pot lega pentru ca asa functioneaza mecanismul. Service account-ul e legat direct pe clientul de pe care s-a facut request-ul, iar type-ul de jwt indica ca s-ar putea sa fie mai multe tipuri definite de ei, dar poate nu folosite aici. Realm-ul aferent e public pe cheie, acolo e locul de joaca.

Semnatura acelei chei in format JWT Token nu pare sa fie validata. Avand in vedere ca foloseste un mecanism de encriptare RSA cu SHA256 are nevoie de o cheie publica pentru a-l valida si de una privata pentru a genera alt token.

Insa ce conteaza cel mai mult la aceasta cheie este headerul:

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "4dWXm6sIvaGQkDwlyc61I3SdXuImtGtDVvAixt7Pybo"
}

care apare ca foloseste RS256. O greseala super comuna ar fi fost sa foloseasca “none” ca si algoritm. Astfel puteam prepara un JWT cu alte valori pe atribute si sa il trimitem spre serverul de autorizare.

S-au mai incercat diferite modalitati de a forma un nou JWT cu alte informatii pentru a-l pune cumva pe acel request, dar daca ne uitam putin pe codul sursa al serviciului putem vedea pentru endpoint-ul de /reset-credentials pentru retrieval de cheie la GET:

@Path(RESET_CREDENTIALS_PATH)
    @GET
    public Response resetCredentialsGET(@QueryParam(AUTH_SESSION_ID) String authSessionId, // optional, can get from cookie instead
                                        @QueryParam(SESSION_CODE) String code,
                                        @QueryParam(Constants.EXECUTION) String execution,
                                        @QueryParam(Constants.CLIENT_ID) String clientId,
                                        @QueryParam(Constants.TAB_ID) String tabId) {
        ClientModel client = realm.getClientByClientId(clientId);
        AuthenticationSessionModel authSession = new AuthenticationSessionManager(session).getCurrentAuthenticationSession(realm, client, tabId);
        processLocaleParam(authSession);

        // we allow applications to link to reset credentials without going through OAuth or SAML handshakes
        if (authSession == null && code == null) {
            if (!realm.isResetPasswordAllowed()) {
                event.event(EventType.RESET_PASSWORD);
                event.error(Errors.NOT_ALLOWED);
                return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.RESET_CREDENTIAL_NOT_ALLOWED);

            }
            authSession = createAuthenticationSessionForClient(clientId);
            return processResetCredentials(false, null, authSession, null);
        }

        event.event(EventType.RESET_PASSWORD);
        return resetCredentials(authSessionId, code, execution, clientId, tabId);
    }

Si pentru POST request al cheii, implicit validarea parametrilor key, sessionId, client_id, tab_id unde se apeleaza handleActionToken. Tipul acesta de request pe reset-credentials se face pe tranzitia inspre cele doua modalitati de alegere a parolei unice:

@Path(RESET_CREDENTIALS_PATH)
    @POST
    public Response resetCredentialsPOST(@QueryParam(AUTH_SESSION_ID) String authSessionId, // optional, can get from cookie instead
                                         @QueryParam(SESSION_CODE) String code,
                                         @QueryParam(Constants.EXECUTION) String execution,
                                         @QueryParam(Constants.CLIENT_ID) String clientId,
                                         @QueryParam(Constants.TAB_ID) String tabId,
                                         @QueryParam(Constants.KEY) String key) {
        if (key != null) {
            return handleActionToken(key, execution, clientId, tabId);
        }

        event.event(EventType.RESET_PASSWORD);

        return resetCredentials(authSessionId, code, execution, clientId, tabId);
    }

Iar in metoda principala de handleActionToken putem observa urmatorul segment de cod:

ClientModel client = null;
        if (clientId != null) {
            client = realm.getClientByClientId(clientId);
        }
        AuthenticationSessionManager authenticationSessionManager = new AuthenticationSessionManager(session);
        if (client != null) {
            session.getContext().setClient(client);
            authSession = authenticationSessionManager.getCurrentAuthenticationSession(realm, client, tabId);
        }

        event.event(EventType.EXECUTE_ACTION_TOKEN);

pentru a forja o , sa zicem ca un clientId nimerim ca ala e deja definit, insa dupa check-ul de null, exista un call getCurrentAuthenticationSession(realm, client, tabId) unde isi ia si tabId-ul. Aici intervine si marea problema legata de acest tabId. Pentru ca:

public AuthenticationSessionModel getCurrentAuthenticationSession(RealmModel realm, ClientModel client, String tabId) {
        List<String> authSessionCookies = getAuthSessionCookies(realm);

        return authSessionCookies.stream().map(oldEncodedId -> {
            AuthSessionId authSessionId = decodeAuthSessionId(oldEncodedId);
            String sessionId = authSessionId.getDecodedId();

            AuthenticationSessionModel authSession = getAuthenticationSessionByIdAndClient(realm, sessionId, client, tabId);

            if (authSession != null) {
                reencodeAuthSessionCookie(oldEncodedId, authSessionId, realm);
                return authSession;
            }

            return null;
        }).filter(authSession -> Objects.nonNull(authSession)).findFirst().orElse(null);
    }

authSessionCookies va forma o lista de string-uri cu Cookie-urile valide din realm prin getAuthSessionCookies(realm). Adica prajiturici AUTH_SESSION_ID. Care va returna {@code null} pentru ca nu exista nicio sesiune activa pentru acel client in realm. Adica la un POST cu session_code, tab_id, client_id, execution forjate se va returna un 500 de la server.

Iar in cazul semnaturii de token va arunca o exceptie pe primul if. Pentru ca nu exista nicio sansa de a semna jwt-ul. SI daca ar trece cumva de primul try/catch si ar intra pe cazul de RSA unde pe cele doua branch-uri unde se verifica publicKey pe null sau pe verificarea cheii publicii, tot n-ar fi validat.

public void verifySignature() throws VerificationException {
        if (this.verifier != null) {
            try {
                if (!verifier.verify(jws.getEncodedSignatureInput().getBytes("UTF-8"), jws.getSignature())) {
                    throw new TokenSignatureInvalidException(token, "Invalid token signature");
                }
            } catch (Exception e) {
                throw new VerificationException(e);
            }
        } else {
            AlgorithmType algorithmType = getHeader().getAlgorithm().getType();

            if (null == algorithmType) {
                throw new VerificationException("Unknown or unsupported token algorithm");
            } else switch (algorithmType) {
                case RSA:
                    if (publicKey == null) {
                        throw new VerificationException("Public key not set");
                    }
                    if (!RSAProvider.verify(jws, publicKey)) {
                        throw new TokenSignatureInvalidException(token, "Invalid token signature");
                    }
                    break;

O ala chestie ce n-am inteles-o e de ce au ales aceasta mapare cu reset-credentials. Dar intr-un fel inteleg situatia, pentru ca daca ar fi facut ceva custom erau sanse foarte mari ca ceva sa crape pe flow-ul de primire/trimitere a cheii.

JWT-urile nu au mecanism de ‘auto distrugere’, ele nu mai sunt acceptate de serverul de autorizare din cauza atributelor temporale ori daca a fost consumata actiunea. Insa ele pot fi decodate oricand dupa expirarea lor. De aceea e foarte important sa nu contina date sensitive sau sa faca legaturi clare intre user si un atribut. Si mai ales sa nu se poata construi si semna un alt JWT studiind datele dintr-unul vechi expirat.

De aceea cel mai important atac -la distanta- care se poate da pe aceasta implementare consider eu ca e in zona de JWT tokens. Prin contextul construit in jurul unui token se pot atribui diferite actiuni pe un anumit user/detinator de card, s-ar putea seta parole finale pentru cardurile ce nu le au setate.

Pasul suplimentar cu un otp pe telefon ingreuneaza destul de mult un atac pe un card furat/gasit. In cadrul acestei analize s-a urmarit si daca pe undeva exista vreo urma a locului unde se trimite acel cod, numar de telefon complet, cei de la BT lasa nemascate doar ultimele 3 cifre, iar aflarea celorlalte e extrem de dificil. Insa, desigur, daca mergem mai in adancime cand cineva dezvolta un atac destul de sofisticat pe o anumita persoana, deja discutam de practici care ar depasi granita virtuala. Cunoscand detinatorul cardului in detaliu s-ar putea dezvalui si restul de cifre asociate cu acel card si se poate apela la un atac prin care ori sa se obtina un control pe acel device trimitand un sms malitios spre victima care sa permita atacatorului sa intercepteze orice trafic/mesaj, ori sa se intercepteze acel sms intr-un anumit perimetru comun. Tot prin tehnici care necesita real surveillance s-ar putea afla si parola finala de dupa 2fa. Ma gandesc la un atac de tip SS7 pentru 2fa code si monitorizare trafic pentru parola finala.

Analizand mai departe elementele din acest pas am observat ca butonul de “Finalizeaza tranzactia” e disabled asa ca m-am uitat sa vad cand e declansat:

function passwordChanged() {
		var pwdEdit = document.getElementById('passwordEdit');
		var submitBtn = document.getElementById('submitButton');
		console.log(pwdEdit.value);
		if (pwdEdit.value.length >= 8)
			submitBtn.disabled=false;
		else
			submitBtn.disabled=true;
	};

La fel ca si in pasul unu, butonul e legat de caracterele din input ca sa stie toata lumea ca parola trebuie sa fie mai mare ca egala cu 8. Smecheria cu mai mare egala e faina, insa faptul ca trebuie sa fie egala inseamna ca e clar ca e de 8. > e doar ceva sa induca in eroare. Pe langa, asta nu exista validare in sensul in care ar fi o nevoie de un vector numeric, alfabetic sau mixt. Aici se verifica doar length-ul. Un punct in plus din punct de vedere al securitatii.

Am tot stat sa ma uit daca exista vreun contor de timp pe acest screen, deoarece dupa decodarea JWT-ului expus mai sus am vazut ca acesta are un interval de 10m de valabilitate. Eu am inteles ca s-a validat un cod bun pe primul pas insa pe acea cheie, ori pe acest screen trebuia sa existe un interval de timp in care sa poti pune parola unica, ori sa incerci o parola. Pentru ca in unele cazuri dupa un anumit timp se blocheaza complet cel putin cand se trimite un otp invalid pe /acs2/acs/api/3ds2/challenge/otp . Nu valideaza tranzactia ­čÖé insa crapa. Dar nu valideaza tranzactia.

In cazul parolei unice invalide apare o clasa error__message in care sunt afisate numarul de incercari. Contorul e setat la 3. Dupa cum am mentionat si mai sus, un punct in plus e ca se face redirect in primul pas, userul nu mai ramane in acelasi pas.

Iar acum sa ne indreptam atentia spre setarea de parola unica, care foloseste cheia decodata mai sus pentru a identifica actiunile.

Printr-un GET folosind carnatul de cheie de mai sus se obtine aceasta resursa care contine doua posibilitati prin care se poate obtine cheia unica. Din cate am observat actiunea e consumata o singura data si e ireversibila. Asa functioneaza principiul de la Keycloak. Nu se mai poate ajunge din nou la aceeasi pagina, se primeste 500 pe urmatorul GET.

Aici se primeste un template cu niste validari de frontend. Acest template nu apare in surse, de aceea in momentul in care e declansat el vine in alt tab, legat de primul(al doilea pas). Exista doua posibilitati prin care se poate trece mai departe. Prima e prin email:

formValidator = $("#kc-set-password-form").validate({
        	onkeyup: false,
         	focusCleanup: true,
			rules: {
           		email: {
                    required: true,
                    emailRegex: true,
                    normalizer: function( value ) {
        						return value.toLowerCase(); 
        						}                      
                },
                emailConfirm: {
                    required: true,
                    equalsIgnoreCaseTo: "#email",
                    normalizer: function( value ) {
        						return value.toLowerCase(); 
        						}                
                },
                secret: {
                    required: true,
                    secretRegex: true,
                    maxlength: 128
                }
            },
            messages: {
                email: {
                    required: "",
                    emailRegex: "Te rugam sa introduci o adresa de email valida"
                },
                emailConfirm: {
                    required: "",
                    equalsIgnoreCaseTo: "Adresele de e-mail nu coincid"
                },
                secret: {
                    required: "",
                    secretRegex: "Numele trebuie sa contina cel putin 3 caractere",
                    maxlength: "Raspunsul trebuie sa fie de maxim 128 caractere" 
                }
            },
            submitHandler: function (form, event) {
                sendForm(form, event);
            }
        });

Exista tratari pentru cele doua inputuri si pentru secretul ce va aparea dupa validarea inputurilor pentru email.

Si pentru al doilea input:

$('#radioSecret').click(function () {
        $('.hiddenContent').hide();
        $(this).parent().parent().parent().find('.hiddenContent').show();
        $("#maxcharLabel").remove()
    });

Daca se merge pe ramura cu mail si confirmare, o existe un alt call spre:

https://parolastatica.btrl.ro/auth/realms/external/login-actions/reset-credentials?session_code=ge6TgQ1g585GXqD1KoIiZ7-zWRMvR8S6VaAmH2R0VY4&execution=0eb09769-a787-4b30-ba8c-d82bfbfecbc5&client_id=account&tab_id=-azMWctd-xo

Cu parametrii de identificare a sesiunii pentru serverul de autorizare, session_code, client_id, tab_id.

Pe raspuns se primeste un template cu un input in care trebuie introdus codul de pe mail. Care mai apoi va face submit spre:

    <form id="kc-otp-form" class="" action="https://parolastatica.btrl.ro/auth/realms/external/login-actions/reset-credentials?session_code=2hbQ5TUiMTCTLfd-WyyXErDL0Jk184Udd6O4wj86h0U&execution=4fbb002e-56d1-427b-a9f5-a9fd08b8969f&client_id=account&tab_id=-azMWctd-xo" method="post">

Exista chiar si un resend button in pagina pe:

<div id="otp_resend_div" style="display: none">
                <a id="otp_resend_link" class="otp-resend-message" href="javascript:void(0)">Retrimite codul</a>
                <input type="hidden" id="otp_resend_input" name="otp_resend_input">
            </div>

Element care e legat de un timeout. El e hidden pana intr-un anumit punct.

setTimeout(function () {
        $("#otp_resend_div").show();
        $("#otp_resend_link").click(function () {
            $("#otp_resend_input").val("true");
            $("#kc-otp-form").submit();
        });
    }, 60 * 1000);

Un alt timeout intalnitm in functia toggleSubmitButton care injecteaza clasa label.error in pagina, e folosita pentru coduri invalide:

function toogleSubmitBtn() {
        if ($("#kc-otp-form").validate().checkForm()) {
            $("input[type=submit]").removeClass("disabled");
            $("input[type=submit]").prop('disabled', false);
        } else {
            $("label.error").hide();
            $("input[type=submit]").addClass("disabled");
            $("input[type=submit]").prop('disabled', true);
        }
        setTimeout(function () {
            $("label.error").show();
        }, 15);
    }

Se ascunde elementul de resend cam 1 minut din pagina, pana cand il face din nou disponibil. Nu are nicio legatura cu vreun timeout in care dispare, este exact contrarul. Nu exista nicio validare de frontend care sa blocheze numarul de retrimiteri. Doar sesiunea(cookie-urile) controleaza toate request-urile.

La fiecare resend de cod pe mail se face un POST pe:

https://parolastatica.btrl.ro/auth/realms/external/login-actions/reset-credentials?session_code=Ky76yteTNHVaT-qMw-5FbJygoW6Gh4rdGYHRZL4cY1M&execution=4fbb002e-56d1-427b-a9f5-a9fd08b8969f&client_id=account&tab_id=bN4Ql1LZgSY

otp: 
otp_resend_input: true

Pe partea de validare a codului avem:

$(function () {

        $("#kc-otp-form").trigger("reset");			

        formValidator = $("#kc-otp-form").validate({
            messages: {
                otp: {
                    minlength: ""
                },
                captcha: {
                	minlength: ""
                    }
            },
            rules: {
                otp: {
                    minlength: 6
                    },
                captcha: {
                    minlength: 6
                    }                                                
                },
            submitHandler: function (form, event) {
            	sendForm(form, event);
            }
        });

        $("#kc-otp-form").on('blur keyup input change', 'input:password, input:text', function (event) {
            let emptyFields = $("#kc-otp-form input:password:visible")
                .filter(function () {
                    return !$(this).val();
                }).length;
            if (emptyFields === 0) {
                validateForm = true;
            }
            if (emptyFields === $("#kc-otp-form input:password:visible").length) {
                validateForm = false;
                $("input[type=submit]").addClass("disabled");
                $("input[type=submit]").prop('disabled', true);
            }
            if (validateForm) {
                toogleSubmitBtn();
            }
        });
    });

Cu un minlength de 6, ceea ce face ca submitul sa devina enabled, dar nu semnifica ca intr-adevar sunt exact 6 necesare sau ce fel, string, digits.

In momentul in care codul expira vine pe request-ul cu session_id mesajul “Codul a expirat” in cadrul template-ului. Sesiunea expira cam in 10minute. Se va face un GET pe parolastatica.btrl.ro/auth/realms/external/login-actions/authenticate?client_id=account&tab_id=bN4Ql1LZgSY unde se identifica sesiunea ca fiind invalida, si se primeste un redirect 302.

Una dintre cele mai mari greseli din punctul meu de vedere, e ca nu exista nicio validare pe mail, totusi banca are mail-urile, nu mi se pare normal ca acel cod sa poata fi transmis pe orice mail != de cel al detinatorul cardului. Sau de ce acel secret e pus doar asa ca o simpla tranzitie in pasul urmator.

Pasul urmator trece prin /auth/realms/external/login-actions/reset-credentials?execution=xxxxxxxxxxxx&client_id=account&tab_id=x-xxxxxxx unde se si alege parola unica. Pare ca aici se trece prin alt client. In pagina aceasta form-ul declanseaza un POST cel mai probabil pe kc-enter-password-form pe auth/realms/external/login-actions/reset-credentials?session_code=xxxxxxxx&execution=xxxxx&client_id=account&tab_id=xxxx. Sesiunea e pastrata pentru scurt timp, orice alta incercare de a ajunge la acelasi executionId folosind cheia de pe /reset-credentials va arunca un 500.

Bun, acum hai sa tragem linia sa vedem ce s-ar putea imbunatati:

  • In tot reasearch-ul asta ce a durat ceva timp, ceva trace cred ca se putea face dupa detinatorul cardului si faptul ca cineva tot incearca in mod repetat sa faca diferite tranzactii. Coduri invalide, jwt-uri aiurea, template-uri mesterite, dar nicio parola setata in final.. nimeni nimic. In astfel de situatii cand nu exista niciun mecanism automat prin care sa blochezi activitatea pana cand se ia legatura cu detinatorul cardului ar trebui sa faca manual o blocare a cardului.
  • Tot pe ideea de mai sus as propune introducerea unor restrictii mai puternice pe partea de coduri invalide. Adica, oke, s-a introdus de 5 ori ceva invalid blocam cardul 12h. Threshold-ul mi se pare omenesc, greseli exista, dar nu atatea. Mi se pare foarte slaba situatia acolo, pentru un alt transactionId cu alta sesiune, mai vin 5 invalide si tot asa.(s-a introdus in timpul research-ului o metoda de blocare a codurilor invalide in primul pas, cu redirect direct dupa primul cod invalid – ~24h)
  • Primul pas, daca tot e acolo, ar trebui sa fie ceva valid. Eu inteleg ca poate e o plasa si are totusi logica treaba explicata mai sus, dar chiar nu e necesar ca cineva sa treaca in pasul doi fara un cod valid. Pentru varianta asta de acs2. (rezolvat pe parcursul research-ului)
  • Sub nicio forma sa nu se accepte mail-uri de confirmare legate de 3D secure care nu au legatura cu detinatorul cardului. In situatia de fata, acele confirmari erau aparate doar de o simpla validare a unui cod pe telefon in prealabil. Exista mail pe contract, se poate trece ca atribut pe fiecare ID si sa fie validat inainte de orice trimitere a unei confirmari. Se poate face o implementare de frontend, in care sa fie vizibil faptul ca se trimite spre orice mail, insa in spate sa trimita doar la cel legat de card.

*Am ajuns in pasul cu setarea parolei. Nu am mai extins raportul curent pentru ca la un moment dat am observat ca cineva a urmarit totusi acel trafic si s-au luat masuri care erau in contradictie cu informatiile de la o zi la alta. Insa vor urma analize si pentru varianta aceea.

#Problema pe partea de bypass din primul pas pare ca a fost intre timp rezolvata.

*Au aparut multiple lucruri noi prin pasi care au imbunatatit securitatea acestui gateway. Cam dupa ~30 zile, timp in care se lucra la acest raport pe varianta aceea. Nu e rau ca cineva a luat masuri fara ca sa raporteze lucrurile mentionate mai sus, insa perioada a fost cam lunga. Log-urile erau super evidente ca ceva se intampla acolo si ca nu era doar o batranica ce tot gresea codurile.

Am constatat ca la un moment dat cineva intervine pe fir pentru ca au introdus un nou call undeva in pasul doi ce face trimitere spre un tool pentru monitoring prin gateway.

https://ecclients.btrl.ro/acs/ruxitagentjs_ICA2SVfqru_10207210127152629.js

un get pe acel fisier care returneaza 500, probabil nu era inca configurat.

*Toate informatiile prezentate spre au fost atent selectionate si nu fac in nici un fel referire la cineva ori la o anumita bresa majora ce inca exista in sistem. Acesta este un simplu raport pur informativ tehnic d.p.dv al implementarii si nu are rolul de a denigra ori de a arata cu degetul spre o anumita institutie bancara.

Leave a Reply