feat: context-aware HF sniff frames annotation(#884) - #885
Conversation
Added a rudimentary context-aware annotator for HF sniff frames. Currently only Mifare Classic/Ultralight/NTAG21x frames are supported. Tested with Mifare Classic 1K, Mifare Ultralight, and Ultralight EV1.
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Introduces a stateful HF (ISO14443-A) sniff parser and shared frame model to improve frame annotation with protocol context (Ultralight/NTAG decoding, MIFARE Classic auth/encrypted detection, APDU hints).
Changes:
- Adds
StatefulHfParserwith context tracking and richer ISO14443-A/MIFARE/Ultralight decoding. - Extracts
HfSniffFrame/HfSniffDirectioninto a dedicatedhf_sniff_models.dart. - Updates existing sniff annotation flow to use the stateful parser and updates GUI imports accordingly.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| chameleonultragui/lib/helpers/hf_sniff_stateful_parser.dart | New stateful parser + family inference + context-aware annotation/decoding logic |
| chameleonultragui/lib/helpers/hf_sniff_models.dart | New shared models for HF sniff frames and direction |
| chameleonultragui/lib/helpers/hf_sniff.dart | Switches annotation to the new stateful parser; removes in-file models |
| chameleonultragui/lib/gui/menu/tools/hf_sniffing.dart | Imports extracted sniff models |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.
Comments suppressed due to low confidence (1)
chameleonultragui/lib/helpers/hf_sniff_stateful_parser.dart:168
feedFrametreats any 16-bit/2-byte frame as ATQA and any 8-bit/1-byte frame as SAK without checking direction/context. This will misclassify common reader->card commands (e.g., 0x93 0x20 anticollision, 0xE0 0x80 RATS, or 0x60 GET_VERSION without CRC) as ATQA/SAK and corruptctx.atqa/sak/family. Restrict ATQA/SAK updates to card->reader responses (and ideally gate ATQA behind a prior REQA/WUPA / blocked-command filter).
// ATQA
if (frame.bitLength == 16 && data.length == 2) {
ctx.atqa = data[0] | (data[1] << 8);
ctx.state = HfParserState.ready;
return;
}
// SAK
if (frame.bitLength == 8 && data.length == 1) {
ctx.sak = data[0];
ctx.family = _inferFamilyFromContext();
ctx.state = HfParserState.active;
return;
}
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (5)
chameleonultragui/lib/helpers/hf_sniff_stateful_parser.dart:70
- Same issue as above: the later scan for
f.data[0] == 0x60can misclassify MIFARE Classic AUTH frames as GET_VERSION. Restrict this check to expected GET_VERSION frame shapes (length/bitLength) before reading the following response's subtype byte.
for (int i = 0; i < frames.length; i++) {
final f = frames[i];
if (f.isReaderToCard && f.data.isNotEmpty && f.data[0] == 0x60) {
if (i + 1 < frames.length) {
final resp = frames[i + 1];
if (resp.isCardToReader && resp.data.length >= 3) {
final hw = resp.data[2];
if (hw == 0x03) return 'MIFARE Ultralight EV1/Ultralight';
if (hw == 0x04) return 'NTAG21x';
}
chameleonultragui/lib/helpers/hf_sniff_stateful_parser.dart:140
reset()does not clear several context fields (e.g.,probableUltralight*,pendingUlCommand,pendingPage,pendingEndPage,pendingCounter,pendingAnticollLevel). If aStatefulHfParserinstance is reused (e.g., viaparseAll()), stale pending state can leak into the next parse and produce incorrect annotations. Reset allctx.*pending/probable fields here as well.
void reset() {
ctx.state = HfParserState.powerOff;
ctx.uid = '';
ctx.atqa = null;
ctx.sak = null;
ctx.family = '';
_expectNt = false;
_expectNrAr = false;
_expectAt = false;
_authenticated = false;
_lastAuthKeyType = null;
_lastAuthBlock = null;
}
chameleonultragui/lib/helpers/hf_sniff_stateful_parser.dart:153
- On REQA/WUPA (and similarly on HLTA), the parser clears
_authenticatedbut leaves_expectNt/_expectNrAr/_expectAtand any pending Ultralight command state untouched. This can cause subsequent unrelated frames to be mislabeled as AUTH continuation or as a response to an old pending UL command after a new anticollision round. Consider clearing the expected-auth flags and pending command fields when a new session starts (REQA/WUPA) or ends (HLTA).
// detect REQA/WUPA
if (frame.rawBitLength == 7 &&
data.length == 1 &&
(data[0] == 0x26 || data[0] == 0x52)) {
ctx.state = HfParserState.idle;
_authenticated = false;
return;
}
chameleonultragui/lib/helpers/hf_sniff_stateful_parser.dart:372
- Compat write step-2 payload matching is too strict:
COMPAT_WRITEdata is 16 bytes of payload and often includes CRC_A (total 18 bytes in the captured frame). With the currentdata.length == 16check, most real frames with CRC will never be annotated. Allow 16 or 18 (and strip trailing CRC when present) whilependingUlCommand == compatWrite.
if (data.length == 16 && ctx.pendingUlCommand == PendingUltralightCommand.compatWrite) {
final page = ctx.pendingPage ?? -1;
ctx.pendingUlCommand = PendingUltralightCommand.compatWriteData;
return 'UL COMPAT_WRITE DATA page=$page data=${_hex(Uint8List.fromList(data)).toUpperCase()}';
}
chameleonultragui/lib/helpers/hf_sniff_stateful_parser.dart:589
_decodeUlReadPagesassumes pages 0-3 always contain 4 bytes and directly indexes/sublistspbytes(e.g.,pbytes[3],pbytes.sublist(0, 3)). WhendecodeDatais shorter than expected (truncated capture, partial frame, or mismatched length), this can throw aRangeErrorand crash annotation/UI. Add length guards for these page descriptors (or fall back to generic hex output whenpbytes.length < 4).
if (pnum >= 0 && pnum <= 3) {
final bcols = pbytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(':')
.toUpperCase();
String desc;
if (pnum == 0) {
desc =
'UID0-2=${_hex(Uint8List.fromList(pbytes.sublist(0, 3)), spaced: false)} BCC0=0x${pbytes[3].toRadixString(16).padLeft(2, '0').toUpperCase()}';
} else if (pnum == 1) {
desc = 'UID3-6=${_hex(pbytes, spaced: false)}';
} else if (pnum == 2) {
desc =
'BCC1=0x${pbytes[0].toRadixString(16).padLeft(2, '0').toUpperCase()} internal=${_hex(Uint8List.fromList(pbytes.sublist(1, 2)))} LOCK0=0x${pbytes[2].toRadixString(16).padLeft(2, '0').toUpperCase()} LOCK1=0x${pbytes[3].toRadixString(16).padLeft(2, '0').toUpperCase()}';
} else {
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (3)
chameleonultragui/lib/helpers/hf_sniff_stateful_parser.dart:141
StatefulHfParser.reset()does not clear several fields stored onctx(e.g.,probableUltralight,pendingUlCommand,pendingPage,pendingEndPage,pendingCounter,pendingAnticollLevel). If aStatefulHfParserinstance is reused, stale pending-command context can leak into the next parse/annotation. Reset should clear all context fields to a known baseline.
void reset() {
ctx.state = HfParserState.powerOff;
ctx.uid = '';
ctx.atqa = null;
ctx.sak = null;
ctx.family = '';
_expectNt = false;
_expectNrAr = false;
_expectAt = false;
_authenticated = false;
_lastAuthKeyType = null;
_lastAuthBlock = null;
}
chameleonultragui/lib/helpers/hf_sniff_stateful_parser.dart:154
- On REQA/WUPA,
feedFrame()setsctx.statetoidlebut leavesuid,atqa,sak,family, and any pending Ultralight command context intact. In captures containing multiple polling / card-present cycles, this can cause subsequent frames to be annotated with stale context from the previous cycle. Consider performing a context reset (or at least clearing UID/family and pending command state) when a new REQA/WUPA is observed.
// detect REQA/WUPA
if (frame.rawBitLength == 7 &&
data.length == 1 &&
(data[0] == 0x26 || data[0] == 0x52)) {
ctx.state = HfParserState.idle;
_authenticated = false;
return;
}
chameleonultragui/lib/helpers/hf_sniff_stateful_parser.dart:219
isHaltFrame(and the HLTA detection infeedFrame()) requiredata.length == 4, i.e.50 00 + CRC. However the legacy decoder inhf_sniff.darttreats any0x50(and summary treats0x50/0xC2) as halt/deselect without requiring CRC. If the sniff capture omits CRC bytes, a 2-byte HLTA (50 00) will be treated as an encrypted frame once_authenticatedis true, andctx.statewill never transition tohalt. Consider recognizing HLTA whendata.length >= 2 && data[0] == 0x50 && data[1] == 0x00(and optionally including CRC in the label when present).
final isHaltFrame = data.length == 4 && data[0] == 0x50 && data[1] == 0x00;
// Mifare Classic in authenticated state, treat all frames except HALT as encrypted
if (_authenticated && !isHaltFrame) {
final family = ctx.family.isEmpty ? '' : '[${ctx.family}] ';
return '${family}MIFARE Classic Encrypted (0x${data[0].toRadixString(16).padLeft(2, '0').toLowerCase()})';
}
|
Lmk when this is ready, seems like youre still a bit busy. Ill see when I can get around to this, prob will take a bit. |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
@GameTec-live, I think this PR is somewhat ready now. Tested locally, and I did not see any issues. Please review when you have time. Thanks. |
The research on the command definition and the initial Python parser was done by Copilot, then I manually rewrote it in Dart. Then, after submitting the PR, the code review is done by Copilot (as you have seen), and the subsequent fixes were 1. from Copilot (those commits with Copilot as co-author) and 2. hand-coded following Copilot's review messages.
My tests with my CU on macOS 26.5, emulating Mifare Classic 1K, Mifare Ultralight, and NTAG 215, did not trigger the RangeError you encountered, and I have tested the code on captured binaries without issue. How did you trigger this error? |
|
RangeError(end) suggests it came from a I have added another length validation, let's see if this solves the issue. |
|
Hm, interestingly cant repro it. Was just using nfctools on my phone to test. Seems to work now. Lmk if you want to investigate that further |
May be related to data truncated mid-transaction? Should not raise any error again since commit ef3148a. |

Added a rudimentary context-aware annotator for HF sniff frames. Currently, only Mifare Classic/Ultralight/NTAG21x frames are supported.
Tested with Mifare Classic 1K, Mifare Ultralight, and Ultralight EV1.