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.
This commit is contained in:
Jorijn van der Graaf 2026-09-03 17:45:27 +02:00
commit e03ce4ae5a
2 changed files with 124 additions and 0 deletions

View file

@ -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<std::byte> 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

View file

@ -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<std::byte> 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<std::byte> au(AuthPayloadSize);