Skip to content

fix hf iclass unhash missing pre-images - #3453

Open
trichimtrich wants to merge 2 commits into
RfidResearchGroup:masterfrom
trichimtrich:fix/iclass-unhash-preimage-enumeration
Open

fix hf iclass unhash missing pre-images#3453
trichimtrich wants to merge 2 commits into
RfidResearchGroup:masterfrom
trichimtrich:fix/iclass-unhash-preimage-enumeration

Conversation

@trichimtrich

Copy link
Copy Markdown

Summary

Two independent bugs make hf iclass unhash omit valid hash0 pre-images, and for
some keys return none at all.

A pre-image is the DES ciphertext you feed to hashcat to recover a site master key. If
the true one is missing, hashcat searches the entire 2^56 keyspace and finds nothing —
with no way to tell that apart from "the master key isn't in this keyspace". You just
get a null result after however long the run took.

Round-tripping 500,000 random pre-images through hash0() and invert_hash0():

true pre-image recovered keys with no output
current master 82.55 % 1108
+ first commit 99.78 % 1117
+ second commit 100.000 % 0

One commit per cause.


1. The modulo forks used a hardcoded table

hash0() reduces each six-bit z chunk modulo a position-dependent value:

_zn  = (zn  % (63 - n)) + n      n = 0..3
_zn4 = (zn4 % (64 - n)) + n      n = 0..3, chunks 4..7

The modulus is below 0x40, so the reduction is lossy — a recovered residue r also
has a pre-image at r + modulus, whenever that still fits in six bits.

invert_hash0() forked on a hardcoded table mapping {00,01,02,03} to
{3f,3e,3d,3c} — i.e. alt = 63 - r — gated on
hydra_head <= n % 4 || hydra_head >= 63 - (n % 4).

Neither matches the arithmetic: the alternative is r + modulus, not 63 - r, and
which chunks can fork follows the modulus rather than n % 4. The table lands
correctly on a few positions by coincidence and misses the rest.

Fix

Take the modulus from hash0() itself:

uint8_t modulus = (n < 4) ? (63 - n) : (64 - (n - 4));
uint8_t alt_head = hydra_head + modulus;

if (alt_head <= 0x3F) {
    // fork: residue in one branch, alternative in the copy
}

Net −15 lines.

Example

Master key 1111111111111111 with CSN 2222222222222222 diversifies to
B94318BC673F0A12, and DES_enc(CSN, master) = 950973182317F80B:

hf iclass unhash -k B94318BC673F0A12
before after
090973182314280B 090973182314280B
4F0973182314280B 090973182317F80B
950973182314280B 4F0973182314280B
DB0973182314280B 4F0973182317F80B
950973182314280B
950973182317F80B ← the true one
DB0973182314280B
DB0973182317F80B

2. check() was not invertible in a single reading

ck() rewrites a repeated value as the index it matched:

ck(i, j, z) = ck(i, j-1, z[i] <- j)    if z[i] == z[j]

So a stored value in 0..3 is ambiguous: it may be such a marker, or a genuine value
that happens to be small. reverse_ck() committed to one reading, and walked j in
the same descending order as the forward pass, so it could not undo a chained
substitution:

half            01 03 24 03
ck()            01 03 24 00     03 matched z[1] -> 1, then 1 matched z[0] -> 0
reverse_ck()    01 03 24 01     restores z[0]=01 at j=0, never revisits j=1

8.9 % of random four-value halves failed the ck() round trip. Most of the time
that still landed on a different but valid pre-image, since hash0 is many-to-one,
which is why the symptom was intermittent — usually a missing pre-image, occasionally
none at all.

Fix

ck() reduces to, for i = 3, 2, 1 in that order:

v = z[i];   for j = i-1 down to 0:   if (v == z[j]) v = j;   out[i] = v

where every z[j] on the right is still the original value, because z[j] is
only rewritten once j becomes the outer index, which happens later.

So out[i] depends solely on the original z[i] and z[0..i-1], and a half can be
inverted index by index: z[0] is known, then z[1], and so on — undoing each step
in reverse order and branching wherever both readings are possible. The two halves are
independent, so the result is their cross product.

reverse_ck() / reverse_check() are replaced by invert_ck_index(),
invert_ck_half() and reverse_check_all(), which return every candidate rather than
one. invert_hash0() iterates them.

Example

22034C5B530140A0 is a diversified key for which current master prints no
pre-images at all
:

$ hf iclass unhash -k 22034C5B530140A0
[=] Diversified key... 22034C5B530140A0
[=] -----------------------------------
[=] -----------------------------------

After:

0D80AA7B01B1D910
5380AA7B01B1D910
9980AA7B01B1D910
DF80AA7B01B1D910     <- a genuine pre-image; hash0() of it is the input key

Testing

  • 500,000 round trips — random pre-image → hash0()invert_hash0(), checking
    the original comes back. Results in the table at the top.
  • Completeness, exhaustively: over alphabets 0..5 and 0..7, every output's
    candidate set was compared against brute-force enumeration of all halves mapping to
    it. 566 and 2106 distinct outputs respectively, exact match on every one — nothing
    missing, nothing spurious
    .
  • Validity: every emitted pre-image was additionally checked to hash back to the
    input key across 50,000 keys. None failed.
  • Output now matches an independent exhaustive sweep of the pre-image space on every
    key tested.

Notes

  • Candidates are still validated through hash0() before being reported, so neither
    change can emit anything invalid — they only add pre-images that verifiably hash
    back to the key.
  • Pre-images per key grow from max 32 / avg 4.2 to max 128 / avg 6.0. Extra hash lines
    cost hashcat almost nothing, so this is not a practical concern.

hash0() reduces each six-bit z chunk modulo a position-dependent value:

    _zn  = (zn  % (63 - n)) + n         n = 0..3
    _zn4 = (zn4 % (64 - n)) + n         n = 0..3, chunks 4..7

The modulus is below 0x40, so the reduction is lossy and a recovered residue
r has a second pre-image at r + modulus whenever that still fits in six bits.

invert_hash0() forked on a hardcoded table mapping {00,01,02,03} to
{3f,3e,3d,3c}, i.e. alt = 63 - r, gated on `hydra_head <= n % 4 ||
hydra_head >= 63 - (n % 4)`. Neither matches the actual arithmetic: the
alternative is r + modulus, not 63 - r, and which chunks can fork follows the
modulus rather than n % 4. The table happens to be right for a few positions
and wrong for the rest, so some valid pre-images were never generated.

Replaced with the modulus arithmetic taken directly from hash0(). Also drops
~15 lines.

Impact: the pre-image is the DES ciphertext for the master key crack. If the
true one is missing, hashcat searches the entire keyspace and finds nothing,
with no way to distinguish that from "the key isn't there".

Measured over randomly generated master/CSN pairs, where the true ciphertext
is computable and can be checked against the output:

  before   the true DES ciphertext was absent for  5 of 30 keys  (17%)
  after    present for 149 of 150 keys

The one remaining failure is unrelated and predates this change:
invert_hash0() returns no pre-images at all for roughly 0.5% of keys
(measured 2 of 400), where neither p nor ~p is found in pi[] and the x search
yields nothing. Left alone here.

Example. Master key 1111111111111111 and CSN 2222222222222222 diversify to
B94318BC673F0A12, and DES_enc(CSN, master) = 950973182317F80B:

    hf iclass unhash -k B94318BC673F0A12

  before                              after
  090973182314280B                    090973182314280B
  4F0973182314280B                    090973182317F80B
  950973182314280B                    4F0973182314280B
  DB0973182314280B                    4F0973182317F80B
                                      950973182314280B
                                      950973182317F80B   <- the true one
                                      DB0973182314280B
                                      DB0973182317F80B

Every printed candidate is still validated through hash0() before being
reported, so this only adds pre-images that verifiably hash back to the key.
Second, independent cause of missing pre-images. Where the previous commit
addressed the modulo forks, this one addresses check().

ck() rewrites a repeated value as the *index* it matched:

    ck(i, j, z) = ck(i, j-1, z[i] <- j)    if z[i] == z[j]

so a stored value in 0..3 is ambiguous: it may be such a marker, or a genuine
small value. reverse_ck() committed to one reading, and walked j in the same
descending order as the forward pass, so it could not undo a chained
substitution:

    half            01 03 24 03
    ck()            01 03 24 00     03 matched z[1] -> 1, then 1 matched z[0] -> 0
    reverse_ck()    01 03 24 01     restores z[0]=01 at j=0, never revisits j=1

8.9% of random four-value halves failed the ck() round-trip. Most of the time
that still landed on a different but valid pre-image, since hash0 is
many-to-one, which is why the visible symptom was intermittent: usually a
missing pre-image, occasionally none at all.

Fix. ck() reduces to, for i = 3, 2, 1 in that order:

    v = z[i];  for j = i-1 down to 0:  if (v == z[j]) v = j;   out[i] = v;

where every z[j] on the right is still the original value, because z[j] is only
rewritten once j becomes the outer index, which happens later. So out[i]
depends solely on the original z[i] and z[0..i-1], and a half can be inverted
index by index: z[0] is known, then z[1], and so on, undoing each step in
reverse and branching wherever both readings are possible. The halves are
independent, so the result is their cross product.

reverse_ck()/reverse_check() are replaced by invert_ck_index(),
invert_ck_half() and reverse_check_all(), which return every candidate rather
than one. invert_hash0() now iterates them. Candidates are still validated
through hash0() before being reported, so nothing invalid can be emitted.

Verified by round-tripping random pre-images through hash0() and invert_hash0():

                        before          after
  recovered             82.55%          100.000%
  keys with no output   1108            0
  pre-images per key    max 32, avg 4.2  max 128, avg 6.0

over 500,000 keys. Every emitted pre-image was additionally checked to hash
back to the input key; none failed. The output now matches an independent
exhaustive sweep of the pre-image space on every key tested.
@Antiklesys

Copy link
Copy Markdown
Contributor

@trichimtrich can we connect on discord about this? Feel free to ping me there or tag me (ATK) in the iClass channel or proxmark_dev channel. I'd like to understand more how you reached your findings and what documentation you used as well as how you tested and reviewed those. I wrote the initial code of this function a while ago :)

// out[i] depends solely on the original z[i] and z[0..i-1], and the halves can
// be inverted index by index, resolving z[0], then z[1], and so on.

#define CK_MAX_PREIMAGES 64

@Antiklesys Antiklesys Aug 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still going through the code, but are we limiting the max number of pre-images to 64 here?
Theoretical max accordingly to the whitepaper is 256
Appreciate your input @trichimtrich

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants