Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ examples_install:
${INSTALL} -m 0644 examples/dnsblock/*.lua ${SCRIPTS_INSTALL_PATH}/examples/dnsblock
${MKDIR} ${SCRIPTS_INSTALL_PATH}/examples/dnsdoctor
${INSTALL} -m 0644 examples/dnsdoctor/*.lua ${SCRIPTS_INSTALL_PATH}/examples/dnsdoctor
${MKDIR} ${SCRIPTS_INSTALL_PATH}/examples/dnsrewrite
${INSTALL} -m 0644 examples/dnsrewrite/*.lua ${SCRIPTS_INSTALL_PATH}/examples/dnsrewrite

examples_uninstall:
${RM} -r ${SCRIPTS_INSTALL_PATH}/examples
Expand Down
16 changes: 16 additions & 0 deletions examples/dnsrewrite/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# SPDX-FileCopyrightText: (c) 2025 Ring Zero Desenvolvimento de Software LTDA
# SPDX-License-Identifier: MIT OR GPL-2.0-only

# This is a simple example with only Lua files
# Installation is handled by the main Makefile's examples_install target

all:
@echo "dnsrewrite example - Lua only, no build required"

install:
@echo "Use 'make install' from the main lunatik directory"

clean:
@echo "Nothing to clean"

.PHONY: all install clean
136 changes: 136 additions & 0 deletions examples/dnsrewrite/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# DNS Rewrite Example

This example demonstrates how to intercept DNS queries and respond directly with a custom IP address. It uses Lunatik's netfilter hooks to handle DNS packets at the kernel level without letting them reach the internet.

## What It Does

The `dnsrewrite` example intercepts DNS queries for `test.internal` and responds directly with `127.0.0.1` (localhost), preventing the query from being sent to external DNS servers.

## How It Works

1. **Netfilter Hooks**: Registers hooks at both `LOCAL_OUT` and `PRE_ROUTING` points to intercept DNS query packets
2. **Packet Filtering**: Only processes UDP packets destined for port 53 (DNS)
3. **Domain Matching**: Parses the DNS question section to identify queries for `test.internal`
4. **Direct Response**: Builds a complete DNS response packet and sends it back to the querying application, preventing the query from reaching the internet

## Files

- `common.lua` - Core DNS packet parsing and response building logic
- `nf_dnsrewrite.lua` - Netfilter hook implementation

## Installation

From the main Lunatik directory:

```bash
sudo make examples_install
```

## Usage

Load the netfilter hook (note: must use `false` parameter for atomic/non-sleepable runtime):

```bash
sudo lunatik run examples/dnsrewrite/nf_dnsrewrite false
```

The `false` parameter is required because netfilter hooks run in atomic context and cannot sleep. The hook will remain active and intercept DNS queries until you unload it.

## Testing

Query for `test.internal`:

```bash
# Using dig
dig test.internal

# Using nslookup
nslookup test.internal
```

The DNS query will resolve to `127.0.0.1` without ever leaving your machine.

You can verify the interception with a packet capture:

```bash
# In another terminal, capture DNS traffic
sudo tcpdump -i any -n port 53
```

You'll see the query and response packets, but no packets will be sent to external DNS servers.

## Stopping

To stop the DNS rewriting:

```bash
sudo lunatik stop examples/dnsrewrite/nf_dnsrewrite
```

## Customization

To rewrite a different domain or use a different IP address, edit `common.lua`:

```lua
-- Change the target domain (line 11)
local target_dns = string.pack("s1s1", "your", "domain")

-- Change the target IP address (line 14)
local target_ip = 0x7F000001 -- Change to your desired IP in hex
```

For example, to use `192.168.1.100`:
```lua
local target_ip = 0xC0A80164 -- 192.168.1.100 in hex
```

## Technical Details

### DNS Packet Structure

The code intercepts UDP/IP packets containing DNS queries and transforms them into responses:
- Ethernet header (14 bytes)
- IP header (variable length, calculated from IHL field)
- UDP header (8 bytes)
- DNS header (12 bytes)
- Question section (variable length, domain name + type + class)
- Answer section (16 bytes, A record with IP address)

### Domain Name Encoding

DNS uses length-prefixed labels. For example, `test.internal` is encoded as:
```
\x04test\x08internal\x00
```

The `string.pack("s1s1", "test", "internal")` function creates this encoding.

### IP Address Format

IP addresses are stored in network byte order (big-endian). The example uses:
- `127.0.0.1` = `0x7F000001` in hexadecimal

### Hook Points

The hooks are registered at two points with priority `MANGLE + 1`:
- **LOCAL_OUT**: Intercepts packets originating from the local machine before they are routed
- **PRE_ROUTING**: Intercepts packets entering the machine before routing decisions are made

This dual-hook approach ensures DNS queries are intercepted whether they come from local applications or are forwarded through the machine.

### How the Response Works

When a matching DNS query is detected:
1. The IP source and destination addresses are swapped
2. The UDP source and destination ports are swapped
3. DNS flags are set to indicate a response (QR=1, AA=1, RA=1)
4. Answer count is set to 1, authority and additional counts to 0
5. An A record answer is added with the target IP address
6. UDP and IP checksums are recalculated
7. The packet is accepted and delivered back to the querying application
8. The original query never reaches external DNS servers

### Non-Matching Domains

DNS queries for domains that don't match `test.internal` are allowed to proceed normally to external DNS servers.

220 changes: 220 additions & 0 deletions examples/dnsrewrite/common.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
local linux = require("linux")
local string = require("string")

local common = {}

local udp = 0x11
local dns = 0x35 -- DNS port 53
local eth_len = 14

-- Target domain: test.internal
local target_dns = string.pack("s1s1", "test", "internal")

-- Target IP: 127.0.0.1
local target_ip = 0x7F000001 -- 127.0.0.1 in network byte order (big-endian)

local function get_domain(skb, off)
local _, nameoff, name = skb:getstring(off):find("([^\0]*)")
return name, nameoff + 1
end

local function calculate_ip_checksum(skb, ip_off, ip_len)
-- Zero out the checksum field first
skb:setuint16(ip_off + 10, 0)

local sum = 0
-- Sum all 16-bit words in the IP header
for i = 0, ip_len - 1, 2 do
sum = sum + skb:getuint16(ip_off + i)
end

-- Fold 32-bit sum to 16 bits
while (sum >> 16) > 0 do
sum = (sum & 0xFFFF) + (sum >> 16)
end

return ~sum & 0xFFFF
end

local function calculate_udp_checksum(skb, ip_off, ihl, udp_off, udp_len)
-- Get source and destination IP addresses
local src_ip = skb:getuint32(ip_off + 12)
local dst_ip = skb:getuint32(ip_off + 16)

-- Zero out the UDP checksum field first
skb:setuint16(udp_off + 6, 0)

local sum = 0

-- Add pseudo-header
sum = sum + ((src_ip >> 16) & 0xFFFF)
sum = sum + (src_ip & 0xFFFF)
sum = sum + ((dst_ip >> 16) & 0xFFFF)
sum = sum + (dst_ip & 0xFFFF)
sum = sum + udp -- Protocol
sum = sum + udp_len -- UDP length

-- Add UDP header and data
for i = 0, udp_len - 1, 2 do
sum = sum + skb:getuint16(udp_off + i)
end

-- Fold 32-bit sum to 16 bits
while (sum >> 16) > 0 do
sum = (sum & 0xFFFF) + (sum >> 16)
end

local checksum = ~sum & 0xFFFF
-- UDP checksum of 0x0000 should be sent as 0xFFFF
if checksum == 0 then
checksum = 0xFFFF
end

return checksum
end

function common.hook(skb, action)
-- Get protocol from IP header (offset 9)
local proto = skb:getuint8(eth_len + 9)

-- Only process UDP packets
if proto ~= udp then
return action.ACCEPT
end

-- Get IP header length (IHL field in first byte of IP header)
local ihl = skb:getuint8(eth_len) & 0x0F
local thoff = eth_len + ihl * 4 -- Transport header offset

-- Check if destination port is DNS (53) - this means it's a DNS query
local dstport = linux.ntoh16(skb:getuint16(thoff + 2))
if dstport ~= dns then
return action.ACCEPT
end

-- DNS payload starts after UDP header (8 bytes)
local dnsoff = thoff + 8

-- Get number of additional records (might include EDNS0)
local nadditional = linux.ntoh16(skb:getuint16(dnsoff + 10))

-- Skip DNS header (12 bytes) to get to question section
local question_off = dnsoff + 12

-- Parse domain name from question section
local domainname, nameoff = get_domain(skb, question_off)

-- Check if this is the domain we want to respond to
if domainname == target_dns then
print("dnsrewrite: intercepted query for test.internal, rewriting response")

-- Get the transaction ID from the query
local txid = skb:getuint16(dnsoff)

-- Get query type and class
local qtype = skb:getuint16(question_off + nameoff)
local qclass = skb:getuint16(question_off + nameoff + 2)

-- Only respond to A record queries (type 1)
local query_type = linux.ntoh16(qtype)
if query_type ~= 1 then
return action.ACCEPT
end

-- Swap IP addresses (source <-> destination)
local src_ip = skb:getuint32(eth_len + 12)
local dst_ip = skb:getuint32(eth_len + 16)
skb:setuint32(eth_len + 12, dst_ip) -- New source = old dest
skb:setuint32(eth_len + 16, src_ip) -- New dest = old source

-- Swap UDP ports (source <-> destination)
local src_port = skb:getuint16(thoff)
local dst_port = skb:getuint16(thoff + 2)
skb:setuint16(thoff, dst_port) -- New source port = old dest (53)
skb:setuint16(thoff + 2, src_port) -- New dest port = old source

-- Build DNS response header
-- Flags: Standard query response, no error
-- QR=1 (response), Opcode=0 (standard query), AA=1 (authoritative)
-- TC=0, RD=1 (recursion desired, copied from query), RA=1, Z=0, RCODE=0
local flags = 0x8580 -- Binary: 1000 0101 1000 0000
skb:setuint16(dnsoff + 2, linux.hton16(flags))

-- Set question count = 1 (already present in query)
-- Set answer count = 1
skb:setuint16(dnsoff + 6, linux.hton16(1))

-- Set authority count = 0
skb:setuint16(dnsoff + 8, linux.hton16(0))

-- Set additional count = 0
skb:setuint16(dnsoff + 10, linux.hton16(0))

-- Answer section starts after question section
local answer_off = question_off + nameoff + 4

-- Calculate required packet size
-- DNS response = header (12) + question (nameoff + 4) + answer (16)
local new_dns_len = 12 + nameoff + 4 + 16
local new_udp_len = 8 + new_dns_len
local new_ip_len = ihl * 4 + new_udp_len
local new_total_len = eth_len + new_ip_len

-- Get current packet length
local curr_ip_len = linux.ntoh16(skb:getuint16(eth_len + 2))
local curr_total_len = eth_len + curr_ip_len

-- Expand packet if needed
local expand_bytes = new_total_len - curr_total_len
if expand_bytes > 0 then
skb:expand(expand_bytes)
end

-- Answer format:
-- Name: Use pointer to question name (2 bytes: 0xC00C points to offset 12)
skb:setuint16(answer_off, linux.hton16(0xC00C))

-- Type: A record (1)
skb:setuint16(answer_off + 2, linux.hton16(1))

-- Class: IN (1)
skb:setuint16(answer_off + 4, linux.hton16(1))

-- TTL: 300 seconds (5 minutes)
skb:setuint32(answer_off + 6, linux.hton32(300))

-- Data length: 4 bytes (IPv4 address)
skb:setuint16(answer_off + 10, linux.hton16(4))

-- IP address: 127.0.0.1
skb:setuint32(answer_off + 12, linux.hton32(target_ip))

-- Calculate new packet length
-- DNS response = header (12) + question (nameoff + 4) + answer (16)
local dns_len = 12 + nameoff + 4 + 16
local udp_len = 8 + dns_len
local ip_len = ihl * 4 + udp_len

-- Update UDP length
skb:setuint16(thoff + 4, linux.hton16(udp_len))

-- Update IP total length
skb:setuint16(eth_len + 2, linux.hton16(ip_len))

-- Recalculate UDP checksum
local udp_csum = calculate_udp_checksum(skb, eth_len, ihl, thoff, udp_len)
skb:setuint16(thoff + 6, linux.hton16(udp_csum))

-- Recalculate IP checksum
local ip_csum = calculate_ip_checksum(skb, eth_len, ihl * 4)
skb:setuint16(eth_len + 10, linux.hton16(ip_csum))

-- Accept the packet (it will be delivered to the querying application)
return action.ACCEPT
end

-- For non-matching domains, allow the query to proceed normally
return action.ACCEPT
end

return common
Loading