IERG 4851 · Week 8

In-Class Lab: Contactless Cards

Five cards, one scoreboard. Nothing is submitted — the ledger keeps score for you.

1. Your address, and how you submit a flag

Do this before you start on the cards, from ~/ierg4851/contactless-ctf on the lab machine. You submit a flag by redeeming it — there is no form, nothing goes to Blackboard, and OK 250 is your receipt.

Make and register your address — once

This is where your points are paid, and the same command registers the address with the scoreboard under your login name, so the board shows u1155203181, not a hex string:

python3 score/keygen.py
wrote mykey.json
address: 3c42822cbbcfb07cac13ad9afa7555cb10fc58f7
pubkey : 04a1f0…
registered as u1155203181

Registration is signed with your key and tied to your account on the lab machine, so nobody can register a classmate's name. If it says ledger not reachable, the lab is not up yet — run the command again once it is; your address will not change.

How the address is verified. Every run re-derives the address from the private key and prints verified: address derives from this key; a WARNING means mykey.json was edited or damaged. The ledger checks the same rule on registration — an address is sha256(public key)[:20], and REGISTER must carry a signature from the matching private key. So an address cannot be claimed by someone who does not hold its key, and you cannot register someone else's name.

Keep your address. keygen.py writes mykey.json, which holds the key to your address. Run it once; running it again keeps the existing address rather than replacing it (--force makes a new one, and points at the old address stay there). To see the address later: python3 score/keygen.py --show.

Submit a flag — each time you read one

The --flag is the text: string from your own dump; the --to is the address printed above:

python3 score/redeem.py --flag IERG4851_NFC_DEFKEY_933e4aa8 --to 3c42822cbbcfb07cac13ad9afa7555cb10fc58f7
OK 250
your total is now 250 points

OK means the flag was accepted and paid. ERR … says why it was refused — unknown flag, already redeemed, or a malformed address. Each flag pays 250 points, once.

Check your score

myscore
your name     u1155203181
your address  3c42822cbbcfb07cac13ad9afa7555cb10fc58f7
your points   500   (250 per flag)
rank          3 of 21

top 5:
  1. u1155203181   500
  2. u1155203191   250
The projector shows the same ledger live at http://<lab-machine>:8080/ — every name, its points, and a grid of which of the five cards each student has solved. It cannot be written to; only the ledger can credit a flag. The page asks for a login: user ierg4851, and the instructor gives the class the password.
Your flags are yours alone. Each was generated for you with a random suffix, so a flag copied from a classmate redeems for them, not for you — and a flag you invent will be refused (ERR unknown flag). Each flag pays once, to the address that redeems it: use your own.

2. What you are attacking

The target is a software simulator, and its README says so. There is no reader and no chip: it models an ISO 14443A card — its sectors, trailers and access bits — over a socket. The mistakes are the ones real deployments make; the silicon is not real.

Every card here fails the way a great many real deployments have: the secret is computable from public data.

3. What you are given

There is no reader and no card in your hand. On the lab machine every challenge is a simulated card: a small process listening on a port of 127.0.0.1. Talking to that port is talking to the card. You have five challenges, five ports.

Your ports — one command

Log in to the lab machine and run:

myports

It prints the five cards that belong to you, with their port numbers:

your cards, u1155203181:
  default    7105
  weakuid    7106
  valueblk   7107
  chain      7108
  canteen    7109
If it says “no port block for …”, the lab has not been started yet (or you are not on the roster) — tell the instructor. The same command works for the TA and for ctf-demo; everyone gets their own five ports.

You do not even need the numbers

nfctool.py takes the card by name and looks your port up itself, so from here on you only ever type this:

cd ~/ierg4851/contactless-ctf
python3 nfc-ctf/nfctool.py --card default scan

The five names are default, weakuid, valueblk, chain and canteen — one per challenge, in the order above. --port 7105 still works if you prefer numbers; that form is mainly for the instructor.

The reader: nfctool.py

One program does everything a reader can do. It lives at ~/ierg4851/contactless-ctf/nfc-ctf/nfctool.py; run it from ~/ierg4851/contactless-ctf. The key you type stays on your side — the tool computes the authentication proofs, and the key is never sent to the card.

commandwhat it doesexample
scanREQA and anticollision: prints the ATQA and the card's UID. Needs no key.nfctool.py --card default scan
selectAnticollision and SELECT: prints the SAK (08 = MIFARE Classic 1K).nfctool.py --card default select
auth <sector> <key>The three-pass authentication for one sector. Prints OK A / OK B or FAIL.nfctool.py --card default auth 1 ffffffffffff
dump <sector> [key]Authenticates (if you give a key), then reads the four blocks: hex, ASCII, and a text: line for anything readable.nfctool.py --card default dump 1 ffffffffffff
read <sector>Reads the four blocks of a sector you have already authenticated.nfctool.py --card default read 1
write <block> <32 hex>Writes one 16-byte block. Needs that block's sector authenticated first.nfctool.py --card default write 8 e8030000…
Each command opens a connection, does one thing and exits. The card remembers which sectors you authenticated for as long as it is running, so auth followed by read in two separate invocations works. A dump with a key authenticates by itself.
The demo from the lecture is at https://yajin.org/teaching/IERG4851/2026/materials/nfc-demo.html — the same page shown in class, no login needed. It is the same protocol with the card drawn on the screen: useful for remembering what a command is doing, not a substitute for the real tool.

4. The first three cards

Three cards, three mistakes. Each card holds a flag in one sector; read it out, then redeem it (section 1). The reader tells you when authentication fails, and the card keeps an authentication for the rest of the connection.

What a flag is, and where it is.
  • Format: IERG4851_NFC_<CARD>_<8 hex>, for example IERG4851_NFC_DEFKEY_933e4aa8.
  • Yours alone. Every flag is generated when the lab starts, with a random suffix: no two people have the same one — not the students, not the TA, not ctf-demo. You cannot guess your neighbour's, and nobody can guess yours.
  • Stored as plain ASCII in a data block of the card, which is why dump prints it on a text: line. It is not in the trailer block, and it is not in block 0.
  • Where: each card below opens with the sector and block that holds its flag, so you know where to look once the sector opens.
  • You get it by reading the card. There is no list of flags anywhere you can consult — only the ledger knows which strings are valid, and it does not tell you them.
How to approach any card — do this first
  1. scan the card. It always answers with its UID, before any key.
  2. Try the factory key on sector 1: dump 1 ffffffffffff.
  3. If that works, you are done — read the blocks and find the flag.
  4. If it fails, this card changed its key. The clue to the new one is either something the card told you (its UID, a note in another sector) or the way the deployment manages keys. That is the actual puzzle.
  5. When a sector opens, the flag is plain text in one of its four blocks.
  6. Redeem it (section 1). Nothing else to hand in.

Card 1 — the factory key

Sector 1 still wears the key it left the factory with. If you have ever seen a MIFARE dump, you know which six bytes those are.

Where the flag is: sector 1, block 5 (it spills into block 6). It is the text: line at the bottom of the dump.

python3 nfc-ctf/nfctool.py --card default scan
python3 nfc-ctf/nfctool.py --card default dump 1 <key>

Card 2 — a key made from the serial number

This vendor did change the key: every card has its own. But it derived that key from the UID — the four bytes the card announces to anyone in range before any key is involved. scan prints them. The scheme is the first six bytes of a hash of the UID. Six bytes is twelve hex characters — exactly one key.

How to find which hash. Only a few are worth trying — MD5, SHA-1, SHA-256 — and trying one costs nothing. Take the UID from scan and hash its four bytes:
python3 -c "import hashlib; u=bytes.fromhex('<the four bytes from scan>'); print(hashlib.md5(u).hexdigest()[:12])"
If the card answers OK, that string is the key — and it is the key for every sector of that card.

Where the flag is: sector 1, block 5 — the same place as card 1, once you have the key.

This is the lesson of the whole card half. The UID is public by design. Anything derived from it is public too.

Card 3 — the wrong access bit

Sector 2 holds a stored-value block. Its access bits say key A may write, which is exactly what they should not say. Those bits sit in the sector's fourth block, the trailer: a read returns the two keys as zeros but does return the four access bytes, so the last block is not hidden. The sector stays locked until the value block says the balance is at least 1000.

python3 nfc-ctf/nfctool.py --card valueblk dump 2 ffffffffffff   # authenticates, then the read fails
python3 nfc-ctf/nfctool.py --card valueblk write 8 <16 bytes, hex>
python3 nfc-ctf/nfctool.py --card valueblk dump 2 ffffffffffff   # now it opens

A MIFARE value block is value, its one's complement, the value again, then the block address and its complement twice. The arithmetic is checkable on paper.

Where to start. Ask for sector 2. The read is refused, and the card says why: the balance is 10, and block 8 holds a value block. So the block is real and the door is the balance, not the key. Build the 16 bytes yourself from the layout above — the card accepts the write only if the value, its complement and the value again agree, and the address bytes are block 8's own.

Where the flag is: sector 2, block 9. It appears in the dump only after the sector unlocks — the read fails until the balance is at least 1000.

5. Two more cards

The last two are not a single trick; they are the shape of a real deployment.

Card 4 — the chained badge

Nobody leaves the key to everything on one page. This badge opens with the factory key, and what is inside tells you where the next key is. Follow it to the end.

python3 nfc-ctf/nfctool.py --card chain dump 1 ffffffffffff
# the text in the blocks tells you the key for the NEXT sector
python3 nfc-ctf/nfctool.py --card chain dump 2 <that key>
# and that sector tells you the next one, until the flag
How to follow the chain. Sector 1 is the only sector still on the factory key. Its text: line is a 12-hex key; use that on sector 2, read the next text: line, and repeat. Two hops, then the flag. If the tool answers auth failed, the key is wrong — copy all twelve hex characters again, no spaces.

Where the flag is: sector 3, block 12 — the last hop of the chain. Each earlier sector's text tells you the key for the next one.

Note what this models: a "secret" that is stored in plaintext one sector away is not protected by the next sector's key at all.

Card 5 — the canteen balance

Ten credits on a stored-value card, and the till costs a thousand. The balance is a value block: the value, its bitwise complement, the value again, then the block's address and its complement, twice (16 bytes in all). The card trusts the block only if every copy agrees — write only the value, or the wrong address bytes, and it stays invalid and the sector stays locked.

python3 nfc-ctf/nfctool.py --card canteen dump 1 ffffffffffff    # see the value block
# then write 16 bytes: value, ~value, value, address
python3 nfc-ctf/nfctool.py --card canteen write 4 <32 hex chars>

Where the flag is: sector 2, block 8. The sector stays locked until block 4 is a valid value block of at least 1000, so the flag appears only after a correct write.

What "consistent" means here. All four parts have to agree: the value, its complement, the value again, and the block's own address. Block 4 carries 04 fb 04 fb in its last four bytes; block 8 carries 08 f7 08 f7. A write copied from card 3 fails on those address bytes, not on the balance. (1000 is e8 03 00 00 in little-endian; its complement is 17 fc ff ff.) dump 1 already authenticated sector 1, so the write to block 4 can follow it.
The check is the lesson. A plain write skips the arithmetic the card would normally do on increment. Getting past the check is exactly what a real attacker does with a cloned card.

6. The explain round

Three or four students, picked by the board, spend three minutes each. You do not need slides. You need:

"I read it out of the source" is a true answer that scores nothing. The question is what someone outside the device could have computed.

7. Rules