fix(pcap): preserve DNS RCODE, EDNS extended RCODE, and OPCODE status in address attribution

Fixes #10211

Supersedes #10218

Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
Alex Cinovojandgensokyo-dev authored and Ysopwnable committed 2026-11-14 16:31:24 +01:00
1 parent 7a03182349
commit f8f019e104
2 files changed
+164 -16

No files matched your search

+110
View File
@@ -37,6 +37,116 @@ it("preserves real TShark display collisions with distinct wire label identities
});
});
it("does not attribute addresses on NXDOMAIN (nonzero RCODE)", () => {
const query = label("612e62", "6578616d706c65");
const answer = rr(query, 1, address);
const bytes = Buffer.concat([
word(1),
word(0x8183), // QR=1, RCODE=3 (NXDOMAIN)
word(1),
word(1),
word(0),
word(0),
query,
word(1),
word(1),
answer,
]);
const facts = parse(bytes);
expect(facts).toMatchObject({
assigned: [],
unattributed: ["192.0.2.9"],
incomplete: true,
});
expect(facts.incomplete_reasons).toContainEqual(
expect.stringContaining("RCODE"),
);
});
it("does not attribute addresses on non-QUERY opcode", () => {
const query = label("612e62", "6578616d706c65");
const answer = rr(query, 1, address);
const bytes = Buffer.concat([
word(1),
word(0x9180), // QR=1, OPCODE=2 (STATUS)
word(1),
word(1),
word(0),
word(0),
query,
word(1),
word(1),
answer,
]);
const facts = parse(bytes);
expect(facts).toMatchObject({
assigned: [],
unattributed: ["192.0.2.9"],
incomplete: true,
});
});
it("does not attribute addresses when EDNS OPT extended RCODE is nonzero (BADVERS)", () => {
const query = label("612e62", "6578616d706c65");
const answer = rr(query, 1, address);
const optTtl = Buffer.alloc(4);
optTtl.writeUInt32BE(1 << 24); // extended RCODE = 1 -> full RCODE = 16 (BADVERS)
const opt = Buffer.concat([
Buffer.from([0]),
word(41),
word(4096),
optTtl,
word(0),
]);
const bytes = Buffer.concat([
word(1),
word(0x8180), // QR=1, RD=1, header RCODE=0
word(1),
word(1),
word(0),
word(1),
query,
word(1),
word(1),
answer,
opt,
]);
const facts = parse(bytes);
expect(facts).toMatchObject({
assigned: [],
unattributed: ["192.0.2.9"],
incomplete: true,
incomplete_reasons: [expect.stringContaining("RCODE is nonzero (16)")],
});
});
it("preserves both nonzero RCODE rejection and TC truncation reasons", () => {
const query = label("612e62", "6578616d706c65");
const answer = rr(query, 1, address);
const bytes = Buffer.concat([
word(1),
word(0x8383), // QR=1, TC=1, RD=1, RCODE=3 (NXDOMAIN)
word(1),
word(1),
word(0),
word(0),
query,
word(1),
word(1),
answer,
]);
const facts = parse(bytes);
expect(facts).toMatchObject({
assigned: [],
unattributed: ["192.0.2.9"],
incomplete: true,
});
expect(facts.incomplete_reasons).toEqual([
expect.stringContaining("RCODE is nonzero (3)"),
"DNS message declares truncation (TC); captured records are incomplete",
]);
});
const label = (...labels: readonly string[]): Buffer =>
Buffer.concat([
...labels.map((hex) => {
+54 -16
View File
@@ -200,12 +200,16 @@ const decodeRecords = (
start: number,
classValue: (value: number) => number,
observed: readonly ObservedAddress[],
): Result<readonly RecordFact[], DnsWireFailure> => {
): Result<
{ records: readonly RecordFact[]; extendedRcode: number },
DnsWireFailure
> => {
const u16 = (at: number): number => message.readUInt16BE(at);
const counts = [u16(6), u16(8), u16(10)];
let at = start;
const addressesByIdentity = addressInventory(observed);
const records: RecordFact[] = [];
let extendedRcode = 0;
for (let section = 0; section < counts.length; section += 1) {
for (let index = 0; index < counts[section]!; index += 1) {
const ownerResult = decodeName(message, at);
@@ -220,6 +224,11 @@ const decodeRecords = (
const end = at + size;
if (end > message.length)
return invalidWire("DNS record data is truncated");
// EDNS: an OPT pseudo-RR in the additional section carries the
// upper eight bits of the extended RCODE in its TTL field.
if (section === 2 && type === 41 && owner.end + 8 <= message.length) {
extendedRcode = message.readUInt32BE(owner.end + 4) >>> 24;
}
let target: string | null = null;
let address: string | null = null;
if (type === 5 && dnsClass === 1) {
@@ -262,28 +271,52 @@ const decodeRecords = (
}
if (at !== message.length)
return invalidWire("DNS message contains unexamined trailing bytes");
return ok(records);
return ok({ records, extendedRcode });
};
const eligibleQuestion = (
decodedQuestions: readonly WireQuestion[],
flags: number,
): WireQuestion | null => {
rcode: number = flags & 0x000f,
): { question: WireQuestion | null; reason: string | null } => {
const question = decodedQuestions[0];
if (
(flags & 0x8000) === 0 ||
decodedQuestions.length !== 1 ||
question === undefined ||
![1, 255].includes(question.dnsClass) ||
![1, 28, 255].includes(question.type)
)
return null;
return question;
// QR bit must be set (response, not query).
if ((flags & 0x8000) === 0)
return { question: null, reason: "DNS response flag (QR) is not set" };
// RCODE must be zero (no error response such as NXDOMAIN or BADVERS).
if (rcode !== 0)
return {
question: null,
reason: `DNS response RCODE is nonzero (${rcode}); error response such as NXDOMAIN`,
};
// OPCODE must be QUERY (0); unsupported opcodes (STATUS, IQUERY, NOTIFY, ...)
// do not establish ordinary successful-query address ownership.
if ((flags & 0x7800) !== 0)
return {
question: null,
reason: `DNS OPCODE is not QUERY (${(flags & 0x7800) >> 11})`,
};
if (decodedQuestions.length !== 1 || question === undefined)
return {
question: null,
reason: "Question count does not establish Internet address ownership",
};
if (![1, 255].includes(question.dnsClass))
return {
question: null,
reason: "Question class does not establish Internet address ownership",
};
if (![1, 28, 255].includes(question.type))
return {
question: null,
reason: "Question type does not establish Internet address ownership",
};
return { question, reason: null };
};
const attributeAddresses = (
questions: readonly DnsWireQuestion[],
question: WireQuestion | null,
eligibility: { question: WireQuestion | null; reason: string | null },
records: readonly RecordFact[],
observed: readonly ObservedAddress[],
): DnsMessageFacts => {
@@ -296,9 +329,11 @@ const attributeAddresses = (
});
if (observed.length === 0)
return { questions, assigned: [], unattributed: [], incomplete: false };
const { question } = eligibility;
if (question === null)
return unknown(
"Question count, response flag, type or class does not establish Internet address ownership",
eligibility.reason ??
"Question count, response flag, type or class does not establish Internet address ownership",
);
const links = new Map<string, Set<string>>();
for (const record of records) {
@@ -424,10 +459,13 @@ export const parseDnsMessageRaw = (
}));
const records = decodeRecords(message, at, classValue, observed);
if (!records.ok) return unknown(records.error.reason);
const extendedRcode = records.value.extendedRcode;
const fullRcode = (extendedRcode << 4) | (flags & 0x000f);
const eligibility = eligibleQuestion(decodedQuestions, flags, fullRcode);
const facts = attributeAddresses(
questions,
eligibleQuestion(decodedQuestions, flags),
records.value,
eligibility,
records.value.records,
observed,
);
// A structurally complete packet can carry only part of the DNS response.