From e03ce4ae5adff67a546f69d03d96ba6fac77884b Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Thu, 3 Sep 2026 17:45:27 +0200 Subject: [PATCH] UPDATE_TEMPLATE: the command stock learns with, and the field that was killing it Stock rewrites the stored template on every successful press. Its post-match loop is QUERY_FINGER_STATUS, CAPTURE_IMAGE, 0x1015 UPDATE_TEMPLATE while the finger stays down, with no REPORT_EVENT in it -- so the matcher does not re-run and the verdict cannot change. Forty-six of those against eighty-six captures in one reference session, and the stored body measurably grows: 333278 bytes at enrolment, 360822 at the next session's load, 371734 after one authentication session. This daemon sent none of them. The command shares REPORT_EVENT's event context. The stock wrapper memsets 732 bytes and writes six fields: a zero byte at +0x2a4, the scan-slot count, a zero word, the count of frames folded so far in this press, a flags word of 0x00080000 with bit 6 set on the frame whose event was FingerTouched, and a zero at +0x2d8. Declared length 0x2dc. +0x2d8 is the one that matters, and it matters by staying zero. The dispatcher stub reads it after the handler returns and only if it is non-zero does it read +0x2dc and make that the response length. Every earlier attempt in this project set both fields and varied the declared length across 0x2e0, 0x400 and 0x1000; all of them answered -90, the trustlet gone, and the conclusion recorded was "do not retry until a template is loaded". A loaded template was necessary but not sufficient. The stock HAL sets neither field. Measured on the device with two templates loaded and no finger: both branches answer rc=0 and ENUMERATE still reports 2, so the app did not fault. Those fields are little endian, assembled low-address-first by the handler. An earlier reading called them big endian, off the bfi order, and was wrong. Bit 6 selects which algorithm entry runs: clear takes libfp_template_x_update, set takes the other, which also reads the scan-slot count. --- interfaces/Fingerprintd-Ta.cppm | 74 +++++++++++++++++++++++++++++++++ tests/Ta/main.cpp | 50 ++++++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/interfaces/Fingerprintd-Ta.cppm b/interfaces/Fingerprintd-Ta.cppm index 9ce55c8..f8a077c 100644 --- a/interfaces/Fingerprintd-Ta.cppm +++ b/interfaces/Fingerprintd-Ta.cppm @@ -34,6 +34,7 @@ export namespace fingerprintd::ta { CaptureImage = 0x1013, QueryEventStatus = 0x101d, SaveData = 0x1014, + UpdateTemplate = 0x1015, ReportEvent = 0x1018, WorkMode = 0x1020, @@ -207,6 +208,79 @@ export namespace fingerprintd::ta { static_assert((SaveMaskTemplate & (1u << 30)) != 0); static_assert((SaveMaskCalibration & (1u << 30)) == 0); + // ---- UPDATE_TEMPLATE -------------------------------------------------- + // + // TEMPLATE LEARNING. Stock folds the frames of a successful press back + // into the stored template and the template GROWS as a result: on the + // reference device ff_template_0_0.bin went 333278 bytes at enrolment -> + // 360822 at the next session's load -> 371734 after one authentication + // session. Over the same session the HAL issued 46 of these against 86 + // captures. This daemon issued none, so every rate this project has ever + // measured was against a day-zero template that no stock user lives with. + // + // The command shares REPORT_EVENT's ff_trustlet_event_context_t. The stock + // wrapper (fingerprint.default.so 0xb8d84, "checking the template...") + // memsets 732 bytes and writes exactly six fields: + // + // +0x2a4 (676) u8 0 + // +0x2c8 (712) u32 scan-slot count (as REPORT_EVENT) + // +0x2cc (716) u32 0 + // +0x2d0 (720) u32 slot index = frames folded so far in this press + // +0x2d4 (724) u32 0x00080000, |0x40 when the frame's event was 5 + // +0x2d8 (728) u32 0 + // + // and calls it with a declared length of 0x2dc (0xbefec). + // + // +0x2d8 IS THE FIELD THAT MATTERS, and it matters by staying zero. The + // dispatcher stub reads it AFTER the handler returns (0x9ee0-0x9f00) and + // only if it is non-zero does it read +0x2dc and compute the response + // length as that value + 0x2dc. Every previous attempt in this project set + // both fields and varied the declared length (0x2e0 / 0x400 / 0x1000); + // all of them answered -90, i.e. the trustlet was gone. The stock HAL + // never sets either one. + // + // Endianness: the handler assembles these bytewise low-address-first + // (0x1300c-0x13028), so they are LITTLE endian. An earlier ledger entry + // called them big endian; it was read off the bfi order and was wrong. + inline constexpr std::size_t UpdateTemplatePayloadSize = 0x2dc; // 732 + inline constexpr std::size_t UpdZeroByteOff = 0x2a4; + inline constexpr std::size_t UpdScanSlotsOff = EvScanSlotsOff; // 712 + inline constexpr std::size_t UpdZeroAOff = EvZeroAOff; // 716 + inline constexpr std::size_t UpdSlotIndexOff = EvSlotIndexOff; // 720 + inline constexpr std::size_t UpdFlagsOff = EvFlagsOff; // 724 + inline constexpr std::size_t UpdRespLenOff = EvZeroBOff; // 728 + + // The flags word. Note it is NOT the event context's 0x08080000: the + // update wrapper builds its own value from scratch. + inline constexpr std::uint32_t UpdFlagsBase = 0x00080000; + // Bit 6 selects which of the algorithm's two update entries runs: clear + // takes libfp_template_x_update (0x20bf4), set takes 0x212e0, which also + // reads the scan-slot count. Stock sets it on the frame whose event was + // FingerTouched, i.e. the first frame of a press. + inline constexpr std::uint32_t UpdFlagsTouchFrame = 0x40; + + inline void BuildUpdateTemplate(std::span out, std::uint32_t slotIndex, + bool touchFrame, + std::uint32_t scanSlots = EvDefaultScanSlots) { + std::ranges::fill(out.first(UpdateTemplatePayloadSize), std::byte{0}); + out[UpdZeroByteOff] = std::byte{0}; + detail::StoreU32(out, UpdScanSlotsOff, scanSlots); + detail::StoreU32(out, UpdZeroAOff, 0); + detail::StoreU32(out, UpdSlotIndexOff, slotIndex); + detail::StoreU32(out, UpdFlagsOff, + UpdFlagsBase | (touchFrame ? UpdFlagsTouchFrame : 0u)); + // Left zero deliberately. See above: a non-zero value here sends the + // stub off to compute a response length from +0x2dc. + detail::StoreU32(out, UpdRespLenOff, 0); + } + + static_assert(UpdRespLenOff + 4 == UpdateTemplatePayloadSize, + "the response-length trigger is the last word of the payload"); + static_assert((UpdFlagsBase & UpdFlagsTouchFrame) == 0, + "the touch bit must not already be in the base value"); + static_assert(UpdFlagsTouchFrame == (1u << 6), + "the handler tests bit 6 of the LOW byte at +0x2d4"); + // ---- ENROLL / AUTHENTICATE payloads ----------------------------------- // ENROLL takes a 69-byte hw_auth_token, a u32 at +69 and a u8 flag at +73 diff --git a/tests/Ta/main.cpp b/tests/Ta/main.cpp index e3e2487..54cc8c1 100644 --- a/tests/Ta/main.cpp +++ b/tests/Ta/main.cpp @@ -175,6 +175,56 @@ int main() { Check((SaveMaskCalibration & (1u << 30)) == 0, "calibration save clears bit 30"); Check(SaveMaskTemplate != SaveMaskCalibration, "the two masks differ"); + // ---- UPDATE_TEMPLATE: template learning + { + std::vector up(UpdateTemplatePayloadSize); + BuildUpdateTemplate(up, /*slotIndex*/ 0, /*touchFrame*/ true); + + // The declared length stock sends. 0x2e0 was tried in this project and + // answered -90; the length is not a free parameter. + Check(UpdateTemplatePayloadSize == 0x2dc, "declared length is 732, as stock sends"); + + Check(Get32(up, UpdScanSlotsOff) == 1, "scan slots default to 1, as REPORT_EVENT"); + Check(Get32(up, UpdZeroAOff) == 0, "+716 is zero"); + Check(Get32(up, UpdSlotIndexOff) == 0, "the first folded frame is slot 0"); + Check(Get32(up, UpdFlagsOff) == 0x00080040, "a touch frame sets bit 6 over the base"); + + // THE invariant. The dispatcher stub reads this word after the handler + // returns and, if it is non-zero, computes the response length from + // +0x2dc. Every attempt in this project that set it killed the app. + Check(Get32(up, UpdRespLenOff) == 0, + "+728 MUST be zero or the stub computes a response length"); + + BuildUpdateTemplate(up, /*slotIndex*/ 3, /*touchFrame*/ false); + Check(Get32(up, UpdSlotIndexOff) == 3, "the slot index counts folded frames"); + Check(Get32(up, UpdFlagsOff) == 0x00080000, "a held frame leaves bit 6 clear"); + Check(Get32(up, UpdRespLenOff) == 0, "+728 stays zero on every frame"); + + // The flags word is NOT the event context's, and confusing the two is + // an easy mistake because the payloads are otherwise the same struct. + Check(UpdFlagsBase != EvDefaultFlags, + "the update flags are 0x00080000, not the event context's 0x08080000"); + + // The fields it shares with REPORT_EVENT really are at the same + // offsets; that is why one struct serves both commands. + Check(UpdScanSlotsOff == EvScanSlotsOff && UpdSlotIndexOff == EvSlotIndexOff + && UpdFlagsOff == EvFlagsOff, + "the update payload reuses the event context's field offsets"); + + // The event id is deliberately NOT written: stock memsets and never + // touches +4, and this command must not re-run the matcher. + Check(Get32(up, EvEventOff) == 0, "no event id -- the matcher must not re-run"); + + // Every byte outside the written fields stays zero: the whole 732-byte + // payload carries three non-zero bytes here -- the scan-slot count, + // the slot index, and the one set byte of 0x00080000. Anything else + // non-zero means a field was written that stock does not write. + std::size_t nonZero = 0; + for (std::size_t i = 0; i < UpdateTemplatePayloadSize; i++) + if (up[i] != std::byte{0}) nonZero++; + Check(nonZero == 3, "only scan slots, slot index and the flags byte are set"); + } + // ---- AUTHENTICATE payload { std::vector au(AuthPayloadSize);