~/posts/web/dns-beacon-fragment-reassembly-authentication.md

DNS Beacon: fragment reassembly and ciphertext authentication

Reconstruct TXT downloads and query-name uploads from DNS Beacon records. Separate controls from data, detect duplicate or missing fragments, and authenticate three ciphertext envelopes before interpreting them.

date[31:24]
read[23:16]
7 min
cat[15:8]
Web security
Contents
  1. 0x00Determine prefixes from the configuration
  2. 0x01XOR control values and lengths, not every answer
  3. 0x02TXT downloads announce length before encoded data
  4. 0x03Visible A fragments are not a complete task
  5. 0x04The label count is not hexadecimal payload
  6. 0x05Authenticate before interpreting plaintext
  7. 0x06Keep a per-transfer evidence ledger
  8. 0x07Official references

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

Selected sample configurationjson
{
"transport": "DNS Beacon",
"domain": "beacon.example",
"DNS_Idle": "8.8.4.4",
"DNS_Sleep": 10000,
"maxdns": 245,
"BeaconID": "19997cf2"
}
object · 6 keys · 151 B

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.

Label roles for this instance6 rows
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:

Control and length calculations4 rows
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
DNS control — 0xF28-bit
family0xFmode1checkin
value0xF2= 242
BitsFieldValue
7–4family0xF (15)
3–1mode0x1
0checkin0

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

TXT transfer — domain replacedlog
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

First A data fragment
000000001340F059

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.

Three queries in one upload3 rows
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.

dns-reassembly-model.pypython
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:

Separate output envelope — 48 bytes
00000000A4E8282A67C7EC1E608C43A040DE4934
000000109C26CF5770907BDCDE06D79A32FA7DD1
0000002051A4343AD495215F265A7E396DB1CC28
  1. ciphertext0x00–0x1F
  2. authentication_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.

dns-verify.mjsjavascript
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()]);
}
Independent offline validation3 rows
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

Minimum reconstruction fields5 rows
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.

NORMAL~/posts/web/dns-beacon-fragment-reassembly-authentication.md§--
0%en