Skip to content

Fix undefined 32-bit shift that traps on arm64 (daemon crashes on card read) - #1256

Open
hyperbotsx wants to merge 2 commits into
mooltipass:masterfrom
hyperbotsx:fix/arm64-shift-ub
Open

Fix undefined 32-bit shift that traps on arm64 (daemon crashes on card read)#1256
hyperbotsx wants to merge 2 commits into
mooltipass:masterfrom
hyperbotsx:fix/arm64-shift-ub

Conversation

@hyperbotsx

Copy link
Copy Markdown

What breaks

When Moolticute is compiled natively for Apple Silicon (arm64), moolticuted crashes every time a card CPZ is read — i.e. on every device connect/unlock, which makes the app unusable. Crash reports from a Mooltipass Mini BLE on macOS 26.5.2 (M-series Mac) are deterministic:

exception: EXC_BREAKPOINT (SIGTRAP)
0  moolticuted  FilesCache::setCardCPZ(QByteArray) + 1376
1  moolticuted  MPDevice::getCurrentCardCPZ()::$_36::operator()(QByteArray const&, bool&) + 644
2  moolticuted  MPCommandJob::start(QByteArray const&)::$_0::operator()(...) + 64
3  moolticuted  MPDevice::newDataRead(QByteArray const&) + 4160
4  moolticuted  MPDevice::platformDataRead(QByteArray const&) + 52
5  moolticuted  _read_report_callback(...) + 72
6  IOKit        __IOHIDDeviceInputReportApplier + 72

Why

FilesCache::setCardCPZ() and MPDevice::getUInt64EncryptionKeyOld() derive the SimpleCrypt key from the 8-byte card CPZ like this:

for (int i = 0; i < std::min(8, cardCPZ.size()); i++)
    key += (static_cast<unsigned int>(cardCPZ[i]) & 0xFF) << (i * 8);   // i*8 reaches 56

The shifted operand is 32-bit, so for i >= 4 this shifts by 32..56 — undefined behaviour. x86 silently wraps the shift count modulo 32, which is what every historical Intel build actually computed. For arm64, clang proves those iterations are undefined and emits a trap instead: disassembling the built binary shows brk #1 (0xd4200020) at exactly the crash offset inside FilesCache::setCardCPZ.

The unit tests don't catch it — none exercise the CPZ path, so the suite passes 63/63 on an arm64 CI runner while the app crashes against real hardware.

Note that MPDevice::getUInt64EncryptionKey() (the SIMPLE_CRYPT_V2 path) already does this correctly in 64-bit; only the legacy path and the FilesCache copy were left with the undefined shift.

The fix

Mask the shift count ((i * 8) & 31) so the historical x86 wrapping becomes explicit and well-defined. Derived keys stay byte-identical to what existing Intel installs produce, so cached data and SimpleCrypt-encrypted exports continue to decrypt — no migration and no data loss. The V2 path is untouched.

The second commit is related and was found alongside it: _read_report_callback() in src/MPDevice_mac.cpp ignored the IOReturn result argument. IOKit invokes that callback with an error result (and a meaningless buffer/length) when a device drops mid-read — for example while a BLE device re-enumerates its interfaces — and those bytes were parsed as if they were a real device message. Errored reports are now discarded.

Verification

Built on a macos-14 (arm64) runner and tested on hardware (Mooltipass Mini BLE, macOS 26.5.2):

  • Before: the daemon crash-loops on 100% of unlock attempts; the GUI briefly shows unlocked, then resets to "daemon is not running".
  • After: unlock works and holds, and device detection, credential read/write (persisting across replug), browser-extension login through the daemon WebSocket, mc-cli, notes read/write, device settings change/revert, and unplug/replug recovery all pass. A binary check confirms no brk remains in FilesCache::setCardCPZ.

Related to #1254 and #1255 (native Apple Silicon builds), but this fix is independent of any build-pipeline change — it is a latent correctness bug in the codebase that only manifests once the code is compiled for arm64.

FilesCache::setCardCPZ and MPDevice::getUInt64EncryptionKeyOld derive a
SimpleCrypt key by shifting a 32-bit value by (i*8) for i in 0..7. A
shift >= 32 is undefined behaviour: x86 silently wrapped the count, but
for arm64 clang compiles the provably-undefined iterations into a brk
trap, so the daemon crashes (EXC_BREAKPOINT) the moment a card CPZ is
read - i.e. on every device connect/unlock on Apple Silicon.

Verified on hardware (Mooltipass Mini BLE, macOS 26.5.2, Mac14,6):
deterministic SIGTRAP at FilesCache::setCardCPZ+1376 in every crash
report, and the shipped binary contains brk mooltipass#1 (0xd4200020) at exactly
that offset.

Mask the shift count ((i*8) & 31) to make the historical x86 wrapping
explicit and well-defined: derived keys stay byte-identical with
existing Intel installs, so cached/exported data continues to decrypt.
The V2 path (getUInt64EncryptionKey) already shifts in 64-bit and is
untouched.
IOKit invokes the input-report callback with a non-success result (and
meaningless buffer/length) when the device drops mid-read, such as
during BLE device interface re-enumeration. The callback ignored the
result code and parsed whatever was in the buffer. Drop such reports
instead of feeding them to the message protocol.
Comment thread src/FilesCache.cpp
// arm64 the compiler turns it into a trap (crash on Apple Silicon).
for (int i = 0;i < std::min(8, static_cast<int>(cardCPZ.size()));i++)
m_key += (static_cast<unsigned int>(cardCPZ[i]) & 0xFF) << (i * 8);
m_key += (static_cast<unsigned int>(cardCPZ[i]) & 0xFF) << ((i * 8) & 31);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

(unless I'm mistaken) and that's why AI isn't there yet: for the same input, this change will lead to different m_key outputs as nothing will be populated after the 32th bit.
https://github.com/mooltipass/minible/wiki/Mooltipass-Database-Model#-cpz-lookup-table

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