From 6bf47474debb1c2d2effc564f0986105ea9fe614 Mon Sep 17 00:00:00 2001 From: kernalix7 Date: Wed, 13 May 2026 15:34:47 +0900 Subject: [PATCH] feat: add experimental ARM64 runtime support Add ARM64 build/static CI coverage, ARM64 patch discovery helpers, and experimental runtime patchers for termwrap, umwrap, and endpwrap. Fix force reinstall replacement behavior and prepare v0.2.0 release metadata and docs. --- .cargo/config.toml | 3 + .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/workflows/ci.yml | 107 +++- .gitignore | 16 + CHANGELOG.md | 28 ++ CONTRIBUTING.md | 1 + Cargo.lock | 16 +- Cargo.toml | 2 +- README.md | 20 +- SECURITY.md | 2 +- crates/endpwrap-dll/src/patches.rs | 74 ++- crates/offset-finder/src/main.rs | 241 ++++++++- crates/patcher/src/arm64.rs | 466 ++++++++++++++++++ crates/patcher/src/lib.rs | 1 + crates/patcher/src/patch.rs | 28 +- crates/rdprrap-installer/src/install.rs | 40 +- crates/rdprrap-installer/src/main.rs | 3 +- crates/termwrap-dll/src/patches/arm64.rs | 306 ++++++++++++ crates/termwrap-dll/src/patches/intel.rs | 590 ++++++++++++++++++++++ crates/termwrap-dll/src/patches/mod.rs | 597 +---------------------- crates/umwrap-dll/src/patches.rs | 84 +++- deny.toml | 1 + docs/CHANGELOG.ko.md | 27 + docs/CONTRIBUTING.ko.md | 1 + docs/README.ko.md | 20 +- docs/SECURITY.ko.md | 2 +- docs/TESTING.ko.md | 62 ++- docs/TESTING.md | 63 ++- docs/TESTING_WINPODX.ko.md | 11 +- docs/TESTING_WINPODX.md | 10 +- 30 files changed, 2133 insertions(+), 691 deletions(-) create mode 100644 crates/patcher/src/arm64.rs create mode 100644 crates/termwrap-dll/src/patches/arm64.rs create mode 100644 crates/termwrap-dll/src/patches/intel.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 1305be2..dca5c97 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -7,3 +7,6 @@ rustflags = ["-C", "target-feature=+crt-static"] [target.i686-pc-windows-msvc] rustflags = ["-C", "target-feature=+crt-static"] + +[target.aarch64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index e04edec..b88cec0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -27,7 +27,7 @@ What actually happened. ## Environment - **OS**: [e.g. Windows 11 23H2, Windows 10 22H2] -- **Architecture**: x64 / x86 +- **Architecture**: x64 / x86 / ARM64 - **rdprrap Version**: - **termsrv.dll Version**: [e.g. 10.0.26100.xxxx] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8207280..5e10769 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,8 @@ jobs: # The release x64 job additionally runs an offset-finder smoke test # against the runner's own C:\Windows\System32\termsrv.dll — that # exercises the pattern-matching pipeline on a real, current Windows - # build (hosted runner image ships Server 2022, 10.0.20348.x). + # build. Artifact names include the runner alias because windows-latest + # can overlap with an explicitly pinned image during GitHub image rollovers. build-windows: runs-on: ${{ matrix.os }} strategy: @@ -48,11 +49,11 @@ jobs: - release # Additional row: pin one extra release x64 build to windows-2025 so # the offset-finder smoke test also hits a Server 2025 termsrv.dll - # alongside whatever windows-latest currently maps to (Server 2022 - # as of 2026-Q2). Two distinct Windows lines without doubling the - # whole matrix. Server 2019 coverage used to live here, but that - # image was retired by GitHub Actions on 2025-06-30 — using it now - # hangs jobs in the queue indefinitely. + # alongside whatever windows-latest currently maps to. When + # windows-latest also maps to Server 2025, this still verifies both + # the moving alias and the pinned image without artifact-name + # collisions. Server 2019 coverage used to live here, but that image + # was retired by GitHub Actions on 2025-06-30. include: - os: windows-2025 target: x86_64-pc-windows-msvc @@ -64,7 +65,7 @@ jobs: targets: ${{ matrix.target }} - uses: Swatinem/rust-cache@v2 with: - key: ${{ matrix.target }}-${{ matrix.profile }} + key: ${{ matrix.os }}-${{ matrix.target }}-${{ matrix.profile }} - name: Build (${{ matrix.profile }}) run: | @@ -229,7 +230,7 @@ jobs: if: matrix.profile == 'release' uses: actions/upload-artifact@v4 with: - name: rdprrap-${{ matrix.target }} + name: rdprrap-${{ matrix.os }}-${{ matrix.target }} path: | target/${{ matrix.target }}/release/*.dll target/${{ matrix.target }}/release/offset-finder.exe @@ -237,6 +238,96 @@ jobs: target/${{ matrix.target }}/release/rdprrap-check.exe target/${{ matrix.target }}/release/rdprrap-conf.exe + # ─── Windows ARM64: build + static artifact checks ─── + # + # CI verifies ARM64 builds and PE/export shape. Runtime validation still + # needs real Windows ARM64: termwrap/umwrap/endpwrap should show ARM64 + # patch-applied logs and termwrap must pass a concurrent-session smoke test. + build-windows-arm64: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + profile: + - debug + - release + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-pc-windows-msvc + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + key: aarch64-pc-windows-msvc-${{ matrix.profile }} + + - name: Build ARM64 (${{ matrix.profile }}) + run: | + $args = @("build", "--target", "aarch64-pc-windows-msvc") + if ("${{ matrix.profile }}" -eq "release") { $args += "--release" } + cargo @args + + - name: Clippy ARM64 + run: cargo clippy --target aarch64-pc-windows-msvc --all-targets -- -D warnings + + - name: Install llvm-tools + if: matrix.profile == 'release' + run: rustup component add llvm-tools-preview + + - name: Verify ARM64 artifacts + if: matrix.profile == 'release' + shell: pwsh + run: | + $base = "target/aarch64-pc-windows-msvc/release" + $sysroot = (& rustc --print sysroot).Trim() + $hostTriple = (& rustc -vV | Select-String "^host: ").ToString().Split()[1] + $readobj = Join-Path $sysroot "lib/rustlib/$hostTriple/bin/llvm-readobj.exe" + if (!(Test-Path $readobj)) { + Write-Error "llvm-readobj.exe not found at $readobj" + exit 1 + } + + function Assert-Export($dll, $name) { + $out = & $readobj --coff-exports $dll | Out-String + if ($out -notmatch [regex]::Escape($name)) { + Write-Error "$dll missing $name export" + exit 1 + } + } + + foreach ($name in @("termwrap_dll.dll", "endpwrap_dll.dll", "umwrap_dll.dll")) { + $dll = "$base/$name" + if (!(Test-Path $dll)) { Write-Error "$name not found"; exit 1 } + $info = & $readobj --file-headers $dll | Out-String + if ($info -notmatch "IMAGE_FILE_MACHINE_ARM64") { + Write-Error "$name is not ARM64" + exit 1 + } + } + + Assert-Export "$base/termwrap_dll.dll" "ServiceMain" + Assert-Export "$base/termwrap_dll.dll" "SvchostPushServiceGlobals" + Assert-Export "$base/endpwrap_dll.dll" "GetTSAudioEndpointEnumeratorForSession" + Assert-Export "$base/endpwrap_dll.dll" "DllGetClassObject" + Assert-Export "$base/endpwrap_dll.dll" "DllCanUnloadNow" + + foreach ($name in @("offset-finder.exe", "rdprrap-installer.exe", "rdprrap-check.exe", "rdprrap-conf.exe")) { + if (!(Test-Path "$base/$name")) { + Write-Error "$name not found at $base/$name" + exit 1 + } + } + Write-Host "ARM64 artifacts: OK" + + - name: Upload ARM64 artifacts + if: matrix.profile == 'release' + uses: actions/upload-artifact@v4 + with: + name: rdprrap-aarch64-pc-windows-msvc + path: | + target/aarch64-pc-windows-msvc/release/*.dll + target/aarch64-pc-windows-msvc/release/*.exe + # ─── cargo-deny: license + banned-deps + source-registry policy gate ─── deny: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 80d3277..ad4cca7 100644 --- a/.gitignore +++ b/.gitignore @@ -16,14 +16,30 @@ Thumbs.db # AI config (actual files in .priv-storage/, root has symlinks) .priv-storage/ CLAUDE.md +AGENTS.md .cursorrules .claude .vscode WORK_STATUS.md +CLAUDE.local.md +.mcp.json +CLAUDE.md.bak +AGENTS.md.bak +.cursorrules.bak +WORK_STATUS.md.bak +.gitignore.bak +.priv-storage/.allow-setup-reread +.codex/ +.aider* +.continue/ +.cline/ +.roo/ # Backup toolkit temp files +tmp-igbkp/ tmp-igbkp/.work/ tmp-igbkp/output/ +uninstall-backup-*/ # Environment .env diff --git a/CHANGELOG.md b/CHANGELOG.md index 93013e6..e43c638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,34 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] +## [0.2.0] - 2026-05-13 + +### Added +- ARM64 Windows build scaffold: `aarch64-pc-windows-msvc` now type-checks + across the workspace, has static-CRT rustflags, and is covered by a + CI build/static-artifact job. +- Experimental ARM64 runtime patching for `umwrap-dll` and `endpwrap-dll`. + `patcher::arm64` now scans ARM64 `.pdata` function entries, resolves + ADR/ADRP+ADD policy-string references, and locates nearby BL calls. + `umwrap` replaces the policy BL call with `mov w0,#1`; `endpwrap` replaces + the referenced audio-capture function start with `mov w0,#1; ret`. +- Experimental ARM64 runtime patching for `termwrap-dll`. The ARM64 path + resolves the same termsrv policy strings via `.pdata`, patches DefPolicy + field checks, forces single-session/local-only checks false, forces + AppServer/NonRDP checks true, patches the PropertyDevice BL result false, + and patches SL policy query BL calls to return true. Real Windows ARM64 runtime + validation is still required before treating this as production-supported. + +### Fixed +- `rdprrap-installer install --force` now actually replaces existing + installed wrapper DLLs before the race-safe `CREATE_NEW` copy. Without + `--force`, an existing destination DLL now fails early with a clear + "use --force" message instead of surfacing a lower-level Win32 create error. +- `offset-finder` no longer treats ARM64 PE32+ images as x64. Pure ARM64 + `termsrv.dll` images now get an ARM64 string/function/BL-site report; + ARM64EC/ARM64X hybrid images still report unsupported until separately + validated. + ## [0.1.3] - 2026-04-23 ### Fixed (license compliance — continued) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c413f0..7648084 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,7 @@ git clone https://github.com/kernalix7/rdprrap.git cd rdprrap rustup target add x86_64-pc-windows-msvc rustup target add i686-pc-windows-msvc +rustup target add aarch64-pc-windows-msvc cargo build --release ``` diff --git a/Cargo.lock b/Cargo.lock index 0d43c1a..c2ae210 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -168,7 +168,7 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "endpwrap-dll" -version = "0.1.3" +version = "0.2.0" dependencies = [ "iced-x86", "patcher", @@ -394,7 +394,7 @@ dependencies = [ [[package]] name = "offset-finder" -version = "0.1.3" +version = "0.2.0" dependencies = [ "anyhow", "iced-x86", @@ -417,7 +417,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "patcher" -version = "0.1.3" +version = "0.2.0" dependencies = [ "iced-x86", "thiserror", @@ -506,7 +506,7 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rdprrap-check" -version = "0.1.3" +version = "0.2.0" dependencies = [ "anyhow", "native-windows-derive", @@ -516,7 +516,7 @@ dependencies = [ [[package]] name = "rdprrap-conf" -version = "0.1.3" +version = "0.2.0" dependencies = [ "anyhow", "native-windows-derive", @@ -526,7 +526,7 @@ dependencies = [ [[package]] name = "rdprrap-installer" -version = "0.1.3" +version = "0.2.0" dependencies = [ "anyhow", "clap", @@ -660,7 +660,7 @@ dependencies = [ [[package]] name = "termwrap-dll" -version = "0.1.3" +version = "0.2.0" dependencies = [ "iced-x86", "patcher", @@ -698,7 +698,7 @@ dependencies = [ [[package]] name = "umwrap-dll" -version = "0.1.3" +version = "0.2.0" dependencies = [ "iced-x86", "patcher", diff --git a/Cargo.toml b/Cargo.toml index 6849311..a31c87b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.1.3" +version = "0.2.0" edition = "2021" license = "MIT" # Internal workspace — the crates here are DLL proxies and glue binaries diff --git a/README.md b/README.md index a965874..cd50f24 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ RDP Wrapper rewritten in Rust. | **termwrap-dll** | Core RDP patching — multi-session support, policy bypass for Home/non-Server editions. 7 patch types: DefPolicy, SingleUser, LocalOnly, NonRDP, PropertyDevice, SLPolicy, CSLQuery::Initialize | | **umwrap-dll** | USB/camera PnP device redirection for all SKUs (legacy + modern Windows) | | **endpwrap-dll** | Audio recording redirection (TSAudioCaptureAllowed) | -| **patcher** | Shared library — PE parsing, x86/x64 disassembly, runtime pattern matching, 14 verified bytecode patches | -| **offset-finder** | Standalone CLI tool for offset detection (pelite-based, no PDB required) | +| **patcher** | Shared library — PE parsing, x86/x64 disassembly, ARM64 `.pdata` function scanning, runtime pattern matching, 18 bytecode patches | +| **ARM64 support** | `aarch64-pc-windows-msvc` build target with experimental ARM64 `termwrap`/`umwrap`/`endpwrap` runtime patchers. Real Windows ARM64 validation is still required before calling it production-supported | +| **offset-finder** | Standalone CLI tool for x86/x64/ARM64 offset detection (pelite-based, no PDB required) | | **rdprrap-installer** | Rust CLI installer/uninstaller — service registration, registry setup, firewall rules, cohort service restart, install-dir ACL hardening (replaces Delphi `RDPWInst.exe`) | | **rdprrap-check** | RDP connection tester — loopback `127.0.0.2` via `mstsc.exe`, NLA guard RAII, 44 disconnect-reason codes (replaces `RDPCheck.exe`) | | **rdprrap-conf** | Configuration GUI — native-windows-gui panel for diagnostics + runtime RDP settings (Enable/Port/SingleSession/HideUsers/AllowCustom/AuthMode/Shadow), replaces `RDPConf.exe` | @@ -25,8 +26,8 @@ RDP Wrapper rewritten in Rust. | Disassembler | [iced-x86](https://crates.io/crates/iced-x86) (pure Rust) | | PE Parsing | [pelite](https://crates.io/crates/pelite) | | Windows API | [windows-rs](https://crates.io/crates/windows) | -| Target | x86_64-pc-windows-msvc, i686-pc-windows-msvc | -| CI | GitHub Actions (Linux check + Windows x64/x86 build) | +| Target | x86_64-pc-windows-msvc, i686-pc-windows-msvc, aarch64-pc-windows-msvc | +| CI | GitHub Actions (Linux check + Windows x64/x86 build, ARM64 build/static artifact checks) | ## Quick Start @@ -42,6 +43,7 @@ cd rdprrap rustup target add x86_64-pc-windows-msvc rustup target add i686-pc-windows-msvc +rustup target add aarch64-pc-windows-msvc cargo build --release ``` @@ -72,7 +74,7 @@ Additional flags: | Flag | Effect | |------|--------| | `--source DIR` | Directory to copy DLLs from (defaults to the installer's own directory) | -| `--force` | Reinstall even if ServiceDll already points to the wrapper | +| `--force` | Reinstall and replace existing wrapper DLLs even if ServiceDll already points to the wrapper | | `--skip-firewall` | Do not add/remove firewall rules | | `--skip-restart` | Do not restart TermService (apply changes manually or on reboot) | | `--disable-nla` | Set `UserAuthentication=0` (opt-in, required for legacy clients) | @@ -100,7 +102,8 @@ rdprrap/ │ │ ├── pe.rs # PE header/section/import/exception table parsing │ │ ├── pattern.rs # 4-byte aligned string pattern matching in .rdata │ │ ├── disasm.rs # iced-x86 decoder wrapper, xref search, branch helpers -│ │ └── patch.rs # WriteProcessMemory wrapper, NOP fill, 14 bytecode constants +│ │ ├── arm64.rs # ARM64 .pdata function scan + ADR/ADRP/ADD/BL helpers +│ │ └── patch.rs # WriteProcessMemory wrapper, NOP fill, 18 bytecode constants │ ├── termwrap-dll/ # cdylib: termsrv.dll proxy (core RDP) │ │ └── src/patches/ # DefPolicy, SingleUser, LocalOnly, NonRDP, PropertyDevice, SLPolicy │ ├── umwrap-dll/ # cdylib: umrdp.dll proxy (USB/camera redirection) @@ -110,7 +113,7 @@ rdprrap/ │ ├── rdprrap-check/ # Binary: RDP loopback tester (mstsc + NLA guard) │ └── rdprrap-conf/ # Binary: configuration GUI (native-windows-gui) ├── .github/ -│ └── workflows/ci.yml # Linux check + Windows x64/x86 build matrix +│ └── workflows/ci.yml # Linux check + Windows x64/x86 build matrix + ARM64 static checks └── docs/ # Korean documentation ``` @@ -122,6 +125,7 @@ rdprrap/ 4. Patch offsets found at runtime: - **x64**: Scan `.rdata` for known strings → search exception table for LEA xrefs → backtrace unwind chains to function start - **x86**: Scan `.text` for function prologues (`8B FF 55 8B EC`) → follow branches → match PUSH/MOV immediates to string RVAs + - **ARM64**: `.pdata` function ranges plus ADR/ADRP+ADD string references locate policy checks. `termwrap` patches ARM64 DefPolicy, SingleUser, LocalOnly, AppServer/NonRDP, PropertyDevice, and SL policy paths with ARM64-specific bytecodes; `umwrap` patches PnP/camera BL calls to `mov w0,#1`; `endpwrap` patches the referenced audio-capture function start to `mov w0,#1; ret`. ## Patch Types (termsrv.dll) @@ -142,7 +146,7 @@ cargo clippy --all-targets -- -D warnings # Lint cargo fmt --check # Format check ``` -CI runs automatically on push/PR: Linux check + Windows x64/x86 full build. +CI runs automatically on push/PR: Linux check + Windows x64/x86 full build + ARM64 build/static artifact checks. ## Contributing diff --git a/SECURITY.md b/SECURITY.md index c80f4e5..1fa60b1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,7 +22,7 @@ Instead, please report them through [GitHub Security Advisories](https://github. 2. **Steps to Reproduce** — Detailed steps to reproduce the issue 3. **Impact** — The potential impact of the vulnerability 4. **Affected Components** — Which crates/DLLs of rdprrap are affected -5. **Environment** — Windows version, architecture (x64/x86), termsrv.dll version +5. **Environment** — Windows version, architecture (x64/x86/ARM64), termsrv.dll version ### Response Timeline diff --git a/crates/endpwrap-dll/src/patches.rs b/crates/endpwrap-dll/src/patches.rs index 6314c45..d291cd1 100644 --- a/crates/endpwrap-dll/src/patches.rs +++ b/crates/endpwrap-dll/src/patches.rs @@ -1,19 +1,24 @@ -use patcher::patch::{debug_log, write_patch}; +use patcher::patch::debug_log; +#[cfg(any(target_arch = "x86_64", target_arch = "x86", target_arch = "aarch64"))] use patcher::pattern::find_pattern_in_section; +#[cfg(any(target_arch = "x86_64", target_arch = "x86", target_arch = "aarch64"))] use patcher::pe::LoadedPe; use windows::Win32::Foundation::HMODULE; /// Wide string: "TerminalServices-DeviceRedirection-Licenses-TSAudioCaptureAllowed" +#[cfg(any(target_arch = "x86_64", target_arch = "x86", target_arch = "aarch64"))] const ALLOW_AUDIO_CAPTURE_BYTES: &[u8] = b"T\0e\0r\0m\0i\0n\0a\0l\0S\0e\0r\0v\0i\0c\0e\0s\0-\0D\0e\0v\0i\0c\0e\0R\0e\0d\0i\0r\0e\0c\0t\0i\0o\0n\0-\0L\0i\0c\0e\0n\0s\0e\0s\0-\0T\0S\0A\0u\0d\0i\0o\0C\0a\0p\0t\0u\0r\0e\0A\0l\0l\0o\0w\0e\0d\0\0\0"; /// `mov eax, 1; ret` — 6 bytes, patches function start to always return TRUE +#[cfg(any(target_arch = "x86_64", target_arch = "x86"))] const MOV_EAX_1_RET: &[u8] = &[0xB8, 0x01, 0x00, 0x00, 0x00, 0xC3]; /// Apply rdpendp.dll patches for audio recording redirection. /// /// # Safety /// `hmod` must be a valid handle to loaded rdpendp.dll; all other threads suspended. +#[cfg(any(target_arch = "x86_64", target_arch = "x86", target_arch = "aarch64"))] pub unsafe fn apply_patches(hmod: HMODULE) { let base = hmod.0 as usize; @@ -53,21 +58,40 @@ pub unsafe fn apply_patches(hmod: HMODULE) { x86_apply::apply(&pe, audio_capture_rva); } - #[cfg(not(any(target_arch = "x86_64", target_arch = "x86")))] - { - let _ = (&pe, audio_capture_rva); + #[cfg(target_arch = "aarch64")] + // SAFETY: `pe` wraps a loaded rdpendp.dll; threads suspended by caller. + unsafe { + arm64_apply::apply(&pe, audio_capture_rva); } } +/// Unsupported CPU architecture fallback. +/// +/// Other Windows architectures may load and forward rdpendp.dll exports, but +/// audio-capture patching is disabled unless an architecture-specific patcher +/// exists. +/// +/// # Safety +/// `hmod` must be a valid handle to loaded rdpendp.dll. +#[cfg(not(any(target_arch = "x86_64", target_arch = "x86", target_arch = "aarch64")))] +pub unsafe fn apply_patches(_hmod: HMODULE) { + debug_log( + "EndpWrap: CPU architecture is not supported for runtime patching; \ + forwarding original rdpendp.dll without modifications\n", + ); +} + // ===================================================================== // x64 path — exception-table driven (RIP-relative LEA) // ===================================================================== #[cfg(target_arch = "x86_64")] mod x64 { - use super::{debug_log, write_patch, MOV_EAX_1_RET}; use iced_x86::{Decoder, DecoderOptions, Instruction, Mnemonic, OpKind, Register}; + use patcher::patch::write_patch; use patcher::pe::LoadedPe; + use super::{debug_log, MOV_EAX_1_RET}; + /// Apply rdpendp.dll audio-capture patch on x64. /// /// # Safety @@ -129,10 +153,12 @@ mod x64 { // ===================================================================== #[cfg(target_arch = "x86")] mod x86_apply { - use super::{debug_log, write_patch, MOV_EAX_1_RET}; use crate::x86_walk::{function_references_rva, PROLOGUE}; + use patcher::patch::write_patch; use patcher::pe::LoadedPe; + use super::{debug_log, MOV_EAX_1_RET}; + /// Apply rdpendp.dll audio-capture patch on x86. /// /// Scans `.text` for function prologues, BFS-walks each candidate function @@ -185,3 +211,39 @@ mod x86_apply { debug_log("EndpWrap: x86 AllowAudioCapture not found\n"); } } + +// ===================================================================== +// ARM64 path — .pdata function scan + ADRP/ADD string reference +// ===================================================================== +#[cfg(target_arch = "aarch64")] +mod arm64_apply { + use patcher::arm64; + use patcher::patch::{bytecodes, write_patch}; + use patcher::pe::LoadedPe; + + use super::debug_log; + + /// Apply rdpendp.dll audio-capture patch on ARM64. + /// + /// # Safety + /// `pe` wraps a loaded rdpendp.dll; threads suspended. + pub(super) unsafe fn apply(pe: &LoadedPe, audio_capture_rva: usize) { + let func = match arm64::find_function_referencing_rva(pe, audio_capture_rva) { + Some(func) => func, + None => { + debug_log("EndpWrap: ARM64 AllowAudioCapture function not found\n"); + return; + } + }; + + let func_addr = pe.adjusted_base + func.begin_address as usize; + // SAFETY: func_addr is the start of an ARM64 function inside loaded PE; + // threads are suspended by the caller. + match unsafe { write_patch(func_addr, bytecodes::ARM64_MOV_W0_1_RET) } { + Ok(_) => debug_log("EndpWrap: ARM64 AllowAudioCapture patched\n"), + Err(e) => debug_log(&format!( + "EndpWrap: ARM64 AllowAudioCapture patch write failed: {e}\n" + )), + } + } +} diff --git a/crates/offset-finder/src/main.rs b/crates/offset-finder/src/main.rs index 0d196de..3e54d56 100644 --- a/crates/offset-finder/src/main.rs +++ b/crates/offset-finder/src/main.rs @@ -4,6 +4,12 @@ use std::path::PathBuf; mod xref_x86; +const IMAGE_FILE_MACHINE_I386: u16 = 0x014c; +const IMAGE_FILE_MACHINE_AMD64: u16 = 0x8664; +const IMAGE_FILE_MACHINE_ARM64: u16 = 0xaa64; +const IMAGE_FILE_MACHINE_ARM64EC: u16 = 0xa641; +const IMAGE_FILE_MACHINE_ARM64X: u16 = 0xa64e; + #[derive(Default)] struct Args { dll_path: Option, @@ -70,21 +76,74 @@ fn main() -> Result<()> { /// Load termsrv.dll as a file and parse PE to find offsets fn find_offsets_file(path: &std::path::Path, assert_all: bool) -> Result<()> { + use pelite::pe32::Pe as Pe32; + use pelite::pe64::Pe as Pe64; + let data = std::fs::read(path).context("Failed to read file")?; eprintln!("File size: {} bytes", data.len()); // Try PE64 first, then PE32 if let Ok(pe64) = pelite::pe64::PeFile::from_bytes(&data) { - eprintln!("Architecture: x64"); - find_offsets_pe64(&pe64, assert_all) + let machine = pe64.file_header().Machine; + match machine { + IMAGE_FILE_MACHINE_AMD64 => { + eprintln!("Architecture: x64"); + find_offsets_pe64(&pe64, assert_all) + } + IMAGE_FILE_MACHINE_ARM64 | IMAGE_FILE_MACHINE_ARM64EC | IMAGE_FILE_MACHINE_ARM64X => { + if machine == IMAGE_FILE_MACHINE_ARM64 { + eprintln!("Architecture: ARM64"); + find_offsets_pe64_arm64(&pe64, assert_all) + } else { + report_unsupported_arch(machine) + } + } + other => bail!( + "Unsupported PE32+ machine type: 0x{other:04X} ({})", + machine_name(other) + ), + } } else if let Ok(pe32) = pelite::pe32::PeFile::from_bytes(&data) { - eprintln!("Architecture: x86"); - find_offsets_pe32(&pe32, assert_all) + let machine = pe32.file_header().Machine; + match machine { + IMAGE_FILE_MACHINE_I386 => { + eprintln!("Architecture: x86"); + find_offsets_pe32(&pe32, assert_all) + } + other => bail!( + "Unsupported PE32 machine type: 0x{other:04X} ({})", + machine_name(other) + ), + } } else { bail!("Failed to parse PE file"); } } +fn report_unsupported_arch(machine: u16) -> Result<()> { + println!("[Offset Report]"); + println!("Arch={}", machine_name(machine)); + println!("Machine=0x{machine:04X}"); + println!("Status=UNSUPPORTED"); + println!(); + bail!( + "{} offset discovery is not implemented. Pure ARM64 images are supported; \ + ARM64EC/ARM64X hybrid images need separate validation.", + machine_name(machine) + ) +} + +fn machine_name(machine: u16) -> &'static str { + match machine { + IMAGE_FILE_MACHINE_I386 => "x86", + IMAGE_FILE_MACHINE_AMD64 => "x64", + IMAGE_FILE_MACHINE_ARM64 => "arm64", + IMAGE_FILE_MACHINE_ARM64EC => "arm64ec", + IMAGE_FILE_MACHINE_ARM64X => "arm64x", + _ => "unknown", + } +} + fn find_offsets_pe64(pe: &pelite::pe64::PeFile<'_>, assert_all: bool) -> Result<()> { use iced_x86::{Decoder, DecoderOptions, Instruction, Mnemonic, OpKind, Register}; use pelite::pe64::Pe; @@ -237,6 +296,180 @@ fn find_offsets_pe64(pe: &pelite::pe64::PeFile<'_>, assert_all: bool) -> Result< Ok(()) } +fn find_offsets_pe64_arm64(pe: &pelite::pe64::PeFile<'_>, assert_all: bool) -> Result<()> { + use patcher::arm64; + use pelite::pe64::Pe; + + let image_base = pe.optional_header().ImageBase; + + let rdata = pe + .section_headers() + .iter() + .find(|s| s.name().ok() == Some(".rdata")) + .or_else(|| { + pe.section_headers() + .iter() + .find(|s| s.name().ok() == Some(".text")) + }) + .context(".rdata not found")?; + let rdata_data = pe + .get_section_bytes(rdata) + .context("Failed to read .rdata")?; + let rdata_va = rdata.VirtualAddress; + + let text = pe + .section_headers() + .iter() + .find(|s| s.name().ok() == Some(".text")) + .context(".text not found")?; + let text_data = pe.get_section_bytes(text).context("Failed to read .text")?; + let text_va = text.VirtualAddress; + + let pdata = pe + .section_headers() + .iter() + .find(|s| s.name().ok() == Some(".pdata")) + .context(".pdata not found")?; + let pdata_data = pe + .get_section_bytes(pdata) + .context("Failed to read .pdata")?; + + let patterns: &[(&str, &[u8])] = &[ + ("CDefPolicy_Query", b"CDefPolicy::Query"), + ( + "IsSingleSessionPerUserEnabled", + b"CSessionArbitrationHelper::IsSingleSessionPerUserEnabled", + ), + ( + "IsTerminalTypeLocalOnly", + b"CSLQuery::IsTerminalTypeLocalOnly", + ), + ( + "IsAllowNonRDPStack", + b"CRemoteConnectionManager::IsAllowNonRDPStack\0", + ), + ("IsAppServerInstalled", b"CSLQuery::IsAppServerInstalled\0"), + ("IsSingleSessionPerUser", b"IsSingleSessionPerUser\0"), + ( + "AllowRemoteConnections", + b"T\0e\0r\0m\0i\0n\0a\0l\0S\0e\0r\0v\0i\0c\0e\0s\0-\0R\0e\0m\0o\0t\0e\0C\0o\0n\0n\0e\0c\0t\0i\0o\0n\0M\0a\0n\0a\0g\0e\0r\0-\0A\0l\0l\0o\0w\0R\0e\0m\0o\0t\0e\0C\0o\0n\0n\0e\0c\0t\0i\0o\0n\0s\0\0\0", + ), + ( + "AllowMultipleSessions", + b"T\0e\0r\0m\0i\0n\0a\0l\0S\0e\0r\0v\0i\0c\0e\0s\0-\0R\0e\0m\0o\0t\0e\0C\0o\0n\0n\0e\0c\0t\0i\0o\0n\0M\0a\0n\0a\0g\0e\0r\0-\0A\0l\0l\0o\0w\0M\0u\0l\0t\0i\0p\0l\0e\0S\0e\0s\0s\0i\0o\0n\0s\0\0\0", + ), + ( + "AllowAppServerMode", + b"T\0e\0r\0m\0i\0n\0a\0l\0S\0e\0r\0v\0i\0c\0e\0s\0-\0R\0e\0m\0o\0t\0e\0C\0o\0n\0n\0e\0c\0t\0i\0o\0n\0M\0a\0n\0a\0g\0e\0r\0-\0A\0l\0l\0o\0w\0A\0p\0p\0S\0e\0r\0v\0e\0r\0M\0o\0d\0e\0\0\0", + ), + ( + "AllowMultimon", + b"T\0e\0r\0m\0i\0n\0a\0l\0S\0e\0r\0v\0i\0c\0e\0s\0-\0R\0e\0m\0o\0t\0e\0C\0o\0n\0n\0e\0c\0t\0i\0o\0n\0M\0a\0n\0a\0g\0e\0r\0-\0A\0l\0l\0o\0w\0M\0u\0l\0t\0i\0m\0o\0n\0\0\0", + ), + ( + "PropertyDeviceGuid", + &[ + 0xD5, 0x59, 0xD3, 0x93, 0x1F, 0x83, 0xB4, 0x47, 0x90, 0xBE, 0x83, 0x83, 0xAF, + 0x8F, 0x1B, 0x0E, + ], + ), + ]; + + println!("[Offset Report]"); + println!("ImageBase=0x{image_base:X}"); + println!("Arch=arm64"); + println!(); + + let mut str_rvas: Vec<(&str, Option)> = Vec::with_capacity(patterns.len()); + for (name, pattern) in patterns { + let rva = find_in_section(rdata_data, rdata_va, pattern); + match rva { + Some(r) => println!("{name}_str=0x{r:X}"), + None => println!("{name}_str=NOT_FOUND"), + } + str_rvas.push((name, rva)); + } + + let functions = arm64::functions_from_pdata(pdata_data, text_va, text_data.len()); + let mut func_found: std::collections::HashSet<&str> = std::collections::HashSet::new(); + + println!(); + for (name, str_rva_opt) in &str_rvas { + let Some(str_rva) = str_rva_opt else { + continue; + }; + + let target_va = image_base + *str_rva as u64; + let mut found = false; + for func in &functions { + let begin = func.begin_address as usize; + if begin < text_va as usize { + continue; + } + let offset = begin - text_va as usize; + let len = (func.end_address - func.begin_address) as usize; + if offset >= text_data.len() { + continue; + } + let end = offset.saturating_add(len).min(text_data.len()); + if end <= offset { + continue; + } + + let code = &text_data[offset..end]; + let code_va = image_base + begin as u64; + if let Some(ref_va) = arm64::code_references_addr(code, code_va, target_va) { + println!( + "{name}_func=0x{begin:X} (xref at 0x{:X})", + ref_va - image_base + ); + if let Some(bl_va) = arm64::find_bl_after_addr(code, code_va, ref_va, 96) { + println!("{name}_bl_patch=0x{:X}", bl_va - image_base); + } + func_found.insert(name); + found = true; + break; + } + } + + if !found { + println!("{name}_func=NOT_FOUND"); + } + } + + if assert_all { + let missing_strings: Vec<&str> = str_rvas + .iter() + .filter_map(|(n, rva)| rva.is_none().then_some(*n)) + .collect(); + let missing_funcs: Vec<&str> = str_rvas + .iter() + .filter_map(|(n, rva)| (rva.is_some() && !func_found.contains(n)).then_some(*n)) + .collect(); + + println!(); + println!( + "[Assert] strings: {}/{} found, functions: {}/{} resolved", + patterns.len() - missing_strings.len(), + patterns.len(), + func_found.len(), + patterns.len() + ); + + if !missing_strings.is_empty() || !missing_funcs.is_empty() { + if !missing_strings.is_empty() { + eprintln!("[Assert] MISSING strings: {missing_strings:?}"); + } + if !missing_funcs.is_empty() { + eprintln!("[Assert] MISSING function xrefs: {missing_funcs:?}"); + } + bail!("--assert-all: required ARM64 patterns not all resolved"); + } + } + + Ok(()) +} + fn find_offsets_pe32(pe: &pelite::pe32::PeFile<'_>, assert_all: bool) -> Result<()> { use pelite::pe32::Pe; diff --git a/crates/patcher/src/arm64.rs b/crates/patcher/src/arm64.rs new file mode 100644 index 0000000..6b86539 --- /dev/null +++ b/crates/patcher/src/arm64.rs @@ -0,0 +1,466 @@ +use crate::error::PatcherError; +use crate::pe::LoadedPe; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Arm64Function { + pub begin_address: u32, + pub end_address: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Arm64Reference { + pub function: Arm64Function, + pub instruction_rva: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Arm64CallPatchSite { + pub function: Arm64Function, + pub reference_rva: u32, + pub call_rva: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Arm64LoadStoreUnsigned { + pub rt: u8, + pub rn: u8, + pub offset: u32, +} + +pub fn loaded_functions(pe: &LoadedPe) -> Result, PatcherError> { + let text = pe.find_section(".text")?; + let pdata = pe.find_section(".pdata")?; + + // SAFETY: `.pdata` is a section inside the loaded PE image. + let pdata_bytes = + unsafe { pe.read_bytes(pdata.virtual_address as usize, pdata.raw_data_size as usize) }; + + Ok(functions_from_pdata( + pdata_bytes, + text.virtual_address, + text.raw_data_size as usize, + )) +} + +pub fn functions_from_pdata(pdata: &[u8], text_rva: u32, text_size: usize) -> Vec { + let text_start = text_rva; + let text_end = text_rva.saturating_add(text_size as u32); + let mut begins = Vec::new(); + + for entry in pdata.chunks_exact(8) { + let begin = u32::from_le_bytes([entry[0], entry[1], entry[2], entry[3]]); + if begin >= text_start && begin < text_end { + begins.push(begin); + } + } + + begins.sort_unstable(); + begins.dedup(); + + let mut out = Vec::with_capacity(begins.len()); + for (idx, begin) in begins.iter().copied().enumerate() { + let end = begins.get(idx + 1).copied().unwrap_or(text_end); + if end > begin { + out.push(Arm64Function { + begin_address: begin, + end_address: end, + }); + } + } + + out +} + +pub fn find_function_referencing_rva(pe: &LoadedPe, target_rva: usize) -> Option { + find_reference_to_rva(pe, target_rva).map(|reference| reference.function) +} + +pub fn find_reference_to_rva(pe: &LoadedPe, target_rva: usize) -> Option { + let funcs = loaded_functions(pe).ok()?; + let target_va = pe.adjusted_base as u64 + target_rva as u64; + + for func in funcs { + let begin = func.begin_address as usize; + let len = (func.end_address - func.begin_address) as usize; + if len == 0 { + continue; + } + + // SAFETY: function bounds come from `.pdata` and are clamped to `.text`. + let code = unsafe { pe.read_bytes(begin, len) }; + if let Some(instruction_va) = + code_references_addr(code, pe.adjusted_base as u64 + begin as u64, target_va) + { + return Some(Arm64Reference { + function: func, + instruction_rva: instruction_va.saturating_sub(pe.adjusted_base as u64) as u32, + }); + } + } + + None +} + +pub fn find_bl_after_reference_rva( + pe: &LoadedPe, + target_rva: usize, + max_bytes_after_reference: usize, +) -> Option { + let reference = find_reference_to_rva(pe, target_rva)?; + let begin = reference.function.begin_address as usize; + let len = (reference.function.end_address - reference.function.begin_address) as usize; + if len == 0 { + return None; + } + + // SAFETY: function bounds come from `.pdata` and are clamped to `.text`. + let code = unsafe { pe.read_bytes(begin, len) }; + let code_va = pe.adjusted_base as u64 + begin as u64; + let reference_va = pe.adjusted_base as u64 + reference.instruction_rva as u64; + let call_va = find_bl_after_addr(code, code_va, reference_va, max_bytes_after_reference)?; + + Some(Arm64CallPatchSite { + function: reference.function, + reference_rva: reference.instruction_rva, + call_rva: call_va.saturating_sub(pe.adjusted_base as u64) as u32, + }) +} + +pub fn code_references_addr(code: &[u8], code_va: u64, target_va: u64) -> Option { + let mut reg_pages = [None; 32]; + + for (idx, chunk) in code.chunks_exact(4).enumerate() { + let word = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + let ip = code_va + (idx as u64 * 4); + + if let Some((reg, addr, page_ref)) = decode_adr(word, ip) { + if addr == target_va { + return Some(ip); + } + reg_pages[reg as usize] = page_ref.then_some(addr); + continue; + } + + if let Some((rd, rn, imm)) = decode_add_imm(word) { + if let Some(page) = reg_pages[rn as usize] { + if page + imm as u64 == target_va { + return Some(ip); + } + } + reg_pages[rd as usize] = None; + continue; + } + + if let Some((reg, literal_va)) = decode_ldr_literal(word, ip) { + if literal_va == target_va { + return Some(ip); + } + reg_pages[reg as usize] = None; + } + } + + None +} + +pub fn find_bl_after_addr( + code: &[u8], + code_va: u64, + reference_va: u64, + max_bytes_after_reference: usize, +) -> Option { + if reference_va < code_va { + return None; + } + + let reference_offset = (reference_va - code_va) as usize; + if reference_offset >= code.len() { + return None; + } + + let start = reference_offset.saturating_add(4); + let end = start + .saturating_add(max_bytes_after_reference) + .min(code.len()); + if start >= end { + return None; + } + + for (idx, chunk) in code[start..end].chunks_exact(4).enumerate() { + let word = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + if is_bl(word) { + return Some(code_va + start as u64 + idx as u64 * 4); + } + } + + None +} + +fn decode_adr(word: u32, ip: u64) -> Option<(u8, u64, bool)> { + let is_adr = (word & 0x9f00_0000) == 0x1000_0000; + let is_adrp = (word & 0x9f00_0000) == 0x9000_0000; + if !is_adr && !is_adrp { + return None; + } + + let rd = (word & 0x1f) as u8; + if rd == 31 { + return None; + } + + let immlo = ((word >> 29) & 0x3) as i64; + let immhi = ((word >> 5) & 0x7ffff) as i64; + let imm = sign_extend((immhi << 2) | immlo, 21); + + let addr = if is_adrp { + let page = (ip & !0xfff) as i64; + (page + (imm << 12)) as u64 + } else { + (ip as i64 + imm) as u64 + }; + + Some((rd, addr, is_adrp)) +} + +fn decode_add_imm(word: u32) -> Option<(u8, u8, u32)> { + if (word & 0x7f00_0000) != 0x1100_0000 { + return None; + } + + let rd = (word & 0x1f) as u8; + let rn = ((word >> 5) & 0x1f) as u8; + if rd == 31 || rn == 31 { + return None; + } + + let shift = (word >> 22) & 0x3; + if shift > 1 { + return None; + } + + let imm12 = (word >> 10) & 0xfff; + let imm = if shift == 1 { imm12 << 12 } else { imm12 }; + Some((rd, rn, imm)) +} + +fn decode_ldr_literal(word: u32, ip: u64) -> Option<(u8, u64)> { + if (word & 0x3b00_0000) != 0x1800_0000 { + return None; + } + + let rt = (word & 0x1f) as u8; + if rt == 31 { + return None; + } + + let imm19 = ((word >> 5) & 0x7ffff) as i64; + let offset = sign_extend(imm19, 19) << 2; + Some((rt, (ip as i64 + offset) as u64)) +} + +pub fn decode_ldr_w_unsigned(word: u32) -> Option { + decode_load_store_w_unsigned(word, 0xb940_0000) +} + +pub fn decode_str_w_unsigned(word: u32) -> Option { + decode_load_store_w_unsigned(word, 0xb900_0000) +} + +fn decode_load_store_w_unsigned(word: u32, opcode: u32) -> Option { + if (word & 0xffc0_0000) != opcode { + return None; + } + + let rt = (word & 0x1f) as u8; + let rn = ((word >> 5) & 0x1f) as u8; + if rt == 31 || rn == 31 { + return None; + } + + let imm12 = (word >> 10) & 0xfff; + Some(Arm64LoadStoreUnsigned { + rt, + rn, + offset: imm12 * 4, + }) +} + +pub fn encode_movz_w(rd: u8, imm16: u16) -> Option { + if rd >= 31 { + return None; + } + + Some(0x5280_0000 | ((imm16 as u32) << 5) | rd as u32) +} + +pub fn encode_str_w_unsigned(rt: u8, rn: u8, offset: u32) -> Option { + if rt >= 31 || rn >= 31 || !offset.is_multiple_of(4) { + return None; + } + + let imm12 = offset / 4; + if imm12 > 0xfff { + return None; + } + + Some(0xb900_0000 | (imm12 << 10) | ((rn as u32) << 5) | rt as u32) +} + +pub fn is_cond_branch(word: u32) -> bool { + (word & 0xff00_0010) == 0x5400_0000 +} + +fn is_bl(word: u32) -> bool { + (word & 0xfc00_0000) == 0x9400_0000 +} + +fn sign_extend(value: i64, bits: u32) -> i64 { + let shift = 64 - bits; + (value << shift) >> shift +} + +#[cfg(test)] +mod tests { + use super::*; + + fn push_word(out: &mut Vec, word: u32) { + out.extend_from_slice(&word.to_le_bytes()); + } + + fn encode_adrp(rd: u8, pc: u64, target: u64) -> u32 { + let pc_page = (pc & !0xfff) as i64; + let target_page = (target & !0xfff) as i64; + let imm = (target_page - pc_page) >> 12; + let imm_u = imm as u32; + let immlo = imm_u & 0x3; + let immhi = (imm_u >> 2) & 0x7ffff; + 0x9000_0000 | (immlo << 29) | (immhi << 5) | rd as u32 + } + + fn encode_add(rd: u8, rn: u8, imm: u32) -> u32 { + 0x9100_0000 | ((imm & 0xfff) << 10) | ((rn as u32) << 5) | rd as u32 + } + + fn encode_bl(imm26: u32) -> u32 { + 0x9400_0000 | (imm26 & 0x03ff_ffff) + } + + fn encode_ldr_w(rt: u8, rn: u8, offset: u32) -> u32 { + 0xb940_0000 | ((offset / 4) << 10) | ((rn as u32) << 5) | rt as u32 + } + + #[test] + fn detects_adrp_add_reference() { + let code_va = 0x0001_8010_0000; + let target = 0x0001_8022_0340; + let mut code = Vec::new(); + push_word(&mut code, encode_adrp(0, code_va, target)); + push_word(&mut code, encode_add(0, 0, 0x340)); + + assert_eq!( + code_references_addr(&code, code_va, target), + Some(code_va + 4) + ); + } + + #[test] + fn ignores_wrong_page_reference() { + let code_va = 0x0001_8010_0000; + let target = 0x0001_8022_0340; + let wrong = 0x0001_8023_0340; + let mut code = Vec::new(); + push_word(&mut code, encode_adrp(0, code_va, wrong)); + push_word(&mut code, encode_add(0, 0, 0x340)); + + assert_eq!(code_references_addr(&code, code_va, target), None); + } + + #[test] + fn finds_bl_after_reference() { + let code_va = 0x0001_8010_0000; + let target = 0x0001_8022_0340; + let mut code = Vec::new(); + push_word(&mut code, encode_adrp(0, code_va, target)); + push_word(&mut code, encode_add(0, 0, 0x340)); + push_word(&mut code, 0xd503_201f); // nop + push_word(&mut code, encode_bl(0x20)); + + let reference = code_references_addr(&code, code_va, target).unwrap(); + assert_eq!( + find_bl_after_addr(&code, code_va, reference, 32), + Some(code_va + 12) + ); + } + + #[test] + fn bl_search_honors_window() { + let code_va = 0x0001_8010_0000; + let target = 0x0001_8022_0340; + let mut code = Vec::new(); + push_word(&mut code, encode_adrp(0, code_va, target)); + push_word(&mut code, encode_add(0, 0, 0x340)); + push_word(&mut code, 0xd503_201f); + push_word(&mut code, encode_bl(0x20)); + + let reference = code_references_addr(&code, code_va, target).unwrap(); + assert_eq!(find_bl_after_addr(&code, code_va, reference, 4), None); + } + + #[test] + fn decodes_and_encodes_w_load_store_unsigned() { + let ldr = encode_ldr_w(9, 0, 0x63c); + assert_eq!( + decode_ldr_w_unsigned(ldr), + Some(Arm64LoadStoreUnsigned { + rt: 9, + rn: 0, + offset: 0x63c, + }) + ); + + let str_word = encode_str_w_unsigned(9, 0, 0x638).unwrap(); + assert_eq!( + decode_str_w_unsigned(str_word), + Some(Arm64LoadStoreUnsigned { + rt: 9, + rn: 0, + offset: 0x638, + }) + ); + } + + #[test] + fn encodes_movz_w() { + assert_eq!(encode_movz_w(9, 0x100), Some(0x5280_2009)); + assert_eq!(encode_movz_w(31, 1), None); + } + + #[test] + fn detects_conditional_branch() { + assert!(is_cond_branch(0x5400_0020)); + assert!(!is_cond_branch(encode_bl(0x20))); + } + + #[test] + fn builds_function_ranges_from_pdata() { + let mut pdata = Vec::new(); + pdata.extend_from_slice(&0x1000u32.to_le_bytes()); + pdata.extend_from_slice(&0u32.to_le_bytes()); + pdata.extend_from_slice(&0x1080u32.to_le_bytes()); + pdata.extend_from_slice(&0u32.to_le_bytes()); + + let funcs = functions_from_pdata(&pdata, 0x1000, 0x100); + assert_eq!( + funcs, + vec![ + Arm64Function { + begin_address: 0x1000, + end_address: 0x1080, + }, + Arm64Function { + begin_address: 0x1080, + end_address: 0x1100, + }, + ] + ); + } +} diff --git a/crates/patcher/src/lib.rs b/crates/patcher/src/lib.rs index 54d4419..24a03b8 100644 --- a/crates/patcher/src/lib.rs +++ b/crates/patcher/src/lib.rs @@ -1,3 +1,4 @@ +pub mod arm64; pub mod disasm; pub mod error; pub mod patch; diff --git a/crates/patcher/src/patch.rs b/crates/patcher/src/patch.rs index 3fe40d8..18e7c4a 100644 --- a/crates/patcher/src/patch.rs +++ b/crates/patcher/src/patch.rs @@ -12,20 +12,22 @@ use crate::error::PatcherError; /// (use thread suspension) #[cfg(windows)] pub unsafe fn write_patch(addr: usize, bytes: &[u8]) -> Result { - use windows::Win32::System::Diagnostics::Debug::WriteProcessMemory; + use windows::Win32::System::Diagnostics::Debug::{FlushInstructionCache, WriteProcessMemory}; use windows::Win32::System::Threading::GetCurrentProcess; let mut written = 0usize; + let process = GetCurrentProcess(); // SAFETY: Caller guarantees addr is valid and threads are suspended unsafe { WriteProcessMemory( - GetCurrentProcess(), + process, addr as *const std::ffi::c_void, bytes.as_ptr() as *const std::ffi::c_void, bytes.len(), Some(&mut written), )?; + FlushInstructionCache(process, Some(addr as *const std::ffi::c_void), bytes.len())?; } Ok(written) @@ -140,6 +142,28 @@ pub mod bytecodes { 0xFF, 0xC0, // inc eax 0xC3, // ret ]; + + /// ARM64 `mov w0, #1; ret` — returns TRUE from a BOOL-like function. + pub const ARM64_MOV_W0_1_RET: &[u8] = &[ + 0x20, 0x00, 0x80, 0x52, // mov w0, #1 + 0xC0, 0x03, 0x5F, 0xD6, // ret + ]; + + /// ARM64 `mov w0, #1` — replaces a BL call whose result is tested as TRUE. + pub const ARM64_MOV_W0_1: &[u8] = &[ + 0x20, 0x00, 0x80, 0x52, // mov w0, #1 + ]; + + /// ARM64 `mov w0, #0` — replaces a BL call whose result is tested as FALSE. + pub const ARM64_MOV_W0_0: &[u8] = &[ + 0x00, 0x00, 0x80, 0x52, // mov w0, #0 + ]; + + /// ARM64 `mov w0, #0; ret` — returns FALSE from a BOOL-like function. + pub const ARM64_MOV_W0_0_RET: &[u8] = &[ + 0x00, 0x00, 0x80, 0x52, // mov w0, #0 + 0xC0, 0x03, 0x5F, 0xD6, // ret + ]; } /// Debug output helper (uses OutputDebugStringA on Windows) diff --git a/crates/rdprrap-installer/src/install.rs b/crates/rdprrap-installer/src/install.rs index 865b20e..b7705dd 100644 --- a/crates/rdprrap-installer/src/install.rs +++ b/crates/rdprrap-installer/src/install.rs @@ -78,7 +78,9 @@ pub fn run(opts: Options) -> Result<()> { validate_install_dir(&install_dir, created_install_dir)?; // Step 2: copy DLLs. Refuse to follow reparse points on the destination. - copy_wrapper_dlls(&source_dir, &install_dir)?; + // `--force` also replaces existing plain files so upgrades/reinstalls can + // refresh the on-disk wrapper payload instead of failing at CREATE_NEW. + copy_wrapper_dlls(&source_dir, &install_dir, opts.force)?; // H3: grant SYSTEM + LocalService read/execute on the install dir so the // wrapper DLL is loadable from every service account upstream rdpwrap @@ -295,7 +297,7 @@ fn validate_install_dir(dir: &Path, created_by_us: bool) -> Result<()> { Ok(()) } -fn copy_wrapper_dlls(source: &Path, target: &Path) -> Result<()> { +fn copy_wrapper_dlls(source: &Path, target: &Path, overwrite_existing: bool) -> Result<()> { let mut missing = Vec::new(); for (built_name, canonical_name) in paths::WRAPPER_DLLS { let src = source.join(built_name); @@ -319,20 +321,32 @@ fn copy_wrapper_dlls(source: &Path, target: &Path) -> Result<()> { // reparse points before writing so the replacement is a real file we // own. `FILE_FLAG_OPEN_REPARSE_POINT` on the attribute query below // means `is_reparse_point` examines the literal name, not its target. - if dst.exists() && is_reparse_point(&dst)? { - eprintln!( - "rdprrap-installer: {} was a reparse point — removing before copy", - dst.display() - ); - fs::remove_file(&dst) - .with_context(|| format!("remove reparse point {}", dst.display()))?; + if dst.exists() { + if is_reparse_point(&dst)? { + eprintln!( + "rdprrap-installer: {} was a reparse point — removing before copy", + dst.display() + ); + fs::remove_file(&dst) + .with_context(|| format!("remove reparse point {}", dst.display()))?; + } else if overwrite_existing { + eprintln!("rdprrap-installer: replacing existing {}", dst.display()); + fs::remove_file(&dst) + .with_context(|| format!("remove existing {}", dst.display()))?; + } else { + bail!( + "destination DLL already exists: {}. Use --force to replace \ + an existing rdprrap install.", + dst.display() + ); + } } // Race-safe write: open `dst` with `CREATE_NEW` + exclusive share so - // that any attacker who re-planted a reparse point (or any other file) - // between the `remove_file` above and this open call causes an - // explicit `ERROR_FILE_EXISTS` failure rather than a silently- - // followed redirection to an attacker-controlled target. + // that any attacker who planted or re-planted a reparse point (or any + // other file) before this open call causes an explicit + // `ERROR_FILE_EXISTS` failure rather than a silently-followed + // redirection to an attacker-controlled target. // `FILE_FLAG_OPEN_REPARSE_POINT` makes the open literal so a race- // planted reparse point fails the `CREATE_NEW` check instead of being // silently traversed. We then write the source bytes straight into diff --git a/crates/rdprrap-installer/src/main.rs b/crates/rdprrap-installer/src/main.rs index 18e3472..ca81ec6 100644 --- a/crates/rdprrap-installer/src/main.rs +++ b/crates/rdprrap-installer/src/main.rs @@ -114,7 +114,8 @@ fn run_non_windows(args: cli::Args) -> Result<()> { cli::Command::Plan => plan::print(), _ => bail!( "rdprrap-installer must be run on Windows. Build with --target \ - x86_64-pc-windows-msvc or i686-pc-windows-msvc and run on the target host." + x86_64-pc-windows-msvc, i686-pc-windows-msvc, or \ + aarch64-pc-windows-msvc and run on the target host." ), } } diff --git a/crates/termwrap-dll/src/patches/arm64.rs b/crates/termwrap-dll/src/patches/arm64.rs new file mode 100644 index 0000000..410971d --- /dev/null +++ b/crates/termwrap-dll/src/patches/arm64.rs @@ -0,0 +1,306 @@ +use patcher::arm64 as arm64_pe; +use patcher::patch::{bytecodes, debug_log, write_patch}; +use patcher::pattern::{find_pattern_in_section, termsrv_strings as strings}; +use patcher::pe::{LoadedPe, SectionInfo}; +use windows::Win32::Foundation::HMODULE; + +const DEF_POLICY_ALLOW_OFFSET: u32 = 0x638; +const DEF_POLICY_COMPARE_OFFSET: u32 = 0x63c; +const SL_POLICY_CALL_WINDOW: usize = 96; +const ARM64_NOP: u32 = 0xd503_201f; + +/// GUID for IS_PNP_DISABLED: {93D359D5-831F-47B4-90BE-8383AF8F1B0E} +const IS_PNP_DISABLED: [u8; 16] = [ + 0xD5, 0x59, 0xD3, 0x93, 0x1F, 0x83, 0xB4, 0x47, 0x90, 0xBE, 0x83, 0x83, 0xAF, 0x8F, 0x1B, 0x0E, +]; + +#[derive(Default)] +struct ResolvedAddrs { + cdefpolicy_query: Option, + single_session_enabled: Option, + single_session_per_user: Option, + is_local_only: Option, + is_allow_nonrdp: Option, + is_appserver: Option, +} + +/// Apply ARM64 termsrv.dll patches. +/// +/// # Safety +/// - `hmod` must be a valid handle to the loaded termsrv.dll. +/// - All other threads must be suspended. +pub unsafe fn apply_patches(hmod: HMODULE) { + let base = hmod.0 as usize; + + let pe = match unsafe { LoadedPe::from_base(base) } { + Ok(pe) => pe, + Err(e) => { + debug_log(&format!("TermWrap ARM64: Failed to parse PE: {e}")); + return; + } + }; + + let rdata = match pe.find_rdata_section() { + Ok(s) => s, + Err(e) => { + debug_log(&format!("TermWrap ARM64: Failed to find .rdata: {e}")); + return; + } + }; + + let cdefpolicy_query_rva = find_pattern_in_section(&pe, &rdata, strings::CDEFPOLICY_QUERY).ok(); + let single_session_enabled_rva = + find_pattern_in_section(&pe, &rdata, strings::IS_SINGLE_SESSION_ENABLED).ok(); + let is_local_only_rva = + find_pattern_in_section(&pe, &rdata, strings::CSLQUERY_IS_LOCAL_ONLY).ok(); + let is_allow_nonrdp_rva = find_pattern_in_section(&pe, &rdata, strings::IS_ALLOW_NONRDP).ok(); + let is_appserver_rva = + find_pattern_in_section(&pe, &rdata, strings::CSLQUERY_IS_APPSERVER).ok(); + + let single_session_per_user_rva = single_session_per_user_rva(&pe, &rdata); + + let addrs = ResolvedAddrs { + cdefpolicy_query: find_function(&pe, cdefpolicy_query_rva), + single_session_enabled: find_function(&pe, single_session_enabled_rva), + single_session_per_user: find_function(&pe, single_session_per_user_rva), + is_local_only: find_function(&pe, is_local_only_rva), + is_allow_nonrdp: find_function(&pe, is_allow_nonrdp_rva), + is_appserver: find_function(&pe, is_appserver_rva), + }; + + if let Some(func) = addrs.cdefpolicy_query { + apply_def_policy(&pe, func); + } else { + debug_log("TermWrap ARM64: CDefPolicy_Query not found\n"); + } + + let mut patched_single_user = false; + if let Some(func) = addrs.single_session_enabled { + patched_single_user |= patch_function_start( + &pe, + func, + bytecodes::ARM64_MOV_W0_0_RET, + "SingleSessionEnabled", + ); + } + if let Some(func) = addrs.single_session_per_user { + patched_single_user |= patch_function_start( + &pe, + func, + bytecodes::ARM64_MOV_W0_0_RET, + "SingleSessionPerUser", + ); + } + if !patched_single_user { + debug_log("TermWrap ARM64: SingleUserPatch not found\n"); + } + + if let Some(func) = addrs.is_local_only { + patch_function_start(&pe, func, bytecodes::ARM64_MOV_W0_0_RET, "LocalOnly"); + } else { + debug_log("TermWrap ARM64: IsTerminalTypeLocalOnly not found\n"); + } + + if let Some(func) = addrs.is_appserver { + patch_function_start( + &pe, + func, + bytecodes::ARM64_MOV_W0_1_RET, + "IsAppServerInstalled", + ); + } else { + debug_log("TermWrap ARM64: IsAppServerInstalled not found\n"); + } + + if let Some(func) = addrs.is_allow_nonrdp { + patch_function_start(&pe, func, bytecodes::ARM64_MOV_W0_1_RET, "AllowNonRDPStack"); + } + + apply_property_device(&pe, &rdata); + apply_sl_policy(&pe, &rdata); +} + +fn find_function(pe: &LoadedPe, rva: Option) -> Option { + arm64_pe::find_function_referencing_rva(pe, rva?) +} + +fn single_session_per_user_rva(pe: &LoadedPe, rdata: &SectionInfo) -> Option { + find_pattern_in_section(pe, rdata, strings::IS_SINGLE_SESSION_PER_USER) + .ok() + .map(|rva| { + if rva < 8 { + return rva; + } + + let check_addr = pe.base + rva - 8; + // SAFETY: rva >= 8, and the matched string is inside the mapped PE image. + let prefix = unsafe { std::slice::from_raw_parts(check_addr as *const u8, 8) }; + if prefix == b"CUtils::" { + rva - 8 + } else { + rva + } + }) +} + +fn patch_function_start( + pe: &LoadedPe, + func: arm64_pe::Arm64Function, + patch: &[u8], + label: &str, +) -> bool { + let len = (func.end_address - func.begin_address) as usize; + if len < patch.len() { + debug_log(&format!("TermWrap ARM64: {label} function too short\n")); + return false; + } + + let addr = pe.adjusted_base + func.begin_address as usize; + // SAFETY: addr is the start of an ARM64 function inside loaded PE .text; + // threads are suspended by the caller. + match unsafe { write_patch(addr, patch) } { + Ok(_) => { + debug_log(&format!("TermWrap ARM64: {label} patched\n")); + true + } + Err(e) => { + debug_log(&format!( + "TermWrap ARM64: {label} patch write failed: {e}\n" + )); + false + } + } +} + +fn apply_def_policy(pe: &LoadedPe, func: arm64_pe::Arm64Function) { + let begin = func.begin_address as usize; + let len = ((func.end_address - func.begin_address) as usize).min(256); + if len < 12 { + debug_log("TermWrap ARM64: DefPolicy function too short\n"); + return; + } + + // SAFETY: function bounds come from `.pdata` and are clamped to `.text`. + let code = unsafe { pe.read_bytes(begin, len) }; + let words: Vec = code + .chunks_exact(4) + .map(|chunk| u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) + .collect(); + + for (idx, word) in words.iter().copied().enumerate() { + let Some(load) = arm64_pe::decode_ldr_w_unsigned(word) else { + continue; + }; + if load.offset != DEF_POLICY_COMPARE_OFFSET && load.offset != DEF_POLICY_ALLOW_OFFSET { + continue; + } + if !looks_like_def_policy_check(&words, idx) { + continue; + } + + let Some(mov) = arm64_pe::encode_movz_w(load.rt, 0x100) else { + continue; + }; + let Some(str_word) = + arm64_pe::encode_str_w_unsigned(load.rt, load.rn, DEF_POLICY_ALLOW_OFFSET) + else { + continue; + }; + + let mut patch = Vec::with_capacity(12); + patch.extend_from_slice(&mov.to_le_bytes()); + patch.extend_from_slice(&str_word.to_le_bytes()); + patch.extend_from_slice(&ARM64_NOP.to_le_bytes()); + + let patch_addr = pe.adjusted_base + begin + idx * 4; + // SAFETY: patch_addr is inside the ARM64 CDefPolicy::Query function; + // threads are suspended by the caller. + match unsafe { write_patch(patch_addr, &patch) } { + Ok(_) => debug_log("TermWrap ARM64: DefPolicyPatch applied\n"), + Err(e) => debug_log(&format!( + "TermWrap ARM64: DefPolicyPatch write failed: {e}\n" + )), + } + return; + } + + debug_log("TermWrap ARM64: DefPolicyPatch not found\n"); +} + +fn looks_like_def_policy_check(words: &[u32], idx: usize) -> bool { + let end = (idx + 5).min(words.len()); + words[idx + 1..end] + .iter() + .copied() + .any(arm64_pe::is_cond_branch) +} + +fn apply_property_device(pe: &LoadedPe, rdata: &SectionInfo) { + let Ok(pnp_rva) = find_pattern_in_section(pe, rdata, &IS_PNP_DISABLED) else { + debug_log("TermWrap ARM64: IS_PNP_DISABLED not found\n"); + return; + }; + + let Some(site) = arm64_pe::find_bl_after_reference_rva(pe, pnp_rva, SL_POLICY_CALL_WINDOW) + else { + debug_log("TermWrap ARM64: PropertyDevice call not found\n"); + return; + }; + + let call_addr = pe.adjusted_base + site.call_rva as usize; + // SAFETY: call_addr is a BL instruction in loaded PE .text; threads are + // suspended by the caller. + match unsafe { write_patch(call_addr, bytecodes::ARM64_MOV_W0_0) } { + Ok(_) => debug_log("TermWrap ARM64: PropertyDevice patched\n"), + Err(e) => debug_log(&format!( + "TermWrap ARM64: PropertyDevice write failed: {e}\n" + )), + } +} + +fn apply_sl_policy(pe: &LoadedPe, rdata: &SectionInfo) { + let policies = [ + ("AllowRemoteConnections", strings::ALLOW_REMOTE_BYTES), + ( + "AllowMultipleSessions", + strings::ALLOW_MULTIPLE_SESSIONS_BYTES, + ), + ("AllowAppServerMode", strings::ALLOW_APPSERVER_BYTES), + ("AllowMultimon", strings::ALLOW_MULTIMON_BYTES), + ]; + + let mut patched = 0usize; + for (label, pattern) in policies { + let Ok(rva) = find_pattern_in_section(pe, rdata, pattern) else { + debug_log(&format!( + "TermWrap ARM64: SLPolicy {label} string not found\n" + )); + continue; + }; + + let Some(site) = arm64_pe::find_bl_after_reference_rva(pe, rva, SL_POLICY_CALL_WINDOW) + else { + debug_log(&format!( + "TermWrap ARM64: SLPolicy {label} call not found\n" + )); + continue; + }; + + let call_addr = pe.adjusted_base + site.call_rva as usize; + // SAFETY: call_addr is a BL instruction in loaded PE .text; threads are + // suspended by the caller. + match unsafe { write_patch(call_addr, bytecodes::ARM64_MOV_W0_1) } { + Ok(_) => { + patched += 1; + debug_log(&format!("TermWrap ARM64: SLPolicy {label} patched\n")); + } + Err(e) => debug_log(&format!( + "TermWrap ARM64: SLPolicy {label} write failed: {e}\n" + )), + } + } + + if patched == 0 { + debug_log("TermWrap ARM64: SLPolicyPatch not found\n"); + } +} diff --git a/crates/termwrap-dll/src/patches/intel.rs b/crates/termwrap-dll/src/patches/intel.rs new file mode 100644 index 0000000..08d28cf --- /dev/null +++ b/crates/termwrap-dll/src/patches/intel.rs @@ -0,0 +1,590 @@ +#[path = "def_policy.rs"] +mod def_policy; +#[path = "local_only.rs"] +mod local_only; +#[path = "nonrdp.rs"] +mod nonrdp; +#[path = "property_device.rs"] +mod property_device; +#[path = "single_user.rs"] +mod single_user; +#[path = "sl_policy.rs"] +mod sl_policy; + +use patcher::patch::debug_log; +use patcher::pattern::{find_pattern_in_section, termsrv_strings as strings}; +use patcher::pe::LoadedPe; +use windows::Win32::Foundation::HMODULE; + +#[cfg(target_arch = "x86_64")] +use patcher::disasm::search_xref_in_function; + +/// Resolved function addresses for all patch targets +struct ResolvedAddrs { + cdefpolicy_query: Option, + get_instance_of_tslicense: Option, + single_session_enabled: Option, + single_session_per_user: Option, + is_local_only: Option, + is_allow_nonrdp: Option, + is_appserver: Option, + get_connection_property: Option, + cslquery_initialize: Option, + cslquery_initialize_len: usize, + #[cfg(target_arch = "x86_64")] + is_appserver_idx: usize, +} + +/// Apply all termsrv.dll patches. +/// +/// # Safety +/// - `hmod` must be a valid handle to the loaded termsrv.dll +/// - All other threads must be suspended +pub unsafe fn apply_patches(hmod: HMODULE) { + let base = hmod.0 as usize; + + let pe = match unsafe { LoadedPe::from_base(base) } { + Ok(pe) => pe, + Err(e) => { + debug_log(&format!("Failed to parse PE: {e}")); + return; + } + }; + + let rdata = match pe.find_rdata_section() { + Ok(s) => s, + Err(e) => { + debug_log(&format!("Failed to find .rdata: {e}")); + return; + } + }; + + // Locate known strings in .rdata + let cdefpolicy_query_rva = find_pattern_in_section(&pe, &rdata, strings::CDEFPOLICY_QUERY).ok(); + let get_instance_rva = + find_pattern_in_section(&pe, &rdata, strings::GET_INSTANCE_OF_TSLICENSE).ok(); + let single_session_enabled_rva = + find_pattern_in_section(&pe, &rdata, strings::IS_SINGLE_SESSION_ENABLED).ok(); + let is_local_only_rva = + find_pattern_in_section(&pe, &rdata, strings::CSLQUERY_IS_LOCAL_ONLY).ok(); + let is_allow_nonrdp_rva = find_pattern_in_section(&pe, &rdata, strings::IS_ALLOW_NONRDP).ok(); + let is_appserver_rva = + find_pattern_in_section(&pe, &rdata, strings::CSLQUERY_IS_APPSERVER).ok(); + let get_connection_property_rva = + find_pattern_in_section(&pe, &rdata, strings::GET_CONNECTION_PROPERTY).ok(); + let allow_remote_rva = find_pattern_in_section(&pe, &rdata, strings::ALLOW_REMOTE_BYTES).ok(); + + // IsSingleSessionPerUser — check for CUtils:: prefix + let single_session_per_user_rva = + find_pattern_in_section(&pe, &rdata, strings::IS_SINGLE_SESSION_PER_USER) + .ok() + .map(|rva| { + // Check if "CUtils::" prefix exists 8 bytes before + if rva < 8 { + return rva; + } + let check_addr = pe.base + rva - 8; + // SAFETY: check_addr is within the mapped PE image (rva >= 8 checked above) + let prefix = unsafe { std::slice::from_raw_parts(check_addr as *const u8, 8) }; + if prefix == b"CUtils::" { + rva - 8 + } else { + rva + } + }); + + // Find import functions + let imports = pe.get_imports(); + let memset_addr = find_memset_import(&pe, &imports); + let verify_version_addr = find_verify_version_import(&pe, &imports); + + // Resolve function addresses + #[cfg(target_arch = "x86_64")] + let addrs = resolve_functions_x64( + &pe, + cdefpolicy_query_rva, + get_instance_rva, + single_session_enabled_rva, + single_session_per_user_rva, + is_local_only_rva, + is_allow_nonrdp_rva, + is_appserver_rva, + get_connection_property_rva, + allow_remote_rva, + ); + + #[cfg(target_arch = "x86")] + let addrs = resolve_functions_x86( + &pe, + cdefpolicy_query_rva, + get_instance_rva, + single_session_enabled_rva, + single_session_per_user_rva, + is_local_only_rva, + is_allow_nonrdp_rva, + is_appserver_rva, + get_connection_property_rva, + allow_remote_rva, + ); + + // === Apply SingleUserPatch === + if let Some(memset) = memset_addr { + let mut patched = false; + if let Some(addr) = addrs.single_session_enabled { + if unsafe { single_user::apply(&pe, addr, memset, verify_version_addr) } { + patched = true; + } + } + if let Some(addr) = addrs.single_session_per_user { + if unsafe { single_user::apply(&pe, addr, memset, verify_version_addr) } { + patched = true; + } + } + if !patched { + debug_log("SingleUserPatch not found\n"); + } + } + + // === Apply DefPolicyPatch === + if let Some(addr) = addrs.cdefpolicy_query { + unsafe { def_policy::apply(&pe, addr) }; + } else { + debug_log("CDefPolicy_Query not found\n"); + } + + // === Apply PropertyDevicePatch === + if let Some(conn_prop_addr) = addrs.get_connection_property { + let pnp_disabled_rva = + find_pattern_in_section(&pe, &rdata, &property_device::IS_PNP_DISABLED); + if let Ok(pnp_rva) = pnp_disabled_rva { + if let Some(prop_addr) = + unsafe { property_device::find_property_device_addr(&pe, conn_prop_addr, pnp_rva) } + { + unsafe { property_device::apply(&pe, prop_addr) }; + } else { + debug_log("PropertyAddr not found\n"); + } + } else { + debug_log("IS_PNP_DISABLED not found\n"); + } + } else { + debug_log("GetConnectionProperty not found\n"); + } + + // === Check CSLQuery::Initialize === + if addrs.cslquery_initialize.is_none() { + debug_log("CSLQuery_Initialize not found\n"); + return; + } + + // === Apply LocalOnlyPatch === + if let Some(instance_addr) = addrs.get_instance_of_tslicense { + if let Some(local_only_addr) = addrs.is_local_only { + unsafe { local_only::apply(&pe, instance_addr, local_only_addr) }; + } else { + debug_log("IsLicenseTypeLocalOnly not found\n"); + } + } else { + debug_log("GetInstanceOfTSLicense not found\n"); + } + + // === Apply NonRDPPatch === + if let Some(nonrdp_addr) = addrs.is_allow_nonrdp { + if let Some(appserver_addr) = addrs.is_appserver { + if !unsafe { nonrdp::apply(&pe, nonrdp_addr, appserver_addr) } { + // IsAppServerInstalled may be inlined, try searching more + #[cfg(target_arch = "x86_64")] + { + let func_table = pe.get_exception_table().unwrap_or_default(); + let mut found = false; + for func in func_table.iter().skip(addrs.is_appserver_idx) { + if let Some(is_appserver_rva) = is_appserver_rva { + if search_xref_in_function(&pe, func, is_appserver_rva as u64).is_some() + { + let bt = pe.backtrace_function(func); + if unsafe { + nonrdp::apply(&pe, nonrdp_addr, bt.begin_address as usize) + } { + found = true; + break; + } + } + } + } + if !found { + debug_log("NonRDPPatch not found\n"); + } + } + } + } else { + debug_log("IsAppServerInstalled not found\n"); + } + } + + // === Apply CSLQuery::Initialize SL policy patching === + if let Some(init_rva) = addrs.cslquery_initialize { + let allow_multiple_rva = + find_pattern_in_section(&pe, &rdata, strings::ALLOW_MULTIPLE_SESSIONS_BYTES).ok(); + let allow_appserver_rva = + find_pattern_in_section(&pe, &rdata, strings::ALLOW_APPSERVER_BYTES).ok(); + let allow_multimon_rva = + find_pattern_in_section(&pe, &rdata, strings::ALLOW_MULTIMON_BYTES).ok(); + + // Clamp length to available memory (x86 has no exception table, uses hardcoded 0x11000) + let text = pe.find_section(".text").ok(); + let max_len = text + .map(|t| { + let text_end = t.virtual_address as usize + t.raw_data_size as usize; + text_end.saturating_sub(init_rva) + }) + .unwrap_or(addrs.cslquery_initialize_len); + let safe_len = addrs.cslquery_initialize_len.min(max_len); + + unsafe { + sl_policy::apply( + &pe, + init_rva, + safe_len, + allow_remote_rva, + allow_multiple_rva, + allow_appserver_rva, + allow_multimon_rva, + ) + }; + } +} + +/// Find memset import thunk RVA +fn find_memset_import(pe: &LoadedPe, imports: &[patcher::pe::ImportInfo]) -> Option { + for dll in &["api-ms-win-crt-string-l1-1-0.dll", "msvcrt.dll"] { + if let Some(imp) = imports + .iter() + .find(|i| i.dll_name.eq_ignore_ascii_case(dll)) + { + if let Ok(rva) = pe.find_import_function(imp, "memset") { + return Some(rva); + } + } + } + None +} + +/// Find VerifyVersionInfoW import thunk RVA +fn find_verify_version_import(pe: &LoadedPe, imports: &[patcher::pe::ImportInfo]) -> Option { + for dll in &["api-ms-win-core-kernel32-legacy-l1-1-1.dll", "KERNEL32.dll"] { + if let Some(imp) = imports + .iter() + .find(|i| i.dll_name.eq_ignore_ascii_case(dll)) + { + if let Ok(rva) = pe.find_import_function(imp, "VerifyVersionInfoW") { + return Some(rva); + } + } + } + None +} + +/// x64: resolve all function addresses by scanning exception table for xrefs +#[cfg(target_arch = "x86_64")] +#[allow(clippy::too_many_arguments)] +fn resolve_functions_x64( + pe: &LoadedPe, + cdefpolicy_query_rva: Option, + get_instance_rva: Option, + single_session_enabled_rva: Option, + single_session_per_user_rva: Option, + is_local_only_rva: Option, + is_allow_nonrdp_rva: Option, + is_appserver_rva: Option, + get_connection_property_rva: Option, + allow_remote_rva: Option, +) -> ResolvedAddrs { + let func_table = pe.get_exception_table().unwrap_or_default(); + + let mut addrs = ResolvedAddrs { + cdefpolicy_query: None, + get_instance_of_tslicense: None, + single_session_enabled: None, + single_session_per_user: None, + is_local_only: None, + is_allow_nonrdp: None, + is_appserver: None, + get_connection_property: None, + cslquery_initialize: None, + cslquery_initialize_len: 0x11000, + is_appserver_idx: 0, + }; + + for (i, func) in func_table.iter().enumerate() { + macro_rules! try_resolve { + ($field:ident, $rva:expr) => { + if addrs.$field.is_none() { + if let Some(rva) = $rva { + if search_xref_in_function(pe, func, rva as u64).is_some() { + let bt = pe.backtrace_function(func); + addrs.$field = Some(bt.begin_address as usize); + continue; + } + } + } + }; + } + + try_resolve!(cdefpolicy_query, cdefpolicy_query_rva); + try_resolve!(get_instance_of_tslicense, get_instance_rva); + try_resolve!(single_session_enabled, single_session_enabled_rva); + try_resolve!(single_session_per_user, single_session_per_user_rva); + try_resolve!(is_local_only, is_local_only_rva); + + if addrs.is_allow_nonrdp.is_none() && is_allow_nonrdp_rva.is_some() { + if let Some(rva) = is_allow_nonrdp_rva { + if search_xref_in_function(pe, func, rva as u64).is_some() { + addrs.is_allow_nonrdp = + Some(pe.backtrace_function(func).begin_address as usize); + continue; + } + } + } + + if addrs.is_appserver.is_none() { + if let Some(rva) = is_appserver_rva { + if search_xref_in_function(pe, func, rva as u64).is_some() { + addrs.is_appserver = Some(pe.backtrace_function(func).begin_address as usize); + addrs.is_appserver_idx = i; + continue; + } + } + } + + try_resolve!(get_connection_property, get_connection_property_rva); + + if addrs.cslquery_initialize.is_none() { + if let Some(rva) = allow_remote_rva { + if search_xref_in_function(pe, func, rva as u64).is_some() { + let bt = pe.backtrace_function(func); + addrs.cslquery_initialize = Some(bt.begin_address as usize); + addrs.cslquery_initialize_len = (bt.end_address - bt.begin_address) as usize; + continue; + } + } + } + + // Check if all found + if addrs.cdefpolicy_query.is_some() + && addrs.get_instance_of_tslicense.is_some() + && addrs.single_session_enabled.is_some() + && addrs.single_session_per_user.is_some() + && addrs.is_local_only.is_some() + && (addrs.is_allow_nonrdp.is_some() || is_allow_nonrdp_rva.is_none()) + && addrs.is_appserver.is_some() + && addrs.get_connection_property.is_some() + && addrs.cslquery_initialize.is_some() + { + break; + } + } + + addrs +} + +/// x86: resolve function addresses by scanning .text for function prologues +/// and PUSH/MOV immediate references to string RVAs +#[cfg(target_arch = "x86")] +#[allow(clippy::too_many_arguments)] +fn resolve_functions_x86( + pe: &LoadedPe, + cdefpolicy_query_rva: Option, + get_instance_rva: Option, + single_session_enabled_rva: Option, + single_session_per_user_rva: Option, + is_local_only_rva: Option, + is_allow_nonrdp_rva: Option, + is_appserver_rva: Option, + get_connection_property_rva: Option, + allow_remote_rva: Option, +) -> ResolvedAddrs { + use iced_x86::{Decoder, DecoderOptions, Instruction, Mnemonic, OpKind, Register}; + use std::cmp::Reverse; + use std::collections::BinaryHeap; + + let text = match pe.find_section(".text") { + Ok(s) => s, + Err(_) => { + debug_log("x86: .text section not found\n"); + return ResolvedAddrs { + cdefpolicy_query: None, + get_instance_of_tslicense: None, + single_session_enabled: None, + single_session_per_user: None, + is_local_only: None, + is_allow_nonrdp: None, + is_appserver: None, + get_connection_property: None, + cslquery_initialize: None, + cslquery_initialize_len: 0x11000, + }; + } + }; + + let mut addrs = ResolvedAddrs { + cdefpolicy_query: None, + get_instance_of_tslicense: None, + single_session_enabled: None, + single_session_per_user: None, + is_local_only: None, + is_allow_nonrdp: None, + is_appserver: None, + get_connection_property: None, + cslquery_initialize: None, + cslquery_initialize_len: 0x11000, + }; + + let base = pe.adjusted_base; + let text_start = base + text.virtual_address as usize; + let text_size = text.raw_data_size as usize; + + // x86 prologue pattern: mov edi,edi; push ebp; mov ebp,esp + const PROLOGUE: &[u8] = &[0x8B, 0xFF, 0x55, 0x8B, 0xEC]; + + let all_rvas: Vec<(usize, &str)> = [ + (cdefpolicy_query_rva, "cdefpolicy"), + (get_instance_rva, "get_instance"), + (single_session_enabled_rva, "single_enabled"), + (single_session_per_user_rva, "single_per_user"), + (is_local_only_rva, "local_only"), + (is_allow_nonrdp_rva, "nonrdp"), + (is_appserver_rva, "appserver"), + (get_connection_property_rva, "conn_property"), + (allow_remote_rva, "allow_remote"), + ] + .iter() + .filter_map(|(rva, name)| rva.map(|r| (r, *name))) + .collect(); + + let mut ip = text_start; + let mut remaining = text_size; + + while remaining >= 5 { + // SAFETY: ip is within the .text section (text_start..text_start+text_size) + let prologue_match = unsafe { std::slice::from_raw_parts(ip as *const u8, 5) }; + + if prologue_match != PROLOGUE { + ip += 1; + remaining -= 1; + continue; + } + + let func_start_rva = ip - base; + + // Decode the function with a priority queue for branch targets + let mut jmp_addrs: BinaryHeap> = BinaryHeap::new(); + jmp_addrs.push(Reverse(ip)); + + while let Some(Reverse(block_start)) = jmp_addrs.pop() { + // SAFETY: block_start is within .text section, avail is clamped to section bounds + let block_code = unsafe { + let avail = text_size.saturating_sub(block_start - text_start); + std::slice::from_raw_parts(block_start as *const u8, avail.min(4096)) + }; + let mut decoder = + Decoder::with_ip(32, block_code, block_start as u64, DecoderOptions::NONE); + let mut inst = Instruction::default(); + + while decoder.can_decode() { + decoder.decode_out(&mut inst); + + // Check PUSH imm32 (5 bytes) or MOV reg/mem, imm32 + let is_push_imm32 = inst.len() == 5 + && inst.mnemonic() == Mnemonic::Push + && inst.op0_kind() == OpKind::Immediate32; + let is_mov_imm32 = inst.mnemonic() == Mnemonic::Mov + && inst.op1_kind() == OpKind::Immediate32 + && ((inst.op0_kind() == OpKind::Register && inst.len() == 5) + || (inst.op0_kind() == OpKind::Memory + && inst.len() >= 7 + && (inst.memory_base() == Register::EBP + || inst.memory_base() == Register::ESP))); + let target_val = if is_push_imm32 || is_mov_imm32 { + Some((inst.immediate32() as usize).wrapping_sub(base)) + } else { + None + }; + + if let Some(tv) = target_val { + for &(rva, name) in &all_rvas { + if tv == rva { + match name { + "cdefpolicy" if addrs.cdefpolicy_query.is_none() => { + addrs.cdefpolicy_query = Some(func_start_rva); + } + "get_instance" if addrs.get_instance_of_tslicense.is_none() => { + addrs.get_instance_of_tslicense = Some(func_start_rva); + } + "single_enabled" if addrs.single_session_enabled.is_none() => { + addrs.single_session_enabled = Some(func_start_rva); + } + "single_per_user" if addrs.single_session_per_user.is_none() => { + addrs.single_session_per_user = Some(func_start_rva); + } + "local_only" if addrs.is_local_only.is_none() => { + addrs.is_local_only = Some(func_start_rva); + } + "nonrdp" if addrs.is_allow_nonrdp.is_none() => { + addrs.is_allow_nonrdp = Some(func_start_rva); + } + "appserver" if addrs.is_appserver.is_none() => { + addrs.is_appserver = Some(func_start_rva); + } + "conn_property" if addrs.get_connection_property.is_none() => { + addrs.get_connection_property = Some(func_start_rva); + } + "allow_remote" if addrs.cslquery_initialize.is_none() => { + addrs.cslquery_initialize = Some(func_start_rva); + } + _ => continue, + } + // Found a match — skip rest of this function + break; + } + } + } + + // Follow conditional branches + if inst.mnemonic() >= Mnemonic::Ja + && inst.mnemonic() <= Mnemonic::Js + && inst.mnemonic() != Mnemonic::Jmp + && inst.op0_kind() == OpKind::NearBranch32 + { + let branch = inst.near_branch_target() as usize; + if branch >= text_start && branch < text_start + text_size { + jmp_addrs.push(Reverse(branch)); + } + } + + if inst.mnemonic() == Mnemonic::Ret || inst.mnemonic() == Mnemonic::Jmp { + break; + } + } + } + + // Check if all found + let all_found = addrs.cdefpolicy_query.is_some() + && addrs.get_instance_of_tslicense.is_some() + && addrs.single_session_enabled.is_some() + && addrs.single_session_per_user.is_some() + && addrs.is_local_only.is_some() + && (addrs.is_allow_nonrdp.is_some() || is_allow_nonrdp_rva.is_none()) + && addrs.is_appserver.is_some() + && addrs.cslquery_initialize.is_some() + && addrs.get_connection_property.is_some(); + + if all_found { + break; + } + + ip += 5; + remaining -= 5; + } + + addrs +} diff --git a/crates/termwrap-dll/src/patches/mod.rs b/crates/termwrap-dll/src/patches/mod.rs index cba0c01..fde7894 100644 --- a/crates/termwrap-dll/src/patches/mod.rs +++ b/crates/termwrap-dll/src/patches/mod.rs @@ -1,584 +1,29 @@ -mod def_policy; -mod local_only; -mod nonrdp; -mod property_device; -mod single_user; -mod sl_policy; +#[cfg(target_arch = "aarch64")] +mod arm64; +#[cfg(any(target_arch = "x86_64", target_arch = "x86"))] +mod intel; +#[cfg(target_arch = "aarch64")] +pub use arm64::apply_patches; +#[cfg(any(target_arch = "x86_64", target_arch = "x86"))] +pub use intel::apply_patches; + +#[cfg(not(any(target_arch = "x86_64", target_arch = "x86", target_arch = "aarch64")))] use patcher::patch::debug_log; -use patcher::pattern::{find_pattern_in_section, termsrv_strings as strings}; -use patcher::pe::LoadedPe; +#[cfg(not(any(target_arch = "x86_64", target_arch = "x86", target_arch = "aarch64")))] use windows::Win32::Foundation::HMODULE; -#[cfg(target_arch = "x86_64")] -use patcher::disasm::search_xref_in_function; - -/// Resolved function addresses for all patch targets -struct ResolvedAddrs { - cdefpolicy_query: Option, - get_instance_of_tslicense: Option, - single_session_enabled: Option, - single_session_per_user: Option, - is_local_only: Option, - is_allow_nonrdp: Option, - is_appserver: Option, - get_connection_property: Option, - cslquery_initialize: Option, - cslquery_initialize_len: usize, - #[cfg(target_arch = "x86_64")] - is_appserver_idx: usize, -} - -/// Apply all termsrv.dll patches. +/// Unsupported CPU architecture fallback. +/// +/// x86, x64, and ARM64 have dedicated runtime patchers. Any other CPU +/// architecture only forwards the original termsrv.dll. /// /// # Safety -/// - `hmod` must be a valid handle to the loaded termsrv.dll -/// - All other threads must be suspended -pub unsafe fn apply_patches(hmod: HMODULE) { - let base = hmod.0 as usize; - - let pe = match unsafe { LoadedPe::from_base(base) } { - Ok(pe) => pe, - Err(e) => { - debug_log(&format!("Failed to parse PE: {e}")); - return; - } - }; - - let rdata = match pe.find_rdata_section() { - Ok(s) => s, - Err(e) => { - debug_log(&format!("Failed to find .rdata: {e}")); - return; - } - }; - - // Locate known strings in .rdata - let cdefpolicy_query_rva = find_pattern_in_section(&pe, &rdata, strings::CDEFPOLICY_QUERY).ok(); - let get_instance_rva = - find_pattern_in_section(&pe, &rdata, strings::GET_INSTANCE_OF_TSLICENSE).ok(); - let single_session_enabled_rva = - find_pattern_in_section(&pe, &rdata, strings::IS_SINGLE_SESSION_ENABLED).ok(); - let is_local_only_rva = - find_pattern_in_section(&pe, &rdata, strings::CSLQUERY_IS_LOCAL_ONLY).ok(); - let is_allow_nonrdp_rva = find_pattern_in_section(&pe, &rdata, strings::IS_ALLOW_NONRDP).ok(); - let is_appserver_rva = - find_pattern_in_section(&pe, &rdata, strings::CSLQUERY_IS_APPSERVER).ok(); - let get_connection_property_rva = - find_pattern_in_section(&pe, &rdata, strings::GET_CONNECTION_PROPERTY).ok(); - let allow_remote_rva = find_pattern_in_section(&pe, &rdata, strings::ALLOW_REMOTE_BYTES).ok(); - - // IsSingleSessionPerUser — check for CUtils:: prefix - let single_session_per_user_rva = - find_pattern_in_section(&pe, &rdata, strings::IS_SINGLE_SESSION_PER_USER) - .ok() - .map(|rva| { - // Check if "CUtils::" prefix exists 8 bytes before - if rva < 8 { - return rva; - } - let check_addr = pe.base + rva - 8; - // SAFETY: check_addr is within the mapped PE image (rva >= 8 checked above) - let prefix = unsafe { std::slice::from_raw_parts(check_addr as *const u8, 8) }; - if prefix == b"CUtils::" { - rva - 8 - } else { - rva - } - }); - - // Find import functions - let imports = pe.get_imports(); - let memset_addr = find_memset_import(&pe, &imports); - let verify_version_addr = find_verify_version_import(&pe, &imports); - - // Resolve function addresses - #[cfg(target_arch = "x86_64")] - let addrs = resolve_functions_x64( - &pe, - cdefpolicy_query_rva, - get_instance_rva, - single_session_enabled_rva, - single_session_per_user_rva, - is_local_only_rva, - is_allow_nonrdp_rva, - is_appserver_rva, - get_connection_property_rva, - allow_remote_rva, +/// `hmod` must be a valid handle to the loaded termsrv.dll. +#[cfg(not(any(target_arch = "x86_64", target_arch = "x86", target_arch = "aarch64")))] +pub unsafe fn apply_patches(_hmod: HMODULE) { + debug_log( + "TermWrap: CPU architecture is not supported for runtime patching; \ + forwarding original termsrv.dll without modifications\n", ); - - #[cfg(target_arch = "x86")] - let addrs = resolve_functions_x86( - &pe, - cdefpolicy_query_rva, - get_instance_rva, - single_session_enabled_rva, - single_session_per_user_rva, - is_local_only_rva, - is_allow_nonrdp_rva, - is_appserver_rva, - get_connection_property_rva, - allow_remote_rva, - ); - - // === Apply SingleUserPatch === - if let Some(memset) = memset_addr { - let mut patched = false; - if let Some(addr) = addrs.single_session_enabled { - if unsafe { single_user::apply(&pe, addr, memset, verify_version_addr) } { - patched = true; - } - } - if let Some(addr) = addrs.single_session_per_user { - if unsafe { single_user::apply(&pe, addr, memset, verify_version_addr) } { - patched = true; - } - } - if !patched { - debug_log("SingleUserPatch not found\n"); - } - } - - // === Apply DefPolicyPatch === - if let Some(addr) = addrs.cdefpolicy_query { - unsafe { def_policy::apply(&pe, addr) }; - } else { - debug_log("CDefPolicy_Query not found\n"); - } - - // === Apply PropertyDevicePatch === - if let Some(conn_prop_addr) = addrs.get_connection_property { - let pnp_disabled_rva = - find_pattern_in_section(&pe, &rdata, &property_device::IS_PNP_DISABLED); - if let Ok(pnp_rva) = pnp_disabled_rva { - if let Some(prop_addr) = - unsafe { property_device::find_property_device_addr(&pe, conn_prop_addr, pnp_rva) } - { - unsafe { property_device::apply(&pe, prop_addr) }; - } else { - debug_log("PropertyAddr not found\n"); - } - } else { - debug_log("IS_PNP_DISABLED not found\n"); - } - } else { - debug_log("GetConnectionProperty not found\n"); - } - - // === Check CSLQuery::Initialize === - if addrs.cslquery_initialize.is_none() { - debug_log("CSLQuery_Initialize not found\n"); - return; - } - - // === Apply LocalOnlyPatch === - if let Some(instance_addr) = addrs.get_instance_of_tslicense { - if let Some(local_only_addr) = addrs.is_local_only { - unsafe { local_only::apply(&pe, instance_addr, local_only_addr) }; - } else { - debug_log("IsLicenseTypeLocalOnly not found\n"); - } - } else { - debug_log("GetInstanceOfTSLicense not found\n"); - } - - // === Apply NonRDPPatch === - if let Some(nonrdp_addr) = addrs.is_allow_nonrdp { - if let Some(appserver_addr) = addrs.is_appserver { - if !unsafe { nonrdp::apply(&pe, nonrdp_addr, appserver_addr) } { - // IsAppServerInstalled may be inlined, try searching more - #[cfg(target_arch = "x86_64")] - { - let func_table = pe.get_exception_table().unwrap_or_default(); - let mut found = false; - for func in func_table.iter().skip(addrs.is_appserver_idx) { - if let Some(is_appserver_rva) = is_appserver_rva { - if search_xref_in_function(&pe, func, is_appserver_rva as u64).is_some() - { - let bt = pe.backtrace_function(func); - if unsafe { - nonrdp::apply(&pe, nonrdp_addr, bt.begin_address as usize) - } { - found = true; - break; - } - } - } - } - if !found { - debug_log("NonRDPPatch not found\n"); - } - } - } - } else { - debug_log("IsAppServerInstalled not found\n"); - } - } - - // === Apply CSLQuery::Initialize SL policy patching === - if let Some(init_rva) = addrs.cslquery_initialize { - let allow_multiple_rva = - find_pattern_in_section(&pe, &rdata, strings::ALLOW_MULTIPLE_SESSIONS_BYTES).ok(); - let allow_appserver_rva = - find_pattern_in_section(&pe, &rdata, strings::ALLOW_APPSERVER_BYTES).ok(); - let allow_multimon_rva = - find_pattern_in_section(&pe, &rdata, strings::ALLOW_MULTIMON_BYTES).ok(); - - // Clamp length to available memory (x86 has no exception table, uses hardcoded 0x11000) - let text = pe.find_section(".text").ok(); - let max_len = text - .map(|t| { - let text_end = t.virtual_address as usize + t.raw_data_size as usize; - text_end.saturating_sub(init_rva) - }) - .unwrap_or(addrs.cslquery_initialize_len); - let safe_len = addrs.cslquery_initialize_len.min(max_len); - - unsafe { - sl_policy::apply( - &pe, - init_rva, - safe_len, - allow_remote_rva, - allow_multiple_rva, - allow_appserver_rva, - allow_multimon_rva, - ) - }; - } -} - -/// Find memset import thunk RVA -fn find_memset_import(pe: &LoadedPe, imports: &[patcher::pe::ImportInfo]) -> Option { - for dll in &["api-ms-win-crt-string-l1-1-0.dll", "msvcrt.dll"] { - if let Some(imp) = imports - .iter() - .find(|i| i.dll_name.eq_ignore_ascii_case(dll)) - { - if let Ok(rva) = pe.find_import_function(imp, "memset") { - return Some(rva); - } - } - } - None -} - -/// Find VerifyVersionInfoW import thunk RVA -fn find_verify_version_import(pe: &LoadedPe, imports: &[patcher::pe::ImportInfo]) -> Option { - for dll in &["api-ms-win-core-kernel32-legacy-l1-1-1.dll", "KERNEL32.dll"] { - if let Some(imp) = imports - .iter() - .find(|i| i.dll_name.eq_ignore_ascii_case(dll)) - { - if let Ok(rva) = pe.find_import_function(imp, "VerifyVersionInfoW") { - return Some(rva); - } - } - } - None -} - -/// x64: resolve all function addresses by scanning exception table for xrefs -#[cfg(target_arch = "x86_64")] -#[allow(clippy::too_many_arguments)] -fn resolve_functions_x64( - pe: &LoadedPe, - cdefpolicy_query_rva: Option, - get_instance_rva: Option, - single_session_enabled_rva: Option, - single_session_per_user_rva: Option, - is_local_only_rva: Option, - is_allow_nonrdp_rva: Option, - is_appserver_rva: Option, - get_connection_property_rva: Option, - allow_remote_rva: Option, -) -> ResolvedAddrs { - let func_table = pe.get_exception_table().unwrap_or_default(); - - let mut addrs = ResolvedAddrs { - cdefpolicy_query: None, - get_instance_of_tslicense: None, - single_session_enabled: None, - single_session_per_user: None, - is_local_only: None, - is_allow_nonrdp: None, - is_appserver: None, - get_connection_property: None, - cslquery_initialize: None, - cslquery_initialize_len: 0x11000, - is_appserver_idx: 0, - }; - - for (i, func) in func_table.iter().enumerate() { - macro_rules! try_resolve { - ($field:ident, $rva:expr) => { - if addrs.$field.is_none() { - if let Some(rva) = $rva { - if search_xref_in_function(pe, func, rva as u64).is_some() { - let bt = pe.backtrace_function(func); - addrs.$field = Some(bt.begin_address as usize); - continue; - } - } - } - }; - } - - try_resolve!(cdefpolicy_query, cdefpolicy_query_rva); - try_resolve!(get_instance_of_tslicense, get_instance_rva); - try_resolve!(single_session_enabled, single_session_enabled_rva); - try_resolve!(single_session_per_user, single_session_per_user_rva); - try_resolve!(is_local_only, is_local_only_rva); - - if addrs.is_allow_nonrdp.is_none() && is_allow_nonrdp_rva.is_some() { - if let Some(rva) = is_allow_nonrdp_rva { - if search_xref_in_function(pe, func, rva as u64).is_some() { - addrs.is_allow_nonrdp = - Some(pe.backtrace_function(func).begin_address as usize); - continue; - } - } - } - - if addrs.is_appserver.is_none() { - if let Some(rva) = is_appserver_rva { - if search_xref_in_function(pe, func, rva as u64).is_some() { - addrs.is_appserver = Some(pe.backtrace_function(func).begin_address as usize); - addrs.is_appserver_idx = i; - continue; - } - } - } - - try_resolve!(get_connection_property, get_connection_property_rva); - - if addrs.cslquery_initialize.is_none() { - if let Some(rva) = allow_remote_rva { - if search_xref_in_function(pe, func, rva as u64).is_some() { - let bt = pe.backtrace_function(func); - addrs.cslquery_initialize = Some(bt.begin_address as usize); - addrs.cslquery_initialize_len = (bt.end_address - bt.begin_address) as usize; - continue; - } - } - } - - // Check if all found - if addrs.cdefpolicy_query.is_some() - && addrs.get_instance_of_tslicense.is_some() - && addrs.single_session_enabled.is_some() - && addrs.single_session_per_user.is_some() - && addrs.is_local_only.is_some() - && (addrs.is_allow_nonrdp.is_some() || is_allow_nonrdp_rva.is_none()) - && addrs.is_appserver.is_some() - && addrs.get_connection_property.is_some() - && addrs.cslquery_initialize.is_some() - { - break; - } - } - - addrs -} - -/// x86: resolve function addresses by scanning .text for function prologues -/// and PUSH/MOV immediate references to string RVAs -#[cfg(target_arch = "x86")] -#[allow(clippy::too_many_arguments)] -fn resolve_functions_x86( - pe: &LoadedPe, - cdefpolicy_query_rva: Option, - get_instance_rva: Option, - single_session_enabled_rva: Option, - single_session_per_user_rva: Option, - is_local_only_rva: Option, - is_allow_nonrdp_rva: Option, - is_appserver_rva: Option, - get_connection_property_rva: Option, - allow_remote_rva: Option, -) -> ResolvedAddrs { - use iced_x86::{Decoder, DecoderOptions, Instruction, Mnemonic, OpKind, Register}; - use std::cmp::Reverse; - use std::collections::BinaryHeap; - - let text = match pe.find_section(".text") { - Ok(s) => s, - Err(_) => { - debug_log("x86: .text section not found\n"); - return ResolvedAddrs { - cdefpolicy_query: None, - get_instance_of_tslicense: None, - single_session_enabled: None, - single_session_per_user: None, - is_local_only: None, - is_allow_nonrdp: None, - is_appserver: None, - get_connection_property: None, - cslquery_initialize: None, - cslquery_initialize_len: 0x11000, - }; - } - }; - - let mut addrs = ResolvedAddrs { - cdefpolicy_query: None, - get_instance_of_tslicense: None, - single_session_enabled: None, - single_session_per_user: None, - is_local_only: None, - is_allow_nonrdp: None, - is_appserver: None, - get_connection_property: None, - cslquery_initialize: None, - cslquery_initialize_len: 0x11000, - }; - - let base = pe.adjusted_base; - let text_start = base + text.virtual_address as usize; - let text_size = text.raw_data_size as usize; - - // x86 prologue pattern: mov edi,edi; push ebp; mov ebp,esp - const PROLOGUE: &[u8] = &[0x8B, 0xFF, 0x55, 0x8B, 0xEC]; - - let all_rvas: Vec<(usize, &str)> = [ - (cdefpolicy_query_rva, "cdefpolicy"), - (get_instance_rva, "get_instance"), - (single_session_enabled_rva, "single_enabled"), - (single_session_per_user_rva, "single_per_user"), - (is_local_only_rva, "local_only"), - (is_allow_nonrdp_rva, "nonrdp"), - (is_appserver_rva, "appserver"), - (get_connection_property_rva, "conn_property"), - (allow_remote_rva, "allow_remote"), - ] - .iter() - .filter_map(|(rva, name)| rva.map(|r| (r, *name))) - .collect(); - - let mut ip = text_start; - let mut remaining = text_size; - - while remaining >= 5 { - // SAFETY: ip is within the .text section (text_start..text_start+text_size) - let prologue_match = unsafe { std::slice::from_raw_parts(ip as *const u8, 5) }; - - if prologue_match != PROLOGUE { - ip += 1; - remaining -= 1; - continue; - } - - let func_start_rva = ip - base; - - // Decode the function with a priority queue for branch targets - let mut jmp_addrs: BinaryHeap> = BinaryHeap::new(); - jmp_addrs.push(Reverse(ip)); - - while let Some(Reverse(block_start)) = jmp_addrs.pop() { - // SAFETY: block_start is within .text section, avail is clamped to section bounds - let block_code = unsafe { - let avail = text_size.saturating_sub(block_start - text_start); - std::slice::from_raw_parts(block_start as *const u8, avail.min(4096)) - }; - let mut decoder = - Decoder::with_ip(32, block_code, block_start as u64, DecoderOptions::NONE); - let mut inst = Instruction::default(); - - while decoder.can_decode() { - decoder.decode_out(&mut inst); - - // Check PUSH imm32 (5 bytes) or MOV reg/mem, imm32 - let is_push_imm32 = inst.len() == 5 - && inst.mnemonic() == Mnemonic::Push - && inst.op0_kind() == OpKind::Immediate32; - let is_mov_imm32 = inst.mnemonic() == Mnemonic::Mov - && inst.op1_kind() == OpKind::Immediate32 - && ((inst.op0_kind() == OpKind::Register && inst.len() == 5) - || (inst.op0_kind() == OpKind::Memory - && inst.len() >= 7 - && (inst.memory_base() == Register::EBP - || inst.memory_base() == Register::ESP))); - let target_val = if is_push_imm32 || is_mov_imm32 { - Some((inst.immediate32() as usize).wrapping_sub(base)) - } else { - None - }; - - if let Some(tv) = target_val { - for &(rva, name) in &all_rvas { - if tv == rva { - match name { - "cdefpolicy" if addrs.cdefpolicy_query.is_none() => { - addrs.cdefpolicy_query = Some(func_start_rva); - } - "get_instance" if addrs.get_instance_of_tslicense.is_none() => { - addrs.get_instance_of_tslicense = Some(func_start_rva); - } - "single_enabled" if addrs.single_session_enabled.is_none() => { - addrs.single_session_enabled = Some(func_start_rva); - } - "single_per_user" if addrs.single_session_per_user.is_none() => { - addrs.single_session_per_user = Some(func_start_rva); - } - "local_only" if addrs.is_local_only.is_none() => { - addrs.is_local_only = Some(func_start_rva); - } - "nonrdp" if addrs.is_allow_nonrdp.is_none() => { - addrs.is_allow_nonrdp = Some(func_start_rva); - } - "appserver" if addrs.is_appserver.is_none() => { - addrs.is_appserver = Some(func_start_rva); - } - "conn_property" if addrs.get_connection_property.is_none() => { - addrs.get_connection_property = Some(func_start_rva); - } - "allow_remote" if addrs.cslquery_initialize.is_none() => { - addrs.cslquery_initialize = Some(func_start_rva); - } - _ => continue, - } - // Found a match — skip rest of this function - break; - } - } - } - - // Follow conditional branches - if inst.mnemonic() >= Mnemonic::Ja - && inst.mnemonic() <= Mnemonic::Js - && inst.mnemonic() != Mnemonic::Jmp - && inst.op0_kind() == OpKind::NearBranch32 - { - let branch = inst.near_branch_target() as usize; - if branch >= text_start && branch < text_start + text_size { - jmp_addrs.push(Reverse(branch)); - } - } - - if inst.mnemonic() == Mnemonic::Ret || inst.mnemonic() == Mnemonic::Jmp { - break; - } - } - } - - // Check if all found - let all_found = addrs.cdefpolicy_query.is_some() - && addrs.get_instance_of_tslicense.is_some() - && addrs.single_session_enabled.is_some() - && addrs.single_session_per_user.is_some() - && addrs.is_local_only.is_some() - && (addrs.is_allow_nonrdp.is_some() || is_allow_nonrdp_rva.is_none()) - && addrs.is_appserver.is_some() - && addrs.cslquery_initialize.is_some() - && addrs.get_connection_property.is_some(); - - if all_found { - break; - } - - ip += 5; - remaining -= 5; - } - - addrs } diff --git a/crates/umwrap-dll/src/patches.rs b/crates/umwrap-dll/src/patches.rs index 07790c9..5a79d63 100644 --- a/crates/umwrap-dll/src/patches.rs +++ b/crates/umwrap-dll/src/patches.rs @@ -1,13 +1,17 @@ use patcher::patch::debug_log; +#[cfg(any(target_arch = "x86_64", target_arch = "x86", target_arch = "aarch64"))] use patcher::pattern::find_pattern_in_section; +#[cfg(any(target_arch = "x86_64", target_arch = "x86", target_arch = "aarch64"))] use patcher::pe::LoadedPe; use windows::Win32::Foundation::HMODULE; /// Wide string: "TerminalServices-DeviceRedirection-Licenses-PnpRedirectionAllowed" +#[cfg(any(target_arch = "x86_64", target_arch = "x86", target_arch = "aarch64"))] const ALLOW_PNP_BYTES: &[u8] = b"T\0e\0r\0m\0i\0n\0a\0l\0S\0e\0r\0v\0i\0c\0e\0s\0-\0D\0e\0v\0i\0c\0e\0R\0e\0d\0i\0r\0e\0c\0t\0i\0o\0n\0-\0L\0i\0c\0e\0n\0s\0e\0s\0-\0P\0n\0p\0R\0e\0d\0i\0r\0e\0c\0t\0i\0o\0n\0A\0l\0l\0o\0w\0e\0d\0\0\0"; /// Wide string: "TerminalServices-DeviceRedirection-Licenses-CameraRedirectionAllowed" +#[cfg(any(target_arch = "x86_64", target_arch = "x86", target_arch = "aarch64"))] const ALLOW_CAMERA_BYTES: &[u8] = b"T\0e\0r\0m\0i\0n\0a\0l\0S\0e\0r\0v\0i\0c\0e\0s\0-\0D\0e\0v\0i\0c\0e\0R\0e\0d\0i\0r\0e\0c\0t\0i\0o\0n\0-\0L\0i\0c\0e\0n\0s\0e\0s\0-\0C\0a\0m\0e\0r\0a\0R\0e\0d\0i\0r\0e\0c\0t\0i\0o\0n\0A\0l\0l\0o\0w\0e\0d\0\0\0"; @@ -15,6 +19,7 @@ const ALLOW_CAMERA_BYTES: &[u8] = /// /// # Safety /// `hmod` must be a valid handle to the loaded umrdp.dll; all other threads suspended. +#[cfg(any(target_arch = "x86_64", target_arch = "x86", target_arch = "aarch64"))] pub unsafe fn apply_patches(hmod: HMODULE) { let base = hmod.0 as usize; @@ -58,15 +63,29 @@ pub unsafe fn apply_patches(hmod: HMODULE) { x86_apply::apply(&pe, pnp_rva, camera_rva, legacy); } - // Silence unused warnings on non-x86 host targets when building for - // neither architecture (e.g. a cross-compile stub). Neither arm here - // is reachable in normal use, but this keeps `cargo check` quiet. - #[cfg(not(any(target_arch = "x86_64", target_arch = "x86")))] - { - let _ = (&pe, pnp_rva, camera_rva, legacy); + #[cfg(target_arch = "aarch64")] + // SAFETY: `pe` wraps a loaded umrdp.dll; caller suspended other threads. + unsafe { + arm64_apply::apply(&pe, pnp_rva, camera_rva, legacy); } } +/// Unsupported CPU architecture fallback. +/// +/// Other Windows architectures may load and forward umrdp.dll exports, but +/// PnP/camera redirection patching is disabled unless an architecture-specific +/// patcher exists. +/// +/// # Safety +/// `hmod` must be a valid handle to the loaded umrdp.dll. +#[cfg(not(any(target_arch = "x86_64", target_arch = "x86", target_arch = "aarch64")))] +pub unsafe fn apply_patches(_hmod: HMODULE) { + debug_log( + "UmWrap: CPU architecture is not supported for runtime patching; \ + forwarding original umrdp.dll without modifications\n", + ); +} + // ===================================================================== // x64 path — exception-table driven (RIP-relative LEA -> CALL rel32) // ===================================================================== @@ -366,3 +385,56 @@ mod x86_apply { } } } + +// ===================================================================== +// ARM64 path — .pdata function scan + ADRP/ADD string reference -> BL patch +// ===================================================================== +#[cfg(target_arch = "aarch64")] +mod arm64_apply { + use patcher::arm64; + use patcher::patch::{bytecodes, write_patch}; + use patcher::pe::LoadedPe; + + use super::debug_log; + + const CALL_SEARCH_WINDOW: usize = 96; + + /// Apply umrdp.dll PnP/camera patches on ARM64. + /// + /// # Safety + /// `pe` wraps a currently-loaded umrdp.dll; threads are suspended. + pub(super) unsafe fn apply( + pe: &LoadedPe, + pnp_rva: usize, + camera_rva: Option, + legacy: bool, + ) { + if legacy { + debug_log("UmWrap: ARM64 legacy slc.dll import detected; using generic BL patch\n"); + } + + patch_call_after_policy_string(pe, pnp_rva, "PnP"); + + if let Some(camera_rva) = camera_rva { + patch_call_after_policy_string(pe, camera_rva, "Camera"); + } + } + + fn patch_call_after_policy_string(pe: &LoadedPe, policy_rva: usize, label: &str) { + let site = match arm64::find_bl_after_reference_rva(pe, policy_rva, CALL_SEARCH_WINDOW) { + Some(site) => site, + None => { + debug_log(&format!("UmWrap: ARM64 {label} patch site not found\n")); + return; + } + }; + + let call_addr = pe.adjusted_base + site.call_rva as usize; + // SAFETY: call_addr is a BL instruction inside loaded PE .text; threads + // are suspended by the caller. + match unsafe { write_patch(call_addr, bytecodes::ARM64_MOV_W0_1) } { + Ok(_) => debug_log(&format!("UmWrap: ARM64 {label} patch applied\n")), + Err(e) => debug_log(&format!("UmWrap: ARM64 {label} patch write failed: {e}\n")), + } + } +} diff --git a/deny.toml b/deny.toml index 10b532c..20fa05b 100644 --- a/deny.toml +++ b/deny.toml @@ -19,6 +19,7 @@ targets = [ { triple = "x86_64-pc-windows-msvc" }, { triple = "i686-pc-windows-msvc" }, + { triple = "aarch64-pc-windows-msvc" }, ] all-features = false diff --git a/docs/CHANGELOG.ko.md b/docs/CHANGELOG.ko.md index c9ab8f1..e43dc65 100644 --- a/docs/CHANGELOG.ko.md +++ b/docs/CHANGELOG.ko.md @@ -9,6 +9,33 @@ ## [Unreleased] +## [0.2.0] - 2026-05-13 + +### 추가됨 +- ARM64 Windows 빌드 scaffold: `aarch64-pc-windows-msvc` 가 워크스페이스 + 전체에서 타입 체크되고, static CRT rustflags 와 CI 빌드/정적 산출물 + 체크 작업으로 보호됨. +- `umwrap-dll` 과 `endpwrap-dll` 의 실험적 ARM64 런타임 패칭. + `patcher::arm64` 가 ARM64 `.pdata` 함수 엔트리, ADR/ADRP+ADD 정책 + 문자열 참조, 근처 BL 호출을 탐색함. `umwrap` 은 정책 BL 호출을 + `mov w0,#1` 로 바꾸고, `endpwrap` 은 참조된 오디오 캡처 함수 시작을 + `mov w0,#1; ret` 로 바꿈. +- `termwrap-dll` 의 실험적 ARM64 런타임 패칭. ARM64 경로는 같은 + termsrv 정책 문자열을 `.pdata` 로 해석하고, DefPolicy 필드 체크, + SingleUser/LocalOnly false 반환, AppServer/NonRDP true 반환, + PropertyDevice BL 결과 false 반환, SL policy 질의 BL 호출 true 반환을 패치함. + production 지원으로 취급하려면 실제 Windows ARM64 런타임 검증이 아직 필요. + +### 수정됨 +- `rdprrap-installer install --force` 가 이제 race-safe `CREATE_NEW` 복사 + 전에 기존 설치된 래퍼 DLL을 실제로 교체함. `--force` 없이 대상 DLL이 + 이미 있으면 낮은 수준의 Win32 생성 오류 대신 "use --force" 안내와 + 함께 일찍 실패하도록 함. +- `offset-finder` 가 ARM64 PE32+ 이미지를 x64 로 취급하지 않도록 함. + 이제 순수 ARM64 `termsrv.dll` 이미지는 ARM64 문자열/함수/BL-site + 리포트를 출력하고, ARM64EC/ARM64X 하이브리드 이미지만 별도 검증 전까지 + unsupported 로 보고. + ## [0.1.3] - 2026-04-23 ### 수정됨 (라이선스 컴플라이언스 — 추가) diff --git a/docs/CONTRIBUTING.ko.md b/docs/CONTRIBUTING.ko.md index d41fcb8..9674ea3 100644 --- a/docs/CONTRIBUTING.ko.md +++ b/docs/CONTRIBUTING.ko.md @@ -17,6 +17,7 @@ git clone https://github.com/kernalix7/rdprrap.git cd rdprrap rustup target add x86_64-pc-windows-msvc rustup target add i686-pc-windows-msvc +rustup target add aarch64-pc-windows-msvc cargo build --release ``` diff --git a/docs/README.ko.md b/docs/README.ko.md index 8912da7..c3423f4 100644 --- a/docs/README.ko.md +++ b/docs/README.ko.md @@ -11,8 +11,9 @@ Rust로 재작성한 RDP Wrapper. | **termwrap-dll** | 핵심 RDP 패칭 — 다중 세션 지원, Home/비서버 에디션 정책 우회. 7가지 패치: DefPolicy, SingleUser, LocalOnly, NonRDP, PropertyDevice, SLPolicy, CSLQuery::Initialize | | **umwrap-dll** | 모든 SKU에서 USB/카메라 PnP 장치 리다이렉션 (레거시 + 모던 Windows) | | **endpwrap-dll** | 오디오 녹음 리다이렉션 (TSAudioCaptureAllowed) | -| **patcher** | 공유 라이브러리 — PE 파싱, x86/x64 디스어셈블리, 런타임 패턴 매칭, 검증된 바이트코드 패치 14개 | -| **offset-finder** | 독립 실행형 CLI 오프셋 탐색 도구 (pelite 기반, PDB 불필요) | +| **patcher** | 공유 라이브러리 — PE 파싱, x86/x64 디스어셈블리, ARM64 `.pdata` 함수 스캔, 런타임 패턴 매칭, 바이트코드 패치 18개 | +| **ARM64 지원** | `aarch64-pc-windows-msvc` 빌드 타깃과 실험적 ARM64 `termwrap`/`umwrap`/`endpwrap` 런타임 패처. production 지원으로 부르기 전 실제 Windows ARM64 검증 필요 | +| **offset-finder** | x86/x64/ARM64 독립 실행형 CLI 오프셋 탐색 도구 (pelite 기반, PDB 불필요) | | **rdprrap-installer** | 설치/제거 CLI — 서비스 등록, 레지스트리, 방화벽(TCP+UDP 3389), 코호트 서비스 재시작, 설치 디렉토리 ACL 강화 (Delphi `RDPWInst.exe` 대체) | | **rdprrap-check** | RDP 연결 테스터 — `mstsc.exe`로 127.0.0.2 루프백 접속, NLA 가드 RAII, 44개 종료 사유 코드 (`RDPCheck.exe` 대체) | | **rdprrap-conf** | 설정 GUI — native-windows-gui 패널로 진단 + 런타임 RDP 설정(Enable/Port/SingleSession/HideUsers/AllowCustom/AuthMode/Shadow) 제어 (`RDPConf.exe` 대체) | @@ -25,8 +26,8 @@ Rust로 재작성한 RDP Wrapper. | 디스어셈블러 | [iced-x86](https://crates.io/crates/iced-x86) (순수 Rust) | | PE 파싱 | [pelite](https://crates.io/crates/pelite) | | Windows API | [windows-rs](https://crates.io/crates/windows) | -| 타겟 | x86_64-pc-windows-msvc, i686-pc-windows-msvc | -| CI | GitHub Actions (Linux 체크 + Windows x64/x86 빌드) | +| 타겟 | x86_64-pc-windows-msvc, i686-pc-windows-msvc, aarch64-pc-windows-msvc | +| CI | GitHub Actions (Linux 체크 + Windows x64/x86 빌드, ARM64 빌드/정적 산출물 체크) | ## 빠른 시작 @@ -42,6 +43,7 @@ cd rdprrap rustup target add x86_64-pc-windows-msvc rustup target add i686-pc-windows-msvc +rustup target add aarch64-pc-windows-msvc cargo build --release ``` @@ -68,7 +70,7 @@ rdprrap-installer.exe uninstall | 플래그 | 효과 | |--------|------| | `--source DIR` | DLL을 복사할 디렉토리 (기본: 인스톨러 자신의 디렉토리) | -| `--force` | ServiceDll이 이미 래퍼를 가리키고 있어도 강제 재설치 | +| `--force` | ServiceDll이 이미 래퍼를 가리키고 있어도 기존 래퍼 DLL을 교체하며 강제 재설치 | | `--skip-firewall` | 방화벽 규칙 추가/제거 생략 | | `--skip-restart` | TermService 재시작 생략 (수동/재부팅 시 적용) | | `--disable-nla` | `UserAuthentication=0` 설정 (레거시 클라이언트용, 옵트인) | @@ -95,7 +97,8 @@ rdprrap/ │ │ ├── pe.rs # PE 헤더/섹션/임포트/예외 테이블 파싱 │ │ ├── pattern.rs # 4바이트 정렬 문자열 패턴 매칭 (.rdata) │ │ ├── disasm.rs # iced-x86 디코더 래퍼, xref 검색, 분기 헬퍼 -│ │ └── patch.rs # WriteProcessMemory 래퍼, NOP 채움, 바이트코드 상수 14개 +│ │ ├── arm64.rs # ARM64 .pdata 함수 스캔 + ADR/ADRP/ADD/BL 헬퍼 +│ │ └── patch.rs # WriteProcessMemory 래퍼, NOP 채움, 바이트코드 상수 18개 │ ├── termwrap-dll/ # cdylib: termsrv.dll 프록시 (핵심 RDP) │ │ └── src/patches/ # DefPolicy, SingleUser, LocalOnly, NonRDP, PropertyDevice, SLPolicy │ ├── umwrap-dll/ # cdylib: umrdp.dll 프록시 (USB/카메라 리다이렉션) @@ -105,7 +108,7 @@ rdprrap/ │ ├── rdprrap-check/ # 바이너리: RDP 루프백 테스터 (mstsc + NLA 가드) │ └── rdprrap-conf/ # 바이너리: 설정 GUI (native-windows-gui) ├── .github/ -│ └── workflows/ci.yml # Linux 체크 + Windows x64/x86 빌드 매트릭스 +│ └── workflows/ci.yml # Linux 체크 + Windows x64/x86 빌드 매트릭스 + ARM64 정적 체크 └── docs/ # 한국어 문서 ``` @@ -117,6 +120,7 @@ rdprrap/ 4. 패치 오프셋 런타임 탐색: - **x64**: `.rdata`에서 알려진 문자열 스캔 → exception table에서 LEA xref 검색 → unwind chain 역추적 - **x86**: `.text`에서 함수 프롤로그(`8B FF 55 8B EC`) 스캔 → 분기 추적 → PUSH/MOV 즉시값과 문자열 RVA 매칭 + - **ARM64**: `.pdata` 함수 범위와 ADR/ADRP+ADD 문자열 참조로 정책 체크를 찾음. `termwrap` 은 ARM64 DefPolicy, SingleUser, LocalOnly, AppServer/NonRDP, PropertyDevice, SL policy 경로를 ARM64 전용 바이트코드로 패치. `umwrap` 은 PnP/Camera BL 호출을 `mov w0,#1` 로, `endpwrap` 은 참조 함수 시작을 `mov w0,#1; ret` 로 패치. ## 패치 종류 (termsrv.dll) @@ -137,7 +141,7 @@ cargo clippy --all-targets -- -D warnings # 린트 cargo fmt --check # 포맷 체크 ``` -CI는 push/PR 시 자동 실행: Linux 체크 + Windows x64/x86 풀 빌드. +CI는 push/PR 시 자동 실행: Linux 체크 + Windows x64/x86 풀 빌드 + ARM64 빌드/정적 산출물 체크. ## 기여 diff --git a/docs/SECURITY.ko.md b/docs/SECURITY.ko.md index 1c8169b..a484e04 100644 --- a/docs/SECURITY.ko.md +++ b/docs/SECURITY.ko.md @@ -22,7 +22,7 @@ rdprrap은 활발히 개발 중이며, 보안 업데이트는 `main` 브랜치 2. **재현 방법** — 이슈를 재현하는 상세한 단계 3. **영향** — 취약점의 잠재적 영향 4. **영향 받는 컴포넌트** — rdprrap의 어떤 크레이트/DLL이 영향을 받는지 -5. **환경** — Windows 버전, 아키텍처 (x64/x86), termsrv.dll 버전 +5. **환경** — Windows 버전, 아키텍처 (x64/x86/ARM64), termsrv.dll 버전 ### 대응 일정 diff --git a/docs/TESTING.ko.md b/docs/TESTING.ko.md index 2d431e7..dc0f366 100644 --- a/docs/TESTING.ko.md +++ b/docs/TESTING.ko.md @@ -2,9 +2,9 @@ [English](TESTING.md) | **한국어** -Linux CI 및 Windows CI 빌드(x64/x86, debug/release)는 컴파일 + clippy + -단위 테스트까지는 커버합니다. 반면 CI 로는 커버할 수 없는 부분이 -있습니다: +Linux CI 및 Windows CI 빌드는 x64/x86 의 컴파일 + clippy + 단위 +테스트와 ARM64 빌드/정적 체크까지 커버합니다. 반면 CI 로는 커버할 +수 없는 부분이 있습니다: - 래퍼 DLL 을 실제 `svchost.exe` / `umrdp.dll` / `rdpendp.dll` 호스트에 로드하여 동작을 확인하는 것. @@ -26,27 +26,31 @@ VM 스냅샷에서 실행하세요. 실행 사이마다 스냅샷을 복원하 - RDP 가 기본 비활성화된 Windows VM. - `cargo build --release` 로 생성된 해당 아키텍처 빌드 산출물 - (x64 호스트 → `x86_64-pc-windows-msvc`, x86 호스트 → `i686-pc-windows-msvc`). + (x64 호스트 → `x86_64-pc-windows-msvc`, x86 호스트 → + `i686-pc-windows-msvc`, ARM64 호스트 → `aarch64-pc-windows-msvc`). - 관리자 계정, 별도 머신의 원격 데스크톱 클라이언트(`mstsc.exe`). - 선택: DebugView (SysInternals) — `OutputDebugString` 캡처용. ## 검증 대상 빌드 매트릭스 -아래 표의 각 행은 해당 OS 가 지원하는 두 아키텍처 모두에 대해 검증되어야 -합니다. 최신 Windows SKU 들은 더 이상 x86 을 공식 공급하지 않지만, -구형 x86 VM(Win10 32비트, Windows 7 랩 이미지) 은 i686 경로가 -실제로 검증되는 유일한 장소입니다. - -| OS | x64 | x86 | 비고 | -|--------------------|-----|-----|------------------------------------------------------| -| Windows 10 22H2 | ✅ | ⚠️ | x86 커버리지는 레거시 이미지에서만 가능 | -| Windows 11 23H2 | ✅ | — | x86 미공급 | -| Windows 11 24H2 | ✅ | — | 최신 컨슈머 SKU | -| Server 2022 | ✅ | — | `windows-latest` 러너와 매칭 | -| Server 2025 | ✅ | — | `windows-2025` 러너와 매칭 | - -각 행은 아래 **설치 / 런타임 / 제거** 섹션이 모두 통과한 뒤에만 -체크합니다. +아래 표의 각 행은 해당 OS 가 지원하는 런타임 지원 아키텍처마다 +검증되어야 합니다. 최신 Windows SKU 들은 더 이상 x86 을 공식 공급하지 +않지만, 구형 x86 VM(Win10 32비트, Windows 7 랩 이미지) 은 i686 경로가 +실제로 검증되는 유일한 장소입니다. ARM64 는 실험적인 상태입니다. +`termwrap`/`umwrap`/`endpwrap` 모두 ARM64 런타임 패처가 있지만, 전체 +RDP 경로는 실제 ARM64 하드웨어/VM 에서 아직 검증해야 합니다. + +| OS | x64 | x86 | ARM64 | 비고 | +|--------------------|-----|-----|-------|------------------------------------------------------| +| Windows 10 22H2 | ✅ | ⚠️ | — | x86 커버리지는 레거시 이미지에서만 가능 | +| Windows 11 23H2 | ✅ | — | ⚠️ | ARM64 실험적 런타임 체크 | +| Windows 11 24H2 | ✅ | — | ⚠️ | 최신 컨슈머 SKU; ARM64 실험적 런타임 | +| Server 2022 | ✅ | — | — | `windows-latest` 러너와 매칭 | +| Server 2025 | ✅ | — | — | `windows-2025` 러너와 매칭 | + +런타임 행은 아래 **설치 / 런타임 / 제거** 섹션이 모두 통과한 뒤에만 +체크합니다. ARM64 를 production 지원으로 표시하려면 `termwrap` 의 실제 +ARM64 patch-applied 로그와 동시 세션 smoke 테스트가 반드시 통과해야 합니다. ## 1. 설치 @@ -68,7 +72,7 @@ VM 스냅샷에서 실행하세요. 실행 사이마다 스냅샷을 복원하 TCP/UDP 3389 허용. - [ ] `sc query TermService` 결과가 `STATE : 4 RUNNING`. -## 2. 런타임 — termwrap (x64 + x86) +## 2. 런타임 — termwrap (x64 + x86 + 실험적 ARM64) ```powershell # 별도 머신에서: @@ -80,6 +84,8 @@ mstsc /v: RDP 접속 성공 (동시 세션 smoke 테스트). - [ ] DebugView 에 `TermWrap:` 패치 적용 로그가 보이고, `patch not found` 경고가 없음. +- [ ] ARM64 에서는 DebugView 에 ARM64 DefPolicy, SingleUser, LocalOnly, + AppServer/NonRDP, PropertyDevice, SL policy 패치 로그가 표시됨. - [ ] `rdprrap-check` (타깃에서 실행) 이 루프백 RDP OK 를 리포트. - [ ] `rdprrap-conf` (타깃에서 실행) 가 Wrapper, TermService, termsrv 버전, RDP-Tcp 리스너를 녹색으로 표시. @@ -89,20 +95,32 @@ termsrv.dll 레이아웃 변경을 잡기 위해 Windows 11 에서도 반복합 못했다는 뜻 — `offset-finder --assert-all C:\Windows\System32\termsrv.dll` 를 돌려 리포트를 보고 분류합니다. +## ARM64 지원 상태 + +ARM64 Windows 에서 `termwrap`, `umwrap`, `endpwrap` 모두 ARM64 +patch-applied 로그를 낼 수 있습니다. `termwrap` 은 DefPolicy, +SingleUser, LocalOnly, AppServer/NonRDP, PropertyDevice, SL policy ARM64 +로그를 보여야 합니다. 이 로그와 실제 Windows ARM64 동시 세션 smoke +테스트가 함께 통과하기 전까지는 실험적 지원입니다. + ## 3. 런타임 — umwrap (PnP 리다이렉션) -목표: i686 경로가 컴파일만 된 게 아니라 실제로 뭔가를 패치했다는 증명. +목표: i686 및 ARM64 장치 리다이렉션 경로가 컴파일만 된 게 아니라 +실제로 뭔가를 패치했다는 증명. - [ ] RDP 클라이언트에서 USB 저장 장치 리다이렉트 (`mstsc` → 로컬 리소스 → 자세히 → 드라이브). - [ ] 디바이스가 RDP 세션 내 `내 PC` 에 표시됨. - [ ] DebugView 에 `UmWrap:` 패치 적용 로그, `PnpRedirection patch not found` 없음. +- [ ] ARM64 에서는 DebugView 에 `UmWrap: ARM64 PnP patch applied` 표시. 카메라 리다이렉션 (Win10+): - [ ] USB 카메라 리다이렉션이 통과. - [ ] `.rdata` 에 `CameraRedirectionAllowed` 문자열이 있을 때 DebugView 가 camera-secondary 패치 적용 로그를 표시. +- [ ] ARM64 에서는 해당 문자열이 `.rdata` 에 있을 때 DebugView 에 + `UmWrap: ARM64 Camera patch applied` 표시. ## 4. 런타임 — endpwrap (오디오 캡처) @@ -110,6 +128,8 @@ termsrv.dll 레이아웃 변경을 잡기 위해 Windows 11 에서도 반복합 로컬 리소스 → 원격 오디오 → 녹음: 이 컴퓨터에서 녹음) 해서 RDP 클라이언트가 마이크 오디오를 원격 세션으로 캡처. - [ ] DebugView 에 `EndpWrap:` 패치 적용 로그가 표시됨. +- [ ] ARM64 에서는 DebugView 에 + `EndpWrap: ARM64 AllowAudioCapture patched` 표시. ## 5. 인스톨러 사전 체크 + 실패 케이스 diff --git a/docs/TESTING.md b/docs/TESTING.md index 419fda2..57e7193 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -2,8 +2,9 @@ **English** | [한국어](TESTING.ko.md) -Linux CI builds and Windows CI builds (x64/x86, debug/release) cover -compile + clippy + unit tests. What CI cannot cover: +Linux CI builds and Windows CI builds cover compile + clippy + unit +tests for x64/x86, plus ARM64 build/static checks. What CI cannot +cover: - Loading the wrapper DLLs into a real `svchost.exe` / `umrdp.dll` / `rdpendp.dll` host. @@ -24,27 +25,33 @@ reusing a [winpodx](https://github.com/kernalix7/winpodx) container - Windows VM with RDP disabled by default. - Matching build artefact from `cargo build --release` - (x64 host → `x86_64-pc-windows-msvc`, x86 host → `i686-pc-windows-msvc`). + (x64 host → `x86_64-pc-windows-msvc`, x86 host → + `i686-pc-windows-msvc`, ARM64 host → `aarch64-pc-windows-msvc`). - Admin account, Remote Desktop client (`mstsc.exe`) on a second machine. - Optional: DebugView (SysInternals) to capture `OutputDebugString`. ## Build Targets to Verify -Each row below must be verified on both architectures the target OS -supports. Modern Windows SKUs no longer ship x86, but older x86 VMs -(Win10 32-bit, Windows 7 lab images) are still the only place the -i686 path gets real coverage. - -| OS | x64 | x86 | Notes | -|--------------------|-----|-----|----------------------------------------------------| -| Windows 10 22H2 | ✅ | ⚠️ | x86 coverage only via legacy images | -| Windows 11 23H2 | ✅ | — | x86 not shipped | -| Windows 11 24H2 | ✅ | — | Latest consumer SKU | -| Server 2022 | ✅ | — | Matches `windows-latest` runner | -| Server 2025 | ✅ | — | Matches `windows-2025` runner | - -Tick a row only after the full **Install**, **Runtime**, **Uninstall** -sections below all pass on that OS/arch pair. +Each row below must be verified on each runtime-supported +architecture the target OS supports. Modern Windows SKUs no longer +ship x86, but older x86 VMs (Win10 32-bit, Windows 7 lab images) are +still the only place the i686 path gets real coverage. ARM64 is +experimental: `termwrap`/`umwrap`/`endpwrap` now have ARM64 runtime +patchers, but the full RDP path still must be validated on real ARM64 +hardware/VMs. + +| OS | x64 | x86 | ARM64 | Notes | +|--------------------|-----|-----|-------|----------------------------------------------------| +| Windows 10 22H2 | ✅ | ⚠️ | — | x86 coverage only via legacy images | +| Windows 11 23H2 | ✅ | — | ⚠️ | ARM64 experimental runtime checks | +| Windows 11 24H2 | ✅ | — | ⚠️ | Latest consumer SKU; ARM64 experimental runtime | +| Server 2022 | ✅ | — | — | Matches `windows-latest` runner | +| Server 2025 | ✅ | — | — | Matches `windows-2025` runner | + +Tick a runtime row only after the full **Install**, **Runtime**, +**Uninstall** sections below all pass on that OS/arch pair. Do not mark +ARM64 as production-supported until `termwrap` emits real ARM64 +patch-applied messages and concurrent-session smoke tests pass. ## 1. Install @@ -66,7 +73,7 @@ Pass criteria: TCP/UDP 3389 allowed. - [ ] `sc query TermService` returns `STATE : 4 RUNNING`. -## 2. Runtime — termwrap (x64 + x86) +## 2. Runtime — termwrap (x64 + x86 + experimental ARM64) ```powershell # From a second machine: @@ -78,6 +85,8 @@ Pass criteria: admin is already signed in locally (concurrent-session smoke test). - [ ] DebugView shows `TermWrap:` patch-applied messages, no `patch not found` warnings. +- [ ] On ARM64, DebugView shows ARM64 DefPolicy, SingleUser, LocalOnly, + AppServer/NonRDP, PropertyDevice, and SL policy patch logs. - [ ] `rdprrap-check` (run on target) reports loopback RDP OK. - [ ] `rdprrap-conf` (run on target) shows green status for Wrapper, TermService, termsrv version, RDP-Tcp listener. @@ -87,20 +96,32 @@ status in `rdprrap-conf` means the patcher failed to resolve an offset — run `offset-finder --assert-all C:\Windows\System32\termsrv.dll` and triage from its report. +## ARM64 support state + +On ARM64 Windows, `termwrap`, `umwrap`, and `endpwrap` may emit ARM64 +patch-applied messages. `termwrap` should show DefPolicy, SingleUser, +LocalOnly, AppServer/NonRDP, PropertyDevice, and SL policy ARM64 logs. +The build remains experimental until those logs are paired with a real +concurrent-session smoke test on Windows ARM64. + ## 3. Runtime — umwrap (PnP redirection) -Goal: prove the i686 path patched something real, not just compiled. +Goal: prove the i686 and ARM64 device-redirection paths patched +something real, not just compiled. - [ ] Redirect a USB storage device from the RDP client (`mstsc` → Local Resources → More → Drives). - [ ] Device appears in `This PC` inside the RDP session. - [ ] DebugView shows `UmWrap:` patch-applied messages, no `PnpRedirection patch not found`. +- [ ] On ARM64, DebugView shows `UmWrap: ARM64 PnP patch applied`. Camera redirection (Win10+): - [ ] USB camera redirection passes through. - [ ] DebugView shows camera-secondary patch applied when `CameraRedirectionAllowed` string was present in `.rdata`. +- [ ] On ARM64, DebugView shows `UmWrap: ARM64 Camera patch applied` + when that string was present in `.rdata`. ## 4. Runtime — endpwrap (audio capture) @@ -108,6 +129,8 @@ Camera redirection (Win10+): Local Resources → Remote audio → Recording: Record from this computer) captures microphone audio into the remote session. - [ ] DebugView shows `EndpWrap:` patch-applied messages. +- [ ] On ARM64, DebugView shows + `EndpWrap: ARM64 AllowAudioCapture patched`. ## 5. Installer preflights + negative cases diff --git a/docs/TESTING_WINPODX.ko.md b/docs/TESTING_WINPODX.ko.md index 27fbd19..bc722f6 100644 --- a/docs/TESTING_WINPODX.ko.md +++ b/docs/TESTING_WINPODX.ko.md @@ -12,14 +12,15 @@ winpodx 는 별도 VM 없이 Linux 개발 호스트에서 검증할 수 있는 수단입니다. 이 문서는 [TESTING.ko.md](TESTING.ko.md) 의 부분집합을 컨테이너 -환경에 맞게 각색한 것입니다. winpodx 가 커버하지 못하는 x86 커버리지 -및 다중 OS 매트릭스는 독립 VM 으로 돌아가세요. +환경에 맞게 각색한 것입니다. winpodx 가 커버하지 못하는 x86 +커버리지, ARM64 빌드/정적 체크, ARM64 런타임 검증, 다중 OS +매트릭스는 CI 또는 독립 VM 으로 돌아가세요. ## 적용 범위 | 커버 가능 | 커버 불가 | |---------------------------------------------------------------|------------------------------------------------------------| -| Win10/11/Server 2022/2025 에서 x64 `termwrap`/`umwrap`/`endpwrap` | x86 (i686) 빌드 — dockur/windows 는 x64 전용 | +| Win10/11/Server 2022/2025 에서 x64 `termwrap`/`umwrap`/`endpwrap` | x86 (i686) 빌드와 ARM64 런타임 검증 — dockur/windows 는 x64 전용 | | SYSTEM 권한 설치/제거 왕복 | 다양한 아키텍처의 USB 리다이렉션 (호스트 USB 패스스루 의존) | | 컨테이너 termsrv.dll 에 대한 `offset-finder --assert-all` | 한 번의 실행으로 여러 Windows 버전 병렬 매트릭스 | | 두 번째 RDP 클라이언트로 멀티세션 smoke | Podman/KVM 이 노출하지 않는 물리 주변기기 | @@ -52,6 +53,10 @@ winpodx 는 별도 VM 없이 Linux 개발 호스트에서 > XWIN_ARCH=x86,x86_64 cargo xwin build --release \ > --target i686-pc-windows-msvc --workspace > ``` + > ARM64 산출물과 실험적 ARM64 패처는 CI 또는 ARM64 지원 MSVC + > 툴체인에서 빌드합니다. 다만 winpodx 는 여전히 x64 전용이므로, + > ARM64 런타임 증거는 실제 Windows ARM64 하드웨어나 ARM64 VM 에서 + > 확보하세요. - 호스트에서 쓸 두 번째 RDP 클라이언트: `xfreerdp` 또는 `Remmina`. - **DebugView** (`Dbgview.exe`) 를 컨테이너에 복사해두면 로그 분석이 쉽습니다 (선택, 다만 강력 권장). diff --git a/docs/TESTING_WINPODX.md b/docs/TESTING_WINPODX.md index e1b523b..3f0e043 100644 --- a/docs/TESTING_WINPODX.md +++ b/docs/TESTING_WINPODX.md @@ -11,14 +11,15 @@ the fastest way to verify the **x64 rows** of [TESTING.md](TESTING.md) from a Linux development host without keeping a separate VM. This document is a subset of [TESTING.md](TESTING.md) adapted to the -container environment. For x86 coverage and multi-OS matrix runs -beyond what winpodx supports, fall back to a standalone VM. +container environment. For x86 coverage, ARM64 build/static checks, ARM64 +runtime validation, and multi-OS matrix runs beyond what winpodx supports, +fall back to CI or a standalone VM. ## Scope | Covered | Not covered | |---------|-------------| -| x64 `termwrap` / `umwrap` / `endpwrap` on Win10/11/Server 2022/2025 | x86 (i686) builds — dockur/windows is x64-only | +| x64 `termwrap` / `umwrap` / `endpwrap` on Win10/11/Server 2022/2025 | x86 (i686) builds and ARM64 runtime validation — dockur/windows is x64-only | | Installer install/uninstall round-trip under SYSTEM | Multi-arch USB redirection (host USB passthrough varies) | | `offset-finder --assert-all` against the container's termsrv.dll | Parallel matrix of multiple Windows versions in one run | | Multi-session smoke via a second RDP client | Physical hardware peripherals beyond what Podman/KVM exposes | @@ -52,6 +53,9 @@ beyond what winpodx supports, fall back to a standalone VM. > XWIN_ARCH=x86,x86_64 cargo xwin build --release \ > --target i686-pc-windows-msvc --workspace > ``` + > ARM64 artifacts and experimental ARM64 patchers are built in CI or + > with an ARM64-capable MSVC toolchain, but winpodx is still x64-only. + > Use real Windows ARM64 hardware or an ARM64 VM for runtime evidence. - A second RDP client on the host: `xfreerdp` or `Remmina`. - **DebugView** (`Dbgview.exe`) copied into the container for log capture (optional but highly recommended for triage).