執行人: Cheng5840
期末專題影片
charliechiou
鳥哥文章最後更新內容時間為 2011 年,這之間 Netfilter 的行為或是可完成的內容是否有更動 ?
RealBigMickey
目前的封包判斷邏輯似乎由 user-space 中的 handle_sniff() 進行,為什麼不直接使用 eBPF 進行邏輯判斷,而是等待 user-space 回傳判斷結果,增加的通訊成本呢?
這樣的設計是否有特別考量?例如是否是為了保留彈性,還是出於 regex 執行限制的考量?
jserv
改寫書寫,避免只是張貼程式碼,這是專題報告,展現自身專業的所在。
2024 年報告
確保可在 Linux v6.8+ 以上運作
彙整你的認知並整理相關背景知識
探討針對 YouTube 一類網站過濾廣告的限制和解決方案
針對 HTTPS 內容,藉由 eBPF 來解析封包,從而進行內容的過濾和阻擋
應當研讀以 eBPF 打造 TCP 伺服器來強化相關認知
去年解說影片
2025 年 Linux 核心設計課程期末專題: 透過 Netfilter 自動過濾廣告
How to decode SSL in case of HTTPS packets
eBPF 執行環境受限:eBPF 程式執行於 kernel space,雖然具備近 native 的效能,但也必須通過 kernel verifier 的嚴格檢查。許多高階功能,例如正規表示式(regex)比對、記憶體動態配置(如 malloc)、遞迴、系統呼叫等,在 eBPF 中都是不允許的。
正規表示式無法在 eBPF 執行:由於我們希望攔截如 "GET /…ad.js" 等類型的廣告封包內容,因此需使用 regex 對 SSL 明文做語意判斷。而 regex 處理只能由 user-space 的 C library 完成(如 regexec()),這是 kernel space 無法實現的功能。
保留彈性與可擴展性:封包過濾邏輯可能隨使用者需求變動,若將邏輯硬編於 kernel 中,不利於更新與除錯。使用者空間實作可大幅提高系統的靈活性與維護性。
本專案設計中,有兩個重要的鏈結串列:order_head 與 verdict_head,分別存放待判斷的封包與 user-space 回傳的判決。由於這些串列在不同上下文下會同時被修改,必須保證其操作具有 thread safety。
insert_order() 中使用 mutex_trylock: 保護 order_head,避免多個核心同時插入造成 race condition。使用 trylock 而非 lock() 是為了避免阻塞在 kernel context 中導致 sleep,而破壞 kernel module 的安全性。若鎖被佔用,則本次封包會直接略過,不會被插入 queue
使用 atomic_read() 來讀取 device 開啟狀態: device_opened 為一全域 flag,用來確認是否有使用者空間程式正在運行,避免無人判斷卻仍將封包送入 queue。
使用 atomic_t 能保證多核心環境下的同步性,而不需要額外加鎖。
專案依賴 user-space 的 SSL_write() hook 來獲得明文,現在的 eBPF 掛在 libssl.so 的 SSL_write() 上,抓的是 outgoing request 的明文。也就是類似 curl → SSL_write(“GET /some_ad_script.js”)
因此你只能擋下「使用 libssl 的 outgoing TLS request」,但很多瀏覽器或廣告 SDK:使用 boringssl、nss、quiche 等非 OpenSSL 實作,甚至根本沒用標準 library
專案中沒有 http.h, tls.c, http.c
雖然在 https://github.com/steven523/Netfilter-Adblock 有這些檔案,但不知道為何該專案內沒有。
儘管有一些非致命警告,但模組有正確載入
後來發現是因為沒有執行 git submodule --init --recursive
把 lipppf submodule 下載
目前在載入 adblock module 後,執行 curl http://googleads.g.doubleclick.net
依舊會得到結果,似乎不如預期。
為了要印出封包內容而新增 print_hex_dump()
其他封包
參考: 鳥哥
Linux 的 Netfilter 機制到底可以做些什麼事情呢?其實可以進行的分析工作主要有:
當一個網路封包要進入到主機之前,會先經由 NetFilter 進行檢查,那就是 iptables 的規則了。 檢查通過則接受 (ACCEPT) 進入本機取得資源,如果檢查不通過,則可能予以丟棄 (DROP) ! 上圖中主要的目的在告知你:『規則是有順序的』!例如當網路封包進入 Rule 1 的比對時, 如果比對結果符合 Rule 1 ,此時這個網路封包就會進行 Action 1 的動作,而不會理會後續的 Rule 2, Rule 3… 等規則的分析了。
source: https://commons.wikimedia.org/wiki/File:Netfilter-packet-flow.svg
簡易版
source : https://linux.vbird.org/linux_server/centos6/0250simple_firewall.php
netfilter 與 eBPF 分別在網路 7 層的哪些 layer 運作 ?
Linux 核心設計: eBPF
Linux 核心設計: 透過 eBPF 觀察作業系統行為
Linux Extended BPF (eBPF) Tracing Tools
eBPF: Unlocking the Kernel[OFFICIAL DOCUMENTARY]
every packets that goes to facebook.com runs through eBPF at XDP layer
cilium: 最初的 eBPF 是組合語言層級的工具,對於一般 end users 難以使用,為了將 eBPF 強大的功能帶給 end users,因此創立 Cilium
18:00 最初是想要建立一個全新的網路層
BPF LSM
An eBPF application usually consists out of at least two parts:
A user-space program (USP) that declares the kernel space program and attaches it to the relevant tracepoint/probe.
A kernel-space program (KSP) is what gets triggered and runs inside the kernel once the tracepoint/probe is met. This is where the actual eBPF logic is implemented.
eBPF 能夠在封包尚未進入到 kernel 時,先過濾封包
然而,不論是 Netfilter 或 eBPF,他們若拿到 ssl 加密過的封包(https),看到的都只會是加密過的亂碼,因此無法進行有效過濾。
因此該專案嘗試直接針對封鎖特定 IP (廣告投遞商) 發出的封包,來達到過濾廣告的目的 ?
https://eunomia.dev/tutorials/0-introduce/
What Makes eBPF So Powerful?
libbpf Overview
BPF application typically goes through the following phases:
Open phase: BPF object file is parsed: BPF maps, BPF programs, and global variables are discovered, but not yet created. After a BPF app is opened, it’s possible to make any additional adjustments (setting BPF program types, if necessary; pre-setting initial values for global variables, etc), before all the entities are created and loaded.
Load phase: BPF maps are created, various relocations are resolved, BPF programs are loaded into the kernel and verified. At this point, all the parts of a BPF application are validated and exist in kernel, but no BPF program is yet executed. After the load phase, it’s possible to set up initial BPF map state without racing with the BPF program code execution.
Attachment phase: This is the phase at which BPF programs get attached to various BPF hook points (e.g., tracepoints, kprobes, cgroup hooks, network packet processing pipeline, etc). This is the phase at which BPF starts performing useful work and read/update BPF maps and global variables.
Tear down phase: BPF programs are detached and unloaded from the kernel. BPF maps are destroyed and all the resources used by the BPF app are freed.
Generated BPF skeleton has corresponding functions to trigger each phase:
<name>__open() – creates and opens BPF application;
<name>__load() – instantiates, loads, and verifies BPF application parts;
<name>__attach() – attaches all auto-attachable BPF programs (it’s optional, you can have more control by using libbpf APIs directly);
<name>__destroy() – detaches all BPF programs and frees up all used resources
在 $gpls -l
的輸出中,mode共有 10 個字元。第一個字元就是檔案類型;後面 9 個字元則是傳統的三組 rwx 權限
第一字元 | 檔案型態 |
---|---|
- |
一般(regular)檔案 |
d |
目錄 (directory) |
l |
符號連結 (symbolic link) |
b |
區塊裝置 (block device) |
c |
字元裝置 (character device) |
p |
命名管線 (FIFO) |
s |
Unix Domain Socket |
Are the major, minor number unique
From The Linux Programming Interface, §14.1
Each device file has a major ID number and a minor ID number. The major ID identifies the general class of device, and is used by the kernel to look up the appropriate driver for this type of device. The minor ID uniquely identifies a particular device within a general class. The major and minor IDs of a device file are displayed by the ls -l command.
區塊裝置會以固定大小的資料區塊(通常 512 bytes 或 4KB)為單位進行 I/O 操作,支援 random access,常用於 硬碟、SSD、USB 隨身碟
$ls -l /dev/ | grep '^b'
or $lsblk
負責處理以一個一個字元(byte)為單位進行資料傳輸的裝置。這種裝置資料是線性、串流的,不支援 random access。
你在 user-space 開啟 /dev/xxx 時,Linux 核心會根據 major number 找到對應的 device driver,再透過 file_operations 提供的 callback 處理。
device driver 不能簡稱為「驅動」,否則會造成理解的困難,使用明確的話語「裝置驅動程式」
模組的啟動入口,負責註冊字元設備、初始化資料結構,並把 Netfilter hook 掛上去
pr_alert("msg")
等同 printk(KERN_ALERT, "msg")
mod_init 流程:
呼叫 register_chrdev(0, "adbdev", &fops),動態取得一個 major number,註冊字元設備,接著透過 class_create("adbdev")
先在 /sys/class/adbdev 建 class,再用 device_create
建立真正的 /dev/adbdev 節點,供 usermode 程式操作
mod_init
是回傳 nf_register_net_hook(…),那如果 hook 註冊失敗,先前創造的 device 依然存在但功能會缺失,所以應該要自動清除,因此是否要改成ip->ihl
為 Header 長度(以 32-bit words 為單位),因此乘以 4 後即為 IP header 的實際 byte 數,tcp->doff
是 TCP Header Length(同樣單位為 32-bit words)
為甚麼 data->off 只需要計算 IPheader, TCPheader 的偏移量?
因為在 Netfilter HOOK 階段,skb->data 已經指到 IP header,MAC header 已經於先前被跳過,具體來說如下:( gpt 說的 有待求證)
eth_type_trans()(由 driver 呼叫)中透過 skb_pull() 從 MAC header 拉到 IP header
這是 在 Netfilter NF_INET_PRE_ROUTING 之前發生的事
所以其實在 hook 階段時 ip = ip_hdr(skb)
其實等價於 ip = skb->data
skb->len
= skb->tail
- skb->data
先確認是否為
ktime_t time = ktime_get();
取得當前的高精度時間(nanosecond 精度),作為該封包的唯一「時間戳記」,也是暫存隊列的識別碼
struct queue_st *order = insert_order(time);
新增一個封包處理單(order)到 order_head 鍊結串列中,order 結構儲存該封包的時間戳記與用來比對的狀態,目的是等一下讓 user-space 根據時間戳去回傳 verdict
result = -1;
初始設為未決定狀態
while(...){...}
不斷輪詢 poll_verdict() 函式,查看有沒有 user-space 程式根據這個時間戳傳入 verdict,最多等 50ms。如果超過時間或得到結果就跳出。
當呼叫 ssl_sniff.c 中的 handle_sniff()
->lseek(fd, pid | (verdict << 31), 0);
時,在 kernel 就會觸發 adbdev_lseek()
核心模組攔截到 TLS 封包時不立即決定是否放行,而是暫存該封包的處理單 queue_st 到 order_head 串列中,讓 user-space 來分析封包內容並做決定。
嘗試取得互斥鎖,避免其他執行緒同時修改 order_head,若沒成功,就不插入任何東西,返回 NULL
甚麼時候會有其他執行緒?
mutex_trylock() return 甚麼?
A:
mutex_trylock() 與 mutex_lock() 差異:
mutex_trylock() : 嘗試取得鎖,如果鎖已被持有,不會阻塞,而是立即回傳 0,取得成功則回傳 1
mutex_lock(): 嘗試取得鎖,如果鎖已經被其他 context 持有,會進入 blocking 直到可以取得鎖為止
為了讓 order_head 保持依照 timestamp 排序,找到 第一個 timestamp 小於新封包的 entry,然後用 list_add() 把新的 order 插在它前面order_head 會變成「由新到舊」(時間遞減)的順序
為甚麼 order_head 順序是時間遞減,這樣舊的封包不就要等新進來的先處理完嗎? 舊的有可能都沒辦法被處理到
A: 因為 poll_verdict()
內的 first = list_first_entry(&order_head, struct queue_st, head); if (!first || first->timestamp != timestamp) return -1;
是將最新封包跟 linked list 第一筆比較 timestamp
那如果真是如此,為甚麼 insert_order()
中會使用 list_add(&order->head, cur);
這樣似乎會出錯。
例子:
order_head → [A:300] → [B:200] → [C:100] → NULL
第一次:order = A,timestamp = 300 → 300 > 250,不 break
第二次:order = B,timestamp = 200 → 200 < 250 → break!
此時 cur 指到 B 節點 → 所以接下來執行list_add(&new_order->head, cur);
把新節點插入在 cur(B)後面 → 出錯 !!!
order_head → [A:300] → [B:200]→ [new:250] → [C:100]
所以是否應將 list_add(&order->head, cur);
改為 list_add_tail(&order->head, cur);
??
list_add source code
甚麼情況會有在 kernel-space 時,封包不按照 timestamp call inser_order() 的情況 ?
如果不會發生這種狀況,那是不是可以不用再走訪 order_head,而是直接插入 head 的下一個。
這個函式的目的是:接收來自 user-space (ssl_sniff.c) 的判決資訊,並加到 verdict_head 鏈結串列中
pid 設計:
poll_verdict(timestamp, pid)
比對第一筆 order_head 的 timestamp 是否為我們要等的,再檢查 verdict_head 中有沒有這個 pid 的結果,如果有就回傳 0 或 1(NF_ACCEPT 或 NF_DROP),同時刪掉 queue 與 verdict
為甚麼只跟第一個 element 比 ?
即使 ret = -1,也會清除該筆封包與 verdict。這設計是強制一筆封包最多等一次
函式 | 來源 | 作用 |
---|---|---|
insert_order() |
kernel | 暫存等待判決的封包(由 TLS 封包觸發) |
insert_verdict() |
user-space (ssl_sniff.c ) |
回傳對某筆封包的允許/封鎖判斷 |
poll_verdict() |
kernel | 驗證是否已有 verdict 結果(最多等 50ms)並做出動作 |
insert_verdict() 與 poll_verdict() 如何與 user-space 和 kernel-space 互動尚不了解 !!
ssl_sniff.c 是使用者空間程式,它透過 eBPF perf buffer 監控 TLS 封包內容(在函式像是 SSL_write() 中攔截明文),再根據正規表示式比對是否為廣告,並透過寫入 /dev/adbdev 回報封包判決(允許或封鎖)給 kernel 模組 adblock.ko
透過 lseek() 寫入一個含 MSB 判斷結果的 pid,驅動 kernel /dev/adbdev 的 .llseek() 實作,其中.llseek() 中呼叫的是 insert_verdict(pid);
屬於 kernel-space
這支程式的功能是:攔截 user-space 中的 SSL_write() 呼叫,把 TLS 明文資料透過 perf buffer 傳到 user-space
<bpf/bpf_tracing.h> 提供 tracing 語法糖,例如 BPF_KPROBE
建立一個 perf event buffer map,類型為 BPF_MAP_TYPE_PERF_EVENT_ARRAY
,可用來 即時將資料傳送給使用者空間程式,這種 map 是用於「事件推送」而非查詢(與 hash map 相對)
要傳給使用者空間的資料格式:
定義了一個 hook 點,掛載在使用者空間的 SSL_write() 函式,攔截 SSL_write() 可以拿到明文
SSL_write_ex() and SSL_write() write num bytes from the buffer buf into the specified ssl connection. On success SSL_write_ex() will store the number of bytes written in *written.
BPF_KPROBE(…) 是語法糖,會展開為:
BPF_F_CURRENT_CPU 表示推送到目前執行的 CPU 上對應的 perf buffer
在呼叫 ssl_write() 之前,訊息會是明文,但經過 ssl_write() 之後就會是密文,這部分在openSSL doc 中並沒有明確提及,stackoverflow 有說到 ssl_write 及 ssl_read 會自動做加解密的動作,但他們是怎麼被實做的呢 ?
似乎要了解 BIO(Basic I/O)in OpenSSL
probe (kprobe, uprobe) 是怎麼運作的?
User-space 如何接收資料?
user space 程式(ssl_sniff.c)會註冊 perf_buffer__new(map_fd, ... , callback);
然後透過 perf_buffer__poll();
不斷接收這些從 kernel 傳來的 data_t 結構資料。
https://github.com/torvalds/linux/blob/master/include/linux/ip.h
https://github.com/torvalds/linux/blob/master/include/linux/skbuff.h
https://github.com/torvalds/linux/blob/master/include/linux/skbuff.h#L883
https://github.com/torvalds/linux/blob/master/include/uapi/linux/ip.h#L87
欄位 | 說明 |
---|---|
version |
IP 版本,IPv4 固定為 4 |
ihl |
Header 長度(以 32-bit words 計算) |
tos |
服務類型(Type of Service) |
tot_len |
封包總長度(標頭 + 資料) |
id |
封包識別碼,用於分段重組 |
frag_off |
分段資訊與 flags(如 Don't Fragment) |
ttl |
存活時間(Hop 數限制) |
protocol |
上層協定號碼(如 TCP=6、UDP=17) |
check |
標頭 checksum 檢查 |
saddr |
Source IP(IPv4) |
daddr |
Destination IP(IPv4) |
https://github.com/torvalds/linux/blob/master/include/uapi/linux/tcp.h#L25C1-L60C3
欄位 | 說明 |
---|---|
source |
TCP 原始埠號 |
dest |
TCP 目的埠號 |
seq |
封包序列號 |
ack_seq |
確認應答序號 |
doff |
Header 長度(以 32-bit word 為單位) |
fin ~ cwr |
TCP 控制旗標(如 SYN、ACK、FIN) |
window |
流量控制視窗大小 |
check |
TCP 檢查碼 |
urg_ptr |
緊急指標 |
Linux 提供 atomic_t 型別來做多核心安全的數值讀寫。它能保證在 SMP(多處理器)環境下:
https://amsekharkernel.blogspot.com/2014/08/what-is-skb-in-linux-kernel-what-are.html
regular-expressions.info
regex(3) — Linux manual page
regexec() is used to match a null-terminated string against the
compiled pattern buffer in *preg, which must have been initialised
with regcomp(). eflags is the bitwise OR of zero or more of the
following flags:
regcomp() returns zero for a successful compilation or an error
code for failure.
regexec() returns zero for a successful match or REG_NOMATCH for
failure.
regerror() returns the size of the buffer required to hold the
string.
範例程式:
windows 無法編譯該程式 ( #include <regex.h> )
用來在檔案中移動讀寫位址
lseek() repositions the file offset of the open file description associated with the file descriptor fd to the argument offset according to the directive whence as follows:
- SEEK_SET The file offset is set to offset bytes.
- SEEK_CUR The file offset is set to its current location plus offset bytes.
- SEEK_END The file offset is set to the size of the file plus offset bytes.
lseek() allows the file offset to be set beyond the end of the
file (but this does not change the size of the file). If data is
later written at this point, subsequent reads of the data in the
gap (a "hole") return null bytes ('\0') until data is actually
written into the gap.