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;