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.
Describe the bug
SubvariableFlowtraces a logical variable through a flow using a bit mask. When a constant enters that flow,SubvariableFlow::addConstantnarrows it to the mask:int4 sa = leastsigbit_set(mask); res->val = (mask & constvn->getOffset()) >> sa;SubvariableFlow::setReplacementaccepts constants unconditionally (except undersextrestrictions), 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: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:Expected behavior
The store is reachable only by falling through the
JZ,MOV AL,0x6dominates it unconditionally, and nothing writesALin between, so the stored value is always 6:Actual behavior
0x6 & 0x1 == 0. The wrong value also propagates into the returned value of the function.What happens
SETC ALreduces the non-zero mask ofALto a single bit, soRuleSubvarCompZerofires on theOR AL,AL/JZcomparison and startsSubvariableFlowwithmask == 0x1. The traced flow reachesMOV AL,0x6through theMULTIEQUALat the merge block, and the constant is truncated there.Pinned by instrumenting
Funcdata::opSetInputtogether with a global holding the currently running action/rule:The raw lifting is correct (
AL = COPY 6); only the sub-variable narrowing loses the value.firstpass(which omits thesubvargroup) 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:
= 0(wrong)OR AL,ALbehind the merge= 6EAX, so not a partial register= 6JZskips past theRET, so no shared merge block= 6v5 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
master@ 817766a — both affected,SubvariableFlow::setReplacementis identical in the two.Additional context
Found while porting a 1993 DOS binary, where the compiler returns values in
ALplus 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
128becoming-128in thesextrestrictionspath). 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.