Docs  /  Architecture & Internals

Unicode String Detection

How Rerius detects genuine Unicode strings in binary files and avoids false positives.

Repository: https://github.com/ECLS-Studio/rerius
Implementation: src/util/unicode.c


Overview#

Many binaries contain Unicode strings - internationalized UI text, error messages, log strings, or embedded Unicode data. Rerius scans non-code sections for UTF-8 multi-byte sequences and UTF-16LE strings, using a set of heuristics to eliminate false positives that plague naive scanners.


UTF-8 Detection#

Algorithm#

For each byte position i in a non-code section:

  1. Call dax_utf8_decode() to attempt decoding a codepoint starting at buf[i]
  2. If the sequence is valid and the codepoint is printable, continue to the next sequence
  3. If the string terminates with a NUL byte, has ≥ 2 characters, and contains at least one multi-byte sequence (codepoint ≥ U+0080), emit the string

Why "at least one multi-byte sequence"?#

Pure ASCII NUL-terminated strings are already reported by the ASCII string scanner (-t flag, bin.strings()). The Unicode scanner only reports strings that contain actual multi-byte encoded characters - Cyrillic, Arabic, CJK, emoji, etc.

Codepoint validation#

dax_utf8_decode() rejects: - Overlong sequences (e.g. 2-byte encoding of a character that fits in 1 byte) - Sequences that decode to surrogate code points (U+D800-U+DFFF) - Sequences above U+10FFFF


UTF-16LE Detection#

UTF-16LE is common in Windows PE files and some Android resources. It is also the encoding that produces the most false positives in naive scanners.

The False Positive Problem#

Consider the symbol name string table (.dynstr) in a Linux ELF:

n\0__cxa_finalize\0__cxa_atexit\0strcmp\0...

A naive scanner reads this two bytes at a time: - [0x6E, 0x00] = U+006E = 'n' (ASCII 'n' in wide encoding) - [0x5F, 0x5F] = U+5F5F = '彟' (CJK unified ideograph) - [0x63, 0x78] = U+7863 = '硣' (CJK)

Result: garbage CJK characters from perfectly normal ASCII symbol names. A naive two-byte scan over /bin/ls produces dozens of these false positives from the symbol tables alone before any of the defenses below are applied.

Seven Layers of Defense#

Layer 1 - Section blacklist:
Skip the following sections entirely for UTF-16LE scanning: .dynstr, .dynsym, .symtab, .strtab, .shstrtab, .gnu.hash, .gnu.version, .gnu.version_r, .note.*, .debug*, .rela.*, .plt, .got, .got.plt

These sections contain binary data and symbol names that structurally look like UTF-16LE but never are.

Layer 2 - Preceding byte guard:
A valid UTF-16LE string must start at a clean string boundary. The byte immediately before the candidate position must be 0x00 (end of a previous NUL-terminated string), or we must be at position 0. This prevents starting mid-way through a null-separated ASCII list like .dynstr.

Layer 3 - Pure null-padded ASCII rejection:
If all high bytes (odd-indexed bytes) are 0x00, the string is just ASCII with null padding - e.g. H\0e\0l\0l\0o\0. This is valid UTF-16LE but uninteresting (the ASCII scanner already reports it). Rejected.

Layer 4 - Beyond-Latin codepoint requirement:
Require at least one codepoint > U+02FF. Codepoints U+0000-U+02FF cover Basic Latin, Latin-1, Latin Extended-A/B - these appear in binary data by accident far too often. Codepoints from U+0300 upward (Greek, Cyrillic, Arabic, CJK, emoji, etc.) are very unlikely to appear by chance.

Layer 5 - Minimum width threshold:
Require ≥ 3 "wide" code units (units where the high byte ≠ 0x00) to avoid accepting very short accidental matches.

Layer 6 - Minimum length:
Require ≥ 6 total code units (12 bytes). This rejects single-character wide matches and very short fragments.

Layer 7 - Surrogate pair acceptance:
Surrogate pairs (emoji and Supplementary Multilingual Plane characters) are always accepted when found, overriding the minimum length/width requirements. An emoji is always genuine.

Result#

Independently re-verified for this pass: scanning a /bin/ls binary (x86-64 ELF, 142 KB) with the current build produced 0 UTF-16LE false positives from the symbol/string tables and 1 UTF-8 result. The table below is the original developer's before/after comparison and wasn't independently re-verified against a pre-fix build (not available in this source tree): the relative story (dozens of naive-scan false positives down to ~0-1 after the seven layers) matches what we observed directly.

Binary False positives (naive 2-byte scan) False positives (after all 7 layers)
/bin/ls (x86-64 ELF) dozens (symbol-table artifacts) 0 (independently verified)
ARM64 ELF with jump-table tricks dozens ~1 (developer's reported figure, not independently re-verified)
Windows PE with actual UTF-16 strings n/a Reports genuine strings

section_skip_utf16 - Sections Always Skipped#

.dynstr       dynamic linker string table - null-separated ASCII
.dynsym       dynamic symbol table - binary struct data
.symtab       symbol table
.strtab       string table - null-separated ASCII
.shstrtab     section header string table
.gnu.hash     hash table - binary data
.gnu.version  version table - binary data
.gnu.version_r  version requirement table
.note.*       note sections - binary data
.debug*       DWARF debug info - complex binary format
.rela.*       relocation tables - binary data
.plt          procedure linkage table - code stubs
.got          global offset table - pointers
.got.plt      GOT for PLT entries

Encoding Identification#

Encoding Detection Typical source
utf-8 Valid UTF-8 sequence, at least one codepoint ≥ U+0080 Linux .rodata, Android logs, modern apps
utf-16le All 7 guards pass Windows PE strings, Android res, wchar_t

Note: STR_ENC_UTF16BE exists as a named value (and is handled in the display code, printed as "UTF-16BE"), but the scanner in unicode.c never actually assigns it: only STR_ENC_UTF8 and STR_ENC_UTF16LE are set by the current scan logic. There's no big-endian UTF-16 detection path today.


API#

// Scan all non-code sections
void dax_scan_unicode(dax_binary_t *bin);

// UTF-8 decoder (exposed for use in disasm.c string annotation)
int dax_utf8_decode(const uint8_t *buf, size_t len,
                    uint32_t *codepoint, int *seq_len);

// Convert UTF-16LE bytes to UTF-8 string
int dax_utf16le_to_utf8(const uint8_t *src, size_t src_bytes,
                         char *dst, size_t dst_max);

JS API: bin.unicodeStrings() - returns array of { address, value, byteLength, encoding }.

Edit this page on GitHub Source: docs/UNICODE_DETECTION.md · Rerius v1.0.0
On this page
Unicode String Detection Overview UTF-8 Detection Algorithm Why "at least one multi-byte sequence"? Codepoint validation UTF-16LE Detection The False Positive Problem Seven Layers of Defense Result section_skip_utf16 - Sections Always Skipped Encoding Identification API
ESC
↑↓ navigate openesc close