The ChameleonUltra is a capable piece of kit. Eight emulation slots, HF and LF reader, MIFARE attacks, BLE and USB, an active community pushing PRs. But every time I used it in the field, I was tethered. Laptop open, CLI running, USB plugged in or BLE paired. Useful for bench work. Not useful when you want to walk up to a reader, do something, walk away.
I wanted the device to operate on its own. You configure it once, put it in your pocket, and go. The buttons do the work. Results wait for you when you get back to the bench.
This post is the story of building that, what broke along the way, and what ended up working on real hardware.
The design problem
ChameleonUltra already has button handling. Button A cycles slots forward, Button B cycles back. Long press A clones the UID. Long press B shows battery. This config is user-settable and the community uses it. I could not touch any of it.
So standalone modes needed their own input channel. The solution was chord gestures: pressing both buttons simultaneously. The existing single-button logic never produces that, so there is no conflict. Duration classifies the intent:
- Both held less than one second: primary action (capture, advance, etc.)
- Both held one to five seconds: arm or disarm toggle
- Both held five or more seconds: destructive action (clear data)
The framework is pluggable. Each mode is a translation unit that exports a descriptor
struct with five callbacks: on_enter, on_exit, on_button,
on_tick, get_result. The framework handles everything else: FDS
persistence, arm/disarm state machine, LED feedback, the seven host commands (CMD 7000-7006)
that let the Python CLI configure and query the device.
Two modes ship with the initial PR:
AuthTrace — CU acts as a reader, authenticates
against a real card, captures the complete wire exchange. REQA, ATQA, anticollision, SELECT,
SAK, auth command, nonce, reader response, tag answer. Up to eight sessions stored in RAM. Feed
two sessions on the same block to mfkey32v2 and recover the sector key offline,
no host connection needed during capture.
Slot Cycle — rotates through enabled emulation slots at a configurable interval. Reference implementation. No RF, no results, clean code to copy from.
Three bugs that needed finding
1. The chord detector never fired
First hardware test. Armed status showed DISARMED no matter how I held the buttons. The slot LEDs cycled instead, which meant the existing single-button CycleSlot function was running.
The cause was in timer_button_event_handle. The GPIOTE driver shares one
debounce timer between both buttons. Every edge event restarts it. When you press both buttons
within the 50ms debounce window, the first event's pending timer gets overwritten by the second.
Only the second button's press flag gets set. The chord condition — both flags true
simultaneously — is never reached.
The fix samples the other button's GPIO state after the per-pin handling on every timer fire. If the hardware says the other button is held but the software flag disagrees, synthesise the missing transition. One pin's timer always fires even when both buttons race. From that single firing, check both pins. Chord detection works.
2. FDS writes crashed the device
standalone config authtrace --block 0 --key-type A --key FFFFFFFFFFFF --timeout 30000
Device reset. USB serial gone. Reconnect, try again, same result.
The first working command was standalone status, which only reads. The second
was standalone set-mode authtrace, which writes a four-byte state record to FDS.
That worked. The third was standalone config, which writes a sixteen-byte config
blob. That crashed.
fds_write_sync requires four-byte-aligned data. Pass it an unaligned pointer
and it returns FDS_ERR_UNALIGNED_ADDR, which hits APP_ERROR_CHECK,
which hard-faults and resets.
My first attempt used a stack-local buffer with __attribute__((aligned(4))).
That is what the documentation suggests and it looks correct. It is not reliable on nRF52 at
runtime. The attribute does not guarantee alignment the way you would expect when the buffer is
inside a function that the compiler has already arranged a stack frame for.
The fix: a file-static uint32_t[] buffer in BSS. The linker guarantees word
alignment for BSS. memcpy the payload in before calling fds_write_sync.
This is the same pattern that settings.c uses everywhere in the existing codebase,
which is why it works.
static uint32_t m_cfg_save_buf[(STANDALONE_CONFIG_MAX_BYTES + 3) / 4];
memset(m_cfg_save_buf, 0, sizeof(m_cfg_save_buf));
memcpy(m_cfg_save_buf, cfg, cfg_len);
fds_write_sync(file_id, key, cfg_len, m_cfg_save_buf);
3. Device reset while idle in ARMED state
Config fixed, arming working. Armed the device, stood at the bench for twenty seconds doing nothing, USB serial disconnected.
The authtrace mode called reader_mode_enter in on_enter and turned
the antenna on, then left it on while armed. That continuous active reader mode, antenna
powered, interferes with BLE and USB processing in ways that cause an eventual reset. The rest
of the CU firmware assumes reader mode is transient: on for one command, off immediately after.
Nothing in the power management or USB stack accounts for sustained reader operation.
The fix mirrors the standard pattern. before_hf_reader_run and
after_hf_reader_run in app_cmd.c are the model: power on, do the
thing, power off. acquire_reader_mode now only switches the device mode, it does
not touch the antenna. run_session powers the antenna on, performs the auth,
powers it off before returning. release_reader_mode calls
tag_mode_enter to return the device to normal emulation. Between captures the
device is in tag emulation mode with the field off, its normal idle state.
The bench test
Two sessions on a MIFARE Classic 1K. Both status OK. Same UID, same block, different nonces.
=== session #0 status=ok ===
--> [ 7 bits] 26
<-- [ 16 bits] 0400
<-- [ 40 bits] b2cfb11ad6
<-- [ 24 bits] 08b6dd
--> [ 32 bits] 6000f57b
<-- [ 32 bits] 619a379d (NT)
--> [ 64 bits] 3e36b683b71d7daa (NR || AR)
<-- [ 32 bits] 04b42698 (AT)
=== session #1 status=ok ===
...
<-- [ 32 bits] 0f97c148 (NT)
--> [ 64 bits] e76a1d213e34bfd6 (NR || AR)
<-- [ 32 bits] 9ae6b20b (AT)
mfkey32v2 b2cfb11a 619a379d 3e36b683 b71d7daa 0f97c148 e76a1d21 3e34bfd6
A conversation worth noting
While working through the implementation I was in the ChameleonUltra Discord. A community member asked whether true passive sniffing was feasible on the hardware. The short answer: it is not. CU has a hardware antenna switch, single-pole double-throw between the nRF52 NFCT peripheral and the RC522. They cannot run simultaneously. NFCT handles reader-to-card frames well, its job by design. Card-to-reader load modulation is a different signal entirely, around one percent field perturbation, and neither frontend can recover it while the other is active. Load modulation would not even look like OOK to the RC522. Hardware limitation, closed.
AuthTrace and the existing emulation-side sniff are mirror images of each other. Sniff has CU as card against a real reader. AuthTrace has CU as reader against a real card. Both produce mfkey32-compatible data. Both are useful for exactly what they do.
What ships
The PR to RfidResearchGroup/ChameleonUltra splits into six atomic commits:
refactor(hf14a)— extract reusablehf14a_auth_trace_run()from CMD 2017feat(standalone)— add host-less mode frameworkfeat(standalone)— add LED feedback vocabularyfeat(standalone)— add authtrace modefeat(standalone)— add slot_cycle modefeat(cli)— add standalone subcommand group
The framework is designed for extension. Mode IDs are persisted and must be appended, not renumbered. The descriptor struct is stable. Adding a new mode is a single translation unit plus registration in four places. A developer template and contributing guide are in the tree.
What is next
The obvious next mode is inline mfkey32. During normal card emulation the NFCT peripheral already sees every auth attempt from every reader that queries the device. The nonces are there. Running mfkey32v2 inline when two sessions accumulate on the same sector, storing the recovered key back into the slot silently, needs no user interaction during capture. You wave the device at a reader a couple of times and keys appear. All the pieces are already in the codebase.
Autoclone and dict-check are reserved mode IDs waiting for implementation. Both require
HOST_OPTED_IN because they write to slots or tag memory. The capability gate is
already in the framework.
Code at github.com/RfidResearchGroup/ChameleonUltra
← Back to blog