Skip to content

fix(findrefs,listclass): bound exact-member scan, decode names as MUTF-8 - #44

Merged
MG1937 merged 3 commits into
MG1937:dev-0.1.0from
dhtzs:fix/locator-bounds-and-mutf8-names
Oct 10, 2026
Merged

MG1937 merged 3 commits into
MG1937:dev-0.1.0from
dhtzs:fix/locator-bounds-and-mutf8-names

Conversation

@dhtzs

@dhtzs dhtzs commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

Two independent correctness fixes in the dex readers, both surfaced by driving the engine from a front-end that calls these operations without the scoping the GUI always supplies. Independently revertable; kept in one PR only because they share that origin.

1. fix(findrefs): bound the exact-member scan to the dex tables

The bug

MethodLocator.locate / FieldLocator.locate, in the clz is None + precise branch:

return {mid for mid, item in enumerate(self.dex.methods) if item.name == method}

dex.methods / dex.fields are MethodsProxy / FieldsProxy — they define __getitem__ and __len__ but no __iter__. enumerate() therefore falls back to the legacy sequence protocol, which stops only on IndexError, and get_method() / get_field() synthesise an object for any index and never raise. The comprehension walks off the end of the table into whatever follows it.

Measured on a 2-method fixture:

len(dex.methods)      = 2
MethodsProxy __iter__ = False
enumerate() yielded   = 35 items, then:
  error: unpack_from requires a buffer of at least 7277229560 bytes
         for unpacking 4 bytes at offset 7277229556 (actual buffer size is 444)

A 7 GB offset read out of a 444-byte buffer. The non-determinism is arguably worse than the crash: when the adjacent bytes happen to parse, the branch returns ids that were never in the table instead of failing.

Who can hit it

Only an exact member search with no class scope — {"method": {"class": None, "method": [name, True]}}. The GUI cannot reach it: the x handler always sets search_class_var from the reference's declaring class before _start_search(exact_member=True), and the search box passes exact_member=False. The two existing tests (test_exact_{method,field}_locator_does_not_use_substring_matching) both supply a class, so they cover only the _match_clz_mids path.

It is reachable for any caller that treats exact_member as a standalone flag — e.g. "find every call to a member named X anywhere in the APK", which is the natural way to use the feature.

The fix

return {mid for mid in range(len(self.dex.methods)) if self.dex.methods[mid].name == method}

len(proxy) is header.methods[1] / header.fields[1] — the same size _build_map already iterates — so the domain of returned ids is unchanged; only the walk is bounded. Class-scoped paths are untouched (clz_mids is a real set and was always safe).

2. fix(listclass): decode class names as MUTF-8

The bug

ApkHandler.list_classes decodes string_data_item payloads with raw_name.decode("utf-8", errors="replace"). DEX string data is MUTF-8: a code point above the BMP is stored as a surrogate pair (CESU-8), which strict UTF-8 rejects, so those bytes all collapse to U+FFFD. DEX.get_string has decoded MUTF-8 since #33, so listclass named a class differently from getclass / findrefs:

stored name listclass before engine (DEX.get_string)
Ll/\U00010000; Ll/\ufffd\ufffd\ufffd\ufffd\ufffd\ufffd; Ll/\U00010000;

Scope

Only names containing a non-BMP code point change; everything else is byte-identical:

input before after changed
ASCII Lexample/Test; identical no
BMP (Ll/\u06e1;, the #33 case) Ll/\u06e1; identical no
astral (Ll/\U00010000;) 6 × U+FFFD correct name yes
malformed bytes L\ufffdbad; identical no

The fallback is the same decoder tinydex uses, so a listing now agrees with DEX.get_string on well-formed and malformed input. Note the project's two MUTF-8 decoders disagree on malformed input — mutf8.decode_modified_utf8 keeps the offending byte, _decode_mutf8_fallback substitutes U+FFFD — which is why this reuses tinydex rather than the mutf8 dependency added in efa3457.

The fallback import is deferred into the method, following the existing convention in for_each_findrefs, so the getclass path pays nothing. The strict fast path is also marginally faster than errors="replace" (13.9 ms vs 18.4 ms per 200k names).

Testing

python tests/run_tests.py --require-decompiler → 107 tests, OK, no skips.

Neither branch has coverage in the test tree today — the astral-name case and the no-class exact search are exercised only in a front-end's own test file that is not part of this PR. I'm happy to add:

  • tests/test_listclass.py: fixture with a non-BMP class name, asserting the listing matches DEX.get_string.
  • tests/test_source_edit.py (or test_references.py): locate() with class omitted and precise, asserting it returns the table ids and does not wander.

Follow-up, not in this PR

getclass still cannot resolve a class whose name is non-BMP: _find_type_idx / get_class_dex binary-search the type table comparing dalvik_class.encode("utf-8") against the stored CESU-8, so F0 90 80 80 never matches ED A0 80 ED B0 80. Fix 2 makes the listing report such a name correctly, but it still can't be fed back into getclass. That means touching the getclass fast path, which sits under the startup budget, so it belongs in its own change.

Copilot AI balanced review requested due to automatic review settings October 10, 2026 20:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@MG1937
MG1937 merged commit 079fc3e into MG1937:dev-0.1.0 Oct 10, 2026
2 checks passed
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.

3 participants