Skip to content

Decompiler: SubvariableFlow truncates a constant with bits outside the traced mask, changing its value #9423

Description

@chaosben

Describe the bug

SubvariableFlow traces a logical variable through a flow using a bit mask. When a constant enters that flow, SubvariableFlow::addConstant narrows it to the mask:

int4 sa = leastsigbit_set(mask);
res->val = (mask & constvn->getOffset()) >> sa;

SubvariableFlow::setReplacement accepts constants unconditionally (except under sextrestrictions), so a constant whose value has bits set outside the mask is silently truncated to a different value. For a flag sized logical variable (bitsize < 8) the guard that nothing outside the mask is consumed is skipped:

else {
  if (bitsize >= 8) {		// Not a flag
    // If the logical variable is not a flag, don't consider the case where multiple variables
    // are packed into a single location, i.e. always consider it a single variable
    if ((!aggressive)&&((vn->getConsume()&~mask)!=0)) // If there is any use of value outside of the logical variable
      return (ReplaceVarnode *)0; // This probably means the whole thing is a variable, i.e. quit

That assumption holds for a real flag location, which legitimately packs several independent one bit variables. It does not hold for a general purpose register that is also used as a whole value, and then the truncated bits are still read from it — the decompiler emits a constant that the program never stores.

To Reproduce

Import these 25 bytes as raw binary, x86:LE:32:default, and create a function at the start:

8a 06 66 0f ba e0 00 0f 92 c0 0a c0 74 08 b0 06 8b 5f 08 88 43 24 0a c0 c3
        MOV  AL,byte ptr [ESI]        ; AL is unknown here
        BT   AX,0x0
        SETC AL                       ; only bit 0 of AL can be non-zero now
        OR   AL,AL
        JZ   merge                    ; taken => AL == 0
        MOV  AL,0x6                   ; not taken => AL := 6, unconditionally
        MOV  EBX,dword ptr [EDI + 0x8]
        MOV  byte ptr [EBX + 0x24],AL ; must store 6
    merge:
        OR   AL,AL                    ; AL is read again here (flag style return)
        RET

Expected behavior

The store is reachable only by falling through the JZ, MOV AL,0x6 dominates it unconditionally, and nothing writes AL in between, so the stored value is always 6:

*(undefined1 *)(*(int *)(unaff_EDI + 8) + 0x24) = 6;

Actual behavior

*(undefined1 *)(*(int *)(unaff_EDI + 8) + 0x24) = 0;

0x6 & 0x1 == 0. The wrong value also propagates into the returned value of the function.

What happens

SETC AL reduces the non-zero mask of AL to a single bit, so RuleSubvarCompZero fires on the OR AL,AL / JZ comparison and starts SubvariableFlow with mask == 0x1. The traced flow reaches MOV AL,0x6 through the MULTIEQUAL at the merge block, and the constant is truncated there.

Pinned by instrumenting Funcdata::opSetInput together with a global holding the currently running action/rule:

[DIAG2] op@0x10000e (COPY) in0 := const 0   from RULE:subvar_compzero   <-- origin
[DIAG]  STORE input2 := const 0             from RULE:propagatecopy     <-- only propagates it

The raw lifting is correct (AL = COPY 6); only the sub-variable narrowing loses the value. firstpass (which omits the subvar group) keeps it, every other simplification style folds it.

Necessary conditions

Each of these variants decompiles correctly, so all three conditions are needed to trigger it — useful to keep as guards:

variant change result
v1 = 0 (wrong)
v2 no OR AL,AL behind the merge = 6
v3 value carried in EAX, so not a partial register = 6
v4 JZ skips past the RET, so no shared merge block = 6
v5 stored value is a loaded byte instead of a constant correct

v5 matters: without a constant the trace aborts on its own, so truncating a constant is the only way a value from outside the mask enters the narrowed flow silently.

Environment

  • Ghidra 12.1.2 (build 2026-06-05) and master @ 817766a — both affected, SubvariableFlow::setReplacement is identical in the two.
  • Linux x86_64, JDK 21.

Additional context

Found while porting a 1993 DOS binary, where the compiler returns values in AL plus the zero flag, so this shape occurs naturally. An exhaustive scan of that 470 KB binary (3943 functions, 5063 provable register-store comparisons against the immediate assigned in the same basic block) produced exactly two occurrences — the pattern is narrow, but it is silent, and both cases would have introduced hard to trace defects downstream.

Related, in the same function but a different branch: #3070 (constant 128 becoming -128 in the sextrestrictions path). That branch already verifies that a constant survives narrowing; the zero extension case does not.

A pull request with a fix and a data test follows.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions