# Linux 專題: 透過 Netfilter 自動過濾廣告
> 執行人: ItisCaleb
> [GitHub](https://github.com/ItisCaleb/Netfilter-Adblock)
:::success
:question: 提問清單
* ?
:::
儘管我們可在網頁瀏覽器中透過像是 [AdBlock](https://getadblock.com/) 這類的 extension 來過濾廣告,但需要額外的設定和佔用更多系統資源,倘若我們能透過 [netfilter](https://www.netfilter.org/),直接在核心層級過濾網路廣告,那所有應用程式都有機會受益。
參考資訊:
* [How to drop 10 million packets per second](https://blog.cloudflare.com/how-to-drop-10-million-packets/)
* [Use the iptables firewall to block ads on your Linux machine](https://securitronlinux.com/debian-testing/use-the-iptables-firewall-to-block-ads-on-your-linux-machine/)
* [2020 年開發紀錄](https://hackmd.io/@ZhuMon/2020q1_final_project)
相關專案:
* [netfilter-blocking](https://github.com/kritpals/netfilter-blocking)
* [netfilter_block](https://github.com/tr0y-kim/netfilter_block)
* [adriver](https://github.com/Jongy/adriver)
## 說明 [netfilter](https://www.netfilter.org/) 阻擋特定來源封包的原理
[netfilter](https://www.netfilter.org/) 的原理就是對每個 [Protocol Stack](https://en.wikipedia.org/wiki/Protocol_stack) 都有一系列的 hook 在每個節點上
在官方文件中提供的 IPv4 範例是長這樣
```graphviz
digraph "IPv4 Diagram" {
rankdir=LR
"Remote IN" -> "[NF_IP_PRE_ROUTING]" -> "ROUTE"
ROUTE -> "[NF_IP_LOCAL_IN]"
"[NF_IP_LOCAL_OUT]" -> "\ROUTE" -> "[NF_IP_POST_ROUTING]" -> "Remote Out"
ROUTE -> "[NF_IP_FORWARD]" -> "[NF_IP_POST_ROUTING]"
}
```
* `NF_IP_PRE_ROUTING` 是在做 Routing 之前的 hook
* `NF_IP_LOCAL_IN` 是在把封包傳遞到 local process 之前的 hook
* `NF_IP_FORWARD` 是在把封包傳遞到別的 network inteface 之前的 hook
* `NF_IP_POST_ROUTING` 是在做 Routing 之後的 hook
* `NF_IP_LOCAL_OUT` 是從 local 往外傳遞的封包的 hook
而開發者便能透過 Netfilter 提供的這些 hook 來對封包進行過濾,甚至是修改。
在 Netfilter 的框架之中,便已經提供叫做 IP Tables 的系統來讓使用者針對封包的 IP 位置進行過濾
開發者提供的 hook function 可以回傳以下幾種回應:
* `NF_DROP`:丟棄封包。
* `NF_ACCEPT`:允許封包通過。
* `NF_STOLEN`:將封包的所有權轉移給這個 hook function,同時也意味著需要自己管理封包佔用的資源。
* `NF_QUEUE`:將封包送往 nfqueue。
* `NF_REPEAT`:重新呼叫這個 hook function。
## 以 Netfilter 阻擋特定的網址
參考 [netfilter_block](https://github.com/tr0y-kim/netfilter_block) 的程式碼,我們可撰寫應用程式來阻擋特定網址。同時也可參考〈[Use the iptables firewall to block ads on your Linux machine](https://securitronlinux.com/debian-testing/use-the-iptables-firewall-to-block-ads-on-your-linux-machine/)〉,該文提到的常見廣告網址來進行阻擋。
如果要針對特定的網域去做過濾,我們可以直接丟棄 DNS Query,但可能會造成其他協定都無法連線。
要在 user space 中處理封包,可以使用 Netfilter 提供的 nfqueue,
我們使用 [GNU gperf](https://www.gnu.org/software/gperf/) 來為要阻擋的 host 建立 hash table,只要從封包提取出來的 host 在我們的 block list 裡面,便可以將封包直接拋棄
值得注意的是,現今幾乎所有的廣告網站都使用 HTTPS 進行連接。某些瀏覽器如 Chrome 和 Firefox 會阻擋從 HTTPS 網站載入的 HTTP 資源:
* [Mixed Content Block in Chrome](https://chromestatus.com/feature/6263395770695680)
* [Mixed Content Block in Firefox](https://support.mozilla.org/en-US/kb/mixed-content-blocking-firefox)
由於大部分網站都遵循使用 HTTPS,如果廣告商未使用 HTTPS,則它們投放的廣告將無法在大部分網站上顯示。這個特性將有助於減少廣告的出現,提供更乾淨的瀏覽體驗。
如果我們要更進一步的進行過濾,像是針對路徑,我們就必須要有辦法讀取未加密的內容
其中一個方法便是利用類似[中間人攻擊](https://en.wikipedia.org/wiki/Man-in-the-middle_attack)的手法,建立一個 proxy server,當客戶端發起連線之時得到的會是自己的憑證,並且加密解密都是使用自己的公私鑰對
另一種方法則是使用 [eBPF](https://ebpf.io/applications/) 來追蹤動態函式庫,像是 OpenSSL 的 SSL_write,在 user space 獲取加密前的封包
:::warning
若能及早在使用者層級取得 SSL 資訊,或許就能追蹤封包,參見 [Debugging with eBPF Part 3: Tracing SSL/TLS connections](https://blog.px.dev/ebpf-openssl-tracing/)
:notes: jserv
:::
## 藉由核心模組阻擋已知的廣告網址
參照 [adriver](https://github.com/Jongy/adriver),以 Linux 核心模組來阻擋已知的廣告網址。
使用 Linux 核心模組,即可在核心裡頭註冊不同的 hook 來處理封包
```c
static struct nf_hook_ops blocker_ops = {.hook = blocker_hook,
.pf = NFPROTO_IPV4,
.hooknum = NF_INET_LOCAL_OUT};
static int mod_init(void)
{
return nf_register_net_hook(&init_net, &blocker_ops);
}
static void mod_exit(void)
{
nf_unregister_net_hook(&init_net, &blocker_ops);
}
```
而 hook function 則需要接收三個參數
* `priv` 為在 `nf_hook_ops` 的 `priv` 提供的物件
* `skb` 為此封包的 `sk_buff`
* `state` 則為此封包的各種資訊,包括裝置、網路的命名空間等
```c
static unsigned int blocker_hook(void *priv,
struct sk_buff *skb,
const struct nf_hook_state *state)
{
...
```
`sk_buff` 可參考 [Socket Buffer](http://vger.kernel.org/~davem/skb.html),這個結構便存著封包的資料。
### 從 `sk_buff` 中提取 TCP 及 UDP 的資料
為了獲取 host,我們首先需要從 TCP 或 UDP 的資料中提取它
對於 UDP 版本,我們將 `tcp->doff * 4` 修改為 `sizeof(struct udphdr)`,這是因為 UDP header 是固定長度的,而 TCP header 可能包含額外的欄位
由於 `sk_buff` 可能不是連續的,這可能導致在讀取資料時讀取到錯誤的記憶體位置,因此我們使用 `skb_linearize()` 函式將 `sk_buff` 轉換為連續的記憶體,這樣可以確保我們能夠正確獲取資料。
```c
static int extract_tcp_data(struct sk_buff *skb, char **data)
{
struct iphdr *ip = NULL;
struct tcphdr *tcp = NULL;
size_t data_off, data_len;
if (!skb || !(ip = ip_hdr(skb)) || IPPROTO_TCP != ip->protocol)
return -1; // not ip - tcp
if (!(tcp = tcp_hdr(skb)))
return -1; // bad tcp
/* data length = total length - ip header length - tcp header length */
data_off = ip->ihl * 4 + tcp->doff * 4;
data_len = skb->len - data_off;
if (data_len == 0)
return -1;
if (skb_linearize(skb))
return -1;
*data = skb->data + data_off;
return data_len;
}
```
### 提取 host 並阻擋
一旦自 `extract_udp_data()` 擷取封包資料後,直接去處理 DNS query,並用 gperf 產生的完美雜湊函數來判斷 host 是否在 block list 中,若是,則拋棄該封包。
DNS 協定的處理可參照 [RFC 1035](https://datatracker.ietf.org/doc/html/rfc1035)
```c
/*
Extract TCP data
*/
len = extract_udp_data(skb, &data);
if (len > 0) {
if (ntohs(udp_hdr(skb)->dest) == 53) {
/*
Extract host from data
*/
dns_protocol->parse_packet(data, len, &host);
/*
Drop packet if host is within block list
*/
if (host) {
result = in_word_set(host, strlen(host)) ? 1 : 0;
}
kfree(host);
}
}
```
### 針對路徑阻擋
對於 HTTP 封包,由於在核心空間內處理,我們只能使用簡單的 [glob](https://man7.org/linux/man-pages/man7/glob.7.html) 來阻擋
```c
else if (strncmp(data, "GET ", sizeof("GET ") - 1) == 0) {
/* HTTP */
result = glob_match("*ad[bcfgklnpqstwxyz_.=?-]*", data + 4);
}
```
而對於 HTTPS 封包,因為是在 user space 處理,於是我們可用 POSIX 的 [<regex.h>](https://man7.org/linux/man-pages/man3/regcomp.3.html) 來做阻擋,並藉由 [character device drivers](https://sysprog21.github.io/lkmpg/#character-device-drivers) 直接把處理結果傳遞給核心模組。
由於 pid 是有號整數,所以我們可將該結果保存於 pid 的 MSB:
```c
const char regexp[] = "[/_.?\\-]ad[bcfgklnpqstwxyz/_.=?\\-]";
regex_t preg;
void handle_sniff(void *ctx, int cpu, void *data, unsigned int data_sz)
{
struct data_t *d = data;
uint32_t result = 0;
if (d->buf[0] == 'G' && d->buf[1] == 'E' && d->buf[2] == 'T') {
int r = regexec(&preg, d->buf, 0, NULL, 0);
if (!r)
result = 1;
}
lseek(fd, d->pid | result << 31, 0);
}
```
Netfilter hook 在執行的時候是非同步的,同時由於多個封包可以由同一個 process 發送,所以我們需要設計一個機制來處理
1. 我們宣告兩個 linked list `order` 跟 `verdict`,前者記錄每一個封包到 hook 的 timestamp 並依照時間先後排順序,而後者則紀錄 pid 跟判斷結果
2. 每次 poll 時,最前面的 order 在 verdict 裡面找對應自己的 pid,並取出判斷結果
3. 如果找不到或超時表示 eBPF 沒有探測到對應的加密函式,只能直接通過
細節如下
```c
struct queue_st {
struct list_head head;
union {
ktime_t timestamp;
pid_t pid;
};
};
struct list_head order_head, verdict_head;
```
插入 `order` 使用 `insert_order(time)`,同一時間只能有一個東西插入 `order`
```c
ktime_t time = ktime_get();
struct queue_st *order = insert_order(time);
```
```c
struct queue_st *insert_order(ktime_t timestamp)
{
int ret = mutex_trylock(&insert_mutex);
if (ret != 0) {
struct list_head *cur;
struct queue_st *order;
list_for_each (cur, &order_head) {
order = list_entry(cur, struct queue_st, head);
if (order->timestamp < timestamp)
break;
}
order = kmalloc(sizeof(struct queue_st), GFP_KERNEL);
order->timestamp = timestamp;
list_add(&order->head, cur);
mutex_unlock(&insert_mutex);
return order;
}
return NULL;
}
```
`insert_verdict()` 則不需要 locking,因為 user space 的 program 是同步的
```c
void insert_verdict(pid_t pid)
{
struct queue_st *verdict;
verdict = kmalloc(sizeof(struct queue_st), GFP_KERNEL);
verdict->pid = pid;
list_add_tail(&verdict->head, &verdict_head);
}
```
取出判斷結果則是不斷使用 `poll_verdict()` 直到超時為止,跟據傳入的 timestamp 來確認是不是 `order` 的第一項,是的話就直接在 `verdict` 尋找對應的 pid 並取出結果
```c
while (result == -1 && ktime_to_ms(ktime_sub(ktime_get(), time)) < 50) {
result = poll_verdict(time, current->pid);
}
```
```c
int poll_verdict(ktime_t timestamp, pid_t pid)
{
struct queue_st *verdict, *first;
int ret = -1;
if (list_empty(&order_head) || list_empty(&verdict_head))
return -1;
first = list_first_entry(&order_head, struct queue_st, head);
if (!first || first->timestamp != timestamp)
return -1;
list_for_each_entry (verdict, &verdict_head, head) {
pid_t cpid = verdict->pid & ((1U << 31) - 1);
int result = (u32) verdict->pid >> 31;
if (cpid == pid) {
ret = result;
break;
}
}
list_del(&verdict->head);
list_del(&first->head);
kfree(verdict);
kfree(first);
return ret;
}
```
若判斷結果是要阻擋,我們在最後向伺服器發起 TCP Reset 來強制中斷連線,並丟棄封包:
```c
if (result > 0) {
send_server_reset(skb, state);
return NF_DROP;
}
```
## 藉由 eBPF 獲取加密前的 HTTP Header
為了要使用 eBPF,我們只好額外建立 user space 的程式,並讓他傳遞資料給我們的核心模組。
我們使用 [libbpf](https://github.com/libbpf/libbpf) 來植入我們的 eBPF 程式
我們只需要 HTTP 的路徑就好,並不需要完整的 HTTP request,同時較小的 buffer 也可以直接塞入 eBPF 的 stack 裡面,而不需要額外去定義 map
而為了能讓判斷結果跟封包映射我們還需要程式的 pid
```c
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(int));
__uint(value_size, sizeof(int));
} tls_event SEC(".maps");
#define BUF_MAX_LEN 256
struct data_t {
unsigned int pid;
int len;
unsigned char buf[BUF_MAX_LEN];
};
```
接著使用 `bpf_perf_event_output()` 來將 request 以及 pid 輸出到我們 user space 的程式
```c
SEC("uprobe")
int BPF_KPROBE(probe_SSL_write, void *ssl, char *buf, int num)
{
unsigned long long current_pid_tgid = bpf_get_current_pid_tgid();
unsigned int pid = current_pid_tgid >> 32;
int len = num;
if (len < 0)
return 0;
struct data_t data;
data.pid = pid;
data.len = (len < BUF_MAX_LEN ? (len & BUF_MAX_LEN - 1) : BUF_MAX_LEN);
bpf_probe_read_user(data.buf, data.len, buf);
bpf_perf_event_output(ctx, &tls_event, BPF_F_CURRENT_CPU, &data,
sizeof(struct data_t));
return 0;
}
```
最後在我們的程式中使用 `perf_buffer__poll()` 就能獲得 BPF 輸出的資料,並且傳遞到我們提供的 callback function `handle_sniff()`
```c
pb = perf_buffer__new(bpf_map__fd(skel->maps.tls_event), 8, &handle_sniff,
NULL, NULL, NULL);
if (libbpf_get_error(pb)) {
fprintf(stderr, "Failed to create perf buffer\n");
return 0;
}
...
printf("All ok. Sniffing plaintext now\n");
while (1) {
int err = perf_buffer__poll(pb, 1);
if (err < 0) {
printf("Error polling perf buffer: %d\n", err);
break;
}
}
```
## 已知問題
### libbpf attach
libbpf 具備透過 binary 路徑和函式名稱獲取 probe 需要 attach 的位置。然而,有時候可能會無法讀取到相應的 symbol。在這種情況下,需要使用腳本計算所需 symbol 的 offset,然後將其傳遞給 libbpf
此外,瀏覽器的加密函式並不一定存在於 `/usr/lib` 或 `/lib/x86_64-linux-gnu` 這類存放共用動態連結函式庫的目錄中。例如 Firefox 將相關的動態連結函式庫放在自定的目錄,而 Chrome 則沒有公開相關的 symbol,需要使用逆向工程的手法來獲取對應函式的 offset。
### HTTP/2
目前無法處理 HTTP/2,遇到對應的封包只能直接通過