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:

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.

Note
This is a pre-existing latent bug in CU's button handling. No upstream feature ever exercised simultaneous press, so nobody had found it. It goes into the PR as a separate commit with its own fix description.

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
Result
Key recovered. The workflow is validated end to end. No host connection during capture. Plug in, pull results, run the tool.

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:

  1. refactor(hf14a) — extract reusable hf14a_auth_trace_run() from CMD 2017
  2. feat(standalone) — add host-less mode framework
  3. feat(standalone) — add LED feedback vocabulary
  4. feat(standalone) — add authtrace mode
  5. feat(standalone) — add slot_cycle mode
  6. feat(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