fix hf iclass unhash missing pre-images - #3453
Open
trichimtrich wants to merge 2 commits into
Open
Conversation
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.
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 :) |
Antiklesys
reviewed
Aug 5, 2026
| // 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 |
Contributor
There was a problem hiding this comment.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two independent bugs make
hf iclass unhashomit validhash0pre-images, and forsome 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()andinvert_hash0():One commit per cause.
1. The modulo forks used a hardcoded table
hash0()reduces each six-bitzchunk modulo a position-dependent value:The modulus is below
0x40, so the reduction is lossy — a recovered residueralsohas 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 onhydra_head <= n % 4 || hydra_head >= 63 - (n % 4).Neither matches the arithmetic: the alternative is
r + modulus, not63 - r, andwhich chunks can fork follows the modulus rather than
n % 4. The table landscorrectly on a few positions by coincidence and misses the rest.
Fix
Take the modulus from
hash0()itself:Net −15 lines.
Example
Master key
1111111111111111with CSN2222222222222222diversifies toB94318BC673F0A12, andDES_enc(CSN, master) = 950973182317F80B:090973182314280B090973182314280B4F0973182314280B090973182317F80B950973182314280B4F0973182314280BDB0973182314280B4F0973182317F80B950973182314280B950973182317F80B← the true oneDB0973182314280BDB0973182317F80B2.
check()was not invertible in a single readingck()rewrites a repeated value as the index it matched:So a stored value in
0..3is ambiguous: it may be such a marker, or a genuine valuethat happens to be small.
reverse_ck()committed to one reading, and walkedjinthe same descending order as the forward pass, so it could not undo a chained
substitution:
8.9 % of random four-value halves failed the
ck()round trip. Most of the timethat still landed on a different but valid pre-image, since
hash0is many-to-one,which is why the symptom was intermittent — usually a missing pre-image, occasionally
none at all.
Fix
ck()reduces to, fori = 3, 2, 1in that order:where every
z[j]on the right is still the original value, becausez[j]isonly rewritten once
jbecomes the outer index, which happens later.So
out[i]depends solely on the originalz[i]andz[0..i-1], and a half can beinverted index by index:
z[0]is known, thenz[1], and so on — undoing each stepin 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 byinvert_ck_index(),invert_ck_half()andreverse_check_all(), which return every candidate rather thanone.
invert_hash0()iterates them.Example
22034C5B530140A0is a diversified key for which current master prints nopre-images at all:
After:
Testing
hash0()→invert_hash0(), checkingthe original comes back. Results in the table at the top.
0..5and0..7, every output'scandidate 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.
input key across 50,000 keys. None failed.
key tested.
Notes
hash0()before being reported, so neitherchange can emit anything invalid — they only add pre-images that verifiably hash
back to the key.
cost hashcat almost nothing, so this is not a practical concern.