Skip to content

Bug: opaque_predicate_insertion produces invalid WAT on modules with empty-body functions #4

Description

@owanesh

opaque_predicate_insertion is completely broken on any module that contains at least one single-line (empty-body) function. These are common in compiled C/C++ WASM binaries (stubs, thunks, no-op wrappers). The underlying parser bug also silently corrupts function header lists for all other strategies, potentially modifying the wrong function without raising errors.

Reproduce

Input module (repro.wat):

(module
  (type $t0 (func (param i32)))
  (type $t1 (func (param i32 i32)))
  (type $t2 (func))
  (func $stub (type $t0) (param $p0 i32))          ;; single-line, no body
  (func $real (type $t1) (param $p0 i32) (param $p1 i32)
    block $B0
      local.get $p0
      local.get $p1
      i32.add
      drop
    end
  )
  (func $another_stub (type $t2))                   ;; single-line, no body
  (func $real2 (type $t1) (param $p0 i32) (param $p1 i32)
    block $B0
      local.get $p0
      drop
    end
  )
  (table $T0 1 funcref)
  (global $g0 (mut i32) (i32.const 0))
  (export "real" (func $real))
  (export "real2" (func $real2))
)
wat2wasm repro.wat -o repro.wasm
swamped obfuscate repro.wasm -o out.wasm -s opaque_predicate_insertion --seed 42 --ratio 0.5

(to simplify tests i am using CLI)

out.wast:5:39: error: unexpected token (, expected ).
(func $f0 (type $t0) (param $p0 i32)) (local $wadelocal i32)
                                      ^
[wat2wasm failed]

The generated WAT contains two malformed lines:

;; line 5  -- $stub's closing )) followed by dangling (local ...)
(func $f0 (type $t0) (param $p0 i32)) (local $wadelocal i32)

;; line 47 -- same for $another_stub
(func $f2 (type $t2)) (local $wadelocal i32)

On larger modules (e.g. Emscripten-compiled C++ with many stub functions) the error cascades into "undefined variable" errors for tables, globals, and functions that are actually defined in the module, making the output completely unusable.

Root cause

1. Parser doesn't finalize single-line functions (wasmParser/parser.py)

parseWast() tracks open/close parentheses via func_bracketNum. When a (func ...) line is matched, the counter is updated:

func_bracketNum += line.count('(') - line.count(')')

For a single-line function like (func $stub (type $t0) (param $p0 i32)), the brackets balance immediately (3 open, 3 close), so func_bracketNum drops to 0 on the same line. However, the finalization logic (extracting params, result, locals, header, body) only runs inside the if 1 not in whichSection block, which is not entered on the (func ...) line itself (because whichSection[2] = 1).

As a result:

  • The function object is created with header=None, body=None
  • funcHeaderArea remains True
  • funcHeaderLine still contains the unfinished function's line

When the next function is matched, its header line is appended to the same funcHeaderLine list. When that next function eventually finalizes, its header contains both its own line and the previous single-line function's line.

2. opaque_predicate_insertion modifies the wrong header line

The strategy adds (local $wadelocal i32) to every function by modifying header[0]:

new_header.append([old_header[0][0].strip() + ' (local $wadelocal i32)\n', ''])

Since header[0] is actually the absorbed single-line function's line (not the current function's), the result is:

(func $stub (type $t0) (param $p0 i32)) (local $wadelocal i32)

The )) closes the (func S-expression, and (local $wadelocal i32) ends up at module scope -- invalid WAT.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions