Why String Obfuscation?
Shellcode often needs to hide its intent from static analysis tools. Storing API names and module names in plaintext makes your shellcode easily detectable:- Removing plaintext: No literal “LoadLibraryA” in the binary
- Hash-based lookup: Use numeric hashes instead of strings
- Compile-time computation: Zero runtime overhead
- Reduces size: Hash values (4 bytes) vs full strings
Compile-Time FNV1a Hashing
Stardust uses the FNV1a (Fowler-Noll-Vo) hash algorithm, computed entirely at compile time using C++20consteval.
The Hash Function
Frominclude/constexpr.h:20-39:
Algorithm Breakdown
FNV1a Algorithm:- Start with offset basis:
0x811c9dc5 - For each byte:
- XOR hash with byte
- Multiply hash by FNV prime
0x01000193
- Return 32-bit hash
"LoadLibraryA" and "loadlibrarya" hash to the same value, matching Windows case-insensitive API resolution.
consteval Keyword
Theconsteval specifier (C++20) forces compile-time evaluation:
- Must be evaluated at compile time (not runtime)
- Produces a constant expression
- If it can’t be evaluated at compile time, compilation fails
Template for String Types
The template supports both narrow and wide strings:UNICODE_STRING structures.
How String Hashing Avoids Detection
Before Obfuscation
Traditional approach stores strings in plaintext:strings command reveals:
After Obfuscation
Stardust’s approach using hashes:strings finds nothing:
Static Analysis Perspective
Without obfuscation:- YARA rules can match:
"LoadLibraryA" and "GetProcAddress" - Signature detection is trivial
- Intent is obvious
- No string signatures to match
- Must analyze behavior to detect
- Harder to create static rules
Practical Usage
Module Resolution
Resolving module handles from the PEB (src/main.cc:26-28):
- Compiler evaluates
expr::hash_string<wchar_t>(L"ntdll.dll")at compile time - Produces hash value:
0x1EDAB0ED - Embeds
0x1EDAB0EDas a constant in the binary - At runtime,
resolve::module()walks the PEB comparing hashes
API Resolution
Two methods for resolving function pointers:Method 1: RESOLVE_API Macro
From the README example (src/main.cc:59):
include/resolve.h:9):
- Automatically stringifies the function name with
# s - Automatically infers the function type with
decltype(s) - Clean, readable syntax
Method 2: Direct Call
Manual resolution:Automatic Import Resolution
TheRESOLVE_IMPORT macro automates resolving all APIs in a module structure.
From include/macros.h:8-12:
src/main.cc:38-39):
-
The instance structure stores hashes in place of function pointers initially:
-
RESOLVE_IMPORTiterates through each field (skipping thehandle) - Treats the stored value as a hash
-
Calls
resolve::_api()to find the real function address - Replaces the hash with the actual function pointer
Hash Collision Considerations
Collision Probability
FNV1a produces 32-bit hashes, giving 2^32 (4,294,967,296) possible values. Birthday paradox: With ~65,536 strings, collision probability reaches ~50%. However, typical shellcode resolves:- ~10-20 module names
- ~50-100 function names
Handling Collisions
If you encounter a collision:Option 1: Verify Manually
Option 2: Use Full String Match
Fallback to a runtime string comparison:Option 3: Different Hash Algorithm
Implement an alternative hash (e.g., CRC32, xxHash) for colliding strings.Real-World Risk
In practice, collisions are rare because:- Windows API names are unique by design
- Module names don’t overlap
- FNV1a distributes well for ASCII strings
Runtime vs Compile-Time Hashing
Compile-Time (consteval)
Frominclude/constexpr.h:20-39:
- ✅ Zero runtime cost
- ✅ Hash computed during compilation
- ✅ Binary contains only the hash value
- ✅ Smaller code size
Runtime Hashing
Stardust also provides a runtime version (include/common.h:82-100):
inline instead of consteval.
Usage: When the string is only known at runtime:
When to Use Each
| Scenario | Use |
|---|---|
| Literal strings known at compile time | expr::hash_string<T>("...") |
| Dynamic strings constructed at runtime | stardust::hash_string<T>(buffer) |
| Module names in PEB walking | Compile-time (expr::) |
| Export table name parsing | Runtime (stardust::) |
| Comparing user input | Runtime (stardust::) |
Export Table Resolution
The runtime hasher is used when walking export tables (src/resolve.cc:76-80):
- Compile time:
expr::hash_string("LoadLibraryA")→0xE0147A9C - Runtime: Find “LoadLibraryA” in export table
- Runtime:
stardust::hash_string("LoadLibraryA")→0xE0147A9C - Compare:
0xE0147A9C == 0xE0147A9C✅ Match! - Return function address
Benefits Summary
Stealth
✅ No plaintext API names: Static analysis tools can’t trivially identify intent ✅ Signature evasion: YARA rules based on strings won’t match ✅ Obfuscated imports: Function resolution doesn’t expose plaintext namesPerformance
✅ Compile-time computation: Zero runtime cost for hash calculation ✅ Small constants: 4-byte hashes instead of variable-length strings ✅ Cache friendly: Integer comparisons are fastCode Quality
✅ Type-safe: Template-based, compiler-enforced correctness ✅ Readable:expr::hash_string("API") is clear and concise
✅ Maintainable: Change an API name in one place, hash updates automatically
Example: Adding Obfuscated API
Let’s addVirtualProtect to the shellcode:
1. Update Instance Structure
2. Automatic Resolution
The constructor automatically resolves it:3. Use It
What Gets Compiled
Compiler output:0x36F8C45B.
