ChameleonUltra UID Walker: van idee naar Android app (USB én Bluetooth)
Datum: 3 juli 2026
Hardware: ChameleonUltra (Proxgrind), ACR122U NFC-lezer, Samsung Galaxy A26
Het idee was simpel: een Android app die via USB-OTG een ChameleonUltra aanstuurt om het UID van een MIFARE Classic kaart te verhogen — een “UID walker”. In plaats van telkens een laptop te moeten aansluiten, gewoon je telefoon erin en op het knopje drukken. Wat volgde was een reis door seriële protocollen, firmware-eigenaardigheden, BLE permissies, en een hardnekkige bug die ons weken kostte.
Het concept
De ChameleonUltra is een draagbare RFID-emulator met 8 slots die elk een HF- of LF-tag kunnen emuleren. Slot 8 wilden we inrichten als MIFARE Classic 4K, waarbij we alleen het UID (de eerste 4 bytes van block 0) wijzigen. De BCC (Block Check Character, XOR van de 4 UID-bytes) wordt automatisch herberekend.
De app moest:
- Verbinden via USB (OTG) of Bluetooth (BLE)
- Het huidige UID tonen en met +1 verhogen
- Handmatig een UID kunnen invoeren
- UIDs opslaan en herladen (AsyncStorage)
- Werken als standalone APK, zonder Metro dev server
Horde 1: USB-serieel op Android
Voor USB-communicatie gebruikten we react-native-serial-transport. De ChameleonUltra presenteert zich als een CDC ACM apparaat (virtuele seriële poort) met VID 0x6868 en PID 0x8686.
Probleem 1: requestPermission() crashte met “undefined is not a function”. De methode bleek private in de Kotlin native module — niet publiek geëxporteerd. De oplossing: gebruik SerialTransport (de wrapper class) in plaats van NativeSerialPort direct. listDevices() triggert automatisch de Android USB-permissiedialog.
Probleem 2: De device_filter.xml bevatte alleen standaard USB-serial chips (CP210x, CH340, FTDI), niet de ChameleonUltra. Zonder deze entry auto-grant Android de USB-permissie niet. Fix: de plugin’s device_filter.xml patchen met de ChameleonUltra VID/PID.
Horde 2: Het seriële protocol
De ChameleonUltra gebruikt een binair framed protocol:
[0x11] [LRC1] [CMD(2)] [Status(2)] [Length(2)] [LRC2] [Data(N)] [LRC3]
LRC = (0x100 - som_van_bytes) & 0xFF. Commands zijn big-endian, status veld is 0x0000 bij verzenden, response bevat de werkelijke status (0x00 = SUCCESS, 0x60 = PAR_ERR, etc.).
De implementatie in TypeScript (src/protocol.ts) was in één keer goed — de LRC-berekening klopte, de frame-encoding matchte de Python referentie-implementatie exact.
Horde 3: Slot 8 is standaard UIT
Slots op de ChameleonUltra hebben standaard enabled = false. We moesten expliciet:
SET_SLOT_TAG_TYPE— type instellen op MIFARE 4KSET_SLOT_DATA_DEFAULT— fabrieksdata ladenSET_SLOT_ENABLE— HF-emulatie aanzettenSLOT_DATA_CONFIG_SAVE— opslaan naar flash
Zonder stap 3 zendt de ChameleonUltra niets uit.
Horde 4: TAG_TYPE_MIFARE_4096 is verplicht, niet 1024
De firmware hardcoded TAG_TYPE_MIFARE_4096 in de read/write functies:
tag_data_buffer_t *buffer = get_buffer_by_tag_type(TAG_TYPE_MIFARE_4096);
Ongeacht welk type je slot configureert, lezen/schrijven gaat altijd via het 4K-buffer. Onze eerste code gebruikte MIFARE_1024 (1001) en de data belandde in een ander buffer dan waaruit gelezen werd.
Ook de tag_type parameter is 2 bytes big-endian. Onze eerste implementatie gaf de waarde als 1 byte mee — de firmware decodeerde 0x03E9 als 0x00E9 = 233 (een ongeldig type).
Horde 5: De doorbraak — UID zit op TWEE plekken
Dit was het meest frustrerende probleem. mf1_write_emu_block_data(0, ...) schreef de UID naar block 0, de app las de nieuwe UID terug — maar de ACR122U NFC-lezer zag nog steeds het oude UID.
Na uren firmware-analyse vonden we de oorzaak. Het nfc_tag_mf1_information_t struct heeft twee UID-locaties:
struct {
struct {
uint8_t uid[7]; // ← anticollision data (res_coll)
uint8_t atqa[2];
uint8_t sak[1];
} res_coll;
uint8_t memory[256][16]; // ← block data (memory[0] = block 0)
struct {
bool use_mf1_coll_res; // ← standaard: FALSE
// ...
} config;
};
Standaard (use_mf1_coll_res = false) gebruikt de RF-emanatie res_coll.uid, niet memory[0]. Schrijven naar block 0 verandert dus wel de data in het buffer, maar niet wat er in de lucht wordt uitgezonden.
De oplossing: Command 4015 (MF1_SET_BLOCK_ANTI_COLL_MODE) met waarde 1 zet de ChameleonUltra in “gebruik block 0 als UID” modus. Daarna werken writes naar block 0 wél direct op de emanatie.
Command 4015 was de sleutel. Zonder deze flag leek alles te werken (app toonde de juiste UID) maar de emanatie klopte niet.
Horde 6: Bluetooth op Android 16
BLE bleek een eigen avontuur. De ChameleonUltra gebruikt de Nordic UART Service (NUS):
| UUID | Functie |
|---|---|
6E400001-B5A3-F393-E0A9-E50E24DCCA9E | Service |
6E400002-... | RX (schrijven naar device) |
6E400003-... | TX (notificaties van device) |
Probleem 1: BLE-scan vond WEL andere apparaten (TV, smartwatch) maar géén ChameleonUltra. De Flutter app (ChameleonUltraGUI) werkte wél. Uiteindelijk bleek dat we de onStateChange callback moesten afwachten (PoweredOn) vóór de scan, en dat manager.devices() op Android 16 leeg retourneert (bug in react-native-ble-plx). De fallback-scan zonder service-UUID filter werkte uiteindelijk.
Probleem 2: UUID-matching gebruikte .endsWith() in plaats van .startsWith(). Vendor-specific UUIDs hebben het korte ID (0002, 0003) aan het begin van de 128-bit UUID, niet aan het einde.
Probleem 3: Locatie-toestemming is vereist op Android voor BLE-scan, ook op Android 12+. De neverForLocation flag in het manifest (die dit uitschakelt) werd niet correct toegepast door de Expo config plugin. Oplossing: expliciet ACCESS_FINE_LOCATION runtime permission toevoegen.
Probleem 4: De Buffer polyfill ontbrak (property buffer doesn't exist). React Native heeft geen globale Buffer. Fix: npm install buffer en import { Buffer } from 'buffer'.
Horde 7: Standalone APK
Tot slot wilden we een APK die werkt zónder Metro dev server. De oplossing: ./gradlew assembleRelease bouwt een release APK met embedded JS bundle.
cd software/mobile/android && ./gradlew app:assembleRelease
# APK: app/build/outputs/apk/release/app-release.apk (72MB)
Resultaat
De app werkt nu via zowel USB als Bluetooth, standalone of met dev server:
| Feature | USB | BLE |
|---|---|---|
| Verbinden | OTG-kabel, direct | Scan, 5-10s |
| UID walker (+1) | ✓ | ✓ |
| Handmatig UID | ✓ | ✓ |
| Save/store | ✓ | ✓ |
| Snelheid | Snel (<1s per cmd) | Trager (BLE latency) |
De ACR122U bevestigt dat het juiste UID wordt uitgezonden — de complete keten van app → ChameleonUltra → NFC-lezer werkt end-to-end.
Lessons learned
- Lees de firmware source — de documentatie is summier, maar de C-code liegt niet
- Verifieer altijd met een externe lezer — de app kan iets anders tonen dan wat er in de lucht zit
- UUIDs in BLE libraries zijn case-sensitive en format-gevoelig — match robuust met
.replace(/-/g, '').toUpperCase() - Expo config plugins zijn krachtig maar fragiel — pas
node_modulespatches toe en documenteer ze - Versiebeheer vanaf dag 1 — we hebben te lang zonder versienummers gewerkt