libresolv/dns_parse_packet broken for DNS queries over TCP

DNS queries (at least those generated by macOS's dig) when sent over TCP, are prefixed with a 2-byte length.

Observe this by firing up a network monitor (e.g., WireShark), and then generating a DNS query over TCP:

% dig @8.8.8.8 <some domain> +tcp

"Normal" DNS queries (sent over UDP) are not prefixed in this manner.

% dig @8.8.8.8 <some domain>

Though WireShark has no problem with these differences, this poses a problem for libresolv. Specifically the dns_parse_packet function will return NULL for DNS queries over TCP. 😤

A simple workaround is to pass dnsPacket +2 to dns_parse_packet() ...but this feels dirty? 😅 Ideally libresolv would be made a little more robust, able to handle both packet styles.

Answered by DTS Engineer in 746502022

DNS queries … when sent over TCP, are prefixed with a 2-byte length.

Yes. That’s the standard DNS framing for TCP.

Specifically the dns_parse_packet function will return NULL for DNS queries over TCP.

That’s expected. dns_parse_packet parses DNS messages. If you want to use it with TCP, you must first remove the framing.

A simple workaround is to pass dnsPacket + 2 to dns_parse_packet

Ah, um, that seems like you’re missing a key point here. Given that these bytes are coming in over TCP, I presume you’re reading them using a streaming API, like the BSD Sockets read system call. Such APIs do not guarantee to preserve record boundaries. So, imagine the sender calls write with the 2-byte length and the DNS message in one system call. There’s no guarantee that your call to read will return those same bytes. You might get them all in one read, you might get some fraction of the bytes and then have to read again, or you might get extra bytes that are for the next framed DNS message.

To deal with this you need an ‘unframer’, that is, something that reads the byte stream and produces unframed DNS messages. An unframer will naturally remove the 2-byte length, and thus you don’t need to remove it again when you call dns_parse_packet.

Pasted in below you’ll find unframer and framer functions. These assume the existence of a QDNSMessage type, with a single data property holding the bytes of the DNS message itself.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

public func dnsUnframer(_ data: Data) throws -> (message: QDNSMessage, residual: Data)? {
    guard data.count >= 2 else { return nil }
    let length16 = data.prefix(2).reduce(0) { soFar, next in (soFar << 8) | UInt16(next) }
    let messageCount = Int(length16)
    let frameCount = Int(2 + messageCount)
    guard data.count >= frameCount else { return nil }
    let message = QDNSMessage(data: data.dropFirst(2).prefix(messageCount))
    return (message, Data(data.dropFirst(frameCount)))
}

public func dnsFramer(_ message: QDNSMessage) throws -> Data {
    guard let count16 = UInt16(exactly: message.data.count) else {
        throw POSIXError(.EMSGSIZE)
    }
    let header = (0..<2).reversed().map { UInt8((count16 >> ($0 * 8)) & 0xff) }
    return header + message.data
}
Accepted Answer

DNS queries … when sent over TCP, are prefixed with a 2-byte length.

Yes. That’s the standard DNS framing for TCP.

Specifically the dns_parse_packet function will return NULL for DNS queries over TCP.

That’s expected. dns_parse_packet parses DNS messages. If you want to use it with TCP, you must first remove the framing.

A simple workaround is to pass dnsPacket + 2 to dns_parse_packet

Ah, um, that seems like you’re missing a key point here. Given that these bytes are coming in over TCP, I presume you’re reading them using a streaming API, like the BSD Sockets read system call. Such APIs do not guarantee to preserve record boundaries. So, imagine the sender calls write with the 2-byte length and the DNS message in one system call. There’s no guarantee that your call to read will return those same bytes. You might get them all in one read, you might get some fraction of the bytes and then have to read again, or you might get extra bytes that are for the next framed DNS message.

To deal with this you need an ‘unframer’, that is, something that reads the byte stream and produces unframed DNS messages. An unframer will naturally remove the 2-byte length, and thus you don’t need to remove it again when you call dns_parse_packet.

Pasted in below you’ll find unframer and framer functions. These assume the existence of a QDNSMessage type, with a single data property holding the bytes of the DNS message itself.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

public func dnsUnframer(_ data: Data) throws -> (message: QDNSMessage, residual: Data)? {
    guard data.count >= 2 else { return nil }
    let length16 = data.prefix(2).reduce(0) { soFar, next in (soFar << 8) | UInt16(next) }
    let messageCount = Int(length16)
    let frameCount = Int(2 + messageCount)
    guard data.count >= frameCount else { return nil }
    let message = QDNSMessage(data: data.dropFirst(2).prefix(messageCount))
    return (message, Data(data.dropFirst(frameCount)))
}

public func dnsFramer(_ message: QDNSMessage) throws -> Data {
    guard let count16 = UInt16(exactly: message.data.count) else {
        throw POSIXError(.EMSGSIZE)
    }
    let header = (0..<2).reversed().map { UInt8((count16 >> ($0 * 8)) & 0xff) }
    return header + message.data
}
libresolv/dns_parse_packet broken for DNS queries over TCP
 
 
Q