From aaa5f76b4107e730e440cb90ef6883a312218aec Mon Sep 17 00:00:00 2001 From: Arif Alam Date: Mon, 24 Nov 2025 13:29:43 -0500 Subject: [PATCH] Add DNS rewrite example with packet expansion support Introduce a new DNS rewrite example that demonstrates kernel-level DNS interception and response generation using Lunatik's netfilter hooks. The example intercepts DNS queries for 'test.internal' and responds directly with 127.0.0.1, preventing queries from reaching external DNS servers. Changes include: - Add dnsrewrite example with netfilter hook implementation that intercepts outgoing and incoming DNS packets at LOCAL_OUT and PRE_ROUTING points - Implement DNS packet parsing, response building, and checksum recalculation - Extend luadata API with expand() method to support dynamic packet buffer expansion using skb_put(), enabling in-place DNS response construction Signed-off-by: Arif Alam --- Makefile | 2 + examples/dnsrewrite/Makefile | 16 ++ examples/dnsrewrite/README.md | 136 ++++++++++++++++ examples/dnsrewrite/common.lua | 220 ++++++++++++++++++++++++++ examples/dnsrewrite/nf_dnsrewrite.lua | 26 +++ lib/luadata.c | 53 +++++++ lib/luadata.h | 3 + lib/luanetfilter.c | 3 + 8 files changed, 459 insertions(+) create mode 100644 examples/dnsrewrite/Makefile create mode 100644 examples/dnsrewrite/README.md create mode 100644 examples/dnsrewrite/common.lua create mode 100644 examples/dnsrewrite/nf_dnsrewrite.lua diff --git a/Makefile b/Makefile index 9ac7326e9..e35fb0f1d 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/examples/dnsrewrite/Makefile b/examples/dnsrewrite/Makefile new file mode 100644 index 000000000..419f3c991 --- /dev/null +++ b/examples/dnsrewrite/Makefile @@ -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 diff --git a/examples/dnsrewrite/README.md b/examples/dnsrewrite/README.md new file mode 100644 index 000000000..c6032b912 --- /dev/null +++ b/examples/dnsrewrite/README.md @@ -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. + diff --git a/examples/dnsrewrite/common.lua b/examples/dnsrewrite/common.lua new file mode 100644 index 000000000..abe6baa95 --- /dev/null +++ b/examples/dnsrewrite/common.lua @@ -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 diff --git a/examples/dnsrewrite/nf_dnsrewrite.lua b/examples/dnsrewrite/nf_dnsrewrite.lua new file mode 100644 index 000000000..906bd3bcb --- /dev/null +++ b/examples/dnsrewrite/nf_dnsrewrite.lua @@ -0,0 +1,26 @@ +local nf = require("netfilter") +local common = require("examples.dnsrewrite.common") + +local action = nf.action +local family = nf.family +local hooks = nf.inet_hooks +local pri = nf.ip_priority + +local function nf_dnsrewrite_hook(skb) + return common.hook(skb, action) +end + +-- Register netfilter hooks to intercept DNS queries +nf.register{ + hook = nf_dnsrewrite_hook, + pf = family.INET, + hooknum = hooks.LOCAL_OUT, + priority = pri.MANGLE + 1, +} + +nf.register{ + hook = nf_dnsrewrite_hook, + pf = family.INET, + hooknum = hooks.PRE_ROUTING, + priority = pri.MANGLE + 1, +} diff --git a/lib/luadata.c b/lib/luadata.c index ed8f66698..e4a12a3cc 100644 --- a/lib/luadata.c +++ b/lib/luadata.c @@ -27,6 +27,8 @@ #include #include +#include + #include #include "luadata.h" @@ -35,6 +37,7 @@ typedef struct luadata_s { char *ptr; size_t size; uint8_t opt; + struct sk_buff *skb; /* optional sk_buff pointer for packet expansion */ } luadata_t; #define LUADATA_NUMBER_SZ (sizeof(lua_Integer)) @@ -267,6 +270,43 @@ static void luadata_release(void *private) lunatik_free(data->ptr); } +/*** +* Expands the packet buffer by adding bytes to the tail. +* This function is only available when the data object is backed by an sk_buff +* (i.e., when used in netfilter hooks). It calls skb_put() to add space at the +* end of the packet buffer. +* @function expand +* @tparam integer bytes The number of bytes to add to the end of the packet buffer. +* @raise Error if the data object is not backed by an sk_buff, is read-only, +* or if there's insufficient tailroom in the sk_buff. +*/ +static int luadata_expand(lua_State *L) +{ + luadata_t *data = luadata_check(L, 1); + lua_Integer bytes = luaL_checkinteger(L, 2); + + luaL_argcheck(L, bytes > 0, 2, "bytes must be positive"); + luadata_checkwritable(L, data); + + if (data->skb == NULL) + return luaL_error(L, "expand not supported: data object not backed by sk_buff"); + + /* Check if there's enough tailroom */ + if (skb_tailroom(data->skb) < bytes) + return luaL_error(L, "insufficient tailroom in sk_buff"); + + /* Expand the sk_buff */ + skb_put(data->skb, bytes); + + /* Update the data object's size to reflect the new packet size */ + if (skb_mac_header_was_set(data->skb)) + data->size = skb_headlen(data->skb) + skb_mac_header_len(data->skb); + else + data->size = skb_headlen(data->skb); + + return 0; +} + /*** * Creates a new data object, allocating a fresh block of memory. * @function new @@ -344,6 +384,7 @@ static const luaL_Reg luadata_mt[] = { #endif {"getstring", luadata_getstring}, {"setstring", luadata_setstring}, + {"expand", luadata_expand}, {NULL, NULL} }; @@ -377,6 +418,7 @@ static inline lunatik_object_t *luadata_create(void *ptr, size_t size, bool slee data->ptr = ptr; data->size = size; data->opt = opt; + data->skb = NULL; } return object; } @@ -410,6 +452,17 @@ int luadata_reset(lunatik_object_t *object, void *ptr, size_t size, uint8_t opt) } EXPORT_SYMBOL(luadata_reset); +void luadata_set_skb(lunatik_object_t *object, struct sk_buff *skb) +{ + luadata_t *data; + + lunatik_lock(object); + data = (luadata_t *)object->private; + data->skb = skb; + lunatik_unlock(object); +} +EXPORT_SYMBOL(luadata_set_skb); + static int __init luadata_init(void) { return 0; diff --git a/lib/luadata.h b/lib/luadata.h index 92f3d9fa8..0e404cdbd 100644 --- a/lib/luadata.h +++ b/lib/luadata.h @@ -17,8 +17,11 @@ LUNATIK_LIB(data); #define luadata_clear(o) (luadata_reset((o), NULL, 0, LUADATA_OPT_KEEP)) +struct sk_buff; + lunatik_object_t *luadata_new(lua_State *L); int luadata_reset(lunatik_object_t *object, void *ptr, size_t size, uint8_t opt); +void luadata_set_skb(lunatik_object_t *object, struct sk_buff *skb); static inline void luadata_close(lunatik_object_t *object) { diff --git a/lib/luanetfilter.c b/lib/luanetfilter.c index 8db47b30f..1d9b59228 100644 --- a/lib/luanetfilter.c +++ b/lib/luanetfilter.c @@ -81,6 +81,9 @@ static int luanetfilter_hook_cb(lua_State *L, luanetfilter_t *luanf, struct sk_b else luadata_reset(data, skb->data, skb_headlen(skb), LUADATA_OPT_NONE); + /* Set the sk_buff pointer so the Lua code can call expand() */ + luadata_set_skb(data, skb); + if (lua_pcall(L, 1, 2, 0) != LUA_OK) { pr_err("%s\n", lua_tostring(L, -1)); return -1;