Safe Penetration Testing Labs · beginner · ~10 min

Render an ISO/IEC 14443 UID as hex

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.

Overview

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.

Why it matters

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:

  • Correlation. An incident responder greps six months of door logs for 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.
  • Interoperability. The colon-separated uppercase-hex convention is shared across libnfc, Proxmark3, Flipper Zero exports, and most SIEM parsers. Matching it exactly means your output pastes cleanly into other people's tooling and reports.
  • Legal defensibility. In a real investigation, logs may become evidence. A formatter with an off-by-one or a buffer overrun is not just a bug — it undermines the integrity of the record.

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.

Core concepts

1. The UID and its three legal lengths

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.

2. A UID is an identifier, not an authenticator

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.

3. Exact output sizing and the capacity check

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.

4. Uppercase hex, done deterministically

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.

Threat model (text diagram)

            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.

  1. What asset does the capacity check protect — the card, or the caller's out buffer (and the log built from it)?
  2. Where is the trust boundary in the diagram, and which two values cross it into format_uid?
  3. What insecure assumption turns this formatter into a buffer overflow, and which single if statement removes it?

Syntax notes

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.
  • Return int: a non-negative count on success, -1 on any rejection (bad length, insufficient capacity, NULL pointer). One error sentinel, checked by every caller.
  • The nibble lookup idiom, which needs no stdio:
static const char HEX[] = "0123456789ABCDEF";
out[j++] = HEX[b >> 4];   /* high nibble */
out[j++] = HEX[b & 0x0F]; /* low  nibble */

Lesson

Why this matters

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.

Your job

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).

Rules

  • n must be 4, 7, or 10. Any other length returns -1.
  • The output length is 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.
  • Return -1 if cap is too small to hold the result.

Common mistakes

  • Using sprintf with %02x, which gives lowercase output. Use %02X, or format the digits by hand.
  • An off-by-one error in the separator count.
  • Forgetting to validate the length.

What this is NOT

  • A reader driver. We do not talk to any hardware.
  • A clone tool. We render the UID; we do not write to a card.

Code examples

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.

Line by line

Walking the secure format_uid for the 4-byte UID {0xAA,0xBB,0xCC,0xDD} with a 64-byte buffer:

  1. uid == NULL || out == NULL — both are valid pointers, so we continue.
  2. uid_len_ok(4) — 4 is legal, so we continue. (Had n been 5, we would return -1 here and never touch out.)
  3. need = 3 * 4 = 12. We require 12 bytes: 11 visible + 1 NUL.
  4. cap < need64 < 12 is false, so we have room. Proceed to write.
  5. The loop, tracing 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.

  1. out[11] = '\0' — the NUL lands at index 11, the 12th byte, exactly the byte we reserved with need.
  2. 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 0x0AHEX[10] = 'A'; 0xAA & 0x0F is 0x0A'A'. So 0xAA becomes "AA". This never indexes past HEX[15] because a nibble is always 0–15.

Common mistakes

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?"

Debugging tips

  • Output is lowercase. You used %02x. Switch to %02X or the HEX[] table.
  • String has garbage after the UID. You forgot the NUL, or wrote it at the wrong index. Confirm the terminator lands at index 3*n - 1 and that you reserved 3*n bytes.
  • Extra or missing colon. Re-check the if (i + 1 < n) guard — a common slip is i < n (adds a trailing colon) or i + 1 <= n (same bug).
  • ASan reports a heap/stack-buffer-overflow. Your cap check is missing or runs after the loop. Move if (cap < 3*n) return -1; above the first write.
  • Function returns a huge or negative-looking value. Remember 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.
  • Real UID doesn't match what another tool prints. Confirm byte order — read the reader's docs for whether it hands you the UID most-significant-byte first. Do not reverse bytes to "make it match" without understanding why.

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

Memory safety (C). This function is a textbook bounded-write. The rules:

  • Compute need = 3*n and refuse if cap < need before touching out. Never write first and hope.
  • Treat 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.
  • Reject NULL uid/out to avoid a NULL dereference.
  • Build with -Wall -Wextra -fsanitize=address,undefined while developing; ASan turns a silent overflow into a loud, located crash.
  • The 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:

  • Log: timestamp (UTC), reader/door ID (the resource), the formatted UID string, the security decision (granted/denied), and a correlation ID tying the event to a session or request. Uniform uppercase colon-hex is what makes these lines greppable and diffable.
  • Never log: card cryptographic keys, sector/authentication keys, challenge-response transcripts, PINs, or any personal data (name, employee number) beyond what the audit legally requires. The UID is an identifier, not a secret, but the keys that authenticate the card absolutely are.
  • Events that signal abuse: the same UID appearing at two physically distant readers within an impossible travel time (possible clone/emulation), a burst of unknown UIDs at one reader (card enumeration), or many denied decisions in seconds (brute-forcing the roster).
  • False positives: a legitimately re-issued card can reuse a UID pattern; maintenance staff badge through many doors quickly; a flaky reader re-reads one card as several events. Tune alerts to physical plausibility, and always corroborate a UID signal with a second factor before acting.

Real-world uses

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.

Practice tasks

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.

Summary

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.

Practice with these exercises