使用 nlmon 驅動抓取 netlink 報文的原理

longyu_wlz發表於2020-09-30

前言

如何抓取 netlink 報文 這篇部落格中,我描述了使用 nlmon 驅動建立虛擬 tap 口抓取 netlink 報文的過程,在這篇文章中,我探討下這一過程背後的原理。

nlmon 驅動

nlmon 驅動原始碼位於核心原始碼樹中 drivers/net/ 目錄中,由單個原始檔——nlmon.c 組成,其程式碼長度只有 150 多行。

與普通的驅動程式碼一樣,它也有一個初始化與解初始化函式,這兩個函式分別實現的功能是在核心中註冊一個 rtnl_link_ops 結構,解除註冊的 rtnl_link_ops 結構。

rtnetlink link_ops 連結串列

這裡首先需要指明的是,在 rtnetlink.c 中建立了一個 link_ops 連結串列,這個連結串列將所有的 rtnl_link_ops 以 find 為唯一識別符號連結起來

這裡描述的 rtnl_link_ops 是什麼東西呢?

這裡的 rtnl_link_ops 是 net device 中 netlink 相關操作的方法,在每個 struct net 結構體中有一個指向 rtnl_link_ops 的指標,用以例項化不同的 rtnl_link_ops。

struct net_device 中的相關定義如下:

  	const struct rtnl_link_ops *rtnl_link_ops;

這裡我需要說明兩點內容:

  1. rtnl_link_register 函式與 rtnl_link_unregister 函式都涉及到了對 link_ops 的操作。
  2. rtnl_link_ops 通過 struct net 中的 rtnl_link_ops 指標與一個 netdev 關聯起來,註冊一個 rtnl_link_ops 並不涉及與 netdev 的關聯,只需要在 link_ops 連結串列中新增一個節點就行,而當刪除一個 rtnl_link_ops 時,就需要對 netdev 中使用到待刪除的 rtnl_link_ops 的網路裝置進行相應處理。

這裡也說明其實 rtnl_link_ops 類似於一個框架性的功能,netdev 是它的客戶,它本身的註冊類似於擴充套件功能的行為,對 netdev 客戶的正在使用的功能沒有影響,但是當解註冊時就需要考慮可能正在被一個 netdev 客戶使用的場景。

下面我分別描述下 rtnl_link_ops 的註冊與解註冊相關函式的原理。

rtnl_link_register 函式

rtnl_link_register 函式的主要邏輯及如下:

  1. 使用 rtnl_link_ops 中的 find 欄位檢索 link_ops 連結串列,如果存在則返回 -EEXIST
  2. find 欄位檢索 link_ops 連結串列發現待鏈入的 ops 不存在則將傳入的 ops 鏈入到 link_ops 中。

這裡核心還對傳入的 rtnl_link_ops 中的函式指標進行了合法值校驗當 setup 函式值非空而 dellink 為空的時候,核心將 dellink 設定為 unregister_netdevice_queue 函式。

同時需要注意的是 link_ops 是一個共享的資料結構,對它的修改需要進行序列化處理。

可以看到,在 rtnl_link_register 中對 link_ops 連結串列的修改是在佔用了 rtnl 鎖的條件下執行的,執行完成後釋放 rtnl 鎖就完成了註冊的完整過程。

rtnl_link_unregister 函式

相較 rtnl_link_register 而言,unregister 函式做了更多的事情,從這個 unregister 函式中我們可以看到一些更內部的原理。

其主要執行邏輯如下:

  1. 獲取 pernet_ops_rwsem 訊號量,就獲取此訊號量是為了消除與 setup_net 及 cleanup_net 的競爭條件。(具體的場景目前我並不清楚!)

  2. 呼叫 rtnl_lock_unregistering_all

rtnl_lock_unregisering_all 函式值得研究,其程式碼如下:

/* Return with the rtnl_lock held when there are no network
 * devices unregistering in any network namespace.
 */
static void rtnl_lock_unregistering_all(void)
{
	struct net *net;
	bool unregistering;
	DEFINE_WAIT_FUNC(wait, woken_wake_function);

	add_wait_queue(&netdev_unregistering_wq, &wait);
	for (;;) {
		unregistering = false;
		rtnl_lock();
		/* We held write locked pernet_ops_rwsem, and parallel
		 * setup_net() and cleanup_net() are not possible.
		 */
		for_each_net(net) {
			if (net->dev_unreg_count > 0) {
				unregistering = true;
				break;
			}
		}
		if (!unregistering)
			break;
		__rtnl_unlock();

		wait_woken(&wait, TASK_UNINTERRUPTIBLE, MAX_SCHEDULE_TIMEOUT);
	}
	remove_wait_queue(&netdev_unregistering_wq, &wait);
}

上述函式,當沒有任何一個 net namespace 中的 netdev 處於 unregistering 狀態時它會直接返回並佔有 rtnl_lock,當檢測到有 netdev 處於 unregistering 狀態時,它會設定 timeout 並將當前執行緒掛起等待,此函式會等待所有 namespace 中的即將 unregistering 的 netdev 事件完成後返回並佔有 rtnl_lock。在這一過程完成後此函式會將當前程式從 netdev_unregistering_wq 等待佇列中移除

這裡需要注意的是在 for_each_net 中通過判斷 dev_unreg_count判斷是否有 netdev 待釋放,實際上 netdev 的釋放過程使用了類似延後釋放的機制,真正釋放是在呼叫了 netdev_run_todo 後完成的,在 netdev_run_todo 中還會喚醒等待 netdev_unregistering 事件的程式,與喚醒相關的程式碼如下:

		wake_up(&netdev_unregistering_wq);

wak_up 最終會呼叫 __wake_up_common 來執行預先註冊的喚醒事件函式,實際上真正將掛起到等待佇列中的程式狀態也是在這個函式中完成的。

上面提到的喚醒事件函式是 wait_queue_entry 結構體中的 func 函式指標。wait_queue_entry 結構體內容如下:

struct wait_queue_entry {
	unsigned int		flags;
	void			*private;
	wait_queue_func_t	func;
	struct list_head	entry;
};
  1. 呼叫 __rtnl_link_unregister

此函式遍歷所有的 netdev 結構,並使用 __rtnl_kill_links 來呼叫 netdev 中使用了傳入的 rtnl_link_ops 的 dellink 函式,然後呼叫 unregister_netdevice_many 來 unregister 這些相關的 netdev。最終將 ops 從註冊連結串列中移除就完成了所有的過程。

  1. 釋放 pernet_ops_rwsem 訊號量

nlmon_link_ops

nlmon_link_ops 是一個 rtnl_link_ops 的例項,它通過呼叫上文描述的 rtnl_link_register 函式來完成工作。

rtnl_link_ops 中的 setup 函式在 nlmon 驅動中有自己的實現,其程式碼如下:

static void nlmon_setup(struct net_device *dev)
{
	dev->type = ARPHRD_NETLINK;
	dev->priv_flags |= IFF_NO_QUEUE;

	dev->netdev_ops	= &nlmon_ops;
	dev->ethtool_ops = &nlmon_ethtool_ops;
	dev->needs_free_netdev = true;

	dev->features = NETIF_F_SG | NETIF_F_FRAGLIST |
			NETIF_F_HIGHDMA | NETIF_F_LLTX;
	dev->flags = IFF_NOARP;

	/* That's rather a softlimit here, which, of course,
	 * can be altered. Not a real MTU, but what is to be
	 * expected in most cases.
	 */
	dev->mtu = NLMSG_GOODSIZE;
	dev->min_mtu = sizeof(struct nlmsghdr);
}

這個 nlmon_setup 函式程式碼對 netdev 中的多個欄位進行了設定,其中需要注意的是 netdev_ops 與 ethtool_ops,這兩個欄位表明 nlmon 實現了一組虛擬網路卡驅動。

nlmon 驅動中 netdev_ops 結構體程式碼如下:

static const struct net_device_ops nlmon_ops = {
	.ndo_init = nlmon_dev_init,
	.ndo_uninit = nlmon_dev_uninit,
	.ndo_open = nlmon_open,
	.ndo_stop = nlmon_close,
	.ndo_start_xmit = nlmon_xmit,
	.ndo_get_stats64 = nlmon_get_stats64,
};

這裡 ndo_open 與 ndo_stop 是 ifconfig up、ifconfig down 最終呼叫到的函式介面。

這裡我著重描述下 nlmon_open 函式的執行過程。首先貼上函式的程式碼:

static int nlmon_open(struct net_device *dev)
{
	struct nlmon *nlmon = netdev_priv(dev);

	nlmon->nt.dev = dev;
	nlmon->nt.module = THIS_MODULE;
	return netlink_add_tap(&nlmon->nt);
}

這裡呼叫了 netlink_add_tap 介面,這個介面可以理解為建立了一個 netlink 型別的 tap 口,tcpdump 抓取 netlink 訊息實際上就是從這個 tap 口的接收與傳送佇列中獲取 netlink 資料包的。

使用者態傳送 netlink 到核心態以及核心態傳送 netlnk 到使用者態,報文都會複製到這個註冊的 netlink tap 口中,這樣 tcpdump 就能夠從這個 netlink tap 口中抓取到 netlink 報文了。

可以看到在 netlink 傳送與接收的介面中都有呼叫 netlink_deliver_tap、 netlink_deliver_tap_kernel 來投遞訊息到 netlink tap 口中。

tcpdump 抓取 netlink 包的原理

上面已經大致描述完了使用 nlmon 驅動抓取 netlink 報文的原理,不過對與 tcpdump 如何從核心抓取報文卻沒有進行描述,這裡簡單的提一提。

tcpdump 首先建立一個 AF_PACKET 型別的 socket,這個 socket 有自己獨立的 proto_os 操作。然後 tcpdump 通過 ioctl 獲取 nlmon0 網路介面的 ifindex,這個 ifindex 被用來獲取 net_device 結構。

tcpdump 的鉤子函式在 af_packet 協議操作的 bind 函式中 hook 到對應的 net_device 結構中,在這個結構中新增了一個協議。

核心程式碼是呼叫 register_prot_hook 函式,此函式中主要通過 dev_add_pack 來完成工作。

dev_add_pack 函式程式碼如下:

void dev_add_pack(struct packet_type *pt)
{
	struct list_head *head = ptype_head(pt);

	spin_lock(&ptype_lock);
	list_add_rcu(&pt->list, head);
	spin_unlock(&ptype_lock);
}

這裡需要注意 ptype_head 函式,其程式碼如下:

static inline struct list_head *ptype_head(const struct packet_type *pt)
{
	if (pt->type == htons(ETH_P_ALL))
		return pt->dev ? &pt->dev->ptype_all : &ptype_all;
	else
		return pt->dev ? &pt->dev->ptype_specific :
				 &ptype_base[ntohs(pt->type) & PTYPE_HASH_MASK];
}

這裡 pt->dev 就是待 hook 的 dev,在呼叫此函式前已經設定了此欄位,這裡的目標就是 nlmon0 的 net_device 結構體。

可以看到 dev_add_pack 實際上是修改 dev->ptype_all、dev->ptype_specific 連結串列的內容。

底層介面在收到包後要進行協議棧分發,這時候就會訪問 ptype_all 與 ptype_specific 連結串列,將報文 deliver 到連結串列中註冊的協議中,這裡的註冊的 af_packet 協議也會被推送報文,這樣 af_packet 協議就能夠得到一份報文的拷貝,並傳遞給抓包模組,就完成了抓包的過程。

與上面描述相關的程式碼如下:

static int __netif_receive_skb_core(struct sk_buff *skb, bool pfmemalloc,
				    struct packet_type **ppt_prev)
{


	list_for_each_entry_rcu(ptype, &ptype_all, list) {
		if (pt_prev)
			ret = deliver_skb(skb, pt_prev, orig_dev);
		pt_prev = ptype;
	}

	list_for_each_entry_rcu(ptype, &skb->dev->ptype_all, list) {
		if (pt_prev)
			ret = deliver_skb(skb, pt_prev, orig_dev);
		pt_prev = ptype;
	}

總結

本文對 nlmon 驅動以及 tcpdump 抓取 netlink 報文的工作原理進行了描述,儘管 nlmon 驅動的程式碼內容很少,但是其依賴的函式介面卻不容小覷,同時也必須指出的是 tcpdump 工具的原理也比表面上看上更復雜一些,不過對其原理進行研究有助於提高我對協議棧工作原理的瞭解。

這篇文章發出,算上私密的兩篇文章,就完成了 200 篇文章的目標,這個目標的實現值得慶祝,同時也意味著我能夠挑戰更高的目標,我想這也是不成為問題的。

相關文章