From 44e0ef1416d53f11b89d400c1ad161fc6682b94e Mon Sep 17 00:00:00 2001 From: Willi Ballenthin Date: Mon, 9 Mar 2026 11:34:16 +0100 Subject: [PATCH 1/4] ci: add permissions Co-authored-by: Moritz --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d99b6c52..8fdba415 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,8 @@ on: pull_request: branches: [master] +permissions: read-all + jobs: lint: runs-on: ubuntu-latest From 1d7d0c360e152927b538b4e25c4f7bac6ec4d8a2 Mon Sep 17 00:00:00 2001 From: Willi Ballenthin Date: Mon, 9 Mar 2026 11:34:58 +0100 Subject: [PATCH 2/4] v2.0.0b1 --- speakeasy/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/speakeasy/version.py b/speakeasy/version.py index 4b3c3ea5..9b8d3ee1 100644 --- a/speakeasy/version.py +++ b/speakeasy/version.py @@ -1 +1 @@ -__version__ = "2.0.0a1" +__version__ = "2.0.0b1" From 0c35735f81c6cfbba3cd535eee1219d65f82acd6 Mon Sep 17 00:00:00 2001 From: Willi Ballenthin Date: Mon, 9 Mar 2026 16:08:22 +0100 Subject: [PATCH 3/4] Fix callback chaining and implement _initterm table parsing The callback handler in winemu.py only executed the first queued callback before returning to the original caller, silently dropping any remaining callbacks. This made setup_callback() unusable for APIs that need to invoke multiple function pointers in sequence (like _initterm). The handler now properly chains callbacks: when one completes, it sets up the next instead of returning early. _initterm and _initterm_e now parse the function pointer table and report the entries in the API event args for analyst visibility. Closes #107 --- speakeasy/windows/winemu.py | 12 ++++++-- speakeasy/winenv/api/usermode/msvcrt.py | 29 ++++++++++++------- tests/test_initterm.py | 37 +++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 tests/test_initterm.py diff --git a/speakeasy/windows/winemu.py b/speakeasy/windows/winemu.py index ea8f1145..db2edc82 100644 --- a/speakeasy/windows/winemu.py +++ b/speakeasy/windows/winemu.py @@ -1626,8 +1626,16 @@ def _hook_mem_unmapped(self, emu, access, address, size, value, ctx): elif address == winemu.API_CALLBACK_HANDLER_ADDR: run = self.get_current_run() if run.api_callbacks: - pc, orig_func, args = run.api_callbacks.pop(0) - self.do_call_return(len(args), pc) + ret, orig_func, args = run.api_callbacks.pop(0) + if run.api_callbacks: + next_ret, next_func, next_args = run.api_callbacks[0] + if next_ret is None: + run.api_callbacks[0] = (ret, next_func, next_args) + sp = self.get_stack_ptr() + self.set_func_args(sp, winemu.API_CALLBACK_HANDLER_ADDR, *next_args) + self.set_pc(next_func) + else: + self.do_call_return(len(args), ret) self._unset_emu_hooks() return True return self._handle_invalid_fetch(emu, address, size, value, ctx) diff --git a/speakeasy/winenv/api/usermode/msvcrt.py b/speakeasy/winenv/api/usermode/msvcrt.py index d944ce29..a8a584e8 100644 --- a/speakeasy/winenv/api/usermode/msvcrt.py +++ b/speakeasy/winenv/api/usermode/msvcrt.py @@ -179,6 +179,17 @@ def _wcsnicmp(self, emu, argv, ctx={}): return rv + def _read_func_table(self, pfbegin, pfend): + ptrsize = self.get_ptr_size() + funcs = [] + ptr = pfbegin + while ptr < pfend: + addr = int.from_bytes(self.mem_read(ptr, ptrsize), "little") + if addr: + funcs.append(addr) + ptr += ptrsize + return funcs + # Reference: https://wiki.osdev.org/Visual_C%2B%2B_Runtime @apihook("_initterm_e", argc=2, conv=e_arch.CALL_CONV_CDECL) def _initterm_e(self, emu, argv, ctx={}): @@ -186,22 +197,20 @@ def _initterm_e(self, emu, argv, ctx={}): static int _initterm_e(_PIFV * pfbegin, _PIFV * pfend) """ - pfbegin, pfend = argv - - rv = 0 - - return rv + funcs = self._read_func_table(pfbegin, pfend) + if funcs: + argv.append(", ".join("0x%x" % f for f in funcs)) + return 0 @apihook("_initterm", argc=2, conv=e_arch.CALL_CONV_CDECL) def _initterm(self, emu, argv, ctx={}): """static void _initterm (_PVFV * pfbegin, _PVFV * pfend)""" - pfbegin, pfend = argv - - rv = 0 - - return rv + funcs = self._read_func_table(pfbegin, pfend) + if funcs: + argv.append(", ".join("0x%x" % f for f in funcs)) + return 0 @apihook("__getmainargs", argc=5) def __getmainargs(self, emu, argv, ctx={}): diff --git a/tests/test_initterm.py b/tests/test_initterm.py new file mode 100644 index 00000000..d3fd1b4d --- /dev/null +++ b/tests/test_initterm.py @@ -0,0 +1,37 @@ +import copy + + +def test_initterm_reports_function_table(config, load_test_bin, run_test): + """_initterm and _initterm_e should parse the function pointer table + and include the entries in the API event args.""" + data = load_test_bin("argv_test_x86.exe.xz") + report = run_test(config, data) + ep = report.entry_points + + initterm_events = [] + for evt in ep[0].events or []: + if evt.event == "api" and "_initterm" in evt.api_name: + initterm_events.append(evt) + + assert len(initterm_events) > 0, "expected at least one _initterm call" + + for evt in initterm_events: + assert len(evt.args) == 3, ( + f"expected 3 args (pfbegin, pfend, func_table) but got {len(evt.args)}: {evt.args}" + ) + func_table_str = evt.args[2] + assert "0x" in func_table_str, f"expected hex addresses in func table: {func_table_str}" + + +def test_initterm_does_not_crash_emulation(config, load_test_bin, run_test): + """_initterm should not crash the emulation - main() should still execute.""" + data = load_test_bin("argv_test_x86.exe.xz") + report = run_test(config, data, argv=["arg1"]) + ep = report.entry_points + + printfs = [] + for evt in ep[0].events or []: + if evt.event == "api" and "__stdio_common_vfprintf" in evt.api_name: + printfs.append(evt) + + assert len(printfs) > 0, "main() should have executed and called printf" From a08e984346210a9253a27105d5acc00c5764bcfb Mon Sep 17 00:00:00 2001 From: Willi Ballenthin Date: Mon, 9 Mar 2026 16:11:41 +0100 Subject: [PATCH 4/4] Fix ruff UP031: use f-strings instead of percent format --- speakeasy/winenv/api/usermode/msvcrt.py | 4 ++-- tests/test_initterm.py | 7 +------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/speakeasy/winenv/api/usermode/msvcrt.py b/speakeasy/winenv/api/usermode/msvcrt.py index a8a584e8..24c45770 100644 --- a/speakeasy/winenv/api/usermode/msvcrt.py +++ b/speakeasy/winenv/api/usermode/msvcrt.py @@ -200,7 +200,7 @@ def _initterm_e(self, emu, argv, ctx={}): pfbegin, pfend = argv funcs = self._read_func_table(pfbegin, pfend) if funcs: - argv.append(", ".join("0x%x" % f for f in funcs)) + argv.append(", ".join(f"0x{f:x}" for f in funcs)) return 0 @apihook("_initterm", argc=2, conv=e_arch.CALL_CONV_CDECL) @@ -209,7 +209,7 @@ def _initterm(self, emu, argv, ctx={}): pfbegin, pfend = argv funcs = self._read_func_table(pfbegin, pfend) if funcs: - argv.append(", ".join("0x%x" % f for f in funcs)) + argv.append(", ".join(f"0x{f:x}" for f in funcs)) return 0 @apihook("__getmainargs", argc=5) diff --git a/tests/test_initterm.py b/tests/test_initterm.py index d3fd1b4d..c51fbb88 100644 --- a/tests/test_initterm.py +++ b/tests/test_initterm.py @@ -1,6 +1,3 @@ -import copy - - def test_initterm_reports_function_table(config, load_test_bin, run_test): """_initterm and _initterm_e should parse the function pointer table and include the entries in the API event args.""" @@ -16,9 +13,7 @@ def test_initterm_reports_function_table(config, load_test_bin, run_test): assert len(initterm_events) > 0, "expected at least one _initterm call" for evt in initterm_events: - assert len(evt.args) == 3, ( - f"expected 3 args (pfbegin, pfend, func_table) but got {len(evt.args)}: {evt.args}" - ) + assert len(evt.args) == 3, f"expected 3 args (pfbegin, pfend, func_table) but got {len(evt.args)}: {evt.args}" func_table_str = evt.args[2] assert "0x" in func_table_str, f"expected hex addresses in func table: {func_table_str}"