When Android’s Missing Source Turns a Unicode Bug Into a Reverse-Engineering Job
An emoji should not be able to make a parser believe it found a newline. Yet after a September 2026 Android 17 update, that is exactly what happened to programs using two old C library functions. The failure was reported in AOSP’s bionic library, and the investigation took an unusual turn: the public source did not match the code on the phone, so the reporter disassembled the shipped library.
Why did Android’s Unicode string scan fail only after a system update? The answer is a small boundary mistake in an optimized wide-string routine, made harder to diagnose by the distance between source releases and device binaries.
The bug in one line of C
AOSP, the Android Open Source Project, publishes the platform source code. Bionic is Android’s C library, the collection of low-level routines behind familiar operations such as file access, memory handling, and string processing. Native Android programs built with the Android NDK, or Native Development Kit, call into that library at runtime through files such as libc.so, a shared system library loaded by many programs.
The affected functions work with wide strings. A wide string is an array of wchar_t values rather than ordinary one-byte characters. Each value can represent a Unicode code point, the numeric label assigned to a character.
wcscspn returns the length of the opening part of a string that contains none of the characters in a reject set. wcspbrk returns a pointer, meaning a memory address, to the first character that appears in a supplied set. A newline scanner might use wcscspn(text, L"\\n\\r"); a lexer might use wcspbrk(text, L"*?><").
On the affected Android 17 builds, this small program should print 4, but prints 1 instead:
#include <stdio.h>
#include <wchar.h>
int main(void) {
wchar_t s[] = {L'a', 0x1F680, 0x0100, L'b', L'x', L'\\n', L'\\0'};
printf("%zu\\n", wcscspn(s, L"x\\n"));
}
The first matching reject character is x, at index 4. The rocket emoji is at index 1, and 0x0100 is the Unicode character U+0100, written in Unicode notation as U+0100. Neither belongs to the reject set. The broken function treats the first character at or above U+0100 as though it were a match.
The boundary matters. Characters from U+0080 through U+00FF can still pass through the lookup table used by the buggy path. The failure begins at U+0100, so emoji, CJK characters, Greek, Cyrillic, and many extended Latin characters expose it.
The optimization that inverted the answer
The fast implementation builds a 256-entry table of true-or-false values when the reject set contains multiple characters that all fit below U+0100. That table makes ASCII delimiter checks quick: the function can use the character value as an index and ask whether table[c] is true.
The correct logic for wcscspn looks like this:
if c is below 0x100 and table[c] is true:
stop
otherwise:
continue scanning
The shipped code effectively did this:
if c is below 0x100 and table[c] is true:
stop
if c is at least 0x100:
stop // wrong for wcscspn
continue scanning
A character at or above U+0100 cannot appear in a set whose members are all below U+0100. The scan therefore has no reason to stop. The branch was likely intended to avoid indexing beyond the 256-byte table, but it accidentally changed “not in the set” into “found a match.”
wcsspn uses similar machinery but asks the opposite question: how long is the opening part made entirely from a keep set? For an ASCII-only keep set, stopping at a non-ASCII character is correct. That is why wcsspn remained correct while wcscspn failed. wcspbrk was affected because its implementation builds on wcscspn and returns the location where that scan stops.
Why disassembly became necessary
Normally, a developer would inspect the source, identify the bad branch, and submit a patch. This case was more awkward. The public Android 17 source tag from June 5, 2026 used a straightforward nested-loop implementation from external/llvm-libc. That implementation was correct. The September Pixel builds carrying the bug contained a different optimized implementation.
The distinction between those snapshots is important. android-latest-release is a repository manifest, a recipe describing which revisions to fetch. android17-release is a Git branch, a line of source history. Neither label guaranteed that the public files matched the exact quarterly platform update running on a Pixel phone.
A disassembler is a tool that translates compiled machine code back into assembly-like instructions. With no matching source, the reporter examined the actual libc.so from the device and found the three paths inside wcscspn: a plain loop for one-character sets, a fallback for sets containing wide characters, and the 256-entry table path. In the table path, a comparison against 0x100 led directly to the stop condition.
That evidence turned a mysterious Unicode failure into a precise control-flow bug. It also showed why ordinary ASCII tests passed for years. The broken branch only appears for a particular combination of set size, set values, and haystack characters.
An application bug hiding inside libc
The impact was larger than a wrong number returned from a library routine. Visual Pinball on Android uses libwinevbs, a Unix build of Wine’s VBScript engine. Its lexer used wcspbrk to skip a comment until a newline. When a comment contained an emoji, the function returned the emoji’s position instead of the line ending, and the parser reported an invalid character.
Another path used the same function to look for wildcard characters in file operations. A path containing a character at or above U+0100 could be classified as a wildcard pattern even though no wildcard was present. A low-level boundary mistake had become a file-system and scripting failure.
What native developers can do
The NDK used to build a reproducer and the bionic library installed on a phone are separate pieces. Updating the compiler or headers does not automatically replace the device’s libc.so. Until a platform fix reaches affected builds, code that depends on these scans can use a small private implementation:
static size_t safe_wcscspn(const wchar_t* s, const wchar_t* reject) {
const wchar_t* p = s;
while (*p!= L'\\0') {
const wchar_t* r = reject;
while (*r!= L'\\0') {
if (*p == *r) return (size_t)(p - s);
++r;
}
++p;
}
return (size_t)(p - s);
}
This nested loop is not a universal replacement for every optimized string routine, but it is predictable and suitable for many parsing paths. A compatibility layer can select it only on affected platform builds, or use it everywhere if the scan is not performance-sensitive.
The regression test should cross the exact boundary the old tests missed:
const wchar_t s[] = {L'a', 0x0100, L'b', L'\\n', L'\\0'};
EXPECT_EQ(3u, wcscspn(s, L"x\\n"));
The broader lesson is uncomfortable but useful. ASCII-only tests can certify the wrong implementation. AOSP source can be publicly available while still lagging behind the binary shipped to users. For platform libraries, Unicode boundary tests, device probes, and source-to-binary version matching are not luxuries. They are part of knowing what code your application is actually running.
The bug was one misplaced decision in a tiny function. The difficult part was discovering which function had shipped in the first place.
Comments (0)
No comments yet. Be the first to respond!
Leave a Comment
Your comment will be visible after review.