hostapd 与 wpa_supplicant详解

根據wiki說明, wpa_supplicant 是一個免費軟體實現了 IEEE 802.11i 管理控制 (在多平台上 Linux, FreeBSD, NetBSD, AROS, Microsoft Windows, Solaris, OS/2 ), 而且wpa_supplicant 既可達到全功能的WPA2 管理控制,它也同時實現了WPA和較舊的無線局域網安全協議。

簡介

IEEE802.11i協議(無線安全標準)作為IEEE802.11協議的一部分,制定了無線安全接入的標準。 WPA和WPA2(RSN)是無線安全標準中的兩種密鑰管理規範。 WPA(或WPA2)無線安全接入又包括使用802.1x協議認證的企業版和使用PSK(預共享密鑰)認證的個人版。 Supplicant是無線客戶端上實現WPA/802.1x認證功能的組件。 wpa_supplicant是無線客戶端上實現密鑰管理和認證的supplicant軟件(對應服務器端的軟件則為hostapd)。

wpa_supplicant功能支持

支援 WPA/IEEE 802.11i features 與 EAP methods (IEEE 802.1X Supplicant):

Supported WPA/IEEE 802.11i features
– WPA-PSK (“WPA-Personal")
– WPA with EAP (e.g., with RADIUS authentication server) (“WPA-Enterprise")
– key management for CCMP, TKIP, WEP104, WEP40
– WPA and full IEEE 802.11i/RSN/WPA2
– RSN: PMKSA caching, pre-authentication
– IEEE 802.11r
– IEEE 802.11w
– Wi-Fi Protected Setup (WPS)

Supported EAP methods (IEEE 802.1X Supplicant)
– EAP-TLS
– EAP-PEAP/MSCHAPv2 (both PEAPv0 and PEAPv1)
– EAP-PEAP/TLS (both PEAPv0 and PEAPv1)
– EAP-PEAP/GTC (both PEAPv0 and PEAPv1)
– EAP-PEAP/OTP (both PEAPv0 and PEAPv1)
– EAP-PEAP/MD5-Challenge (both PEAPv0 and PEAPv1)
– EAP-TTLS/EAP-MD5-Challenge
– EAP-TTLS/EAP-GTC
– EAP-TTLS/EAP-OTP
– EAP-TTLS/EAP-MSCHAPv2
– EAP-TTLS/EAP-TLS
– EAP-TTLS/MSCHAPv2
– EAP-TTLS/MSCHAP
– EAP-TTLS/PAP
– EAP-TTLS/CHAP
– EAP-SIM
– EAP-AKA
– EAP-AKA’
– EAP-PSK
– EAP-FAST
– EAP-PAX
– EAP-SAKE
– EAP-IKEv2
– EAP-GPSK
– LEAP (note: requires special support from the driver)

補充:無線網路使用者身份認證及加密
無線網路以空氣作為傳輸的介質,透過無線網路傳送資料時,任何人只要透過適當的設備,就可以收集並且竊取未經保護的資料。因此,如果以未加密的方式透過無線傳送是相當危險的!假定有意的攻擊者已準備竊聽您所傳送的資料,那麼,前述適當的設備有可能只需要一支高增益天線以及一台筆記型電腦,攻擊者就可以躲在遠處準備搜集您的資料,而整個過程中使用者可能都毫無警覺。

[WEP]
當傳送資料無可避免地會被有心人士竊取,又必須保護其安全性,最有效的方法就是對傳送的資料進行加密。起初,WEP(Wired Equivalent Privacy, 有線等效加密)為1999年9月通過的IEEE 802.11標準的一部份,當初被視為無線安全解決方案。WEP使用RC4(Rivest Cipher)加密技術達到機密性,RC4密碼鎖是屬於一種對稱串流密碼鎖。傳送端使用金鑰串流與訊息結合產生密文傳送;接收端收到密文後,再使用同一把金鑰串流處理密文還原原始資料。由於RC4演算法設計所造成的先天性弱點,一旦重覆使用金鑰串流、串流密碼鎖,且WEP採用IV的方式,讓攻擊者在搜集到夠多的資訊後,就能夠針對其重覆部份進行分析。可供運用的IV值並不大(小於一千七百萬),在繁忙的網路環境中必然會產生重覆的狀況。不過由於WEP設計相當容易實作,設備並不需要相當強大的計算能力,在許多的設備上也必定會支援。

[802.1X]
使用WEP僅是針對具有加密金鑰的機器進行加密動作,而802.1X則是對於使用者身份進行確認,而不會造成使用同一機器但不同使用者權限授權的困擾。802.1X只是一個架構,是IEEE採用IETF的可延伸身份認證協定(Extensible Authentication Protocol, EAP)制訂而成的,屬於一種架構協定。EAP是一個基礎的封裝方式,可以適於任何的鏈路層如PPP、802.3、802.11,以及各種身份認證的方式如TLS、AKA/SIM、Token card。

EAP身份驗證方式
EAP的使用者身份驗證作業是一個稱為EAP method的附屬協定所處理,這樣的優點是EAP並不需要處理使用者驗證的細節,當有不同的需求產生時,就僅需開發新的EAP method來滿足不同的需求,如MD5 Challenge、GTC、EAP-TLS、TTLS、PEAP、EAP-SIM及MS-CHAP-V2都是常見的EAP method。

[LEAP]
LEAP(Lightweight EAP)為Cisco所專屬的,為最早普遍使用的無線網路身份認證方式。相對於WEP用手動方式設定金鑰,LEAP進行二次MS-CHAP-V1交換程序,在交換MS-CHAP程序時則衍生出動態金鑰。但也因MS-CHAP-V1所致,也導致與EAP-MD5一樣易遭受字典攻擊。不過在Cisco本身的認定上,如果使用十分複雜的密碼,LEAP仍是相當安全可靠的;但對於現實生活中的人們使用複雜的密碼,則是有其困難的一面。而較新的協定在進行MS-CHAP-V2用戶認證程序時,先行建立一個安全的傳輸層安全通道(TLS),如EAP-TTLS、PEAP,則可避免這些問題的發生。

[EAP-TLS]
EAP-TLS(Transport Layer Security)的前身為SSL(Secure Socket Layer),利用PKI來保護RADIUS的通訊。雖然EAP-TLS相當安全,但並未受到廣泛的運用,因為在無線網路環境中使用EAP-TLS,所有潛在的用戶都必須具備數位憑證;而產生以及傳遞憑證與佈置的環境中,都是相當繁雜且不容易的。

wpa_supplicant軟件設計的目標

1)與硬件和驅動不相關性
wpa_supplicant作為應用層軟件,是伴隨著IEEE802.11標準的發展以及各種硬件驅動軟件的發展和不斷完善成熟的。與硬件驅動的不相關性的設計目標也是為了在不同操作系統上的移植以及支持早期的各種wifi協議棧驅動軟件。 wpa_supplicant可以支持windows的ndis驅動,linux平台的hosap驅動,madwif驅動,ralink和atheros驅動,以及目前linux內核中主流支持的mac80211協議棧驅動。

2)OS不相關性
wpa_supplicant支持windows(包括wince),linux,BSD和Mac OS X以及嵌入式系統。

3)所有WPA功能C代碼的可移植性。

4)wpa_supplicant 是被設計成守護進程(daemon)的方式運行,就是在後台執行的程式, 通過控制接口由其他外部控制軟件(wpa_cli, wpa_gui或者其他用戶開放的軟件進行配置控制無線接入)。

總之,靈活的可移植性,可配置編譯方式可以方便地讓開發者根據需要在不同開發平台和嵌入式系統上實現WPA的安全認證功能。

wpa_supplicant軟件架構

下圖為wpa_supplicant的基本架構圖(摘自wpa_supplicant官網開發文檔圖)。

1. event loop 是核心事件處理模塊,也是wpa_supplicant的主進程程序。
從模塊名可知,它負責事件處理,處理所有的其他模塊發送過來的事件以及超時事件。並且是loop循環執行的。

2. driver i/f模塊,是負責配置控制無線驅動的API接口。
真正的功能則在wext,hosap,madwifi等接口模塊中實現。這些驅動接口模塊針對不同的OS 無線驅動的配置接口標準,或者驅動開發者自定義的配置接口。 wext是linux系統早期定義的無線配置接口標準,nl80211也是linux無線配置接口,與wext相比有些技術上的優勢(圖中未列出)。

3. driver event模塊。
wlan網絡設備驅動有些事件需要通知wpa_supplicant,讓wpa_supplicant進行下一步的處理。包括網絡連接,斷開,MIC校驗錯,關聯成功等等。該模塊的實現也是OS相關的,不同的操作系統,事件通知的機制不同。

4. L2 packet模塊。 
WPA(WPA2)的安全認證是通過EAPOL幀(以太類型0x888E)來完成的。 L2 packet模塊就是完成驅動層到應用層的EAPOL的獲取和EAPOL幀的發送。 windows系統上採用NDIS協議驅動的抓包技術,wpa_supplicant的實現支持NDISUIO協議驅動(微軟提供的協議驅動)抓包,winpcap的協議驅動抓包技術。 linux系統上則採用Packet socket抓包技術。

5. Configuration模塊。
無線配置功能模塊。

6. Ctrl i/f模塊。
提高外部配置程序的控制和事件通知。

7. WPA/WPA2狀態機。
實現WPA/WPA2的密鑰協商。

8. EAPOL狀態機。
802.1x的狀態機實現。

9. EAP狀態機。
企業級安全認證實現。

Wpa_supplicant提供的接口

從通信層次上劃分,wpa_supplicant提供向上的控制接口control interface,用於與其他模塊(如UI)進行通信,其他模塊可以通過control interface來獲取信息或下發命令。
Wpa_supplicant通過socket通信機制實現下行接口,與內核進行通信,獲取信息或下發命令。

1 上行接口

Wpa_supplicant提供兩種方式的上行接口。一種基於傳統dbus機制實現與其他進程間的IPC通信;另一種通過Unix domain socket機制實現進程間的IPC通信。

1.1 Dbus接口

該接口主要在文件“ ctrl_iface_dbus.h ”,“ ctrl_iface_dbus.c ”,“ ctrl_iface_dbus_handler.h ”和“ ctrl_iface_dbus_handler.c ”中實現,提供一些基本的控制方法。

 
DBusMessage * wpas_dbus_new_invalid_iface_error(DBusMessage *message);
DBusMessage * wpas_dbus_global_add_interface(DBusMessage *message, struct wpa_global *global);
DBusMessage * wpas_dbus_global_remove_interface(DBusMessage *message, struct wpa_global *global);
DBusMessage * wpas_dbus_global_get_interface(DBusMessage *message, struct wpa_global *global);
DBusMessage * wpas_dbus_global_set_debugparams(DBusMessage *message, struct wpa_global *global);
DBusMessage * wpas_dbus_iface_scan(DBusMessage *message, struct wpa_supplicant *wpa_s);
DBusMessage * wpas_dbus_iface_scan_results(DBusMessage *message, struct wpa_supplicant *wpa_s);
DBusMessage * wpas_dbus_bssid_properties(DBusMessage *message, struct wpa_supplicant *wpa_s, struct wpa_scan_res *res);
DBusMessage * wpas_dbus_iface_capabilities(DBusMessage *message, struct wpa_supplicant *wpa_s);
DBusMessage * wpas_dbus_iface_add_network(DBusMessage *message, struct wpa_supplicant *wpa_s);
DBusMessage * wpas_dbus_iface_remove_network(DBusMessage *message, struct wpa_supplicant *wpa_s);
DBusMessage * wpas_dbus_iface_set_network(DBusMessage *message, struct wpa_supplicant *wpa_s, struct wpa_ssid *ssid);
DBusMessage * wpas_dbus_iface_enable_network(DBusMessage *message, struct wpa_supplicant *wpa_s,  struct wpa_ssid *ssid);
DBusMessage * wpas_dbus_iface_disable_network(DBusMessage *message, struct wpa_supplicant *wpa_s, struct wpa_ssid *ssid);
DBusMessage * wpas_dbus_iface_select_network(DBusMessage *message, struct wpa_supplicant *wpa_s);
DBusMessage * wpas_dbus_iface_disconnect(DBusMessage *message, struct wpa_supplicant *wpa_s);
DBusMessage * wpas_dbus_iface_set_ap_scan(DBusMessage *message, struct wpa_supplicant *wpa_s);
DBusMessage * wpas_dbus_iface_set_smartcard_modules( DBusMessage *message, struct wpa_supplicant *wpa_s);
DBusMessage * wpas_dbus_iface_get_state(DBusMessage *message, struct wpa_supplicant *wpa_s);
DBusMessage * wpas_dbus_iface_get_scanning(DBusMessage *message, struct wpa_supplicant *wpa_s);
DBusMessage * wpas_dbus_iface_set_blobs(DBusMessage *message, struct wpa_supplicant *wpa_s);
DBusMessage * wpas_dbus_iface_remove_blobs(DBusMessage *message, struct wpa_supplicant *wpa_s);

1.2 Unix domain socket接口
該接口主要在文件“ wpa_ctrl.h ”,“ wpa_ctrl.c ”,“ ctrl_iface_unix.c ”,“ ctrl_iface.h ”和“ ctrl_iface.c ”實現。

(1) “ wpa_ctrl.h ”,“ wpa_ctrl.c ”完成對control interface的封裝,對外提供統一的接口。
其主要的工作是通過Unix domain socket建立一個control interface的client結點,與作為server的wpa_supplicant結點通信。

主要功能函數:

struct wpa_ctrl * wpa_ctrl_open (const char *ctrl_path);
/*建立並初始化一個Unix domain socket的client結點,並與作為server的wpa_supplicant結點綁定*/

void wpa_ctrl_close (struct wpa_ctrl *ctrl);
/*撤銷並銷毀已建立的Unix domain socket的client結點*/
 
int wpa_ctrl_request (struct wpa_ctrl *ctrl, const char *cmd, size_t cmd_len,
                   char *reply, size_t *reply_len,
                   void (*msg_cb)(char *msg, size_t len​​));

/*用戶模塊直接調用該函數對wpa_supplicant發送命令並獲取所需信息 */

Note: Wpa_supplicant提供兩種由外部模塊獲取信息的方式:一種是外部模塊通過發送request命令然後獲取response的問答模式,另一種是wpa_supplicant主動向外部發送event事件,由外部模塊監聽接收。一般的常用做法是外部模塊通過調用wpa_ctrl_open()兩次,建立兩個control interface接口,一個為ctrl interface,用於發送命令,獲取信息,另一個為monitor interface,用於監聽接收來自於wpa_supplicant的event時間。此舉可以降低通信的耦合性,避免response和event的相互干擾。

int wpa_ctrl_attach (struct wpa_ctrl *ctrl); /*註冊 某個control interface作為monitor interface */ int wpa_ctrl_detach (struct wpa_ctrl *ctrl); /*撤銷某個monitor interface為 普通的control interface */ int wpa_ctrl_pending (struct wpa_ctrl *ctrl); /*判斷是否有掛起的event事件*/ int wpa_ctrl_recv (struct wpa_ctrl *ctrl, char *reply, size_t *reply_len); /*獲取掛起的event事件 */

(2) “ ctrl_iface_unix.c ”實現wpa_supplicant的Unix domain socket通信機制中server結點,完成對client結點的響應。

其中最主要的兩個函數為:

static void wpa_supplicant_ctrl_iface_receive (int sock, void *eloop_ctx, void *sock_ctx)
/*接收並解析client發送request命令,然後根據不同的命令調用底層不同的處理函數;
 *然後將獲得response結果回饋到client結點。
 */
 
static void wpa_supplicant_ctrl_iface_send (struct ctrl_iface_priv *priv, int level, const char *buf, size_t len​​)
/*向註冊的monitor interfaces主動發送event事件*/

(3) “ ctrl_iface.h ”和“ ctrl_iface.c ”主要實現了各種request命令的底層處理函數。

2 下行接口:

wpa_supplicant與kernel(driver)進行通信, wpa_supplicant通過socket通信機制實現下行接口,與kernel(driver)進行通信,獲取信息或下發命令。
附圖說明wpa_supplicant與驅動交互的過程。
從該圖看,因為應用層部分還有WifiLayer類,說明android源碼應該是2.1的,比較老了。
不過分析了下wpa_supplicant,感覺變動不大,還是可以參考一下底層這一部分的。

wpa_supplicant下行接口主要包括三種重要的接口:

1 .PF_INET socket接口,主要用於向kernel發送ioctl命令,控制並獲取相應信息。
2 .PF_NETLINK socket接口,主要用於接收kernel發送上來的event事件。
3 .PF_PACKET socket接口,主要用於向driver傳遞802.1X報文。

主要涉及到的文件包括:
driver.h,drivers.c,driver_wext.h,driver_wext.c,l2_packet.h和l2_packet_linux.c。
其中,
– driver.h,drivers.c,driver_wext.h和driver_wext.c實現PF_INET socket接口和PF_NETLINK socket接口;
– l2_packet.h和l2_packet_linux.c實現PF_PACKET socket接口。

(1) driver.h/drivers.c:主要用於封裝底層差異,對外顯示一個相同的wpa_driver_ops接口。
wpa_supplicant可支持atmel, Broadcom, ipw, madwifi, ndis, nl80211, wext等多種驅動。
其中一個最主要的數據結構為wpa_driver_ops, 其定義了driver相關的各種操作接口。

/**
 * struct wpa_driver_ops - Driver interface API definition
 *
 * This structure defines the API that each driver interface needs to implement
 * for core wpa_supplicant code. All driver specific functionality is captured
 * in this wrapper.
 */
struct wpa_driver_ops {
	/** Name of the driver interface */
	const char *name;
	/** One line description of the driver interface */
	const char *desc;

	/**
	 * get_bssid - Get the current BSSID
	 * @priv: private driver interface data
	 * @bssid: buffer for BSSID (ETH_ALEN = 6 bytes)
	 *
	 * Returns: 0 on success, -1 on failure
	 *
	 * Query kernel driver for the current BSSID and copy it to bssid.
	 * Setting bssid to 00:00:00:00:00:00 is recommended if the STA is not
	 * associated.
	 */
	int (*get_bssid)(void *priv, u8 *bssid);

	
	.... (太長了!! 省略)...
};

(2) driver_nl80211.c實現了nl80211形式的wpa_driver_ops

並創建了PF_INET socket接口,然後通過這接口完成與kernel的信息交互。
nl80211提供的一個主要資料結構為:

struct wpa_driver_nl80211_data {
	struct nl80211_global *global;
	struct dl_list list;
	struct dl_list wiphy_list;
	char phyname[32];
	void *ctx;
	int ifindex;
	int if_removed;
	int if_disabled;
	int ignore_if_down_event;
	struct rfkill_data *rfkill;
	struct wpa_driver_capa capa;
	u8 *extended_capa, *extended_capa_mask;
	unsigned int extended_capa_len;
	int has_capability;

	int operstate;

	int scan_complete_events;

	struct nl_cb *nl_cb;

	u8 auth_bssid[ETH_ALEN];
	u8 auth_attempt_bssid[ETH_ALEN];
	u8 bssid[ETH_ALEN];
	u8 prev_bssid[ETH_ALEN];
	int associated;
	u8 ssid[32];
	size_t ssid_len;
	enum nl80211_iftype nlmode;
	enum nl80211_iftype ap_scan_as_station;
	unsigned int assoc_freq;

	int monitor_sock;
	int monitor_ifidx;
	int monitor_refcount;

	unsigned int disabled_11b_rates:1;
	unsigned int pending_remain_on_chan:1;
	unsigned int in_interface_list:1;
	unsigned int device_ap_sme:1;
	unsigned int poll_command_supported:1;
	unsigned int data_tx_status:1;
	unsigned int scan_for_auth:1;
	unsigned int retry_auth:1;
	unsigned int use_monitor:1;
	unsigned int ignore_next_local_disconnect:1;
	unsigned int allow_p2p_device:1;

	u64 remain_on_chan_cookie;
	u64 send_action_cookie;

	unsigned int last_mgmt_freq;

	struct wpa_driver_scan_filter *filter_ssids;
	size_t num_filter_ssids;

	struct i802_bss *first_bss;

	int eapol_tx_sock;

#ifdef HOSTAPD
	int eapol_sock; /* socket for EAPOL frames */

	int default_if_indices[16];
	int *if_indices;
	int num_if_indices;

	int last_freq;
	int last_freq_ht;
#endif /* HOSTAPD */

	/* From failed authentication command */
	int auth_freq;
	u8 auth_bssid_[ETH_ALEN];
	u8 auth_ssid[32];
	size_t auth_ssid_len;
	int auth_alg;
	u8 *auth_ie;
	size_t auth_ie_len;
	u8 auth_wep_key[4][16];
	size_t auth_wep_key_len[4];
	int auth_wep_tx_keyidx;
	int auth_local_state_change;
	int auth_p2p;
};

裡面有一個 struct nl80211_global *global;
看一下定義如下

struct nl80211_global {
	struct dl_list interfaces;
	int if_add_ifindex;
	u64 if_add_wdevid;
	int if_add_wdevid_set;
	struct netlink_data *netlink;
	struct nl_cb *nl_cb;
	struct nl_handle *nl;
	int nl80211_id;
	int ioctl_sock; /* socket for ioctl() use */  // PF_INET socket接口

	struct nl_handle *nl_event;
};

初始化

static void * nl80211_global_init(void)
{
	struct nl80211_global *global;
	struct netlink_config *cfg;

	global = os_zalloc(sizeof(*global));
	if (global == NULL)
		return NULL;
	global->ioctl_sock = -1;
	dl_list_init(&global->interfaces);
	global->if_add_ifindex = -1;

	cfg = os_zalloc(sizeof(*cfg));
	if (cfg == NULL)
		goto err;

	cfg->ctx = global;
	cfg->newlink_cb = wpa_driver_nl80211_event_rtm_newlink;
	cfg->dellink_cb = wpa_driver_nl80211_event_rtm_dellink;
	global->netlink = netlink_init(cfg);
	if (global->netlink == NULL) {
		os_free(cfg);
		goto err;
	}

	if (wpa_driver_nl80211_init_nl_global(global) < 0)
		goto err;

	global->ioctl_sock = socket(PF_INET, SOCK_DGRAM, 0);  //建立 PF_INET 的 socket
	if (global->ioctl_sock < 0) {
		perror("socket(PF_INET,SOCK_DGRAM)");
		goto err;
	}

	return global;

err:
	nl80211_global_deinit(global);
	return NULL;
}

將各函式包進wpa_driver_ops類型資料結構 wpa_driver_nl80211_ops裡面
這樣對wpa_supplicant較上層的部分, 可以統一呼叫的API
只要這裡有對應不同平台的function call即可

const struct wpa_driver_ops wpa_driver_nl80211_ops = {
	.name = "nl80211",
	.desc = "Linux nl80211/cfg80211",
	.get_bssid = wpa_driver_nl80211_get_bssid,
	.get_ssid = wpa_driver_nl80211_get_ssid,
	.set_key = driver_nl80211_set_key,
	.scan2 = driver_nl80211_scan2,
	.sched_scan = wpa_driver_nl80211_sched_scan,
	.stop_sched_scan = wpa_driver_nl80211_stop_sched_scan,
	.get_scan_results2 = wpa_driver_nl80211_get_scan_results,
	.deauthenticate = driver_nl80211_deauthenticate,
	.authenticate = driver_nl80211_authenticate,
	.associate = wpa_driver_nl80211_associate,
	.global_init = nl80211_global_init,
	.global_deinit = nl80211_global_deinit,
	.init2 = wpa_driver_nl80211_init,
	.deinit = driver_nl80211_deinit,
	.get_capa = wpa_driver_nl80211_get_capa,
	.set_operstate = wpa_driver_nl80211_set_operstate,
	.set_supp_port = wpa_driver_nl80211_set_supp_port,
	.set_country = wpa_driver_nl80211_set_country,
	.get_country = wpa_driver_nl80211_get_country,
	.set_ap = wpa_driver_nl80211_set_ap,
	.set_acl = wpa_driver_nl80211_set_acl,
	.if_add = wpa_driver_nl80211_if_add,
	.if_remove = driver_nl80211_if_remove,
	.send_mlme = driver_nl80211_send_mlme,
	.get_hw_feature_data = wpa_driver_nl80211_get_hw_feature_data,
	.sta_add = wpa_driver_nl80211_sta_add,
	.sta_remove = driver_nl80211_sta_remove,
	.hapd_send_eapol = wpa_driver_nl80211_hapd_send_eapol,
	.sta_set_flags = wpa_driver_nl80211_sta_set_flags,
#ifdef HOSTAPD
	.hapd_init = i802_init,
	.hapd_deinit = i802_deinit,
	.set_wds_sta = i802_set_wds_sta,
#endif /* HOSTAPD */
#if defined(HOSTAPD) || defined(CONFIG_AP)
	.get_seqnum = i802_get_seqnum,
	.flush = i802_flush,
	.get_inact_sec = i802_get_inact_sec,
	.sta_clear_stats = i802_sta_clear_stats,
	.set_rts = i802_set_rts,
	.set_frag = i802_set_frag,
	.set_tx_queue_params = i802_set_tx_queue_params,
	.set_sta_vlan = driver_nl80211_set_sta_vlan,
	.sta_deauth = i802_sta_deauth,
	.sta_disassoc = i802_sta_disassoc,
#endif /* HOSTAPD || CONFIG_AP */
	.read_sta_data = driver_nl80211_read_sta_data,
	.set_freq = i802_set_freq,
	.send_action = driver_nl80211_send_action,
	.send_action_cancel_wait = wpa_driver_nl80211_send_action_cancel_wait,
	.remain_on_channel = wpa_driver_nl80211_remain_on_channel,
	.cancel_remain_on_channel =
	wpa_driver_nl80211_cancel_remain_on_channel,
	.probe_req_report = driver_nl80211_probe_req_report,
	.deinit_ap = wpa_driver_nl80211_deinit_ap,
	.deinit_p2p_cli = wpa_driver_nl80211_deinit_p2p_cli,
	.resume = wpa_driver_nl80211_resume,
	.send_ft_action = nl80211_send_ft_action,
	.signal_monitor = nl80211_signal_monitor,
	.signal_poll = nl80211_signal_poll,
	.send_frame = nl80211_send_frame,
	.shared_freq = wpa_driver_nl80211_shared_freq,
	.set_param = nl80211_set_param,
	.get_radio_name = nl80211_get_radio_name,
	.add_pmkid = nl80211_add_pmkid,
	.remove_pmkid = nl80211_remove_pmkid,
	.flush_pmkid = nl80211_flush_pmkid,
	.set_rekey_info = nl80211_set_rekey_info,
	.poll_client = nl80211_poll_client,
	.set_p2p_powersave = nl80211_set_p2p_powersave,
	.start_dfs_cac = nl80211_start_radar_detection,
	.stop_ap = wpa_driver_nl80211_stop_ap,
#ifdef CONFIG_TDLS
	.send_tdls_mgmt = nl80211_send_tdls_mgmt,
	.tdls_oper = nl80211_tdls_oper,
#endif /* CONFIG_TDLS */
	.update_ft_ies = wpa_driver_nl80211_update_ft_ies,
	.get_mac_addr = wpa_driver_nl80211_get_macaddr,
	.get_survey = wpa_driver_nl80211_get_survey,
#ifdef ANDROID_P2P
	.set_noa = wpa_driver_set_p2p_noa,
	.get_noa = wpa_driver_get_p2p_noa,
	.set_ap_wps_ie = wpa_driver_set_ap_wps_p2p_ie,
#endif
#ifdef ANDROID
	.driver_cmd = wpa_driver_nl80211_driver_cmd,
#endif
};

(3) driver_wext.h/driver_wext.c實現了wext形式的wpa_driver_ops

並創建了PF_INET socket接口和PF_NETLINK socket接口(wpa_supplicant_8中沒有創建PF_NETLINK socket接口),然後通過這兩個接口完成與kernel的信息交互。

wext提供的一個主要數據結構為:

struct wpa_driver_wext_data {
	void *ctx;
	int event_sock;  // PF_NETLINK socket接口 (似乎沒用到)
	int ioctl_sock;  // PF_INET socket接口
	int mlme_sock;
	char ifname[IFNAMSIZ + 1];
	int ifindex;
	int ifindex2;
	u8 *assoc_req_ies;
	size_t assoc_req_ies_len;
	u8 *assoc_resp_ies;
	size_t assoc_resp_ies_len;
	struct wpa_driver_capa capa;
	int has_capability;
	int we_version_compiled;

	/* for set_auth_alg fallback */
	int use_crypt;
	int auth_alg_fallback;

	int operstate;

	char mlmedev[IFNAMSIZ + 1];

	int scan_complete_events;
};

其中event_sock 為PF_NETLINK socket接口,ioctl_sock為PF_INET socket接口。

driver_wext.c 實現了大量底層處理函數用於實現wpa_driver_ops操作參數,其中比較重要的有:

/* 初始化wpa_driver_wext_data數據結構,並創建PF_NETLINK socket和PF_INET socket接口 */
void * wpa_driver_wext_init( void *ctx, const char * ifname);

/**
 * wpa_driver_wext_init - Initialize WE driver interface
 * @ctx: context to be used when calling wpa_supplicant functions,
 * e.g., wpa_supplicant_event()
 * @ifname: interface name, e.g., wlan0
 * Returns: Pointer to private data, %NULL on failure
 */
void * wpa_driver_wext_init(void *ctx, const char *ifname)
{
	struct wpa_driver_wext_data *drv;
	struct netlink_config *cfg;
	struct rfkill_config *rcfg;
	char path[128];
	struct stat buf;

	wpa_printf(MSG_DEBUG, "WEXT: wpa_driver_wext_init 001");
	drv = os_zalloc(sizeof(*drv));
	if (drv == NULL)
		return NULL;
	drv->ctx = ctx;
	os_strlcpy(drv->ifname, ifname, sizeof(drv->ifname));

	os_snprintf(path, sizeof(path), "/sys/class/net/%s/phy80211", ifname);
	if (stat(path, &buf) == 0) {
		wpa_printf(MSG_DEBUG, "WEXT: cfg80211-based driver detected");
		drv->cfg80211 = 1;
		wext_get_phy_name(drv);
	}
	wpa_printf(MSG_DEBUG, "WEXT: wpa_driver_wext_init 002");

	drv->ioctl_sock = socket(PF_INET, SOCK_DGRAM, 0);
	if (drv->ioctl_sock < 0) { 		perror("socket(PF_INET,SOCK_DGRAM)"); 		goto err1; 	} 	cfg = os_zalloc(sizeof(*cfg)); 	if (cfg == NULL) 		goto err1; 	cfg->ctx = drv;
	cfg->newlink_cb = wpa_driver_wext_event_rtm_newlink;
	cfg->dellink_cb = wpa_driver_wext_event_rtm_dellink;
	drv->netlink = netlink_init(cfg);
	if (drv->netlink == NULL) {
		os_free(cfg);
		goto err2;
	}
	wpa_printf(MSG_DEBUG, "WEXT: wpa_driver_wext_init 003");

	rcfg = os_zalloc(sizeof(*rcfg));
	if (rcfg == NULL)
		goto err3;
	rcfg->ctx = drv;
	os_strlcpy(rcfg->ifname, ifname, sizeof(rcfg->ifname));
	rcfg->blocked_cb = wpa_driver_wext_rfkill_blocked;
	rcfg->unblocked_cb = wpa_driver_wext_rfkill_unblocked;
	drv->rfkill = rfkill_init(rcfg);
	if (drv->rfkill == NULL) {
		wpa_printf(MSG_DEBUG, "WEXT: RFKILL status not available");
		os_free(rcfg);
	}

	drv->mlme_sock = -1;

#ifdef ANDROID
	drv->errors = 0;
	drv->driver_is_started = TRUE;
	drv->bgscan_enabled = 0;
#endif /* ANDROID */
	wpa_printf(MSG_DEBUG, "WEXT: wpa_driver_wext_init 005");

	if (wpa_driver_wext_finish_drv_init(drv) < 0) 		goto err3; 	wpa_driver_wext_set_auth_param(drv, IW_AUTH_WPA_ENABLED, 1); 	wpa_printf(MSG_DEBUG, "WEXT: wpa_driver_wext_init 006"); 	return drv; err3: 	rfkill_deinit(drv->rfkill);
	netlink_deinit(drv->netlink);
err2:
	close(drv->ioctl_sock);
err1:
	os_free(drv);
	return NULL;
}

/* 銷毀wpa_driver_wext_data數據結構,PF_NETLINK socket和PF_INET socket接口 */
void wpa_driver_wext_deinit( void * priv);

 
/**
 * wpa_driver_wext_deinit - Deinitialize WE driver interface
 * @priv: Pointer to private wext data from wpa_driver_wext_init()
 *
 * Shut down driver interface and processing of driver events. Free
 * private data buffer if one was allocated in wpa_driver_wext_init().
 */
void wpa_driver_wext_deinit(void *priv)
{
	struct wpa_driver_wext_data *drv = priv;

	wpa_driver_wext_set_auth_param(drv, IW_AUTH_WPA_ENABLED, 0);

	eloop_cancel_timeout(wpa_driver_wext_scan_timeout, drv, drv->ctx);

	/*
	 * Clear possibly configured driver parameters in order to make it
	 * easier to use the driver after wpa_supplicant has been terminated.
	 */
	wpa_driver_wext_disconnect(drv);

	netlink_send_oper_ifla(drv->netlink, drv->ifindex, 0, IF_OPER_UP);
	netlink_deinit(drv->netlink);
	rfkill_deinit(drv->rfkill);

	if (drv->mlme_sock >= 0)
		eloop_unregister_read_sock(drv->mlme_sock);

	(void) linux_set_iface_flags(drv->ioctl_sock, drv->ifname, 0);

	close(drv->ioctl_sock);
	if (drv->mlme_sock >= 0)
		close(drv->mlme_sock);
	os_free(drv->assoc_req_ies);
	os_free(drv->assoc_resp_ies);
	os_free(drv);
}

最後,將實現的操作函數映射到一個全局的wpa_driver_ops類型數據結構wpa_driver_wext_ops中。

const struct wpa_driver_ops wpa_driver_wext_ops = {
	.name = "wext",
	.desc = "Linux wireless extensions (generic)",
	.get_bssid = wpa_driver_wext_get_bssid,
	.get_ssid = wpa_driver_wext_get_ssid,
	.set_key = wpa_driver_wext_set_key,
	.set_countermeasures = wpa_driver_wext_set_countermeasures,
	.scan2 = wpa_driver_wext_scan,
	.get_scan_results2 = wpa_driver_wext_get_scan_results,
	.deauthenticate = wpa_driver_wext_deauthenticate,
	.associate = wpa_driver_wext_associate,
	.init = wpa_driver_wext_init,
	.deinit = wpa_driver_wext_deinit,
	.add_pmkid = wpa_driver_wext_add_pmkid,
	.remove_pmkid = wpa_driver_wext_remove_pmkid,
	.flush_pmkid = wpa_driver_wext_flush_pmkid,
	.get_capa = wpa_driver_wext_get_capa,
	.set_operstate = wpa_driver_wext_set_operstate,
	.get_radio_name = wext_get_radio_name,
#ifdef ANDROID
	.sched_scan = wext_sched_scan,
	.stop_sched_scan = wext_stop_sched_scan,
#endif /* ANDROID */
};

(3) l2_packet.h/l2_packet_linux.c 主要用於實現PF_PACKET socket接口,
通過該接口,wpa_supplicant可以直接將802.1X packet發送到L2層,而不經過TCP/IP協議棧。

其中主要的功能函數為:

/*  創建並初始化PF_PACKET socket接口,其中rx_callback為從L2接收到的packet處理callback函數  */ 
struct l2_packet_data * l2_packet_init(
        const  char *ifname, const u8 *own_addr, unsigned short protocol,
        void (*rx_callback)( void * ctx, const u8 * src_addr,
                          const u8 * buf, size_t len),
        void *rx_callback_ctx, int l2_hdr);

ps:l2_packet_init方法中有代碼:

eloop_register_read_sock(l2->fd, l2_packet_receive, l2, NULL);

即註冊了與socket關聯的回調方法為l2_packet_receive。

/*  銷毀PF_PACKET socket接口  */ 
void l2_packet_deinit( struct l2_packet_data * l2);
 
/* L2層packet發送函數,wpa_supplicant用此發送L2層802.1X packet   */ 
int l2_packet_send( struct l2_packet_data *l2, const u8 * dst_addr, u16 proto,
                  const u8 * buf, size_t len);
 
/*   L2層packet接收函數,接收來自L2層數據後,將其發送到上層   */ 
static  void l2_packet_receive( int sock, void *eloop_ctx, void *sock_ctx);

關於 L2_packet 模塊

L2_packet模塊是wpa_supplicant軟件中實現EAPOL幀(frame)的收發功能的模塊。 L2即網絡協議層的數據鏈路層 layer 2 。 wpa_supplicant針對不同的OS系統,採用了不同的抓包技術實現。 windows平台採用NDIS協議驅動抓包技術,linux平台採用packet socket抓包技術。

該模塊的實現代碼在目錄wpa_supplicant/src/l2_packet中。
l2_packet_linux.c是linux系統下的收發EAPOL幀實現。
l2_packet_ndis.c是windows系統下使用ndisuio協議驅動實現收發EAPOL幀實現。
l2_packet_pcap是windows系統下使用winpcap協議驅動實現EAPOL收發,採用輪詢的方式抓包。
l2_packet_winpcap是windows系統下使用winpcap協議驅動實現EAPOL收發,採用接收線程的方式抓包。相比l2_packet_pcap抓包,接收EAPOL幀的延遲從100ms降到了3ms。
l2_packet.h是api接口聲明和struct l2_packet_data聲明頭文件。

l2_packet主要的接口函數說明

l2_packet_init()
l2_packet_init()函數在wpa_supplicant初始化時候調用。

struct l2_packet_data * l2_packet_init(  
    const char *ifname, const u8 *own_addr, unsigned short protocol,  
    void (*rx_callback)(void *ctx, const u8 *src_addr,  
                const u8 *buf, size_t len),  
    void *rx_callback_ctx, int l2_hdr);  

ifname: 網絡設備名
own_addr: mac地址
protocol:協議類型或者以太類型。如抓取EAPOL幀,以太類型為0x888E。
rx_callback: 接收到EAPOL幀的回調處理函數。
ctx:上面回調處理函數的回調參數。
l2_hrd:收發數據是否包含l2層以太頭。通常設置為0,不包含。

l2_packet_deinit
wpa_supplicant退出或清除時調用,釋放相關資源。

l2_packet_send
向驅動發送EAPOL幀(frame)接口函數。

l2_packet_notify_auth_start
該函數接口的實現只在l2_packet_winpcap中實現,因其採用創建一個線程抓包,所以在無線聯網關聯成功之後,喚醒l2_packet接收線程,準備獲取EAPOL幀。

l2_packet_linux的實現分析

在linux系統上,wpa_supplicant採用PACKET SOCKET技術抓取EAPOL幀(frame)。 wpa_supplicant的Event Loop模塊採用輪詢方式獲取EAPOL幀(frame),並處理。

初始化

struct l2_packet_data * l2_packet_init(  
    const char *ifname, const u8 *own_addr, unsigned short protocol,  
    void (*rx_callback)(void *ctx, const u8 *src_addr,  
                const u8 *buf, size_t len),  
    void *rx_callback_ctx, int l2_hdr)  
{  
    struct l2_packet_data *l2;  
    struct ifreq ifr;  
    struct sockaddr_ll ll;  
  
    l2 = os_zalloc(sizeof(struct l2_packet_data));  
    if (l2 == NULL)  
        return NULL;  
    os_strlcpy(l2->ifname, ifname, sizeof(l2->ifname));  
    l2->rx_callback = rx_callback;  
    l2->rx_callback_ctx = rx_callback_ctx;  
    l2->l2_hdr = l2_hdr;  
  
    l2->fd = socket(PF_PACKET, l2_hdr ? SOCK_RAW : SOCK_DGRAM,  
            htons(protocol));  
    if (l2->fd < 0) {           wpa_printf(MSG_ERROR, "%s: socket(PF_PACKET): %s",                  __func__, strerror(errno));           os_free(l2);           return NULL;       }       os_memset(&ifr, 0, sizeof(ifr));       os_strlcpy(ifr.ifr_name, l2->ifname, sizeof(ifr.ifr_name));  
    if (ioctl(l2->fd, SIOCGIFINDEX, &ifr) < 0) {           wpa_printf(MSG_ERROR, "%s: ioctl[SIOCGIFINDEX]: %s",                  __func__, strerror(errno));           close(l2->fd);  
        os_free(l2);  
        return NULL;  
    }  
    l2->ifindex = ifr.ifr_ifindex;  
  
    os_memset(&ll, 0, sizeof(ll));  
    ll.sll_family = PF_PACKET;  
    ll.sll_ifindex = ifr.ifr_ifindex;  
    ll.sll_protocol = htons(protocol);  
    if (bind(l2->fd, (struct sockaddr *) &ll, sizeof(ll)) < 0) {           wpa_printf(MSG_ERROR, "%s: bind[PF_PACKET]: %s",                  __func__, strerror(errno));           close(l2->fd);  
        os_free(l2);  
        return NULL;  
    }  
  
    if (ioctl(l2->fd, SIOCGIFHWADDR, &ifr) < 0) {           wpa_printf(MSG_ERROR, "%s: ioctl[SIOCGIFHWADDR]: %s",                  __func__, strerror(errno));           close(l2->fd);  
        os_free(l2);  
        return NULL;  
    }  
    os_memcpy(l2->own_addr, ifr.ifr_hwaddr.sa_data, ETH_ALEN);  
  
    eloop_register_read_sock(l2->fd, l2_packet_receive, l2, NULL);  
  
    return l2;  
}  

1)分配struct l2_packet_data,並設置回調函數和回調參數以及l2_hdr標記
2)調用socket創建端口文件句柄,其協議族為PF_PACKET,SOCK_TYPE根據是否包含以太幀頭設置為SOCK_RAW或者SOCK_DGRAM,發送和接收到以太幀類型為EAPOL幀。
3)調用ioctl(l2->fd, SIOCGIFINDEX, &ifr)獲取網絡設備對應的索引值。
4)配置sockaddr_ll數據結構,調用bind函數綁定端口文件句柄。
5)調用ioctl(l2->fd, SIOCGIFHWADDR, &ifr)獲取硬件MAC地址備用。
6)調用eloop_register_read_sock向wpa_supplicant的Event Loop模塊註冊一個讀取socket的操作。事件觸發的回調函數為l2_packet_receive。回調函數上下文參數為l2。

Event Loop 模塊如何調用 l2_packet_receive ?

在l2_packet_init()函數中調用了eloop_register_read_sock註冊了一個讀socket操作。

在Event Loop模塊的eloop_run()函數中
循環執行調用 eloop_sock_table_dispatch(&eloop.readers, rfds) ,對所有註冊的讀取socket的操作進行處理,
通過調用FD_ISSET(table->table[i].sock, fds )判斷socket文件句柄的狀態是否發生變化,如果發生變化,則調用註冊的回調函數(即l2_packet_receive)
在l2_packet_init()註冊: eloop_register_read_sock(l2->fd, l2_packet_receive, l2, NULL);

void eloop_run(void)
{
#ifdef CONFIG_ELOOP_POLL
	...
#else /* CONFIG_ELOOP_POLL */
	fd_set *rfds, *wfds, *efds;
	struct timeval _tv;
#endif /* CONFIG_ELOOP_POLL */
	int res;
	struct os_time tv, now;

#ifndef CONFIG_ELOOP_POLL
	...
#endif /* CONFIG_ELOOP_POLL */

	while (!eloop.terminate &&
	       (!dl_list_empty(&eloop.timeout) || eloop.readers.count > 0 || eloop.writers.count > 0 || eloop.exceptions.count > 0)) {
		struct eloop_timeout *timeout;
		timeout = dl_list_first(&eloop.timeout, struct eloop_timeout, list);
		if (timeout) {
			os_get_time(&now);
			if (os_time_before(&now, &timeout->time))
				os_time_sub(&timeout->time, &now, &tv);
			else
				tv.sec = tv.usec = 0;
#ifdef CONFIG_ELOOP_POLL
			...
#else /* CONFIG_ELOOP_POLL */
			_tv.tv_sec = tv.sec;
			_tv.tv_usec = tv.usec;
#endif /* CONFIG_ELOOP_POLL */
		}

#ifdef CONFIG_ELOOP_POLL
		...
#else /* CONFIG_ELOOP_POLL */
		eloop_sock_table_set_fds(&eloop.readers, rfds);
		eloop_sock_table_set_fds(&eloop.writers, wfds);
		eloop_sock_table_set_fds(&eloop.exceptions, efds);
		res = select(eloop.max_sock + 1, rfds, wfds, efds,
			     timeout ? &_tv : NULL);
		if (res < 0 && errno != EINTR && errno != 0) {
			perror("select");
			goto out;
		}
#endif /* CONFIG_ELOOP_POLL */
		eloop_process_pending_signals();

		/* check if some registered timeouts have occurred */
		timeout = dl_list_first(&eloop.timeout, struct eloop_timeout, list);
		if (timeout) {
			os_get_time(&now);
			if (!os_time_before(&now, &timeout->time)) {
				void *eloop_data = timeout->eloop_data;
				void *user_data = timeout->user_data;
				eloop_timeout_handler handler = timeout->handler;
				eloop_remove_timeout(timeout);
				handler(eloop_data, user_data);
			}

		}

		if (res <= 0)
			continue;

#ifdef CONFIG_ELOOP_POLL
		...
#else /* CONFIG_ELOOP_POLL */
		eloop_sock_table_dispatch(&eloop.readers, rfds); //循環執行調用 eloop_sock_table_dispatch(&eloop.readers, rfds) ,對所有註冊的讀取socket的操作進行處理
		eloop_sock_table_dispatch(&eloop.writers, wfds);
		eloop_sock_table_dispatch(&eloop.exceptions, efds);
#endif /* CONFIG_ELOOP_POLL */
	}

	eloop.terminate = 0;
out:
#ifndef CONFIG_ELOOP_POLL
	os_free(rfds);
	os_free(wfds);
	os_free(efds);
#endif /* CONFIG_ELOOP_POLL */
	return;
}

static void eloop_sock_table_dispatch(struct eloop_sock_table *table, fd_set *fds)
{
	int i;
	if (table == NULL || table->table == NULL)
		return;
	table->changed = 0;
	for (i = 0; i < table->count; i++) {
		//通過調用FD_ISSET(table->table[i].sock, fds )判斷socket文件句柄的狀態是否發生變化
                if (FD_ISSET(table->table[i].sock, fds)) { 
                        //如果發生變化,則調用註冊的回調函數(即l2_packet_receive)
			table->table[i].handler(table->table[i].sock, table->table[i].eloop_data, table->table[i].user_data); 
			if (table->changed)
				break;
		}
	}
}

EAPOL帧frame的接收

static void l2_packet_receive(int sock, void *eloop_ctx, void *sock_ctx)  
{  
    struct l2_packet_data *l2 = eloop_ctx;  
    u8 buf[2300];  
    int res;  
    struct sockaddr_ll ll;  
    socklen_t fromlen;  
  
    os_memset(&ll, 0, sizeof(ll));  
    fromlen = sizeof(ll);  
    res = recvfrom(sock, buf, sizeof(buf), 0, (struct sockaddr *) &ll,  &fromlen);  
    if (res < 0) { 
          wpa_printf(MSG_DEBUG, "l2_packet_receive - recvfrom: %s", strerror(errno)); 
          return; 
    } 
    l2->rx_callback(l2->rx_callback_ctx, ll.sll_addr, buf, res);  
}  

1)調用recvfrom把接收到的EAPOL幀拷貝到buf緩存中(最長2300字節)。
2)調用註冊的EAPOL接收處理函數,即wpa_supplicant_rx_eapol()。(註冊在wpa_supplicant_update_mac_addr()裡, 帶入參數去呼叫l2_packet_init(wpa_s->ifname, wpa_drv_get_mac_addr(wpa_s), ETH_P_EAPOL, wpa_supplicant_rx_eapol, wpa_s, 0);)

EAPOL帧frame的發送

int l2_packet_send(struct l2_packet_data *l2, const u8 *dst_addr, u16 proto, const u8 *buf, size_t len)  
{  
    int ret;  
    if (l2 == NULL)  
        return -1;  
    if (l2->l2_hdr) {  
        ret = send(l2->fd, buf, len, 0);  
        if (ret < 0) 
              wpa_printf(MSG_ERROR, "l2_packet_send - send: %s", strerror(errno)); 
    } else {
        struct sockaddr_ll ll;
        os_memset(&ll, 0, sizeof(ll));
        ll.sll_family = AF_PACKET;
        ll.sll_ifindex = l2->ifindex;  
        ll.sll_protocol = htons(proto);  
        ll.sll_halen = ETH_ALEN;  
        os_memcpy(ll.sll_addr, dst_addr, ETH_ALEN);  
        ret = sendto(l2->fd, buf, len, 0, (struct sockaddr *) &ll, sizeof(ll));  
        if (ret < 0) {  
            wpa_printf(MSG_ERROR, "l2_packet_send - sendto: %s", strerror(errno));  
        }  
    }  
    return ret;  
}  

1)根據l2_hdr標記,如果l2_hdr配置為1,則直接發送原始數據。如果l2_hdr配置為0,則需要添加Ehernet frame header進行發送。
2)根據兩種情況,分別調用send(RAW) 或者sendto(DGRAM)發送EAPOL幀。


  • 3
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值