Safe Penetration Testing Labs · beginner · ~10 min
By the end of this lesson you will be able to: - Explain what an ISO/IEC 14443 UID is, why it is 4, 7, or 10 bytes, and how NFC tooling logs it. - Write a C11 function that renders a raw byte array as uppercase, colon-separated hex (`AA:BB:CC:DD`) with a NUL terminator. - Compute the exact output buffer size (`n * 3 - 1`, plus one for the NUL) and bound-check the caller's capacity **before** writing a single byte. - Validate the UID length and reject anything that is not 4, 7, or 10 bytes. - Log a scanned UID safely for an authorized access-control audit, and reason about what a UID does and does not prove about identity.
Security objective. In an access-control system (a badge reader on a door, a transit gate, a hotel lock), the asset being protected is entry to a physical space, and the log is your evidence trail. The UID printed by an NFC reader is the identifier you write into that log. This lesson teaches the small but load-bearing job of turning the raw UID bytes into the exact canonical text every NFC tool uses, so your audit logs are diffable against nfc-list, libnfc, Proxmark, and every incident report you will ever read. A malformed or truncated UID string is a corrupted piece of evidence.
What ISO/IEC 14443 is. ISO/IEC 14443 is the international standard behind virtually every contactless smartcard: transit passes, building badges, hotel keys, passports (the contactless chip), and NFC tags. Every such card advertises a UID (Unique Identifier) during the anticollision phase of the protocol — a short byte string that is 4, 7, or 10 bytes long. Those three lengths correspond to the cascade levels the reader walks through to select one card out of a field of many.
What this lesson is (and is not). This is the formatter side only: you receive the bytes and produce text. It builds directly on your two prerequisites — pointers (you walk a const uint8_t * and write into a caller-owned char *out) and bounded-copy (you must know the exact output length and refuse to write past cap). You will not talk to hardware, drive a reader, or write to a card. Reading a UID off a real PN532 or PN5180 reader is a separate firmware project; cloning a card is out of scope and, against a card you do not own, illegal.
Where you will do this. All work here runs on your own machine with byte arrays you type in yourself, or against tags you personally own in a lab. No third-party card, badge, or building system is ever a valid target.
In authorized professional work — a physical-security assessment, a red-team engagement with a signed scope, or building the logging layer of an access-control product — the UID string is the primary key that ties an event to a credential. Consider what depends on getting the render right:
04:A2:2F:80:6B:23:90. If your formatter emitted lowercase, dropped a colon, or truncated the last byte, that search silently misses events. Evidence you cannot find is evidence you do not have.libnfc, Proxmark3, Flipper Zero exports, and most SIEM parsers. Matching it exactly means your output pastes cleanly into other people's tooling and reports.And because this is C, the formatter sits one mistake away from a buffer overflow. A function that writes UID text into a fixed stack buffer without checking cap is exactly the class of bug attackers hunt for in embedded readers. Learning to size the buffer and refuse to overrun it is the whole point.
Definition. A UID is the byte string a 14443 card returns during anticollision so the reader can single it out. It is 4, 7, or 10 bytes — nothing else is valid.
Plain explanation. Early cards used a single 4-byte UID. Because 4 bytes ran out of unique values (and some were reused), the standard added cascade levels: a 7-byte UID (double-size) and a 10-byte UID (triple-size). The reader learns the length while selecting the card. For our formatter, the length arrives as the argument n.
How it works. Given n bytes, you print each byte as two hex digits and put a colon between consecutive bytes — one fewer colon than there are bytes. So the visible text is 2*n hex characters plus n-1 colons, which is 3*n - 1 characters, then one NUL terminator.
When / when not. Treat 4, 7, and 10 as the only legal inputs. Reject 0, 5, 8, or anything else with an error — do not silently format a length the standard does not allow, because that hides a bug upstream (a partial read, a corrupted frame).
Pitfall. The famous NXP MIFARE Classic 4-byte UID 08 prefix and the 04 manufacturer prefix on 7-byte cards are data, not structure. Do not special-case them; format all bytes uniformly.
Definition. The UID identifies which card, but it is transmitted in the clear and is trivially readable by any reader in range.
Plain explanation. Because it is not secret and (on many card types) can be emulated, a UID alone should never be used as a password or proof of identity. "The door opened because we saw UID X" is a weak control; the UID is a username, not a credential. Real systems layer cryptographic authentication (challenge-response with a key stored on the card) on top.
Pitfall / misconception. "UID-only" access control is common and insecure. Recognizing a UID string is fine for logging and correlation; treating it as authentication is the insecure assumption that gets buildings cloned into.
Definition. The output buffer must hold 3*n - 1 visible characters plus one NUL byte, so you need 3*n bytes total.
How it works. Before writing anything, compute need = 3*n (including NUL). If cap < need, return an error and touch nothing. This is the bounded-copy discipline: know the size, verify the room, then write.
Pitfall. Off-by-one on the NUL. 3*n - 1 is the string length; the buffer requirement is 3*n. Forgetting the terminator's byte is the single most common bug here and produces a non-terminated string that later reads run off the end of.
Definition. The canonical render uses uppercase digits 0-9A-F.
How it works. printf("%02X", b) yields uppercase; %02x yields lowercase. Either use %02X, or map each nibble by hand through a lookup string "0123456789ABCDEF". The manual approach avoids snprintf entirely and makes the byte math explicit — useful when you want to prove you never overrun.
Pitfall. %02x (lowercase) silently breaks case-sensitive log diffs. Pick uppercase and be consistent.
AUTHORIZED PHYSICAL-SECURITY LAB
+-------------------------------------------------------+
| Your machine / owned NFC tag |
| |
| [owned tag] --RF--> [reader you own] --> raw UID |
| bytes[n] |
| | |
| TRUST BOUNDARY = = = = = = = = = = = = =|= = = = |
| (untrusted input crosses here: n and v |
| the bytes come from the RF field) format_uid() |
| | |
| v |
| out[] hex text |
| | |
| v |
| ACCESS LOG (asset)|
+-------------------------------------------------------+
ASSET : the access log's integrity + the buffer 'out'
ENTRY POINT : (uid, n) — attacker-influenced length/contents
BAD ASSUMPTION: 'n is always sane' -> overflow if unchecked
DEFENSE : validate n in {4,7,10}; verify cap >= 3*n; then write
Knowledge check.
out buffer (and the log built from it)?format_uid?if statement removes it?The function contract, annotated:
#include <stdint.h> /* uint8_t */
#include <stddef.h> /* size_t */
/* Render n UID bytes as "AA:BB:CC" into out (NUL-terminated).
* uid : source bytes (read-only) -> const, so we can't clobber it
* n : must be 4, 7, or 10 -> validated, not trusted
* out : caller-owned destination -> we write here, never past cap
* cap : size of out in bytes -> the guardrail
* returns: number of visible bytes written (== 3*n - 1), or -1 on error
*/
int format_uid(const uint8_t *uid, size_t n, char *out, size_t cap);
Key structural points:
const uint8_t *uid — read-only source; the const is a compiler-enforced promise you will not modify the caller's bytes.size_t for n and cap — the natural type for sizes; but note size_t is unsigned, so guard against surprising values before doing arithmetic.int: a non-negative count on success, -1 on any rejection (bad length, insufficient capacity, NULL pointer). One error sentinel, checked by every caller.stdio:static const char HEX[] = "0123456789ABCDEF";
out[j++] = HEX[b >> 4]; /* high nibble */
out[j++] = HEX[b & 0x0F]; /* low nibble */
ISO/IEC 14443 is the standard behind every contactless card. Transit passes, hotel keys, and NFC tags all use it.
Each card has a UID (Unique Identifier), which is 4, 7, or 10 bytes long. Every NFC tool prints it the same way, as uppercase hex separated by colons:
AA:BB:CC:DD
This exercise covers the formatter side only: turning the raw bytes into that text. Reading a UID from a real PN532 or PN5180 reader is a separate firmware project.
Implement this function:
int format_uid(const uint8_t *uid, size_t n, char *out, size_t cap);
Render uid into out as uppercase hex, colon-separated, and NUL-terminated. Return the number of bytes written (not counting the NUL terminator).
n must be 4, 7, or 10. Any other length returns -1.n * 3 - 1 bytes. Each byte takes 2 hex characters plus 1 separator, minus the trailing separator that you don't write. Add 1 more byte for the NUL terminator.-1 if cap is too small to hold the result.sprintf with %02x, which gives lowercase output. Use %02X, or format the digits by hand.Below is a complete, compilable C11 program. It shows the insecure version first (so you can see the exact shape of the bug), then the secure version, then a small verify harness that proves the fix rejects bad input and accepts good input.
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
/* =====================================================================
* WARNING: intentionally vulnerable -- use only in a local, isolated,
* authorized lab. Do not deploy.
*
* This version trusts n and ignores the destination capacity. If n is
* larger than the caller's buffer, it writes past the end -> a classic
* stack/heap buffer overflow. Shown ONLY so you can recognize the bug.
* ===================================================================== */
int format_uid_insecure(const uint8_t *uid, size_t n, char *out)
{
static const char HEX[] = "0123456789ABCDEF";
size_t j = 0;
for (size_t i = 0; i < n; i++) {
out[j++] = HEX[uid[i] >> 4];
out[j++] = HEX[uid[i] & 0x0F];
if (i + 1 < n)
out[j++] = ':'; /* no cap check anywhere */
}
out[j] = '\0'; /* may write past the buffer */
return (int)j;
}
/* =====================================================================
* SECURE version: validate the length, size the output exactly, verify
* the caller's capacity BEFORE writing, reject NULL pointers.
* ===================================================================== */
static int uid_len_ok(size_t n)
{
return n == 4 || n == 7 || n == 10; /* the only legal lengths */
}
int format_uid(const uint8_t *uid, size_t n, char *out, size_t cap)
{
static const char HEX[] = "0123456789ABCDEF";
if (uid == NULL || out == NULL) /* defend against NULL */
return -1;
if (!uid_len_ok(n)) /* reject illegal lengths */
return -1;
size_t need = 3 * n; /* 3*n - 1 visible + 1 NUL */
if (cap < need) /* the guardrail */
return -1;
size_t j = 0;
for (size_t i = 0; i < n; i++) {
out[j++] = HEX[uid[i] >> 4];
out[j++] = HEX[uid[i] & 0x0F];
if (i + 1 < n)
out[j++] = ':';
}
out[j] = '\0';
return (int)j; /* == 3*n - 1 */
}
/* =====================================================================
* VERIFY: prove the fix REJECTS bad input and ACCEPTS good input.
* ===================================================================== */
static int checks_passed = 0, checks_total = 0;
static void expect(int cond, const char *what)
{
checks_total++;
if (cond) { checks_passed++; printf(" ok : %s\n", what); }
else { printf(" FAIL : %s\n", what); }
}
int main(void)
{
/* A known 4-byte UID. */
const uint8_t uid4[4] = { 0xAA, 0xBB, 0xCC, 0xDD };
char buf[64];
/* ACCEPTS good input, exact canonical text. */
int r = format_uid(uid4, 4, buf, sizeof buf);
expect(r == 11, "4-byte UID returns length 3*4-1 = 11");
expect(strcmp(buf, "AA:BB:CC:DD") == 0, "renders AA:BB:CC:DD (uppercase)");
/* A 7-byte UID. */
const uint8_t uid7[7] = { 0x04, 0xA2, 0x2F, 0x80, 0x6B, 0x23, 0x90 };
r = format_uid(uid7, 7, buf, sizeof buf);
expect(r == 20, "7-byte UID returns length 20");
expect(strcmp(buf, "04:A2:2F:80:6B:23:90") == 0, "renders 7-byte colon-hex");
/* REJECTS an illegal length. */
expect(format_uid(uid4, 5, buf, sizeof buf) == -1, "len 5 rejected (-1)");
expect(format_uid(uid4, 0, buf, sizeof buf) == -1, "len 0 rejected (-1)");
/* REJECTS a buffer that is too small (needs 12, give 11). */
char tiny[11];
expect(format_uid(uid4, 4, tiny, sizeof tiny) == -1, "cap 11 rejected");
/* Exactly enough room (12) is accepted. */
char exact[12];
expect(format_uid(uid4, 4, exact, sizeof exact) == 11, "cap 12 accepted");
/* REJECTS NULL. */
expect(format_uid(NULL, 4, buf, sizeof buf) == -1, "NULL uid rejected");
printf("\n%d/%d checks passed\n", checks_passed, checks_total);
return checks_passed == checks_total ? 0 : 1;
}
Build and run:
cc -std=c11 -Wall -Wextra -fsanitize=address,undefined format_uid.c -o format_uid
./format_uid
Expected output (order as written):
ok : 4-byte UID returns length 3*4-1 = 11
ok : renders AA:BB:CC:DD (uppercase)
ok : 7-byte UID returns length 20
ok : renders 7-byte colon-hex
ok : len 5 rejected (-1)
ok : len 0 rejected (-1)
ok : cap 11 rejected
ok : cap 12 accepted
ok : NULL uid rejected
9/9 checks passed
Compiling with -fsanitize=address,undefined matters: if you swap in format_uid_insecure and hand it an 11-byte buffer for a 4-byte UID, AddressSanitizer reports the out-of-bounds write immediately. That is the whole lesson — the secure version never trips the sanitizer because it checks cap first.
Walking the secure format_uid for the 4-byte UID {0xAA,0xBB,0xCC,0xDD} with a 64-byte buffer:
uid == NULL || out == NULL — both are valid pointers, so we continue.uid_len_ok(4) — 4 is legal, so we continue. (Had n been 5, we would return -1 here and never touch out.)need = 3 * 4 = 12. We require 12 bytes: 11 visible + 1 NUL.cap < need → 64 < 12 is false, so we have room. Proceed to write.i (byte index) and j (write cursor):| i | byte | writes | j after |
|---|---|---|---|
| 0 | 0xAA | A,A,: |
3 |
| 1 | 0xBB | B,B,: |
6 |
| 2 | 0xCC | C,C,: |
9 |
| 3 | 0xDD | D,D |
11 |
Note the colon is written only when i + 1 < n, so byte 3 (the last) gets no trailing colon. That is the n-1 colon rule in code.
out[11] = '\0' — the NUL lands at index 11, the 12th byte, exactly the byte we reserved with need.return (int)j returns 11, which equals 3*n - 1. The caller can trust the string length without calling strlen.The nibble math per byte: for 0xAA, 0xAA >> 4 is 0x0A → HEX[10] = 'A'; 0xAA & 0x0F is 0x0A → 'A'. So 0xAA becomes "AA". This never indexes past HEX[15] because a nibble is always 0–15.
| Wrong approach | Why it's wrong | Corrected | How to recognize / prevent |
|---|---|---|---|
| Write bytes, then check length | The overflow already happened before the check runs | Validate n and cap before the first write |
ASan fires on write; code review: "is every bound checked before the loop?" |
Buffer sized 3*n - 1 |
No room for the NUL; strlen/printf later run off the end |
Size 3*n (or reserve +1 explicitly) |
Off-by-one; a "cap N rejected / N+1 accepted" test pins it |
sprintf(out, "%02x", b) |
Lowercase output breaks case-sensitive log diffs | %02X, or the HEX[] lookup |
Diff your output against a known vector "AA:BB:CC:DD" |
Format any n the caller sends |
A partial/corrupt read (e.g. n==2) gets silently formatted, hiding an upstream bug |
Reject anything not in {4,7,10} |
Unit test that format_uid(...,5,...) == -1 |
Trailing colon AA:BB:CC:DD: |
Extra separator; won't match other tools | Emit colon only when i+1 < n |
Compare exact string, not just prefix |
| Treat the UID as a password | A UID is public and (often) clonable — using it to authenticate is the insecure assumption | Log the UID; authenticate with card crypto | Ask: "does anything grant access on UID alone?" |
%02x. Switch to %02X or the HEX[] table.3*n - 1 and that you reserved 3*n bytes.if (i + 1 < n) guard — a common slip is i < n (adds a trailing colon) or i + 1 <= n (same bug).cap check is missing or runs after the loop. Move if (cap < 3*n) return -1; above the first write.size_t is unsigned; if you compute n - 1 when n == 0 it wraps to a giant number. Validate n first so this arithmetic never runs on bad input.Questions to ask when it fails: Did I check cap before writing? Is my reserved size 3*n, not 3*n - 1? Is the length one of {4,7,10}? Does my output byte-for-byte equal a known vector?
Memory safety (C). This function is a textbook bounded-write. The rules:
need = 3*n and refuse if cap < need before touching out. Never write first and hope.n as untrusted input, not a fact. Validate it to {4,7,10} so no later arithmetic (n-1, 3*n) can overflow or wrap. size_t is unsigned; n - 1 at n == 0 becomes SIZE_MAX.uid/out to avoid a NULL dereference.-Wall -Wextra -fsanitize=address,undefined while developing; ASan turns a silent overflow into a loud, located crash.HEX[b & 0x0F] and HEX[b >> 4] indices are always 0–15, so the lookup is provably in-bounds — no check needed there.Security & safety — detection & logging. When this formatter feeds a real access-control audit trail:
denied decisions in seconds (brute-forcing the roster).Authorized use case. A physical-security consultant, under a signed scope, audits a client's badge-reader logs. Their tooling scans client-owned test cards, formats each UID with exactly this routine, and correlates the strings against the door-access database to find orphaned credentials and UID-only doors that lack cryptographic authentication. The deliverable is a report, not a cloned badge.
Professional habits this lesson builds:
| Habit | Beginner | Advanced |
|---|---|---|
| Input validation | Reject lengths not in {4,7,10} and NULL pointers |
Fuzz format_uid with random n/cap; add the fuzzer to CI |
| Least privilege | Formatter only reads bytes and writes text — no hardware, no card writes | Isolate any reader driver in its own process with no filesystem/network access |
| Secure defaults | Uppercase colon-hex, always NUL-terminated | Emit structured logs (one canonical UID field) so a SIEM parses them without regex guessing |
| Logging | Log UID + decision + timestamp | Add correlation IDs and impossible-travel detection; alert on UID-only doors |
| Error handling | Single -1 sentinel, checked by every caller |
Distinguish error causes for diagnostics without leaking secrets into logs |
Authorization reminder. Everything here runs on hardware and cards you own or are explicitly, in writing, authorized to test. Scanning, logging, or cloning someone else's badge, transit card, or building credential without authorization is illegal regardless of how easy the tooling makes it. A UID is easy to read; that does not make reading a stranger's card lawful.
All tasks are lab-only: use byte arrays you type in, or tags you personally own. Each security-flavored task ends by remediating and verifying, not by attacking anything.
Beginner 1 — Lowercase variant with a switch.
Objective: add int format_uid_case(const uint8_t *uid, size_t n, char *out, size_t cap, int upper) that renders uppercase when upper is nonzero, lowercase otherwise.
Requirements: reuse the length and capacity checks; do not duplicate the sizing logic.
Input/Output: {0xAB,0xCD,0xEF,0x01}, upper=0 → "ab:cd:ef:01".
Constraints: no sprintf; use two HEX/hex tables.
Hints: pick the table with a ternary before the loop. Concepts: pointers, bounded write.
Beginner 2 — Classify by cascade level.
Objective: const char *uid_class(size_t n) returning "single" (4), "double" (7), "triple" (10), or "invalid".
Requirements: no allocation; return string literals.
Input/Output: 7 → "double"; 5 → "invalid".
Constraints: a single switch or if-chain. Concepts: the legal-length set.
Intermediate 1 — Exact-fit buffer sizing helper.
Objective: size_t uid_hex_size(size_t n) returning the buffer bytes needed (3*n) for legal n, or 0 for illegal n; then make format_uid call it.
Requirements: format_uid must reject when cap < uid_hex_size(n) and when uid_hex_size(n) == 0.
Input/Output: uid_hex_size(10) → 31; uid_hex_size(8) → 0.
Constraints: no magic 3*n sprinkled through the code — centralize it. Concepts: single source of truth for sizing.
Intermediate 2 — Round-trip parser (defensive).
Objective: int parse_uid_hex(const char *s, uint8_t *out, size_t cap, size_t *n_out) that reads "AA:BB:CC:DD" back into bytes, validating the resulting length is in {4,7,10}.
Requirements: reject non-hex characters, wrong separators, odd digit counts, and over-long input; never write past cap. Prove it by round-tripping: format → parse → format equals the original string.
Input/Output: "04:A2:2F:80:6B:23:90" → 7 bytes, *n_out == 7; "04:A2:2F" (3 bytes) → error.
Constraints: no strtok that mutates the input. Concepts: untrusted-input parsing, bounded write.
Challenge — Harden a logging path and verify it.
Objective: given a mock struct { uint8_t uid[10]; size_t n; int granted; } event;, write int log_line(const struct event *e, char *out, size_t cap) that emits one audit line "<UTC-ISO8601> door=<id> uid=<colon-hex> result=<granted|denied>", and refuses to emit anything if e->n is illegal or cap is too small.
Requirements: never log any secret; treat e->n as untrusted; return -1 on any rejection with out left unchanged.
Defensive conclusion (mandatory): write a test harness that (a) feeds an illegal n and asserts -1 with out untouched, (b) feeds a too-small cap and asserts -1, (c) feeds a valid event and asserts the exact expected line, then (d) run it under -fsanitize=address,undefined to prove no overflow. State in a comment which log field an incident responder would grep and why UID-only access is a finding, not a feature.
Hints: build the UID substring with your format_uid; assemble the rest with a single bounded snprintf and check its return. Concepts: bounded formatting, detection/logging discipline, mitigation verification.
Main concepts. An ISO/IEC 14443 UID is a 4-, 7-, or 10-byte identifier a contactless card advertises during anticollision; the three lengths are the standard's cascade levels. The canonical text render is uppercase, colon-separated hex (AA:BB:CC:DD) — the format every NFC tool and SIEM expects, which is why getting it exact makes your logs diffable and your evidence trustworthy.
Key syntax / commands. format_uid(const uint8_t *uid, size_t n, char *out, size_t cap); nibble render via HEX[b >> 4] and HEX[b & 0x0F]; visible length 3*n - 1, buffer requirement 3*n (include the NUL); build with cc -std=c11 -Wall -Wextra -fsanitize=address,undefined.
Common mistakes. Sizing 3*n - 1 and forgetting the NUL byte; checking capacity after writing (too late — the overflow already happened); lowercase %02x; a trailing colon; formatting an illegal length instead of rejecting it; and the security-level error of treating a public, clonable UID as an authenticator.
What to remember. Validate first (n ∈ {4,7,10}, non-NULL), verify cap >= 3*n, then write. A UID identifies a card; it does not authenticate one. Log the UID plus timestamp, door, and decision — never the card's keys. And every scan, log, or test happens only on hardware and cards you own or are explicitly authorized to assess. Nothing here is ever "completely secure"; the goal is correct, bounded, and auditable.