Skip to content

Typed disassembly: operands, flow, and the unwind region - #145

Merged
glslang merged 12 commits into
mainfrom
feat/typed-disassembly
Sep 10, 2026
Merged

Typed disassembly: operands, flow, and the unwind region#145
glslang merged 12 commits into
mainfrom
feat/typed-disassembly

Conversation

@glslang

@glslang glslang commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Instruction gains mnemonic, operands and flow beside the text it already had.

The engine has no structured disassembly, so the third column was a string and every caller wanting an immediate or a branch target re-parsed it downstream — twice over in windbg-mcp, once for the reachability walk and once for the path recipe. The parse belongs at the seam that produces the rendering.

The two rules the shapes are chosen for

  • An operand this does not recognise is Other with its text, never forced into one of the other four.
  • Every destination in Flow is an Option. "The engine printed a resolvable address" and "this is indirect" are different facts, and a caller reading None as no edge stays sound.

Reading is gated on InstructionSet. x86 and x64 are read; ARM64 reports its mnemonic, no operands and Flow::Unknown — not the Fallthrough most instructions happen to be, because an unread b.eq called a fall-through hands a walk one edge of two.

Measured, not composed

The unit tests are written against renderings copied by hand, which pins the rules and does not prove the parser: a hand-copied line is chosen from the shapes its author already knew about. examples/typed_disassembly.rs runs the reading over a whole real dispatch routine instead.

Measurement mountmgr!MountMgrDeviceControl, 26100 image
Instructions walked 376
Operands kept as Other 0
Instructions with Flow::Unknown 0
Control-code compares recovered 11

It found both defects this PR pins with tests:

  • Literals must be u64. That routine renders mov rax,8000000000000000h and mov qword ptr [rbp+0A8h],0FFFFFFFFFFFFFFFFh; a signed parse dropped all three of the routine's unread operands on the floor.
  • Registers must be matched before literals. ah, bh, ch and dh are registers and well-formed h-suffixed hexadecimal, so mov ah,5 reported a destination of 0xa. Swapping those two arms passes every other test in the file, which is why it is pinned on its own.

function_extent

Reads the .pdata entry for an address, rebased. Named a region deliberately: MSVC splits a function across several entries, and this answers 0x14750..0x147a3 for that routine — 83 bytes, which .fnent confirms exactly — while its compare chain lives past 0x147dd. Bounding a walk with it recovered zero control codes where following the flow recovered twelve. So it is a sanity bound, not a function's extent, and the doc comment says so.

The x64 entry is three u32 RVAs rather than the 64-bit addresses the API's name suggests. Reading them as u64 reports "no entry" for a function that plainly has one, which is how this was first written.

symbol_for exposes the symbol lookup that was already there privately — what names a call's destination once a walk has one.

Checks

cargo fmt --all -- --check clean, cargo clippy --all-targets adds no warning, cargo test --lib 200 passed / 0 failed.

What this is for

The consumer is a windbg-mcp change that ports the Driver Buddy Revolutions Ghidra script's analyses natively — a static IOCTL map, a device-security gate and a hazard scan — and reworks reachable_from_dispatch off its uf-text parse onto these fields.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

… function

`Instruction` gains `mnemonic`, `operands` and `flow`. The engine has no
structured disassembly, so the third column was a string and every caller
wanting an immediate or a branch target re-parsed it downstream — twice over,
in windbg-mcp's case, once for the walk and once for the recipe. The parse
belongs at the seam that produces the rendering.

Two rules the shapes are chosen for. An operand this does not recognise is
`Other` with its text, never forced into one of the other four. And every
destination in `Flow` is an `Option`, because "the engine printed a resolvable
address" and "this is indirect" are different facts; a caller reading `None`
as no edge stays sound.

Gated on `InstructionSet`. x86 and x64 are read; ARM64 reports its mnemonic and
`Flow::Unknown`, not the `Fallthrough` most instructions happen to be — an
unread `b.eq` called a fall-through hands a walk one edge of two.

Measured rather than composed. `examples/typed_disassembly.rs` runs the reading
over a whole real dispatch routine: 376 instructions of
`mountmgr!MountMgrDeviceControl` on a 26100 image, zero unrecognised operands,
zero unknown flows, and its eleven control-code compares recovered as values.
It found both defects now pinned by tests — literals must be `u64`, since that
routine renders `8000000000000000h` and `0FFFFFFFFFFFFFFFFh`, and registers must
be matched before literals, since `ah`, `bh`, `ch` and `dh` are both.

`function_extent` reads the `.pdata` entry for an address. It is deliberately
named a region: MSVC splits a function across several, and this answers
`0x14750..0x147a3` for that routine — 83 bytes, which `.fnent` confirms — while
its compare chain lives past `0x147dd`. Bounding a walk with it recovered zero
control codes where following the flow recovered twelve. The x64 entry is three
`u32` RVAs rather than the 64-bit addresses the API's name suggests, and reading
them as `u64` reports no entry for a function that plainly has one.

`symbol_for` exposes the symbol lookup that was already there privately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-10T12:20:40.061387Z 0ec420c New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Sep 10, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 1c93eb0f-d0ba-4518-8acf-8724b1bcf794


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1f67db3528

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/dbgeng.rs
/// They are also relative, so they are rebased here against the module that holds the address
/// — what `.fnent` prints, at the RVAs it prints them.
pub fn function_extent(&self, address: u64) -> Result<Option<(u64, u64)>, DbgEngError> {
let mut entry = [0u32; 3];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Decode the ARM64 function-entry layout before returning an extent

When function_extent is called on an ARM64 target—which the crate advertises and the new example permits—the function entry is not three u32 RVAs: its second word is packed unwind data or an .xdata RVA, not an end address. Interpreting entry[1] as EndAddress can therefore return a huge bogus region or None for valid ARM64 code; branch on the processor type and decode its function length, or explicitly reject non-x64 targets.

Useful? React with 👍 / 👎.

Comment thread src/dbgeng.rs Outdated
"jmp" | "jmpf" => Flow::Jmp(destination()),
"ret" | "retf" | "retn" | "iret" | "iretd" | "iretq" | "sysret" | "sysexit" => Flow::Return,
"ud0" | "ud1" | "ud2" | "hlt" => Flow::Trap,
"int" | "int1" | "int3" | "into" => Flow::Trap,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve fallthrough for returning INT instructions

On 32-bit Windows code that issues a returning software interrupt, notably the int 2eh system-call path, classifying every int as Trap makes falls_through() false and causes a reachability walk to discard all instructions after the syscall. Only non-returning trap forms such as int 29h and int3 should stop the walk; classify int using its operand rather than its mnemonic alone.

Useful? React with 👍 / 👎.

Comment thread src/dbgeng.rs Outdated
Comment on lines +5974 to +5977
.is_err()
{
// No entry for this address is the ordinary answer for a leaf or for data.
return Ok(None);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Propagate genuine function-entry query failures

If GetFunctionEntryByOffset fails because the debugger is in an invalid state, the target cannot be read, or another COM error occurs, this branch converts it to Ok(None), making a failed query indistinguishable from a leaf function with no unwind entry despite the Result<Option<_>> return type. Map only the documented no-entry result to None and return other failures with context.

AGENTS.md reference: AGENTS.md:L22-L24

Useful? React with 👍 / 👎.

…ring

All three findings on #145 were correct. Two of them turned on facts the
API's documentation does not state, so `examples/function_entry_probe.rs`
measures them rather than arguing: which failure means "no entry", and what
the record looks like off x64.

`function_extent` answers a three-state `FunctionExtent` instead of an
`Option`, because the two non-answers were different facts collapsed into
one. `NoEntry` is now reported for the single measured failure that means it
-- `E_NOINTERFACE`, which a real dbgeng 10.x gives for address zero, for a
module's header page, and for every x86 address, 32-bit Windows having no
unwind table -- and every other failure is returned as an error rather than
read as a leaf.

`Unsupported` is every instruction set but x64, and refusing rather than
decoding is the choice here. ARM64's record is two words whose second is
packed unwind data or an `.xdata` RVA: measured on an ARM64 kernel dump,
`nt!KeBugCheckEx` fills `needed = 8` with `[0x0025df60, 0x0005f218]`. Read
as an end address that is a bogus region, and for any function whose
`BeginAddress` is below the `.xdata` RVA it is a bogus region that contains
the address asked about, so it passes every sanity check in the function. A
wrong region that looks right is worse than no region. Decoding ARM64's
packed unwind length is the other option the finding offered and buys
nothing yet: the operand reading already refuses that architecture, so
nothing downstream could use the bound. The engine's own `needed` is now
checked against the x64 shape as well, so the layout is a check rather than
an assumption.

A software interrupt is classified by its vector rather than its mnemonic.
`int 2eh` is the 32-bit system-call path and returns; classifying every
`int` as a trap made `falls_through()` false and discarded every instruction
after a syscall. `int 29h` and `int 3` still stop a walk -- the second by
vector as well as by the `int3` spelling, since that is how the engine
renders `0xcc`.

Mutation-verified: restoring the blanket `int` trap fails the new vector test
and nothing else. The two engine-dependent fixes are verified by the probe
against a real ARM64 dump and a real x64 one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6e01a4716a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/dbgeng.rs Outdated
Comment on lines +5964 to +5965
match self.processor_type() {
Ok(machine) => InstructionSet::from_processor_type(machine),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use the effective processor for disassembly

When the debugger's effective machine differs from the physical target—such as a WOW64 session, emulated x64 code on ARM64, or after .effmachDisassemble renders the effective instruction set, while processor_type() calls GetActualProcessorType. This can expose the wrong InstructionSet, suppress otherwise supported operand parsing, and make function_extent select the wrong unwind-entry layout. Query GetEffectiveProcessorType for the rendering instead, and avoid using the physical processor as the code-layout discriminator.

Useful? React with 👍 / 👎.

Comment thread src/dbgeng.rs Outdated
"fword" => 6,
"qword" => 8,
"tbyte" => 10,
"mmword" | "xmmword" => 16,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Report MMWORD operands as eight bytes

When DbgEng renders an MMX memory operand such as mmword ptr [rax], this branch reports size: Some(16), but an MMWORD is 64 bits (8 bytes); XMMWORD is the 16-byte form. Any consumer using the typed operand to determine access width will therefore double the size of MMX accesses, so mmword and xmmword need separate mappings.

Useful? React with 👍 / 👎.

Both findings on the second round were correct.

`instruction_set` read `GetActualProcessorType`, which is the machine, while
`Disassemble` renders with the *effective* one. They diverge wherever one
machine runs another's code -- a WOW64 process, x64 emulated on ARM64, or any
target after `.effmach` -- and discriminating a rendering by the machine
underneath it reads x86 output with x64 rules and picks the wrong unwind
record shape in `function_extent`.

No fixture here has that divergence, so it was forced rather than left
argued: `.effmach x86` on an x64 kernel dump moves the effective type to
`0x14c` with the physical still `0x8664`, and the reading now follows it --
`nt!KeBugCheckEx` answers "not decoded for X86" instead of decoding an x64
entry against x86 output. `effective_processor_type` is a new method rather
than a change to `processor_type`, because the pool and heap walkers read
that one for pointer width, and a pointer's width is a fact about the machine
rather than about a rendering.

`mmword` was folded in with `xmmword` at sixteen bytes. An MMX operand is
eight; the fold doubled the width reported for every MMX access, with nothing
about the rendering to show for it. Every `ptr` width now has a test asserting
the width its name means, including the two non-powers of two.

Mutation-verified: restoring `mmword` to sixteen fails the new width test and
nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7fda435b46

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/dbgeng.rs Outdated
/// against that shape rather than assumed, since it is the field that says ARM64's is
/// different.
pub fn function_extent(&self, address: u64) -> Result<FunctionExtent, DbgEngError> {
let set = self.instruction_set();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Propagate processor-query failures from function_extent

When no target is active or GetEffectiveProcessorType otherwise fails, instruction_set() converts the error to Other(0), so this method returns Ok(FunctionExtent::Unsupported(Other(0))) without attempting the entry query. That makes an engine failure indistinguishable from a valid but unsupported architecture despite this method returning Result; fresh evidence in the current tree is that the newly added processor preflight still swallows failures even though GetFunctionEntryByOffset errors now propagate.

AGENTS.md reference: AGENTS.md:L22-L24

Useful? React with 👍 / 👎.

Comment thread src/dbgeng.rs Outdated
Comment on lines +2547 to +2549
',' if depth <= 0 => {
out.push(&rest[start..index]);
start = index + 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve commas inside decorated target symbols

When DbgEng renders a call or branch to a C++ template specialization such as module!foo<int,int> (00007ff612345678), the comma is outside brackets and parentheses here, so the single target is split into two operands. The first fragment then has no parenthesized address, causing classify_flowto produceCall(None)orBranch(None)` and making reachability or call-target analysis silently lose the direct edge.

Useful? React with 👍 / 👎.

Comment thread src/dbgeng.rs Outdated
Comment on lines +2808 to +2809
_ if is_conditional_branch(mnemonic) => Flow::Branch(destination()),
_ => Flow::Fallthrough,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Classify xbegin as a conditional branch

For binaries using Intel TSX, xbegin has both a fallthrough path when the transaction starts and a relative branch target used when it aborts or cannot start. Falling into the default Flow::Fallthrough arm means a reachability walk never schedules the fallback path, which is commonly where the lock-based implementation lives, so analyses can omit a substantial portion of the routine.

Useful? React with 👍 / 👎.

Three findings on the third round, all correct.

A control transfer's operand text is no longer split on commas. It takes
exactly one operand -- a far jump renders its segment with a colon -- and a
demangled C++ name carries commas of its own, so
`call module!std::map<int,int>::insert (…)` was severed at the template
argument, leaving a first fragment with no parenthesised address. That
reports `Call(None)`, which a reachability walk reads as an indirect call and
drops: a direct edge lost with nothing to show for it.

Tracking angle-bracket depth is the other way to fix that and is not taken.
`operator<<` and `operator<` leave it unbalanced, and an unbalanced opener
swallows every later operand into one -- which on a `cmp` would take the
immediate with it and lose exactly the control codes this reading exists to
recover. Not splitting where there is nothing to split needs no such
judgement. The cost is that a comma inside a symbol still severs it on a
non-transfer instruction, where no edge is at stake.

`xbegin` is a conditional branch. It falls through into the transaction and
takes its operand on an abort or a failure to start, and the abort path is
usually where the lock-based fallback lives, so classifying it as a plain
fall-through misses a whole implementation of the routine.

`function_extent` asks the processor directly instead of through
`instruction_set`, which folds a failed query into `Other(0)`. That fold is
right where a rendering still exists to hand back with its operands unread;
here there is no partial answer, so a failed query is an error and
`Unsupported` keeps meaning "a real architecture this does not decode". Both
sides now say why they differ.

Two of these landed on `classify_flow`, which is a hand-maintained table, so
its doc now states what a missing entry costs rather than implying the table
is complete: the default arm reads an unlisted transfer as a fall-through,
which loses an edge and never invents one, so a gap degrades along the
"reachable is sound, not-reachable is best-effort" boundary a walk over this
already documents.

Decoding ARM64's packed unwind length is now tracked as #146 and cited from
the code.

Mutation-verified one at a time: always splitting fails the decorated-symbol
test and nothing else, and removing the `xbegin` arm fails its test and
nothing else. The real-target measurement is unchanged at 376 instructions,
zero unread operands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de4a0db96d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/dbgeng.rs
Comment on lines +6097 to +6098
if set != InstructionSet::Amd64 {
return Ok(FunctionExtent::Unsupported(set));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Return NoEntry for x86 targets

When the effective target is x86, this blanket branch returns Unsupported(X86), but the new public FunctionExtent::NoEntry documentation explicitly promises that every x86 address produces NoEntry because 32-bit Windows has no unwind table. Callers that distinguish an absent entry from an unsupported layout therefore receive the wrong state for every x86 input; special-case X86 as NoEntry while retaining Unsupported for architectures whose entry layout actually needs decoding.

Useful? React with 👍 / 👎.

Comment thread src/dbgeng.rs Outdated
Comment on lines +2639 to +2641
let (name, address) = match text.split_once('(') {
Some((name, tail)) => (name.trim(), tail.trim_end_matches(')').trim().to_string()),
None => (text.trim(), String::new()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Parse the final parenthesized address

When DbgEng resolves a control transfer to a symbol whose name itself contains parentheses, such as module!Functor::operator() (00007ff612345678), split_once('(')splits at the operator name rather than the appended address. The address parse then fails, the symbol is truncated, and the instruction becomesCall(None)orBranch(None)`, dropping the direct edge. The current comma fix leaves this decorated-symbol form exposed; split from the final address suffix instead.

Useful? React with 👍 / 👎.

Comment thread src/dbgeng.rs Outdated
Comment on lines +2847 to +2849
"xbegin" => Flow::Branch(destination()),
_ if is_conditional_branch(mnemonic) => Flow::Branch(destination()),
_ => Flow::Fallthrough,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Mark xabort as non-fallthrough

Whenever a reachability walk encounters xabort, the default arm classifies it as Fallthrough. Inside an RTM transaction it aborts and resumes at the fallback specified by the corresponding xbegin, while outside a transaction it faults, so execution never continues at the next sequential instruction. This classification causes callers to mark dead instructions after an abort as reachable; represent xabort with a non-fallthrough flow.

Useful? React with 👍 / 👎.

Comment thread examples/typed_disassembly.rs Outdated
Comment on lines +99 to +103
if let Some(target) = instruction.flow.target() {
// A call leaves this function; every other edge stays in it.
if !matches!(instruction.flow, Flow::Call(_)) {
queue.push(target);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude same-module tail calls from the walk

When the selected routine ends with a tail jmp to another function in the same module, the module bounds accept the destination and this branch enqueues it because every non-call target is assumed to remain in the function. The probe then walks that callee and potentially its transitive tail-call graph, so its instruction counts, unread-operand totals, IOCTL candidates, and call list no longer describe the requested routine and may eventually hit the 20,000-address cap. Use symbol/function ownership to reject cross-function jump targets rather than relying only on module bounds.

AGENTS.md reference: AGENTS.md:L3-L5

Useful? React with 👍 / 👎.

… costs

Round four. Three taken, one declined.

A destination is split from the **last** parenthesis, and only when what
follows it parses as an address. `module!Functor::operator()` is a demangled
call operator, and splitting at the first parenthesis took the name apart at
`operator`, left `) (…` to parse as an address, and reported `Call(None)` --
the same direct edge the comma case lost, reached through a different
character. Requiring the tail to parse is what keeps a symbol whose last
parenthesis is its own intact.

`function_extent`'s doc promised `NoEntry` for x86 while the gate has always
answered `Unsupported`. Both readings are defensible and only one can be
true, so the doc moves rather than the code: 32-bit Windows has no unwind
table and every x86 address measured here answers `E_NOINTERFACE`, but that
is a fact about a platform, and returning `NoEntry` would assert it about an
address this never asked about. x86's record is also `IMAGE_FUNCTION_ENTRY`,
whose fields are addresses rather than the RVAs rebased here, and nothing has
measured one. One rule everywhere: decode the layout that has been measured,
refuse the rest by name.

The probe no longer follows a tail `jmp` out of the routine it was asked
about. A neighbour is inside the module, so module bounds alone let the walk
wander into that function and its own tail calls, and every number the probe
prints would then describe more than the routine. Edges are kept inside the
entry's symbol, with the module bound as the fallback where there is no
symbol, and the run says which. Measured on `mountmgr!MountMgrDeviceControl`:
zero cross-function jumps declined, 376 instructions and eleven control-code
compares unchanged -- so the figures quoted for this branch were already
describing the right routine.

**Declined: `xabort` as non-fallthrough.** The finding's premise is that
execution never continues at the next instruction, and outside a transaction
that is not so -- the SDM specifies `IF RTM_ACTIVE = 0 THEN treat as NOP`, so
it falls straight through. Inside one it resumes at the outer `xbegin`'s
fallback, and no static reading tells the two apart. Classifying it as a
transfer would drop every instruction after it wherever RTM is inactive,
which on current parts is nearly everywhere. Listed explicitly as a
fall-through so the decision is visible rather than left to the default arm.

That finding did show up a sentence of mine that was wrong. `classify_flow`'s
doc claimed an unlisted mnemonic "never invents an edge", which holds for a
missing *conditional* transfer and not for an unconditional one, whose
fall-through does not exist. The doc now separates the two and states that
the table is complete for the second kind.

Mutation-verified: splitting from the first parenthesis fails the new
parentheses test and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5810f9f573

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/dbgeng.rs
Comment on lines +1857 to +1860
matches!(
self,
Self::Fallthrough | Self::Call(_) | Self::Branch(_) | Self::Unknown
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not fall through unreadable instructions

When DbgEng renders ??? because code pages are unavailable, read_operation assigns Flow::Unknown, and this match makes falls_through() return true. The new typed_disassembly reachability walk therefore queues subsequent addresses through bytes it never decoded—potentially until its 20,000-address cap—rather than stopping at the unreadable region. Distinguish unreadable renderings from unsupported instruction sets so only the latter receive the intentional best-effort fallthrough.

Useful? React with 👍 / 👎.

Comment thread src/dbgeng.rs Outdated
Comment on lines +2613 to +2615
if text.contains('[') {
if let Some(memory) = read_memory_operand(text) {
return Operand::Memory(memory);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve brackets inside decorated target symbols

When a direct call or jump resolves to a C++ overload such as module!Vector::operator[] (00007ff612345678), this test treats the brackets in the symbol name as a memory expression before the !-based target case runs. read_memory_operandconsequently returnsMemory, classify_flowproducesCall(None)orJmp(None)`, and reachability loses the resolvable direct edge. Detect actual memory-expression brackets without consuming brackets that are part of a decorated symbol.

Useful? React with 👍 / 👎.

Comment thread src/dbgeng.rs Outdated
"call" | "callf" => Flow::Call(destination()),
"jmp" | "jmpf" => Flow::Jmp(destination()),
"ret" | "retf" | "retn" | "iret" | "iretd" | "iretq" | "sysret" | "sysexit" => Flow::Return,
"ud0" | "ud1" | "ud2" | "hlt" => Flow::Trap,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep the wake-up edge after hlt

For kernel or firmware code containing hlt, execution resumes at the following instruction after an enabled interrupt or NMI wakes the processor. Grouping hlt with undefined-instruction traps makes falls_through() false, so a reachability walk truncates common idle-loop or wait-path code immediately after the halt. Classify hlt separately with a fallthrough edge.

Useful? React with 👍 / 👎.

Five review rounds said this. Three of them found the same defect through a
different character -- a comma inside `std::map<int,int>`, a parenthesis
inside `operator()`, a bracket inside `operator[]` -- each one severing a
direct call edge that a walk then dropped as indirect, each fix locally
correct, the next character already waiting. Four rounds added an entry to
the mnemonic table: `int` by vector, `xbegin`, `xabort`, `hlt`. Neither list
was ever going to end, because both were consequences of one choice:
recovering structure from another program's rendering.

So the rendering stops being the source. `mnemonic`, `operands` and `flow`
now come from decoding `bytes` -- the engine's own read of the instruction,
already in hand, so no extra round trip -- with `iced-x86`, default features
off, `decoder` and `instr_info` only. No formatter: the engine's rendering is
what this crate promises and it stays verbatim in `text`.

What that deletes, rather than fixes. Symbols leave the picture: a
destination is an address and naming it is `symbol_for`'s job, so no symbol's
spelling can take an operand apart again -- `Operand::Target` and
`MemoryOperand` lose their symbol halves. The mnemonic table is gone; flow
control comes from the decoder and is complete by construction, leaving two
decisions that are about semantics rather than spelling (a software
interrupt's vector, and `xabort`), both keyed on `Code` and both carrying the
reasoning that was established under review. Roughly 300 lines of parser and
every special case in it go with them.

`decode_range` is the other half: one memory read for a whole span instead of
one engine call per instruction, which is what a bounded traversal over a
hundred functions needs. Those instructions carry no `text`, nothing having
rendered them, and the doc says to ask `disassemble` for the few a caller
displays rather than filling the field from a second formatter -- two
renderings of one instruction in one type is worse than none.

`Flow::Unreadable` splits off `Unknown`, which round five caught. An
instruction set this does not decode still has an instruction there and falls
through; a `???` rendering has none, and a walk that fell through one would
step through bytes, one address at a time, to its own cap. Checked before the
instruction set, because unreadable bytes are unreadable on every
architecture.

`hlt` falls through, also from round five, and now for free: the decoder
calls it `Next`, which is right -- a halted processor resumes when an
interrupt wakes it, and grouping it with the undefined-instruction traps
truncated every idle loop at the halt.

Behaviour-preserving where it counts, measured rather than asserted. On
`mountmgr!MountMgrDeviceControl`: 376 instructions, zero unreadable operands,
zero unknown flows, the same eleven control-code compares at the same
addresses, the same forty calls with the same import names -- identical
before and after. The example now also compares the two decode paths over one
region: 23 range-decoded, 23 compared, 0 disagreements.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 66eb5edbbf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/dbgeng.rs Outdated
Comment on lines +2573 to +2574
if decoded.is_invalid() {
return (String::new(), Vec::new(), Flow::Unreadable);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat decoder disagreement as unknown, not unreadable

When DbgEng successfully renders a valid x86 instruction that iced-x86 1.21 does not recognize, such as an instruction from a newer extension, this path labels it Unreadable even though its bytes and rendering were read successfully. Since Unreadable::falls_through() is false, reachability walks stop and discard the remainder of the routine; reserve Unreadable for missing bytes/??? and represent a decoder-version disagreement as Unknown.

Useful? React with 👍 / 👎.

Comment thread src/dbgeng.rs Outdated
Comment on lines +2617 to +2618
} else if base == iced_x86::Register::None && index == iced_x86::Register::None {
Some(decoded.memory_displacement64())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude segmented references from static addresses

For an operand such as the documented kernel access gs:[188h], both base and index are None, so this branch reports address: Some(0x188). The actual linear address depends on the runtime GS segment base and therefore cannot be computed from the instruction alone; consumers may consequently read or symbolize an invalid low address. Require the absence of an effective segment override before exposing a static address.

Useful? React with 👍 / 👎.

Comment thread src/dbgeng.rs Outdated
base: (base != iced_x86::Register::None).then(|| register_name(base)),
index: (index != iced_x86::Register::None).then(|| register_name(index)),
scale: decoded.memory_index_scale() as u8,
displacement: decoded.memory_displacement64() as i64,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the encoded RIP-relative displacement

For RIP/EIP-relative operands, iced-x86 exposes the normalized absolute target through memory_displacement64(), so assigning that value to displacement duplicates address rather than returning the signed encoded displacement promised by this field. For example, call [rip+0xffa] at 0x1000 reports a displacement of 0x2000, breaking consumers that inspect or reconstruct the addressing expression; derive the displacement from the target and the instruction's next IP instead.

Useful? React with 👍 / 👎.

Round six, all three taken, and none of them about symbol punctuation or the
mnemonic table -- those stopped existing with the decoder. These are edges in
the new code.

Bytes that were read and did not decode are `Unknown`, not `Unreadable`. The
line between the two is whether there are bytes at all: a `???` rendering has
none, and a walk must stop rather than step through them one address at a
time; an encoding this decoder does not know -- an extension newer than the
pinned version, or a span entered mid-instruction -- has an instruction
there, and stopping would discard the rest of a routine over a version skew.
The same correction applies in `decode_range`, where a linear read runs into
data as a matter of course.

A segment override rules out a static address. `gs:[188h]` has no base and no
index, so it was reported as address `0x188`; it is the KPCR, its linear
address is the segment base plus that displacement, and the base is a runtime
fact. A consumer taking the old value would read or symbolise a low address
that means nothing.

A RIP-relative operand keeps the displacement it encodes. The decoder
normalises that displacement to its target, so taking it verbatim reported
`[rip+0xffa]` at `0x1000` as `0x2000` -- a second copy of `address`, where
the addressing expression should be. Derived back against the instruction's
own end, which is what the displacement is relative to.

Mutation-verified one at a time: each of the three, reverted, fails exactly
one test and no other. Re-measured on `mountmgr!MountMgrDeviceControl`
unchanged at 376 instructions, eleven control-code compares, and 23 of 23
instructions agreeing between the two decode paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 86649e23a0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/dbgeng.rs
let displacement = if rip_relative {
(decoded.ip_rel_memory_address() as i64).wrapping_sub(decoded.next_ip() as i64)
} else {
decoded.memory_displacement64() as i64

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Sign-extend 32-bit based displacements

For x86 addressing forms, iced-x86 preserves the 32-bit effective-address representation in memory_displacement64(), so a negative displacement such as [ebp-8] is returned as 0xfffffff8; this direct i64 cast reports 4294967288 rather than the signed -8 promised by MemoryOperand::displacement. The same issue affects address-size-overridden x64 instructions, so based displacements need sign extension according to their effective address/displacement width.

Useful? React with 👍 / 👎.

Comment thread src/dbgeng.rs
Comment on lines +1890 to +1892
/// The image has no unwind entry covering the address — a leaf function, or an address that
/// is not code. Every x86 address answers this, 32-bit Windows having no unwind table.
NoEntry,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Correct the x86 NoEntry contract

On every x86 target, function_extent returns Unsupported(X86) before querying an entry, so this public variant documentation still promises an outcome callers can never receive. Fresh evidence in the final tree is that the method-level documentation now explicitly chooses Unsupported for x86 while this enum-level contract still says every x86 address returns NoEntry; callers matching these states can therefore implement the wrong fallback.

Useful? React with 👍 / 👎.

Round seven, both taken.

The decoder keeps a narrow effective address in its own width, so `[ebp-8]`
comes back as `0xfffffff8` and the widening cast reported the commonest
local-variable reference there is as 4,294,967,288 -- against a field whose
whole promise is a *signed* displacement. Measured before fixing: the test
went in asserting -8 and printed 4294967288. Sign-extended from the width the
address registers are, that being what the addressing form is computed in,
and an absolute reference with no register left unsigned, because a 32-bit
`[0xfffff000]` is a high address and not a negative offset. The 64-bit form
was already right, and is now pinned beside the 32-bit one so the two cannot
drift.

And the `FunctionExtent::NoEntry` variant still promised that every x86
address answers it, which the gate has never done and which the *method* doc
was corrected about last round. Fixing one site and leaving the other is how
a contract ends up saying two things; both now say x86 answers `Unsupported`,
and why.

Mutation-verified: dropping the sign extension fails the new displacement
test and nothing else. Re-measured on `mountmgr!MountMgrDeviceControl`
unchanged -- 376 instructions, eleven control-code compares, 23 of 23
agreeing across the two decode paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dfc3e64822

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/dbgeng.rs Outdated
match decoded.op_kind(index) {
OpKind::Register => Operand::Register(register_name(decoded.op_register(index))),
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64 => {
Operand::Target(decoded.near_branch_target())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Sign-extend x86 targets into DbgEng's address space

On an x86 target where bit 31 is set—such as a 32-bit kernel or a large-address-aware user process—DbgEng exposes offsets as sign-extended u64 values, while iced-x86's NearBranch32 target is zero-extended. The instruction can therefore be at 0xffffffff80001000 while this operand and the corresponding Flow::target() report 0x80002000; the module-bound walk in typed_disassembly rejects such direct edges, and other consumers read or symbolize the wrong address. Canonicalize decoded x86 targets to DbgEng's sign-extended address form, including the value returned by near_target().

Useful? React with 👍 / 👎.

Round eight, one finding, taken. Measured before fixing: at
`ffffffff`80001000` with a 32-bit effective machine, `call +0xffb` reported
its destination as `0x80002000` rather than `ffffffff`80002000`. Decoding
32-bit code computes a 32-bit target, so an instruction whose own address
carries a high half named a destination in a different address space from
itself -- which a module-bounds walk rejects as out of range, and which
anything reading or symbolising follows to the wrong place. `.effmach x86`
over a 64-bit kernel target is a reachable way to be there, and this branch
already knows that mode exists.

The high half is inherited from the instruction rather than sign-extended.
Both would fix the reported case; inheriting takes the address form from the
caller's own value instead of assuming which convention the engine uses, and
this code has no business deciding that on the engine's behalf.

64-bit decoding is deliberately untouched, and that is not just "it needs no
help": a `rel32` reaches two gigabytes either way, so it can legitimately
cross a 4 GB boundary, and masking there would drag a correct target back by
four gigabytes. Both directions are asserted, and each of the two mutations --
dropping the canonicalization, and applying it to 64-bit as well -- fails the
test.

The one case this does not get right is the mirror image, a 32-bit branch
wrapping across its own sign boundary, which keeps the high half it started
in. Said in the doc rather than left to be discovered.

Re-measured on `mountmgr!MountMgrDeviceControl` unchanged: 376 instructions,
eleven control-code compares, 23 of 23 agreeing across the two decode paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b1fae017db

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/dbgeng.rs Outdated
} else if rip_relative {
Some(decoded.ip_rel_memory_address())
} else if base == iced_x86::Register::None && index == iced_x86::Register::None {
Some(decoded.memory_displacement64())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Canonicalize absolute x86 memory addresses

On 32-bit targets where DbgEng exposes high addresses in sign-extended form, an absolute operand such as [0x80002000] is decoded by iced-x86 as the zero-extended 0x80002000, so this branch reports an address outside the debugger's 0xffffffff80000000 address space. Absolute global and import-slot references are common in x86 kernel code, and consumers using this documented field to read or symbolize the operand will access the wrong address; canonicalize it against the instruction address just as direct x86 branch targets are.

Useful? React with 👍 / 👎.

Comment thread src/dbgeng.rs Outdated
Comment on lines +2647 to +2648
} else if index != iced_x86::Register::None {
index.size() * 8

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Derive VSIB displacement width from the address size

When decoding a 32-bit or address-size-overridden VSIB gather/scatter operand with no GPR base, the index is an XMM/YMM/ZMM register, so index.size() * 8 produces at least 128 rather than the effective address width. sign_extend() consequently becomes a no-op, and a negative displacement such as 0xfffffff8 is exposed as positive 4294967288; derive the width from the instruction's effective address size rather than the vector index register.

Useful? React with 👍 / 👎.

… from the field

Round nine, both taken, and both are the previous two rounds' fixes not
carried to their siblings.

An absolute memory address was left in the decoder's 32-bit space while a
branch target was canonicalised into the instruction's. `[0x80002000]` under
an instruction at `ffffffff`80001000` is an address in a different space from
the code reading it, and absolute globals and import slots are ordinary in
x86 kernel code, so this is the field a consumer follows. Same rule, same
helper, same reason 64-bit is exempt.

And a displacement's signed width now comes from the displacement field
rather than from the address registers. The register width was the first
answer and is wrong for a VSIB gather, whose index is an `xmm`/`ymm`/`zmm`:
`index.size() * 8` is 128 or more, `sign_extend` becomes a no-op, and a
negative displacement comes back as four billion -- exactly the defect the
sign extension was added to fix, surviving in the one addressing form whose
index is not a general-purpose register. The encoded field is the right width
by construction, a `disp8` of `0xf8` being `-8` whatever computes the
address, and it sidesteps the address-size override too.

Mutation-verified one at a time: each reverted fix fails exactly one test.
The VSIB case is a real encoding rather than a shape argued about --
`c4 e2 79 92 0c 95 f8 ff ff ff`, and the test asserts the index really is a
vector register before asserting the displacement, so it cannot pass by
decoding something else.

Re-measured on `mountmgr!MountMgrDeviceControl` unchanged: 376 instructions,
eleven control-code compares, 23 of 23 agreeing across the two decode paths,
and its import thunks still naming themselves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c2e35e2710

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/dbgeng.rs Outdated
// An absolute reference with no register stays unsigned, because a 32-bit `[0xfffff000]` is a
// high address rather than a negative offset.
let displacement = if rip_relative {
(decoded.ip_rel_memory_address() as i64).wrapping_sub(decoded.next_ip() as i64)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Compute EIP-relative displacement modulo 32 bits

When 64-bit code uses an address-size override, the base is EIP: ip_rel_memory_address() is then a zero-extended 32-bit target, while next_ip() retains the instruction's high 64-bit address. Subtracting them directly reports an enormous negative displacement instead of the encoded value, so consumers cannot reconstruct operands such as 67 48 8b 05 fa 0f 00 00 at a high address. Fresh evidence after the earlier RIP/EIP fix is that this new subtraction does not distinguish EIP from RIP; perform the EIP calculation modulo 32 bits.

Useful? React with 👍 / 👎.

Comment thread src/dbgeng.rs Outdated
Comment on lines +2661 to +2663
sign_extend(
decoded.memory_displacement64(),
decoded.memory_displ_size() as usize * 8,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not sign-extend scaled EVEX displacements as raw disp8

For EVEX instructions using compressed disp8, iced-x86 exposes memory_displacement64() after applying the tuple scale while memory_displ_size() still describes the one encoded byte. Sign-extending that scaled value from eight bits corrupts valid offsets once the scaled magnitude crosses 0x7f; for example, a ZMM full-vector operand with encoded disp8 2 represents +128 but becomes -128 here. Fresh evidence after the earlier displacement-width fix is the new use of the encoded field width for an already-expanded value; derive signedness from the effective address width instead.

Useful? React with 👍 / 👎.

…t to it

Round ten, both taken, and both measured before fixing: the EVEX case printed
-128 for a real +128, and the address-size-overridden relative operand printed
4294971386 for an encoded 0xffa -- four gigabytes out, exactly as claimed.

The width to sign-extend a displacement from is the **effective address
width**. Two simpler readings of that have now each been tried and each is
wrong in one addressing form:

  * the index register's width breaks a VSIB gather, whose index is a vector
    register, so the extension is a no-op and a negative displacement comes
    back as four billion;
  * the encoded field's width breaks EVEX, whose `disp8` is compressed -- the
    decoder returns it already multiplied by the tuple scale while the field
    is still one byte, so extending the expanded value from eight bits turns a
    real `+128` into `-128`.

So: the address registers give the width where there are any, an address-size
override being precisely what makes them narrow; and where there is no
general-purpose register -- a pure VSIB form -- the encoding makes the
displacement field the address width, and compression cannot arise there
because a compressed `disp8` needs a base. All three readings are pinned by
mutation, including the two wrong ones, so the next round finds a failing test
rather than a plausible-looking alternative.

And an `EIP`-relative operand computes its displacement at 32 bits. An
address-size override on a 64-bit instruction leaves the decoder wrapping the
target to 32 while the instruction's own next address stays 64, and
subtracting across that puts the answer 2^32 out.

Re-measured on `mountmgr!MountMgrDeviceControl` unchanged: 376 instructions,
eleven control-code compares, 23 of 23 agreeing across the two decode paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f2a2feb3d2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/dbgeng.rs
if decoded.code_size() == iced_x86::CodeSize::Code64 {
return value;
}
(decoded.ip() & 0xffff_ffff_0000_0000) | (value & 0xffff_ffff)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Sign-extend x86 addresses instead of copying the source half

When a 32-bit instruction and its target lie on opposite sides of bit 31, this copies the instruction's upper half rather than canonicalizing the decoded target itself. For example, a low-address instruction referencing absolute [0x80002000] still returns 0x0000000080002000 instead of DbgEng's sign-extended offset, and a relative branch crossing the boundary has the same problem, so memory reads, symbol lookup, and flow edges use the wrong address. Fresh evidence in the final tree is that canonical_address now explicitly performs this source-half OR, while the added tests cover only instructions and targets that already occupy the same half.

Useful? React with 👍 / 👎.

Round eleven, one finding, **declined** -- and measured rather than argued,
because it asks to reverse round eight's choice and the whole thing turns on
one fact nobody had checked: does the engine hand out high x86 addresses in
sign-extended form?

It does not. Measured on this build against a 32-bit user target
(`cppthrow-fastfail-x86.dmp`): `? 80002000` evaluates to `80002000`, and
`.formats` prints `Hex: 80002000` -- eight digits, unextended. Sign-extending
would therefore invent `ffffffff80002000` for an address the engine itself
calls `80002000`, and on a 32-bit kernel, where most code lives above bit 31,
it would do that for nearly every address. Inheriting the instruction's own
half reproduces the measurement exactly, a genuine 32-bit target's
instructions having a zero high half.

The finding is right that the tests covered only same-half cases, so the
straddling one it names is now asserted: a low instruction reaching a high
absolute keeps the low half. That is the residual cost of this choice and it
is the narrower of the two exposures -- the sign-extending rule would be wrong
for every high address rather than for the ones that straddle.

Mutation-verified in the direction that matters here: swapping the inherit for
a sign-extension fails the new test. So the alternative is no longer a
plausible-looking suggestion, it is a red build with the measurement in the
test's name.

What would reopen it is a measurement from a 32-bit **kernel** target, which
no fixture in this repo has. Said in the doc rather than left implicit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0ec420cea8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return false;
}
// `NTSTATUS` severity lives in the top two bits; a defined control code has none set.
if value & 0x8000_0000 != 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Accept vendor-defined CTL_CODE values

When a driver uses a vendor-defined device type in the 0x8000..=0xffff range, its valid CTL_CODE has bit 31 set as the Common bit, so this check discards every such IOCTL as though it were an NTSTATUS. This makes the probe silently omit private control codes—the ones most relevant to third-party driver analysis—so status filtering must not categorically reject the Common bit.

Useful? React with 👍 / 👎.

@glslang
glslang merged commit 43ce259 into main Sep 10, 2026
7 checks passed
glslang added a commit that referenced this pull request Sep 10, 2026
…ring

All three findings on #145 were correct. Two of them turned on facts the
API's documentation does not state, so `examples/function_entry_probe.rs`
measures them rather than arguing: which failure means "no entry", and what
the record looks like off x64.

`function_extent` answers a three-state `FunctionExtent` instead of an
`Option`, because the two non-answers were different facts collapsed into
one. `NoEntry` is now reported for the single measured failure that means it
-- `E_NOINTERFACE`, which a real dbgeng 10.x gives for address zero, for a
module's header page, and for every x86 address, 32-bit Windows having no
unwind table -- and every other failure is returned as an error rather than
read as a leaf.

`Unsupported` is every instruction set but x64, and refusing rather than
decoding is the choice here. ARM64's record is two words whose second is
packed unwind data or an `.xdata` RVA: measured on an ARM64 kernel dump,
`nt!KeBugCheckEx` fills `needed = 8` with `[0x0025df60, 0x0005f218]`. Read
as an end address that is a bogus region, and for any function whose
`BeginAddress` is below the `.xdata` RVA it is a bogus region that contains
the address asked about, so it passes every sanity check in the function. A
wrong region that looks right is worse than no region. Decoding ARM64's
packed unwind length is the other option the finding offered and buys
nothing yet: the operand reading already refuses that architecture, so
nothing downstream could use the bound. The engine's own `needed` is now
checked against the x64 shape as well, so the layout is a check rather than
an assumption.

A software interrupt is classified by its vector rather than its mnemonic.
`int 2eh` is the 32-bit system-call path and returns; classifying every
`int` as a trap made `falls_through()` false and discarded every instruction
after a syscall. `int 29h` and `int 3` still stop a walk -- the second by
vector as well as by the `int3` spelling, since that is how the engine
renders `0xcc`.

Mutation-verified: restoring the blanket `int` trap fails the new vector test
and nothing else. The two engine-dependent fixes are verified by the probe
against a real ARM64 dump and a real x64 one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf
@glslang
glslang deleted the feat/typed-disassembly branch September 10, 2026 13:33
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.

1 participant