A Rust wrapper that turns any PowerShell script into a single .exe, keeping the
script encrypted at rest inside the binary. At runtime the wrapper decrypts the
script in memory and executes it as one block via the powershell_script
crate.
This is a step up from ps2exe (which embeds the script as plaintext — recoverable
with nothing more than strings or cat). Before relying on it for anything sensitive,
read Security reality.
Requirements: Rust (stable) and Windows (the wrapper shells out to
powershell.exe).
# 1. clone
git clone https://github.com/gadmaj/rs-ps2exe.git
cd rs-ps2exe
# 2. drop your real script in place of the example
# (keep the same filename, or update the path in build.rs)
copy C:\path\to\your-script.ps1 payload\autohash.ps1
# 3. set your own key in BOTH build.rs and src/main.rs (see below)
# 4. build
cargo build --release
# 5. ship it
# output: target\release\autohash-wrapper.exeThe example ships with payload/autohash.ps1 (a Windows Autopilot HWID collector) so a
fresh cargo build --release works out of the box.
rs-ps2exe/
├── Cargo.toml
├── build.rs <- encrypts the script at compile time
├── payload/
│ └── autohash.ps1 <- your script goes here
└── src/
└── main.rs <- decrypts at runtime and runs it
build.rs (runs at compile time) reads payload/autohash.ps1 as raw bytes,
XOR-encrypts them with key, and writes the ciphertext to OUT_DIR/payload.bin:
use std::{env, fs, path::Path};
fn main() {
// NOTE: this key must match KEY in src/main.rs exactly.
let key: &[u8] = b"replace-this-with-a-long-random-value";
let plaintext = fs::read("payload/autohash.ps1")
.expect("could not read payload/autohash.ps1");
let encrypted: Vec<u8> = plaintext
.iter()
.enumerate()
.map(|(i, b)| b ^ key[i % key.len()])
.collect();
let out = Path::new(&env::var("OUT_DIR").unwrap()).join("payload.bin");
fs::write(out, encrypted).unwrap();
println!("cargo:rerun-if-changed=payload/autohash.ps1");
}src/main.rs embeds the ciphertext with include_bytes!, decrypts it in memory, and
runs the whole script as a single block (never line-by-line — functions, loops, if
blocks, and here-strings span multiple lines):
use powershell_script::PsScriptBuilder;
const KEY: &[u8] = b"replace-this-with-a-long-random-value";
const ENCRYPTED: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/payload.bin"));
fn main() {
let decrypted: Vec<u8> = ENCRYPTED
.iter()
.enumerate()
.map(|(i, b)| *b ^ KEY[i % KEY.len()])
.collect();
let script = String::from_utf8_lossy(&decrypted).into_owned();
let ps = PsScriptBuilder::new()
.no_profile(true)
.non_interactive(true)
.hidden(false) // set true to suppress the PowerShell window
.print_commands(false)
.build();
if let Err(e) = ps.run(&script) {
eprintln!("error: {e}");
}
}Cargo.toml tunes the release profile for a smaller, stripped binary:
[profile.release]
strip = true
opt-level = "z"
lto = true
panic = "abort"-
KEYinmain.rsandkeyinbuild.rsare byte-for-byte identical. A mismatch decrypts to garbage and the script fails at runtime. - Replace the placeholder key with a long random value.
-
payload/autohash.ps1exists before building (build.rsfails loudly via.expectif it's missing). - Do not split the script on newlines — pass the full decrypted string to
ps.run(). - Windows only:
powershell_scriptshells out to the systempowershell.exe.
- Hide the window: set
.hidden(true)in the builder. - Stronger crypto: swap XOR for AES-GCM via the
aes-gcmcrate — same shape (encrypt inbuild.rs, decrypt inmain.rs). Note this does not change the threat model below; the key still ships in the binary.
This encrypts the script at rest, which defeats cat, strings, and a casual Ghidra
glance. It does not make the script secure:
- The key is in the binary. The wrapper must contain the key to decrypt, so anyone
running it under a debugger or dumping its memory recovers the plaintext script the
moment it decrypts. (A
strings/Ghidra search for the key literal finds it in.rdata.) - The script crosses into
powershell.exe. Once decrypted and handed off, ETW / AMSI / Script Block Logging can capture it in flight. Hidden at rest ≠ hidden at runtime. - Interpreted code is always recoverable. The interpreter needs the source, so a determined reverser with the binary always wins eventually.
If the goal is to protect a secret (API key, credential, token): this does NOT protect it. Assume anything in the binary is already compromised — move the check server-side and rotate the secret.
If the goal is to protect an algorithm/IP from a serious reverser: encrypting a PowerShell string won't hold. Port the sensitive logic into native Rust (real compiled machine code) or move it server-side so the client only sees inputs and outputs.
If the goal is to discourage casual snooping: this is a reasonable, proportionate step up from ps2exe. That's the bar it clears — nothing higher.