DNS Beacon analysis starts by deciding which bytes belong to one transfer, not by choosing AES parameters. Poll controls, length announcements, sequence identifiers, and ciphertext can all appear as query labels or address-shaped answers. Mixing those layers feeds the wrong input to an otherwise correct key.
The following reconstruction separates task downloads from output uploads in a 2021 competition trace. Query domains are replaced with beacon.example. Answers such as 8.8.4.4 remain protocol data, not connection targets. Validation uses recorded bytes and candidate keys offline; no Beacon was run, and no complete PCAP or process dump is available here.
Determine prefixes from the configuration
This is a selected-field summary with a documentation domain. The extracted payload type is DNS Beacon; HTTP fields elsewhere in the configuration do not make HTTP the right parser for this traffic. BeaconID identifies a running instance, not an immutable binary identity.
| Configuration field | Prefix | Direction and role |
|---|---|---|
DNS_beacon |
Empty | Poll for tasks |
DNS_A |
cdn |
Download through A answers |
DNS_AAAA |
www6 |
Download through AAAA answers |
DNS_TXT |
api |
Download through TXT answers |
DNS_metadata |
www |
Upload metadata in queries |
DNS_output |
post |
Upload output in queries |
The vendor added DNS subhost overrides in version 4.3. These names are configuration clues, not universal detection signatures. DNS_Idle denotes the idle answer and masks other control values; DNS_resolver is a separate resolver setting. The sample's exact version has not been independently established.
XOR control values and lengths, not every answer
XOR the four-byte A answer with the idle value:
| Answer | After XOR with 8.8.4.4 |
Meaning in its phase |
|---|---|---|
8.8.4.4 |
0 |
No task |
8.8.4.246 |
0xF2 |
Select TXT task transport |
8.8.4.68 |
64 |
This task envelope's length |
8.8.4.116 |
112 |
Another A-channel envelope's length |
| Bits | Field | Value |
|---|---|---|
| 7–4 | family | 0xF (15) |
| 3–1 | mode | 0x1 |
| 0 | checkin | 0 |
In this control family, 0xF0/0xF1, 0xF2/0xF3, and 0xF4/0xF5 select A, TXT, and AAAA respectively. Odd values additionally request check-in. The component's mode is shifted right by one: its displayed 1 corresponds to the original 0x02 mask. Bit 0 of 0xF2 is clear, so this poll does not request metadata first.
Interpret a length answer in its transfer phase rather than dispatching it again as a control mode. Octets in a payload A answer are already ciphertext bytes; repeating the XOR corrupts them.
TXT downloads announce length before encoded data
Q A api.07311917.19997cf2.beacon.example
R A 8.8.4.68
Q TXT api.17311917.19997cf2.beacon.example
R TXT ZUZBozZmBi10KvISBcqS0nxp32b7h6WxUBw4n70cOLP13eN7PgcnUVOWdO+tDCbeElzdrp0b0N5DIEhB7eQ9Yg==07311917 and 17311917 identify one batch: the initial counter is zero, the next advances, and the transfer identifier stays associated. The length query still uses A. Only after the announced 64 does the client query TXT for Base64 content, which decodes to exactly 64 bytes.
For multiple fragments, group and sort by numeric counter, preserve TXT string boundaries, and join and decode according to that envelope. The inspected parser concatenates ordered encoded content for this channel before decoding. That is not a universal fragmentation rule for arbitrary DNS TXT records, nor a reason to merge instances by capture time.
Visible A fragments are not a complete task
At this position, 19.64.240.89 encodes 13 40 F0 59 rather than a host to contact. Each A answer contributes four bytes; each AAAA answer contributes sixteen. A length announcement of 112 therefore requires the equivalent of 28 four-byte data fragments.
The available excerpt contains only counters 1 through a: ten payload answers, totaling 40 bytes and leaving 72 bytes unaccounted for. This A transfer remains incomplete. Do not zero-fill it, duplicate its tail, or report a successfully decrypted 112-byte task.
If another instance pads its final address slot, use that instance's length and framing rules. A declaration of 112, divisible by four, provides no evidence about partial-slot padding.
The label count is not hexadecimal payload
In post.140.09842910.19997cf2.beacon.example, split 140 into 1 | 40: one count digit followed by the hexadecimal length 0x40 = 64. Interpreting the whole label as 0x140 gives the wrong length.
| Transfer label | Data-label count | Data interpretation |
|---|---|---|
09842910 |
1 | 40 announces 64 bytes |
19842910 |
2 | Remove the first count digit and append the second label, producing 56 bytes |
29842910 |
1 | debfa06ab4786477 supplies the final 8 bytes |
A DNS label is limited to 63 octets, so long hexadecimal data needs splitting. ASCII hexadecimal uses one octet per character. The complete-name limit and configured maxdns further constrain capacity; adding unlimited labels is not valid.
Counters, Beacon IDs, and domain suffixes are not ciphertext. This strict model accepts only identified data labels and separately checks order, duplicates, conflicts, and length. It avoids the error of removing every dot and decoding everything.
import base64
from ipaddress import IPv4Address
def xor_value(answer, idle="8.8.4.4"):
return int(IPv4Address(answer)) ^ int(IPv4Address(idle))
def counted_labels(labels):
if not labels or not labels[0] or labels[0][0] not in "123456789":
raise ValueError("invalid label count")
count = int(labels[0][0])
if len(labels) != count:
raise ValueError("label count mismatch")
return labels[0][1:] + "".join(labels[1:])
def assemble(parts, expected):
unique = {}
for counter, data in parts:
if counter in unique and unique[counter] != data:
raise ValueError("conflicting duplicate")
unique[counter] = data
if sorted(unique) != list(range(1, len(unique) + 1)):
raise ValueError("missing sequence")
result = b"".join(unique[n] for n in sorted(unique))
if len(result) != expected:
raise ValueError("length mismatch")
return result
assert xor_value("8.8.4.4") == 0
assert xor_value("8.8.4.246") == 0xF2
assert xor_value("8.8.4.68") == 64
assert xor_value("8.8.4.116") == 112
txt = "ZUZBozZmBi10KvISBcqS0nxp32b7h6WxUBw4n70cOLP13eN7PgcnUVOWdO+tDCbeElzdrp0b0N5DIEhB7eQ9Yg=="
download = base64.b64decode(txt, validate=True)
assert len(download) == 64
first = [
"2942880f933a45cf2d048b0c14917493df0cd10a0de26ea103d0eb1b3",
"4adf28c63a97deb5cbe4e20b26902d1ef427957323967835f7d18a42",
]
last = ["1debfa06ab4786477"]
a = bytes.fromhex(counted_labels(first))
b = bytes.fromhex(counted_labels(last))
expected = int(counted_labels(["140"]), 16)
upload = assemble([(2, b), (1, a), (1, a)], expected)
assert (len(a), len(b), len(upload)) == (56, 8, 64)
assert upload != download
addresses = [
"19.64.240.89", "241.225.135.56", "127.132.170.127",
"87.30.231.4", "97.156.155.27", "253.162.241.39",
"61.217.211.72", "154.197.14.224", "211.139.207.53",
"150.38.89.208",
]
partial = b"".join(IPv4Address(x).packed for x in addresses)
assert len(partial) == 40 and 112 - len(partial) == 72
for parts, length in (
([(1, a), (1, b)], 64),
([(2, b)], 8),
([(1, a)], 64),
):
try:
assemble(parts, length)
except ValueError:
pass
else:
raise AssertionError("invalid input accepted")
print("PASS: 4 XOR cases; TXT64; output56+8; 40/112 A bytes; reorder/dedup; 3 rejected cases")The model passes four XOR checks, a 64-byte TXT decode, a 56+8 output assembly, and the A-channel 40/112 count. Identical duplicates are deduplicated; conflicting data for one sequence, missing sequences, and insufficient lengths raise errors. The download and upload both happen to be 64 bytes, but their contents differ.
Authenticate before interpreting plaintext
The historical analysis used Didier Stevens's cs-parse-traffic.py and cs-extract-key.py. Unknown-key parsing extracts envelopes; memory-assisted analysis seeks candidates. Merely locating sixteen bytes in memory does not establish a valid key.
The separate 48-byte envelope is kept apart from the earlier upload:
ciphertext0x00–0x1Fauthentication_tag0x20–0x2F
Three supplied envelopes can be independently authenticated and decrypted. This helper accepts the instance-specific format: the trailing sixteen bytes are truncated HMAC-SHA256 over the preceding ciphertext, and AES-128-CBC uses the fixed IV abcdefghijklmnop. Check HMAC first; do not apply automatic PKCS#7 unpadding to the application envelope.
import {createHmac, createDecipheriv, timingSafeEqual} from "node:crypto";
export function verifyAndDecrypt(blob, aesKey, hmacKey) {
if (aesKey.length !== 16 || hmacKey.length !== 16)
throw new Error("expected sample-specific 16-byte keys");
if (blob.length < 32 || (blob.length - 16) % 16 !== 0)
throw new Error("invalid encrypted envelope length");
const cipher = blob.subarray(0, -16);
const tag = blob.subarray(-16);
const expected = createHmac("sha256", hmacKey)
.update(cipher).digest().subarray(0, 16);
if (!timingSafeEqual(tag, expected))
throw new Error("HMAC mismatch");
const decipher = createDecipheriv(
"aes-128-cbc", aesKey, Buffer.from("abcdefghijklmnop"));
decipher.setAutoPadding(false);
return Buffer.concat([decipher.update(cipher), decipher.final()]);
}| Input | Envelope / CBC bytes | HMAC | Decrypted structural fields |
|---|---|---|---|
| TXT download | 64 / 48 | Pass | Length field 37; first command field 78 |
| Query-name upload | 64 / 48 | Pass | Counter 2; length field 25; callback 30 |
| Separate upload | 48 / 32 | Pass | Counter 9; length field 12; callback 30 |
Candidate keys came from the supplied records, not from DNS alone, and their values are omitted from public content. Structural fields were checked in the format's big-endian order; plaintext identifying a host is omitted. Passing HMAC establishes compatibility with these ciphertexts, not verification of a complete capture, every callback, or another running instance.
A separate 240-byte output record shows Solidity code and a challenge string, but its complete ciphertext is absent. It is therefore excluded from the independent decryption results above. Distinguishing an existing recorded result from a newly computed result is more useful than showing one readable line.
Keep a per-transfer evidence ledger
| Field group | Purpose |
|---|---|
| Beacon ID, direction, prefix, transfer identifier | Keep unrelated downloads and uploads separate |
| Original frame, query name, DNS type, raw answer | Preserve a path back to the input |
| Declared length, counters, deduplication result | Expose gaps and conflicts |
| Ciphertext length, tag check, application structure | Separate transport, key, and parsing failures |
| Key-material provenance and instance association | Prevent using another process's candidate |
When lengths differ, check grouping, sequence, and capture completeness first. When lengths match but HMAC fails, examine the candidate and envelope boundary. Partially readable text without a valid overall structure is not a successful decryption.
The useful output is not the longest possible hexadecimal string. It is a layered byte record: configuration explains labels, batches establish order, lengths bound data, and authentication constrains decryption. Stop inference at whichever layer lacks evidence.
Official references
Vendor documentation establishes configuration meanings; the RFC establishes DNS label and record formats. Ciphertext and controls are checked separately against the supplied records.