Skip to content

Commit 96eda18

Browse files
committed
fix: send the magic UID frames exactly as the tag vendor documents
The rfidfriend.com instruction sheet shipped with the magic SLIX-L tags specifies `hf 15 raw -akrc` for both UID frames, step 1 = the 0x41 half (first four UID bytes), step 2 = the 0x40 half. This repo sent `-acw`, in the opposite order, and — worse — as two separate pm3 invocations, which drops the RF field between the halves and can leave the UID half-written. - build_uid_frames() now emits `-akrc` and returns the frames in vendor order; the byte mapping was already correct and is unchanged. - write_uid() and `doctor --probe-magic` pass both frames to a single pm3 session so `-k` can keep the field up between them. - HARDWARE.md: correct the flag reference, document the single-session requirement, and stop claiming that `csetuid` without `--v2` is harmless — the vendor warns against csetuid in any form, and the gen1 path writes config blocks 0x3E/0x3F. - CLAUDE.md: add the frame order/session invariant as a hard rule. - Tests cover the vendor sheet's worked example (E0 04 03 50 12 34 56 78) and assert both frames share one session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012caw768bkSR8AKYiFQh77W
1 parent 056132c commit 96eda18

7 files changed

Lines changed: 92 additions & 53 deletions

File tree

CLAUDE.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,14 @@ Preview without touching hardware: `./tonie write "Zuma" --dry-run`.
4141
layout command `02 E0 09 47 …`, which **permanently bricks magic SLIX-L tags**. The
4242
UID is written with two raw frames instead; `tonie_writer/proxmark.py` already does
4343
this correctly. See "Why this repo never calls `csetuid`" in `docs/HARDWARE.md`.
44-
2. **Data blocks are written before the UID.** Once the tag carries a foreign UID the
44+
2. **The two UID frames go out together, `0x41` half first** (`-akrc`, one pm3 session),
45+
exactly as the rfidfriend.com instruction sheet documents. Splitting them across two
46+
pm3 invocations power-cycles the tag mid-write.
47+
3. **Data blocks are written before the UID.** Once the tag carries a foreign UID the
4548
blocks may no longer be writable. Do not reorder.
46-
3. **Block writes are non-addressed** (`hf 15 wrbl --ua`, flags `0x02`, retry with `-o`
49+
4. **Block writes are non-addressed** (`hf 15 wrbl --ua`, flags `0x02`, retry with `-o`
4750
= `0x42`). `-*` means "scan, then write addressed" and is not the verified sequence.
48-
4. **Never write SLIX passwords or enable privacy mode** (`slixwritepwd`,
51+
5. **Never write SLIX passwords or enable privacy mode** (`slixwritepwd`,
4952
`passprotectafi`, `passprotecteas`). These are irreversible and are not needed —
5053
genuine Tonie dumps have `Privacy Mode: false`.
5154

docs/HARDWARE.md

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -117,14 +117,15 @@ instead.
117117
| `hf 15 info` | tag present? shows UID, chip type |
118118
| `hf 15 wrbl --ua -b <0-7> -d AABBCCDD` | write one 4-byte block, non-addressed (flags `0x02`) |
119119
| `hf 15 wrbl --ua -o -b <0-7> -d AABBCCDD` | same with the OPTION flag (`0x42`) — retry variant |
120-
| `hf 15 raw -acw -d 02E00940<b7><b6><b5><b4>` | magic UID write, high half |
121-
| `hf 15 raw -acw -d 02E00941<b3><b2><b1><b0>` | magic UID write, low half |
120+
| `hf 15 raw -akrc -d 02E00941<b3><b2><b1><b0>` | magic UID write, step 1 (first four UID bytes) |
121+
| `hf 15 raw -akrc -d 02E00940<b7><b6><b5><b4>` | magic UID write, step 2 (last four UID bytes) |
122122
| `hf 15 rdbl --ua -b <0-7>` | read one block back (verification) |
123123
| `hf 15 dump --ns` | read all blocks, don't save to file |
124124

125-
`hf 15 raw` flags: `-a` activate field, `-c` append CRC, `-w` wait longer
126-
(writes). `--ua` means unaddressed; without it, `hf 15` scans for a tag and
127-
writes *addressed*, which is not what we want here.
125+
`hf 15 raw` flags: `-a` activate field, `-k` keep the field on after the
126+
frame, `-r` don't wait for a reply (the magic UID write doesn't send one),
127+
`-c` append CRC. `--ua` means unaddressed; without it, `hf 15` scans for a
128+
tag and writes *addressed*, which is not what we want here.
128129

129130
### ⚠️ Why this repo never calls `csetuid`
130131

@@ -135,27 +136,35 @@ reference implementation, sends only the two UID frames and states
135136
explicitly that the layout command is "intentionally NOT sent... will brick
136137
SLIX-L magic cards."
137138

138-
The safe equivalent — what this repo actually sends — is those two frames by
139-
hand:
139+
The safe equivalent — what this repo actually sends, and what the printed
140+
instruction sheet shipped with the rfidfriend.com tags documents — is those
141+
two frames by hand:
140142

141143
```
142-
hf 15 raw -acw -d 02E00940<uid[7]><uid[6]><uid[5]><uid[4]>
143-
hf 15 raw -acw -d 02E00941<uid[3]><uid[2]><uid[1]><uid[0]>
144+
hf 15 raw -akrc -d 02E00941<uid[3]><uid[2]><uid[1]><uid[0]>
145+
hf 15 raw -akrc -d 02E00940<uid[7]><uid[6]><uid[5]><uid[4]>
144146
```
145147

146148
Each half is sent **reversed** (`uid[0]` = the first byte of the `.nfc`
147149
file's `UID:` line = `E0`), because the card stores the UID LSB-first. For
148150
`UID: E0 04 03 50 20 30 36 1D`:
149151

150152
```
151-
hf 15 raw -acw -d 02E009401D363020
152-
hf 15 raw -acw -d 02E00941500304E0
153+
hf 15 raw -akrc -d 02E00941500304E0
154+
hf 15 raw -akrc -d 02E009401D363020
153155
```
154156

155-
`hf 15 csetuid` *without* `--v2` is a different, older magic protocol. It
156-
won't brick the tag, but it doesn't work on these tags either. There is
157-
deliberately no `--gen2` flag or automatic gen1→gen2 fallback anywhere in
158-
this CLI — only the two raw frames above are ever sent.
157+
Both frames go out in a **single** `pm3 -c` session, and `-k` keeps the
158+
field up between them. Sending them as two separate pm3 invocations power-
159+
cycles the tag in between, which can leave the UID half-written.
160+
161+
`hf 15 csetuid` *without* `--v2` is a different, older magic protocol —
162+
it writes tag blocks `0x3E`/`0x3F` (config) plus `0x38`/`0x39`. The vendor
163+
sheet warns against `csetuid` in *any* form, saying it destroys
164+
configuration properties and internally reformats the tag; assume that
165+
covers the gen1 path too. There is deliberately no `--gen1`/`--gen2` flag
166+
or automatic fallback anywhere in this CLI — only the two raw frames above
167+
are ever sent.
159168

160169
Do **not** use `hf 15 restore -f …` either: it requires a full binary tag
161170
struct dump whose layout changes between Proxmark3 releases.

tests/test_proxmark.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,25 @@
3838
class TestBuildUidFrames(unittest.TestCase):
3939
def test_known_vector_from_plan(self):
4040
uid = bytes.fromhex("E00403502030361D")
41-
high, low = build_uid_frames(uid)
42-
self.assertEqual(high, "hf 15 raw -acw -d 02E009401D363020")
43-
self.assertEqual(low, "hf 15 raw -acw -d 02E00941500304E0")
41+
first, last = build_uid_frames(uid)
42+
self.assertEqual(first, "hf 15 raw -akrc -d 02E00941500304E0")
43+
self.assertEqual(last, "hf 15 raw -akrc -d 02E009401D363020")
44+
45+
def test_matches_vendor_documented_example(self):
46+
"""The worked example on the rfidfriend.com instruction sheet:
47+
UID E0 04 03 50 12 34 56 78, step 1 then step 2."""
48+
first, last = build_uid_frames(bytes.fromhex("E004035012345678"))
49+
self.assertEqual(first, "hf 15 raw -akrc -d 02E00941500304E0")
50+
self.assertEqual(last, "hf 15 raw -akrc -d 02E0094078563412")
4451

4552
def test_rejects_wrong_length(self):
4653
with self.assertRaises(ValueError):
4754
build_uid_frames(bytes.fromhex("E00403"))
4855

4956
def test_never_mentions_csetuid(self):
5057
uid = bytes.fromhex("E00403502030361D")
51-
high, low = build_uid_frames(uid)
52-
self.assertNotIn("csetuid", high)
53-
self.assertNotIn("csetuid", low)
58+
for frame in build_uid_frames(uid):
59+
self.assertNotIn("csetuid", frame)
5460

5561

5662
class TestBuildCommands(unittest.TestCase):

tests/test_writer.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
"hf 15 wrbl --ua -b 5 -d 2FB3911D",
1818
"hf 15 wrbl --ua -b 6 -d 982C1C55",
1919
"hf 15 wrbl --ua -b 7 -d 00F20064",
20-
"hf 15 raw -acw -d 02E009401D363020",
21-
"hf 15 raw -acw -d 02E00941500304E0",
20+
"hf 15 raw -akrc -d 02E00941500304E0",
21+
"hf 15 raw -akrc -d 02E009401D363020",
2222
]
2323

2424

@@ -83,15 +83,33 @@ class TestWriteUid(unittest.TestCase):
8383
def test_success_on_first_attempt(self):
8484
tag = parse_nfc_file(FIXTURES / "Zuma.nfc")
8585
responses = [
86-
Pm3Result("", "", 0), # high frame
87-
Pm3Result("", "", 0), # low frame
86+
Pm3Result("", "", 0), # both UID frames, one session
8887
Pm3Result("[+] UID.................. E0 04 03 50 20 30 36 1D", "", 0), # info
8988
]
9089
runner = FakeRunner(responses=responses)
9190
result = writer.write_uid(tag, WriteOptions(), run_fn=runner, sleep_fn=lambda s: None)
9291
self.assertTrue(result.ok)
9392
self.assertEqual(result.read_back_uid, tag.uid)
9493

94+
def test_both_frames_share_one_session(self):
95+
"""The field must stay up between the two halves — one pm3 call, in
96+
vendor order (0x41 first)."""
97+
tag = parse_nfc_file(FIXTURES / "Zuma.nfc")
98+
runner = FakeRunner(
99+
responses=[
100+
Pm3Result("", "", 0),
101+
Pm3Result("[+] UID.................. E0 04 03 50 20 30 36 1D", "", 0),
102+
]
103+
)
104+
writer.write_uid(tag, WriteOptions(), run_fn=runner, sleep_fn=lambda s: None)
105+
self.assertEqual(
106+
runner.calls[0],
107+
[
108+
"hf 15 raw -akrc -d 02E00941500304E0",
109+
"hf 15 raw -akrc -d 02E009401D363020",
110+
],
111+
)
112+
95113
def test_failure_when_uid_never_matches(self):
96114
tag = parse_nfc_file(FIXTURES / "Zuma.nfc")
97115
info_unchanged = Pm3Result("[+] UID.................. E0 04 02 11 22 33 44 55", "", 0)

tonie_writer/cli.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -388,17 +388,15 @@ def cmd_doctor(args: argparse.Namespace) -> int:
388388
confirm = ""
389389
if confirm == "y":
390390
test_uid = bytes.fromhex("E004030000000001")
391-
high, low = proxmark.build_uid_frames(test_uid)
392-
proxmark.run([high], port=args.port, pm3_bin=pm3_bin)
393-
proxmark.run([low], port=args.port, pm3_bin=pm3_bin)
391+
frames = list(proxmark.build_uid_frames(test_uid))
392+
proxmark.run(frames, port=args.port, pm3_bin=pm3_bin)
394393
check = proxmark.run(["hf 15 info"], port=args.port, pm3_bin=pm3_bin)
395394
new_uid = proxmark.parse_info_uid(check.stdout)
396395
if new_uid == test_uid.hex().upper():
397396
print("✅ magic write succeeded — this is a magic tag.")
398397
if uid:
399-
orig_high, orig_low = proxmark.build_uid_frames(bytes.fromhex(uid))
400-
proxmark.run([orig_high], port=args.port, pm3_bin=pm3_bin)
401-
proxmark.run([orig_low], port=args.port, pm3_bin=pm3_bin)
398+
orig = list(proxmark.build_uid_frames(bytes.fromhex(uid)))
399+
proxmark.run(orig, port=args.port, pm3_bin=pm3_bin)
402400
print(f"Restored original UID {_format_uid(uid)}.")
403401
else:
404402
print("❌ UID unchanged — this tag's UID cannot be written (fixed-UID tag).")

tonie_writer/proxmark.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,19 +115,25 @@ def build_rdbl_command(block: int) -> str:
115115

116116

117117
def build_uid_frames(uid_bytes: bytes) -> tuple[str, str]:
118-
"""The two safe raw magic-UID frames from plan §4.4. Never `csetuid`.
118+
"""The two safe raw magic-UID frames, in the order and with the flags the
119+
tag vendor (rfidfriend.com) documents. Never `csetuid`.
119120
120121
Each half is sent reversed (the card stores the UID LSB-first):
121-
high frame (0x40): uid[7] uid[6] uid[5] uid[4]
122-
low frame (0x41): uid[3] uid[2] uid[1] uid[0]
122+
step 1 (0x41): uid[3] uid[2] uid[1] uid[0] — the first four UID bytes
123+
step 2 (0x40): uid[7] uid[6] uid[5] uid[4] — the last four UID bytes
124+
125+
`-akrc` = activate field, keep it on after the frame, don't wait for a
126+
reply (the magic write doesn't send one), append CRC. Both frames must
127+
reach the tag in one field session, so callers pass them to `run()`
128+
together — see `writer.write_uid`.
123129
"""
124130
if len(uid_bytes) != 8:
125131
raise ValueError(f"UID must be 8 bytes, got {len(uid_bytes)}")
126-
high = bytes([uid_bytes[7], uid_bytes[6], uid_bytes[5], uid_bytes[4]])
127-
low = bytes([uid_bytes[3], uid_bytes[2], uid_bytes[1], uid_bytes[0]])
128-
frame_high = "hf 15 raw -acw -d 02E00940" + high.hex().upper()
129-
frame_low = "hf 15 raw -acw -d 02E00941" + low.hex().upper()
130-
return frame_high, frame_low
132+
first = bytes([uid_bytes[3], uid_bytes[2], uid_bytes[1], uid_bytes[0]])
133+
last = bytes([uid_bytes[7], uid_bytes[6], uid_bytes[5], uid_bytes[4]])
134+
frame_first = "hf 15 raw -akrc -d 02E00941" + first.hex().upper()
135+
frame_last = "hf 15 raw -akrc -d 02E00940" + last.hex().upper()
136+
return frame_first, frame_last
131137

132138

133139
def parse_info_uid(output: str) -> str | None:

tonie_writer/writer.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -78,17 +78,15 @@ def ok(self) -> bool:
7878
def plan_write_commands(tag: TonieTag, options: WriteOptions) -> list[str]:
7979
"""The exact, ordered pm3 command list a real write would send.
8080
81-
Data blocks are written before the UID (§4.4/§8.5): once the tag carries
82-
a foreign UID, the blocks may no longer be writable. Never reorder this.
81+
Data blocks are written before the UID: once the tag carries a foreign
82+
UID, the blocks may no longer be writable. Never reorder this.
8383
"""
8484
commands: list[str] = []
8585
if not options.uid_only:
8686
for block, data in enumerate(tag.blocks):
8787
commands.append(proxmark.build_wrbl_command(block, data))
8888
if not options.blocks_only:
89-
high, low = proxmark.build_uid_frames(tag.uid_bytes)
90-
commands.append(high)
91-
commands.append(low)
89+
commands.extend(proxmark.build_uid_frames(tag.uid_bytes))
9290
return commands
9391

9492

@@ -132,15 +130,16 @@ def write_uid(
132130
run_fn: RunFn = proxmark.run,
133131
sleep_fn: Callable[[float], None] = time.sleep,
134132
) -> UidWriteResult:
135-
"""Write the target UID using the two safe raw frames from §4.4 — never
136-
csetuid. Confirms via `hf 15 info` read-back, which is the only success
137-
signal these raw frames give. Retries up to 3 times with a short delay."""
138-
high, low = proxmark.build_uid_frames(tag.uid_bytes)
133+
"""Write the target UID using the two safe raw frames — never csetuid.
134+
Both frames go into a single pm3 session so the field stays up between
135+
them (`-k`); a power cycle in between can leave the UID half-written.
136+
Confirms via `hf 15 info` read-back, which is the only success signal
137+
these raw frames give. Retries up to 3 times with a short delay."""
138+
frames = list(proxmark.build_uid_frames(tag.uid_bytes))
139139
read_back: str | None = None
140140
max_attempts = 3
141141
for attempt in range(1, max_attempts + 1):
142-
run_fn([high], port=options.port, pm3_bin=options.pm3_bin)
143-
run_fn([low], port=options.port, pm3_bin=options.pm3_bin)
142+
run_fn(frames, port=options.port, pm3_bin=options.pm3_bin)
144143
info = run_fn(["hf 15 info"], port=options.port, pm3_bin=options.pm3_bin)
145144
read_back = proxmark.parse_info_uid(info.stdout)
146145
if read_back == tag.uid:

0 commit comments

Comments
 (0)