Unknocking was a networking task on CyBRICS CTF 2019 quals.

The objective of the task was to reverse engineer a port knocking daemon that implements unconventional port knocking algorithm over IPv6, and do the knocking over Tor.

Reverse engineering

The only thing provided was a file named unknocking.zip. It contained a file system with a few interesting files:

  • rootfs/etc/knock.whitelist contained a list of IPv6 addresses. Upon investigation, it turned out to be a list of known Tor exit nodes.
  • rootfs/etc/network/interfaces had public IPv6 address statically configured.
  • rootfs/srv/server was a binary executable file, which we will analyze shortly.

The binary used a well-known libpcap library to capture network traffic.

When you run tcpdump, you can specify filter program in pcap filter language. What makes it efficient is that the program specified is compiled into BPF bytecode, and then passed into kernel. The kernel interprets the bytecode, and wakes the userspace program up only when a packet matching the filter comes. This minimizes both context switches and kernel-userspace copies.

In this program, however, the filter was embedded into program binary as bytecode, so we had to reverse engineer it as well.

To our surprise, we did not find an easy way to correctly disassemble BPF filter for network packets during the CTF and it took us rather long to understand what it does. We tried BPF disassembler added to latest versions of Capstone, but for some reason it could not disassemble some instructions (it was stopping on the first instruction it could not handle). We also tried a disassembler for seccomp BPF, it worked, but it’s output is not very good for analyzing packet filter - for example, it showed two returns instructions at the end of the filter as both ret KILL whereas first of them actually accepted a packet (ret 0x80) and second one dropped it (ret 0x0). (Also, and X and sub X were for some reason shown as and 0x0 and sub 0x0). At the end we used a compilation of outputs given by these two.

Apparently, this bytecode was generated by some suboptimal compiler, as there’s quite a few redundant and unreachable instructions. Basically, this bytecode accepts IPv6 packets destined to aaaa:bbbb:cccc:dddd:eeee:ffff:7777:3333 (this address is replaced with real local host address before applying the filter), destination TCP port 443, and SYN flag set.

 line  OP   JT   JF   K
=================================
; check EtherType == 0x86dd (IPv6)
 0x00: 0x28 0x00 0x00 0x0000000c   ldh $data[0xc]
 0x01: 0x15 0x00 0x2d 0x000086dd   jeq 0x86dd true:0x2 false:LABEL_ZERO
; check EtherType == 0x86dd (IPv6) (again)
 0x02: 0x28 0x00 0x00 0x0000000c   ldh $data[0xc]
 0x03: 0x15 0x00 0x2b 0x000086dd   jeq 0x86dd true:0x4 false:LABEL_ZERO
; check IPv6 destination, first word
 0x04: 0x20 0x00 0x00 0x00000026   ld  $data[0x26]
 0x05: 0x15 0x00 0x29 0xaaaabbbb   jeq 0xaaaabbbb true:0x6 false:LABEL_ZERO
; check IPv6 destination, second word
 0x06: 0x20 0x00 0x00 0x0000002a   ld  $data[0x2a]
 0x07: 0x15 0x00 0x27 0xccccdddd   jeq 0xccccdddd true:0x8 false:LABEL_ZERO
; check IPv6 destination, third word
 0x08: 0x20 0x00 0x00 0x0000002e   ld  $data[0x2e]
 0x09: 0x15 0x00 0x25 0xeeeeffff   jeq 0xeeeeffff true:0xa false:LABEL_ZERO
; check IPv6 destination, fourth word
 0x0a: 0x20 0x00 0x00 0x00000032   ld  $data[0x32]
 0x0b: 0x15 0x00 0x23 0x77773333   jeq 0x77773333 true:0xc false:LABEL_ZERO
; check EtherType == 0x86dd (IPv6) (again)
 0x0c: 0x28 0x00 0x00 0x0000000c   ldh $data[0xc]
 0x0d: 0x15 0x00 0x04 0x000086dd   jeq 0x86dd true:0xe false:0x12
; check Next Header == 0x6 (TCP)
 0x0e: 0x30 0x00 0x00 0x00000014   ldb $data[0x14]
 0x0f: 0x15 0x00 0x02 0x00000006   jeq 0x6 true:0x10 false:0x12
; check TCP destination port == 443
 0x10: 0x28 0x00 0x00 0x00000038   ldh $data[0x38]
 0x11: 0x15 0x09 0x00 0x000001bb   jeq 0x1bb true:0x1b false:0x12
; check EtherType == 0x800 (IPv4)
 0x12: 0x28 0x00 0x00 0x0000000c   ldh $data[0xc]
 0x13: 0x15 0x00 0x1b 0x00000800   jeq 0x800 true:0x14 false:LABEL_ZERO
; check IPv4 Protocol == 0x6 (TCP)
 0x14: 0x30 0x00 0x00 0x00000017   ldb $data[0x17]
 0x15: 0x15 0x00 0x19 0x00000006   jeq 0x6 true:0x16 false:LABEL_ZERO
; check IPv4 fragment offset == 0
 0x16: 0x28 0x00 0x00 0x00000014   ldh $data[0x14]
 0x17: 0x45 0x17 0x00 0x00001fff   jset 0x1fff true:LABEL_ZERO false:0x18
; X = IHL
 0x18: 0xb1 0x00 0x00 0x0000000e   ldx 4 * $data[0xe] & 0x0f
; check TCP destination port == 443
 0x19: 0x48 0x00 0x00 0x00000010   ldh $data[X + 0x10]
 0x1a: 0x15 0x00 0x14 0x000001bb   jeq 0x1bb true:0x1b false:LABEL_ZERO
; check EtherType == 0x86dd (IPv6) (again)
 0x1b: 0x28 0x00 0x00 0x0000000c   ldh $data[0xc]
 0x1c: 0x15 0x00 0x12 0x000086dd   jeq 0x86dd true:0x1d false:LABEL_ZERO
; calculate offset of tcp flags in packet
 0x1d: 0x00 0x00 0x00 0x00000035   ld  0x35
 0x1e: 0x02 0x00 0x00 0x00000000   st  $temp[0x0]
 0x1f: 0x61 0x00 0x00 0x00000000   ldx $temp[0x0]
; load byte at offset 0x35 + 0xe = 0x43 (tcp flags) into A
 0x20: 0x50 0x00 0x00 0x0000000e   ldb $data[X + 0xe]
 0x21: 0x02 0x00 0x00 0x00000001   st  $temp[0x1]
 0x22: 0x00 0x00 0x00 0x00000002   ld  0x2
 0x23: 0x02 0x00 0x00 0x00000002   st  $temp[0x2]
 0x24: 0x61 0x00 0x00 0x00000002   ldx $temp[0x2]
 0x25: 0x60 0x00 0x00 0x00000001   ld  $temp[0x1]
; take tcp flags by mask 2: get value of SYN flag
 0x26: 0x5c 0x00 0x00 0x00000000   and X
 0x27: 0x02 0x00 0x00 0x00000002   st  $temp[0x2]
 0x28: 0x00 0x00 0x00 0x00000000   ld  0x0
 0x29: 0x02 0x00 0x00 0x00000003   st  $temp[0x3]
 0x2a: 0x61 0x00 0x00 0x00000003   ldx $temp[0x3]
 0x2b: 0x60 0x00 0x00 0x00000002   ld  $temp[0x2]
 0x2c: 0x1c 0x00 0x00 0x00000000   sub X
; check that SYN flag is set
 0x2d: 0x15 0x01 0x00 0x00000000   jeq 0x0 true:LABEL_ZERO false:0x2e
 0x2e: 0x06 0x00 0x00 0x00000080   ret 0x80
LABEL_ZERO:
 0x2f: 0x06 0x00 0x00 0x00000000   ret 0

Packets that pass the filter go straight to the handler which was passed to pcap_loop function. This handler contains lots of STL code which boils down to pretty simple algorithm. Essentialy, the program associates a remote host with a string, and when you connect to new port p it adds a character "0123456789abcdef"[(P >> 8) & 0xF] to this string. If string is equal to secretPhrase (which is 24 characters long) then you get a flag; if string is not a prefix of secretPhrase it resets to empty one; same thing happens if more than 30 seconds passed since last packet from you.

Oh well, another small detail: your host must appear in knock.whitelist, which, as mentioned above, is a list of IPv6-enabled Tor exit nodes. It means you need to perform this “port knocking authorization” over Tor using IPv6.

Usually port knocking means that in order to connect to some protected port (say, SSH port 22), you have to try to connect to some ports of the remote machine in specific sequence.

In this task, however, the port knocking daemon checks the source port of connection attempts instead. The third nibbles of connection attempts to port 443 must form a certain secret sequence. For example, if secret sequence were 1337, you must choose source ports, in order, 0xn1nn, 0xn3nn, 0xn3nn, 0xn7nn.

If you connect using incorrect source port, you receive connection reset, and must start the sequence anew. If the source port is correct, the connection will succeed, and you will either got nothing (if it wasn’t the last element of the sequence), or the flag.

To sum up, you have to connect to server 24 times, from source ports with specific values - each time the third nibble of port number should have a certain value. A single connection from a port with incorrect third nibble resets the sequence as well as failing to make next connection within 30 seconds since the previous one.

Doing the port knocking

It it weren’t for Tor, things would be very easy. Although operating systems usually choose source port for outgoing connections arbitrarily, one can easily force any free port by calling bind() before connect().

However, since we have to do the knocking through Tor, we can’t ask the exit node to bind the port we want before connecting. We have to somehow abuse the algorithm that allocates port numbers to get the desired port sequence.

We assumed that the source port are allocated sequentially, using some global counter. Expirements confirmed this behaviour:

>>> for i in range(4):
...  print(socket.create_connection(("ya.ru", 80)).getsockname())
... 
('10.0.0.50', 47268)
('10.0.0.50', 47270)
('10.0.0.50', 47272)
('10.0.0.50', 47274)

It’s only half of the trouble, though.

First, we don’t know the current counter value of the Tor node. Second, other activity on the said node may cause the counter to advance very fast, skipping the desired value.

To solve these problems, we wrote a program that binds some port, and connects through Tor back to it, revealing current ephemeral port value of current Tor exit node. It does so in a loop, checking whether current nibble matches the next one of the secret sequence. If it does, we connect to the machine with the flag, and repeat the same with the next nibble.

It wasn’t as simple, though. We noticed that the first exit node we got apparently had some heavy activity, and source ports were skipping too fast.

Changing exit node is simple: you can use Tor’s IsolateSOCKSAuth, and connect to local SOCKS proxy using different passwords, and Tor will build different circuits. You can also use ExitNodes config directive to force specific exit node.

After going through some unsuitable exit nodes with unpredictable sequences, tuning the algorithm a bit, we got the flag.

The exit node we used was ACDD9E85A05B127BA010466C13C8C47212E8A38F.

package main

import (
	"flag"
	"fmt"
	"io"
	"log"
	"net"
	"os"
	"strconv"
	"time"

	"golang.org/x/net/proxy"
)

const secret = "4f70334e5f73337a344d3321"

func getLocalAddr() string {
	sock, err := net.Dial("udp6", "[2000::1]:148")
	if err != nil {
		log.Fatal(err)
	}
	defer sock.Close()

	host, _, _ := net.SplitHostPort(sock.LocalAddr().String())
	return host
}

func prinimatel(ch chan string) net.Addr {
	ln, err := net.Listen("tcp6", net.JoinHostPort(getLocalAddr(), "0"))
	if err != nil {
		log.Fatal(err)
	}

	go func() {
		for {
			conn, err := ln.Accept()
			if err != nil {
				log.Fatal(err)
			}

			ch <- conn.RemoteAddr().String()

			conn.Close()
		}
	}()

	return ln.Addr()
}

func getCurrentNibble(d proxy.Dialer, addr net.Addr, ch chan string) (uint64, uint64) {
	c, err := d.Dial(addr.Network(), addr.String())
	if err != nil {
		log.Fatal(err)
	}
	defer c.Close()

	remoteAddr := <-ch

	_, port, _ := net.SplitHostPort(remoteAddr)

	u, _ := strconv.ParseUint(port, 10, 16)

	log.Printf("port 0x%x (%s)", u, remoteAddr)

	return (u >> 8) & 0xF, (u & 0xFF)
}

func tryConnect(d proxy.Dialer) {
	const target = "[2a05:f480:1800:ba7:5400:2ff:fe2f:b33b]:443"
	//const target = "[2a01:4f8:c2c:75e0::1]:443"

	conn, err := d.Dial("tcp6", target)
	if err != nil {
		log.Fatal(err)
		return
	}
	defer conn.Close()

	if err := conn.SetDeadline(time.Now().Add(time.Second * 1)); err != nil {
		log.Fatal(err)
	}

	if _, err := io.Copy(os.Stdin, conn); err != nil {
		log.Print(err)
	}
}

func main() {
	var seed = flag.String("seed", fmt.Sprintf("%d", os.Getpid()), "Seed")

	flag.Parse()

	log.Printf("Using seed %q", *seed)

	auth := &proxy.Auth{
		User:     "tor",
		Password: *seed,
	}

	tor, err := proxy.SOCKS5("tcp", "127.0.0.1:9050", auth, nil)
	if err != nil {
		log.Fatal(err)
	}

	ch := make(chan string)

	prinimatelAddr := prinimatel(ch)

	for i := 0; i < len(secret); i++ {
		targetNibble, _ := strconv.ParseUint(secret[i:i+1], 16, 8)

		log.Printf("waiting for %x", targetNibble)

		for {
			log.Printf(" i=%d s=%s", i, secret[i:i+1])

			currentNibble, rem := getCurrentNibble(tor, prinimatelAddr, ch)
			if currentNibble == targetNibble && rem < 0xf0 {
				break
			}

			for i := uint64(0); i < (targetNibble-currentNibble)%16; i++ {
				go getCurrentNibble(tor, prinimatelAddr, ch)
			}
		}
		tryConnect(tor)
	}
}

Linux port randomization

The official write-up mentioned that there was an obstacle that Linux uses different source port offset for different destinations. Indeed, it can be observed even for different ports of the same destination IP:

>>> print(socket.create_connection(("ya.ru", 80)).getsockname())
('10.0.0.75', 53372)
>>> print(socket.create_connection(("ya.ru", 443)).getsockname())
('10.0.0.75', 60286)
>>> print(socket.create_connection(("ya.ru", 80)).getsockname())
('10.0.0.75', 53376)
>>> print(socket.create_connection(("ya.ru", 443)).getsockname())
('10.0.0.75', 60290)

But we didn’t take it into account and didn’t even know about it until after we saw the official write up.

How so? We got curious, and investigated a bit.

It turned out the exit node we chose was running FreeBSD, which doesn’t do hash based source port randomization.