Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

rs-ps2exe

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.


Quick start

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.exe

The example ships with payload/autohash.ps1 (a Windows Autopilot HWID collector) so a fresh cargo build --release works out of the box.


Project layout

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

How it works

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"

Checklist

  • KEY in main.rs and key in build.rs are 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.ps1 exists before building (build.rs fails loudly via .expect if it's missing).
  • Do not split the script on newlines — pass the full decrypted string to ps.run().
  • Windows only: powershell_script shells out to the system powershell.exe.

Optional hardening

  • Hide the window: set .hidden(true) in the builder.
  • Stronger crypto: swap XOR for AES-GCM via the aes-gcm crate — same shape (encrypt in build.rs, decrypt in main.rs). Note this does not change the threat model below; the key still ships in the binary.

Security reality — do not oversell this

This encrypts the script at rest, which defeats cat, strings, and a casual Ghidra glance. It does not make the script secure:

  1. 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.)
  2. 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.
  3. 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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages