Skip to content

Protocols ​

Every port has a protocol. New ports start as tcp or udp; set http, ftp or dns yourself for protocol-aware rules. A wrong guess would break the service, which is why vulnboxd does not guess.

yaml
ports:
  "8080":
    protocol: http

tcp ​

Each direction of a connection is one continuous stream. contains and regex find a pattern wherever it lands in it, even split across reads or megabytes apart, in constant memory.

  • Block closes the connection.
  • larger_than counts everything one side has sent over the connection.
  • replace rewrites the stream as it passes.
yaml
"1337":
  protocol: tcp
  rules:
    - name: shell payloads
      match: { regex: '/bin/(ba)?sh|/etc/passwd' }
    - name: oversized upload
      match: { larger_than: 64K }

http ​

Requests and responses are parsed, so rules can use method, path, headers, headers_opt, body and status, on top of contains and regex over the whole message.

A few things vulnboxd does on every http port:

  • Accept-Encoding is removed from requests, so responses come back uncompressed and body rules can read them.
  • Hop-by-hop headers (Connection, Keep-Alive, Upgrade and the like) are not forwarded, as a proxy should not; WebSocket upgrades are the exception.
  • Connections to the service are kept and reused for the next client, which saves a TCP handshake per request. If a service cannot cope with that, set pool_upstream: false for the service or the port.
  • A request whose headers take longer than timeouts.http_request (30 s) to arrive, or whose body does, gets a 408.

h2c ​

An http port serves whichever version the client speaks. A connection that opens with the http/2 preface is served as cleartext http/2 (h2c), anything else as http/1.1. vulnboxd then talks the same version to the service, since a service reached over h2c generally speaks nothing else. Rules see the same requests and responses either way.

WebSockets ​

WebSocket connections on http ports are proxied too. The upgrade request and response go through the http rules as usual. After that, each data frame is matched like an http message with:

  • method set to WEBSOCKET,
  • path set to the path of the upgrade request,
  • the frame's payload as body, and for contains and regex.
yaml
- name: no shell commands over the socket
  match: { method: WEBSOCKET, path: '^/ws', body: '(?i)\b(bash|sh|nc)\b' }

Blocking closes the connection. Compression (permessage-deflate) is not negotiated, so payloads stay readable. Frames are checked one at a time, so a pattern split across the frames of one fragmented message is not matched.

ftp ​

Each line of the control connection is matched on its own, in both directions: commands going in, replies coming out.

  • Block on a command answers it with 550 and the session carries on. Blocking a reply closes the session.
  • Data connections use ports of their own, which are handled like any other port: plain tcp unless you say otherwise.
yaml
"2121":
  protocol: ftp
  rules:
    - name: no site commands
      match: { regex: '(?i)^SITE ' }

udp ​

Each datagram is matched on its own.

  • Block drops the datagram.
  • larger_than counts each datagram.

dns ​

Datagrams are parsed as DNS, so rules can also use qname, a regex over the lower-cased query name, and qtype, such as A, TXT or ANY.

  • Block answers the query with REFUSED.
yaml
"53/udp":
  protocol: dns
  rules:
    - name: data exfiltration over txt records
      match: { qtype: TXT }
    - name: only our own zone
      not: { qname: '(^|\.)example\.ctf$' }

Timeouts ​

tcp, ftp, http and WebSocket connections that see no traffic for timeouts.idle (2 minutes) are closed. There are deliberately no connection limits: in many games all traffic arrives through one NATed address, so any cap would hit the checker too.