Posts 物联网设备消息总线机制的使用及安全问题
Post
Cancel

物联网设备消息总线机制的使用及安全问题

本文对物联网设备中常用的两种消息总线机制——MQTT协议及ubus系统 进行介绍,并用两个真实设备(totolink路由器、2021强网杯小米路由器)的漏洞介绍它们的安全问题。

0x00 引言


提到总线(BUS),我们通常会想到和计算机硬件相关的总线机制,如数据总线、地址总线、控制总线和汽车用到的CAN总线等。但是,由于总线机制松耦合的特性,其越来越多的被物联网设备开发者应用于操作系统软件中,用来处理进程间或者设备间事件、消息的分发和处理。 物联网设备中的消息总线机制可以非常方便地实现设备间/进程间通信。在消息总线模型中,通常包含三种角色:

  • 总线服务端,用来实现消息的分发,并接收客户端的消息订阅与注册(如下文将提到的ubusd以及MQTT代理)。
  • 消息接收端,向总线服务端订阅/注册自己感兴趣的消息主题/对象,等待总线服务器发来的消息,并调用回调函数进行处理。
  • 消息发送端,向总线服务端发送消息,发送时携带消息的主题或者对象及方法,告诉服务端将消息转发给谁。

然而,由于消息总线机制在一定程度上将数据的接收端和发送端解耦合,因此对于数据的接收端来说,通常情况下很难确认发送端的真实身份。攻击者可模拟消息发送端,通过总线服务端向消息接收端发送恶意消息,以触发接收端的消息解析漏洞。

物联网设备中常见的消息总线机制有MQTT协议以及ubus系统。其中MQTT作为一种应用层协议,不仅可以实现设备和应用(如APP、Web管理系统)之间远程通信,也可以将MQTT代理开放在设备内部实现进程间通信;类似Linux中的DBus机制,ubus系统是OpenWrt系统当中一个重要的进程间通信的方式,系统中很多应用都使用到了ubus,如涉及到网络管理的/sbin/netifd以及系统配置程序uci等等,ubus也提供了开源API供设备开发者调用。下文将对这两种机制进行介绍,并用两个真实设备(totolink路由器、2021强网杯小米路由器)的漏洞介绍它们的安全问题。

0x01 MQTT协议


MQTT协议是一种轻量级的物联网协议,该协议的特点是简单、开放、可扩展性强且易于实现,这些特点使它的应用场景非常广泛,涵盖了智慧生活、智慧城市、工业物联网、移动应用等领域。MQTT的安全问题从2015年起逐渐受到关注,主要目光集中在互联网中大量存在的缺少用户身份认证和授权的MQTT服务器,这些服务器导致了敏感信息被泄露,也允许攻击者向客户端设备越权发布消息,实现对设备的操控。关于MQTT协议安全性的研究工作有很多,其中趋势科技于2018年发布了一篇报告,揭示了MQTT和CoAP这两种物联网常见协议在实现时的脆弱性;我国西安电子科大的研究人员于2020年在国际网络安全顶会IEEE S&P也发表了题目为Burglars’ IoT paradise: Understanding and mitigating security risks of general messaging protocols on IoT clouds的一篇文章,介绍了流行的物联网云平台在实现MQTT协议时存在的安全问题。这些工作都对理解MQTT协议的安全问题有很大帮助。

同时,在IoT设备中,也可能存在MQTT服务器。由于MQTT的总线特性并且非常易于实现,部分设备开发者非常喜欢使用内嵌在设备中的MQTT服务来实现进程间通信或者设备间通信,但这也在一定程度上带来了一些安全隐患。

1. MQTT实现原理

mqtt-arch 不同于HTTP等请求/响应模型的协议,MQTT协议基于订阅/发布模型。在该模型中,有三种重要的角色:

  • MQTT代理。也称MQTT服务器,负责处理消息的订阅和转发。
  • 消息订阅者。负责订阅自己感兴趣的消息,接收从MQTT代理转发而来的消息并处理。
  • 消息发布者。负责向订阅者发布消息,消息需先发布至MQTT代理,并由代理转发至订阅者。

可以看出,MQTT协议的订阅/发布模型具有总线的特点,所有数据并不直接发送至接收端,而是先发送至一个总线服务器(MQTT代理),由总线服务器负责消息的转发。MQTT消息通过主题(topic)进行标识,订阅者通过topic订阅自己感兴趣的消息,发布者在发布消息时,需携带对应的topic字段。MQTT代理在收到消息之后,会根据topic将消息转发至所有订阅该topic的订阅者,完成消息的转发。

MQTT协议支持使用多种身份认证方式,其中使用最为广泛的是用户名/密码认证以及客户端证书认证。但是,许多MQTT代理软件在默认情况下是不配置任何身份认证方式的,需要用户自行配置。如果用户忽略了这一步,就将导致上文提到的敏感信息泄露和越权发布消息的问题。大多数内嵌在设备内部的MQTT代理都缺乏身份认证机制,这是因为开发者在开发设备时考虑到内嵌的代理服务器并不会暴露在互联网中,而仅用作进程间或内网设备间通信,因此便不配置复杂的身份认证和授权机制。若消息订阅端存在消息解析漏洞,那么攻击者就可以伪造消息发布端的身份,向订阅端发布恶意消息,从而触发漏洞。下面将以totolink路由器MQTT命令注入漏洞为例,介绍内嵌MQTT服务的安全问题。

2. totolink路由器MQTT消息解析漏洞(CNVD-2020-28090,CNVD-2020-28089)

mqtt-toto

totolink A950RG和T10等型号路由器在处理HTTP数据时,会将HTTP数据通过内嵌的MQTT代理转发至对应的handler进行处理。而处理这些数据的handler在解析消息时存在多个漏洞,如setDiagnosisCfg的handler在解析诊断数据时,会将JSON格式消息的ipDomain对应的value拼接在ping命令中,并调用system函数执行ping命令。攻击者可以在ipDomain键值中注入恶意命令,实现远程命令注入。

mqtt-toto-diag

若想通过Web接口触发此漏洞,需要先绕过Web认证方可实现。然而,由于设备对外开放了MQTT服务的1883端口,攻击者可以从外部直接向1883端口发布MQTT消息,从而绕过Web端的流程,实现命令注入。一个可行的exp消息如下所示:

1
2
3
4
5
{
	"topicurl":"setting/setDiagnosisCfg",
	"actionFlag":"1",
	"ipDoamin":"www.bai$(reboot)du.com"
}

3. 思考

内嵌在设备内部的MQTT服务为攻击者打开了新的大门,使攻击者无需绕过Web认证,亦可触发到后端处理HTTP数据时的命令注入漏洞。在上述案例中,内嵌的MQTT服务原本仅用来实现进程间通信,对于消息的订阅端程序(cste_sub)来说,它只想接收来自Web cgi发布而来的消息。但是由于MQTT的服务端口(TCP 1883)对外开放(监听在0.0.0.0),导致任何能够访问到设备的用户均可访问这个内嵌的MQTT服务。开发者可以将该端口监听在127.0.0.1,便可保证外部无法访问。

进一步可以思考,若开放在云端的MQTT服务器缺少身份认证和授权机制,可能也可以导致订阅端设备在不开放任何端口(或隐藏在NAT下)的前提下受到远程攻击,危害极大。作为一种消息总线类型的协议,MQTT将消息的订阅者和发布者在一定程度上解耦合,只有在MQTT代理配置了健全的身份认证和授权机制的前提下,才能保证数据的收发两端紧密绑定,防止第三方攻击者越权发布恶意消息。

0x02 ubus机制


ubus全称为OpenWrt micro bus architecture,是OpenWrt系统中提供的一种进程间通信机制,该机制包括一个服务端程序(ubusd),一个命令行客户端(ubus)以及提供了API接口的动态链接库(libubus)。ubus的核心是ubusd,该程序位于/sbin目录,负责接收其它ubus客户端的注册,并处理消息分发。ubus通信使用了Unix sockets,消息数据使用了TLV(type-length-value)格式,由ubox(libubox)提供,下文详述。ubus的源码可从 https://git.openwrt.org/project/libubus.git 获得。

开发者可以调用libubus.so提供的API开发客户端程序,进行注册、消息发送和接收。ubus的客户端有两种角色:一种为消息的接收端(本文称为receiver),另一种为消息发送端(本文称为sender)。 每个receiver可以向ubusd注册一个或多个path,并为每个path提供消息处理的方法。sender负责发送消息至ubusd,发送时携带path信息,告诉ubusd将该消息转发到哪个receiver的哪个path。

receiver注册时使用的path,包括对象(object)和方法(method)的概念。一个object中可以包括多个method,对应多个处理函数。object和method都有自己的名字,在注册时需要提供,同时在receiver程序内部也有所体现。

1. ubus实现原理

ubus的实现可参考以下几篇博客:

引用上述博客的例子:

ubus-communication

如上图所示,sender通过ubus命令请求在ubusd侧注册的“interface”对象的“setlanip”方法,试图修改ip地址。对应的处理函数(func2)在receiver程序中定义,并由receiver程序在启动后向ubusd注册。整个请求过程如下:

  1. receiver向ubusd注册两个object:“interface”和“dotalk”,其中“interface”对象注册了两个method:“getlanip”和“setlanip”,对应的处理函数分别为func1()和func2()。“dotalk”对象中注册了两个method:“sayhi”和“saybye”,对应的处理函数分别为func3()和func4()。
  2. sender试图与receiver通信,它们之间不能直接通信,需要经过ubusd中转消息。sender可以用shell/lua/C来实现。这里sender直接调用了ubus call命令发送消息,ubus程序的命令如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
root@XiaoQiang:~# ubus
Usage: ubus [<options>] <command> [arguments...]
Options:
 -s <socket>:           Set the unix domain socket to connect to
 -t <timeout>:          Set the timeout (in seconds) for a command to complete
 -S:                    Use simplified output (for scripts)
 -v:                    More verbose output

Commands:
 - list [<path>]                        List objects
 - call <path> <method> [<message>]     Call an object method
 - listen [<path>...]                   Listen for events
 - send <type> [<message>]              Send an event
 - wait_for <object> [<object>...]      Wait for multiple objects to appear on ubus
  1. ubusd接收到请求消息之后,根据object名找到对应的处理程序receiver,然后将消息发送到receiver。
  2. receiver收到消息后,根据object和method定位到消息处理函数func2(),并处理消息。如果处理完消息需要回复,则发送响应消息。

2. ubus消息数据格式

ubus消息数据使用了libubox中的blob模块。libubox是OpenWrt中的一个基础库,提供了很多基础功能,其源码可从 http://git.openwrt.org/project/libubox.git 获得,其中ubus主要使用到的有事件监控模块uloop以及二进制数据处理模块blob/blobmsg。blob全称为Binary large object,负责处理二进制数据。其数据结构详解可以参考如下文章:

blob

blob基于TLV格式,即type-length-value格式,这可以从blob中最基本的数据类型blob_attr的结构看出:

1
2
3
4
struct blob_attr {
	uint32_t id_len;      // 32位,其中高8位为id,低24位为len
	char data[];          // 二进制数据内容
} __packed;

该结构的id_len为type+length,其中高8位为id,即type,低24位为length,即二进制数据的长度。8位id的最高位为extend标志,表示blob类型(0)或者blobmsg类型(1),后7位为type,即数据的类型。每个blob_attr表示一个二进制数据,当传输多个blob数据时,多个blob_attr可以通过数组+嵌套的方式保存在一起,由blob_buf结构表示。如下图所示:

blob-struct

其中blob_buf结构体如下:

1
2
3
4
5
6
struct blob_buf {
	struct blob_attr *head;    // 没完全搞清楚,可能是指向当前最外层的blob_attr
	bool (*grow)(struct blob_buf *buf, int minlen);    //扩大buf的方法
	int buflen;                // buf的总长度
	void *buf;                 // 指向buf头,即最外层blob_attr起始位置
};

ubus在使用blob时,会将ubus的消息类型(属性)传递给blob,记录在blob_attr->id_len的高8位,作为blob数据类型。ubus消息属性有以下15种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
enum ubus_msg_attr {
	UBUS_ATTR_UNSPEC,

	UBUS_ATTR_STATUS,

	UBUS_ATTR_OBJPATH,
	UBUS_ATTR_OBJID,
	UBUS_ATTR_METHOD,

	UBUS_ATTR_OBJTYPE,
	UBUS_ATTR_SIGNATURE,

	UBUS_ATTR_DATA,
	UBUS_ATTR_TARGET,

	UBUS_ATTR_ACTIVE,
	UBUS_ATTR_NO_REPLY,

	UBUS_ATTR_SUBSCRIBERS,

	UBUS_ATTR_USER,
	UBUS_ATTR_GROUP,

	/* must be last */
	UBUS_ATTR_MAX,
};

blobmsg

blobmsg数据称为blob消息对象,包裹在blob_attr中的data部分,即为blob TLV的value。格式为name-value,即每个消息数据都有一个对应的name字符串,放在blobmsg_hdr结构体中:

1
2
3
4
struct blobmsg_hdr {
	uint16_t namelen;     // 两个字节,name字符串长度
	uint8_t name[];       // name字符串,表示消息的名字
} __packed;

blobmsg数据在发送时是包含在blob_attr中的,格式如下图所示:

blobmsg-struct

blobmsg消息类型共有13种,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum blobmsg_type {
	BLOBMSG_TYPE_UNSPEC,
	BLOBMSG_TYPE_ARRAY,
	BLOBMSG_TYPE_TABLE,
	BLOBMSG_TYPE_STRING,
	BLOBMSG_TYPE_INT64,
	BLOBMSG_TYPE_INT32,
	BLOBMSG_TYPE_INT16,
	BLOBMSG_TYPE_INT8,
	BLOBMSG_TYPE_BOOL = BLOBMSG_TYPE_INT8,
	BLOBMSG_TYPE_DOUBLE,
	__BLOBMSG_TYPE_LAST,
	BLOBMSG_TYPE_LAST = __BLOBMSG_TYPE_LAST - 1,
	BLOBMSG_CAST_INT64 = __BLOBMSG_TYPE_LAST,
};

消息类型被记录在blob_attr的id_len的高8位中,对于blobmsg数据,其中最高位extend值为1,表示blob的扩展数据类型。

blob数据生成

blob消息的生成有以下几个步骤:

  • blob_buf_init函数初始化blob_buf,该函数原型如下:
1
int blob_buf_init(struct blob_buf *buf, int id)
  • 使用blob_put_xxx系列函数,向blob_buf中加入blob_attr数据。常用的blob_put_xxx系列函数如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static inline struct blob_attr *
blob_put_string(struct blob_buf *buf, int id, const char *str)
 
static inline struct blob_attr *
blob_put_u8(struct blob_buf *buf, int id, uint8_t val)
 
static inline struct blob_attr *
blob_put_u16(struct blob_buf *buf, int id, uint16_t val)
 
static inline struct blob_attr *
blob_put_u32(struct blob_buf *buf, int id, uint32_t val)
 
static inline struct blob_attr *
blob_put_u64(struct blob_buf *buf, int id, uint64_t val)
 
#define blob_put_int8   blob_put_u8
#define blob_put_int16  blob_put_u16
#define blob_put_int32  blob_put_u32
#define blob_put_int64  blob_put_u64
  • 亦可使用blobmsg_add_xxx系列函数向blob_buf中添加blobmsg数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int blobmsg_add_field(struct blob_buf *buf, int type, const char *name,
                      const void *data, unsigned int len)

static inline int
blobmsg_add_u8(struct blob_buf *buf, const char *name, uint8_t val)
 
static inline int
blobmsg_add_u16(struct blob_buf *buf, const char *name, uint16_t val)
 
static inline int
blobmsg_add_u32(struct blob_buf *buf, const char *name, uint32_t val)
 
static inline int
blobmsg_add_u64(struct blob_buf *buf, const char *name, uint64_t val)
 
static inline int
blobmsg_add_string(struct blob_buf *buf, const char *name, const char *string)
 
static inline int
blobmsg_add_blob(struct blob_buf *buf, struct blob_attr *attr)

blob数据解析与获取

当receiver收到消息时,需要解析原始数据为blob数据。blob数据的解析可以用blobmsg_parse()函数实现,该函数原型如下:

1
2
3
4
5
6
7
8
9
10
/**
 * 从data (blob_attr)中根据policy策略过滤,得到的结果存储在tb (blob_attr数组)中
 *
 * @param  policy 过滤策略
 * @param  policy_len 策略个数
 * @param  tb 返回blob_attr数据
 * @param  len data长度
 */
int blobmsg_parse(const struct blobmsg_policy *policy, int policy_len,
                  struct blob_attr **tb, void *data, unsigned int len)

该函数负责解析发来的blob数据(data参数,blob_attr格式),并将数据分解至blob_attr数组中(tb参数),供后续数据获取。解析数据时需根据策略进行(第一个参数policy),该参数在receiver中提前定义好,为blobmsg_policy结构体,结构体中的每一项对应一个name-type结构,其中name标识了blobmsg的name(在blobmsg的blobmsg_hdr中),type为对应的类型(在blob_attr的id_len中)。policy可按照如下的模板进行定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static const struct blobmsg_policy foo_policy[] = {
	[FOO_MESSAGE] = {
		.name = "message",
		.type = BLOBMSG_TYPE_STRING,
	},
	[FOO_LIST] = {
		.name = "list",
		.type = BLOBMSG_TYPE_ARRAY,
	},
	[FOO_TESTDATA] = {
		.name = "testdata",
		.type = BLOBMSG_TYPE_TABLE,
	},
};

blobmsg_parse()之后,可以通过遍历tb数组,获取各blob_attr中的数据。blob数据的获取可以用blob_get_xxx和blobmsg_get_xxx系列函数实现,在此不详述。

3. 从ubus call命令的解析来观察blob数据的构建和发送

下面从ubus call命令的源码来观察blob数据是如何被构建和发送的。本节涉及到libubox和libubus两部分源码,其中libubus是负责处理ubus逻辑的,而libubox负责处理blob相关数据的。

ubus call命令可以向对应的object->method发送数据,其格式如下:

ubus call <object> <method> [<message>]

其中object为receiver注册的对象名,method为receiver注册的方法名,message为要向该方法发送的消息数据,需要为JSON格式。ubus call命令由libubus中ubus_cli_call()函数处理,代码位于cli.c文件。

总流程

首先来看ubus_cli_call()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
 * ubus call <object> <method> [<message>]
 *
 * argv[0]为object名
 * argv[1]为method名
 * argv[2]为message内容
 */
static int ubus_cli_call(struct ubus_context *ctx, int argc, char **argv)
{
	uint32_t id;
	int ret;

	if (argc < 2 || argc > 3)
		return -2;

	blob_buf_init(&b, 0);
	if (argc == 3 && !blobmsg_add_json_from_string(&b, argv[2])) {
		if (!simple_output)
			fprintf(stderr, "Failed to parse message data\n");
		return -1;
	}

	ret = ubus_lookup_id(ctx, argv[0], &id);
	if (ret)
		return ret;

	return ubus_invoke(ctx, id, argv[1], b.head, receive_call_result_data, NULL, timeout * 1000);
}

该函数流程如下:

  • 调用blob_buf_init()函数初始化全局blob_buf变量b
  • 调用blobmsg_add_json_from_string函数将JSON格式的message参数内容转换为JSON对象,并添加到全局变量b
  • 调用ubus_lookip_id()函数,向ubusd发起请求,以获取object对应的id值(32位)
  • 调用ubus_invoke()函数,构建请求数据,发送至ubusd,等待响应

这里在分析时遇到一个小问题:ubus_cli_call()函数在一开始调用了blob_buf_init()初始化了全局变量b,并紧接着将message的内容保存在了b中,但是之后在调用ubus_lookup_id()时,再次调用到ubus_buf_init对全局变量b进行了初始化,那不就将之前已保存的message抹去了?经过分析发现,这里的全局变量b是静态全局变量,该类型的变量只在当前.c文件中有效,无法跨文件调用。而ubus_cli_call()函数位于cli.c,ubus_lookup_id()函数位于libubus.c,两个.c文件中均有各自的static struct blob_buf b,因此它们属于不同的变量,完全无需担心上述问题。

请求并获取object id

继续分析。首先来看ubus_lookup_id()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int ubus_lookup_id(struct ubus_context *ctx, const char *path, uint32_t *id)
{
	struct ubus_request req;

	blob_buf_init(&b, 0);
	if (path)
		blob_put_string(&b, UBUS_ATTR_OBJPATH, path);    // UBUS_ATTR_OBJPATH为消息属性,会被添加至b->id_len当中,path拷贝至b->data

	if (ubus_start_request(ctx, &req, b.head, UBUS_MSG_LOOKUP, 0) < 0)
		return UBUS_STATUS_INVALID_ARGUMENT;

	req.raw_data_cb = ubus_lookup_id_cb;
	req.priv = id;

	return ubus_complete_request(ctx, &req, 0);
}

该函数作用为:向ubusd发送查询请求,查询目标object对应的id值。首先调用blob_put_string()将object的名字添加进全局blob_buf当中,之后即可调用ubus_start_request()函数发送请求。在每次调用ubus_start_request()函数时,会在message首部添加一个ubus_msghdr结构:

1
2
3
4
5
6
struct ubus_msghdr {
	uint8_t version;
	uint8_t type;
	uint16_t seq;
	uint32_t peer;
} __packetdata;

该结构共8字节,其中第二个字节type标识了本次发送的消息类型,ubus消息类型共有如下几种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
enum ubus_msg_type {
	/* initial server message */
	UBUS_MSG_HELLO,

	/* generic command response */
	UBUS_MSG_STATUS,

	/* data message response */
	UBUS_MSG_DATA,

	/* ping request */
	UBUS_MSG_PING,

	/* look up one or more objects */
	UBUS_MSG_LOOKUP,

	/* invoke a method on a single object */
	UBUS_MSG_INVOKE,

	UBUS_MSG_ADD_OBJECT,
	UBUS_MSG_REMOVE_OBJECT,

	/*
	 * subscribe/unsubscribe to object notifications
	 * The unsubscribe message is sent from ubusd when
	 * the object disappears
	 */
	UBUS_MSG_SUBSCRIBE,
	UBUS_MSG_UNSUBSCRIBE,

	/*
	 * send a notification to all subscribers of an object.
	 * when sent from the server, it indicates a subscription
	 * status change
	 */
	UBUS_MSG_NOTIFY,

	UBUS_MSG_MONITOR,

	/* must be last */
	__UBUS_MSG_LAST,
};

可以看到,第5个值UBUS_MSG_LOOKUP即为ubus_lookup_id()函数发送请求时使用的消息类型,表示当前消息是一个请求查询object id的消息。 在这8个字节的msghdr之后,即为要发送的blob_attr数据部分。总的消息格式如下:

ubus-call-lookup

请求后从ubusd处返回object对应的id值(4字节),保存至ubus_cli_call()函数的id参数中。至此,ubus_lookup_id()函数的使命完成。

发送消息到对应的object->method

接下来,ubus_invoke()函数负责处理整个消息的构建和发送。该函数最终会调用ubus_invoke_async_fd()函数,该函数定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int ubus_invoke_async_fd(struct ubus_context *ctx, uint32_t obj,
			 const char *method, struct blob_attr *msg,
			 struct ubus_request *req, int fd)
{
	blob_buf_init(&b, 0);         // 初始化blob_buf
	blob_put_int32(&b, UBUS_ATTR_OBJID, obj);   // 添加object id
	blob_put_string(&b, UBUS_ATTR_METHOD, method);    // 添加method字符串
	ubus_put_data(&b, msg);          // 添加message

	memset(req, 0, sizeof(*req));
	req->fd = fd;
	if (__ubus_start_request(ctx, req, b.head, UBUS_MSG_INVOKE, obj) < 0)
		return UBUS_STATUS_INVALID_ARGUMENT;
	return 0;
}

在初始化blob_buf之后,该函数分别将三个blob_attr添加到blob_buf中,依次是上一步获取的object id(int32)、method字符串(string)以及经过JSON解析后的message(blobmsg)。最后函数调用__ubus_start_request()函数,发送整个消息,消息指针为b.head,该函数在构建ubus_msghdr时,会将收到的没有经过网络字节序转换的4字节object id放入ubus_msghdr->peer中。总的消息格式如下:

ubus-call-msg

和源码流程相同,消息中主要包含了三部分数据:object id blob_attr,method blob_attr以及message blob_attr(blobmsg格式)。消息被发送到ubusd之后,ubusd会根据object id找到对应的receiver,并将消息发送给该程序。receiver根据object和method找到对应的处理例程,解析消息,需要时将结果返回。至此,ubus call的整个工作流程结束。

4. 从2021强网杯Mi-Router观察ubus可能带来的安全问题

mi-netapi-device

Mi-Router是2021年强网杯的一道Real World题目,选手需找寻出题人预埋的漏洞并获取系统的控制权。具体的解题思路可以参考如下博客,本文只对其中涉及ubus的知识点和安全问题进行剖析:

漏洞产生的原因,是因为/usr/lib/lua/traffic.lua脚本的cmdfmt函数被出题人删除了对“$”符号的转义,导致攻击者可以向脚本中的trafficd_lua_ecos_pair_verify()函数注入恶意命令,实现命令执行。利用grep命令全局搜索,可找到traffic.lua脚本被/usr/sbin/trafficd和/usr/sbin/netapi两个程序加载,然而只有netapi程序调用了trafficd_lua_ecos_pair_verify()函数,因此我们的目标就是要找到如何向netapi程序的处理函数发送恶意消息。

这里提个与ubus无关的知识点。经过分析,可以发现该函数只有在路由器发现了小米自己家信号放大器设备时才会触发,也就是说,要正常触发到这个逻辑,需要用户把一个小米信号放大器和路由器绑定。小米信号放大器如下图所示,插在路由器后面的USB口,即可通过手机APP进行配对和绑定。 mi-fangdaqi

tbus和ubus

在小米路由器中,开发者使用了一种称作tbus的机制。其中/usr/sbin/trafficd为bus的服务端,负责处理消息注册和分发,其它一些进程如netapi注册相应的object和method等待消息,或者发送消息。但是与ubus不同的是,tbus并不通过Unix socket进行通信,而是通过TCP socket通信,由/usr/sbin/trafficd开放784端口实现数据的交互。如netapi程序在注册时,是向784端口发送注册数据的,而不是走Unix socket。和ubus相同,tbus也有一个客户端程序/usr/sbin/tbus,它向trafficd服务端发送数据时也走784端口。那么tbus和ubus究竟有什么不同?经过分析发现,tbus基本上完全使用了ubus源码进行开发,只不过把Unix socket换成了TCP socket。其实libubox是支持使用其它socket类型的,这在libubox的usock()函数可以看出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#define USOCK_SERVER		0x0100
#define USOCK_NOCLOEXEC		0x0200
#define USOCK_NONBLOCK		0x0400
#define USOCK_NUMERIC		0x0800
#define USOCK_IPV6ONLY		0x2000
#define USOCK_IPV4ONLY		0x4000
#define USOCK_UNIX		0x8000

// 并且usock函数(在libubox)似乎也是支持其它类型socket的:
int usock(int type, const char *host, const char *service) {
	int sock;

	if (type & USOCK_UNIX)  // 如果是USOCK_UNIX
		sock = usock_unix(type, host);
	else                    // 如果是其它类型socket
		sock = usock_inet(type, host, service, NULL);

	if (sock < 0)
		return -1;

	return sock;
}

然而在ubus中,只能使用Unix socket实现通信,具体处理函数在ubus_reconnect()中:

1
2
3
4
5
6
7
8
9
10
11
12
13
int ubus_reconnect(struct ubus_context *ctx, const char *path)
{
	.......
	// ubus连接服务端。如果path为NULL,则自动默认连接ubus unix socket
	// 路径为"/var/run/ubus/ubus.sock"
	if (!path)
		path = UBUS_UNIX_SOCKET;
	.......
	ctx->sock.eof = false;
	ctx->sock.error = false;
	// 这里并没有区分USOCK_UNIX和其它类型的sock,统一用了USOCK_UNIX
	ctx->sock.fd = usock(USOCK_UNIX, path, NULL);
}

netapi程序解析blob数据

从netapi程序入手,来看数据是如何发送到traffic.lua脚本的。

通过字符串追踪可以找到处理函数sub_402070(),函数内的日志打印告诉我们该函数名可能为trafficd_lua_ecos_pair()。

mi-netapi-loadlua

该函数加载了traffic.lua脚本,并调用了脚本中存在漏洞的trafficd_lua_ecos_pair_verify()函数,传入的参数为v14变量。

mi-netapi-v14

v14变量可以追踪到v31,该变量为blobmsg_parse函数的第三个参数。从上文介绍blobmsg_parse函数参数可以得知,该参数为解析后的blob_attr数组,同时,函数的解析需根据第一个参数policy进行,并且由第二个参数可知,policy中只有1项。查看policy:

mi-netapi-policy

可以看出,policy唯一的一项,name为“data”,type为0,表示BLOBMSG_TYPE_UNSPEC。这里为什么是UNSPEC类型,我猜测可能是netapi并不能确定传进来的值究竟是什么type。

已经明白netapi是如何处理blob消息的,那么就要想办法构建blob消息,并通过784端口将数据直接发送到trafficd,转发至netapi。

netapi程序注册object和method

接下来,我们需要搞明白netapi注册的object和method是什么,才能通过设备内部的tbus call命令发送请求消息,获取数据包。在做题时,我们队是通过tbus命令请求消息抓包,并通过流量逆向成功复现出流程,但是对于数据包内容的含义并不完全清楚。预期解是把设备内部的tbus程序拖出来,通过qemu模拟直接在远程执行tbus call命令,这种方法不用考虑数据包格式,本文不再讨论。

netapi程序的main函数调用了sub_40478C()函数进行object注册,通过对比ubus源码,可以判断该函数其实就是ubus中的ubus_add_object(),我们姑且称该函数为tbus_add_object()。该函数第二个参数为要注册的ubus_object,其结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct ubus_object {
	struct avl_node avl;  // 28 bytes

	const char *name;
	uint32_t id;

	const char *path;
	struct ubus_object_type *type;

	ubus_state_handler_t subscribe_cb;
	bool has_subscribers;

	const struct ubus_method *methods;
	int n_methods;
};

为了使用ubus call命令,我们需要关注的只有两项:object名和method名。在该数据结构中,第二个元素name即为object,而第八个元素methods为方法数组的指针,第九个元素告诉我们有几个方法。通过添加数据结构,我们将tbus_add_object()函数的第二个参数设置为ubus_object格式,并查看内存:

mi-netapi-ubusobj mi-netapi-methods

可以看出,object名为“netapi”,methods中只有一个method,名为init,并且method的policy指向了上节图中的policy。

至此,我们已经可以构建tbus call命令了:

tbus call netapi init {“data”:<message>}

其中message可以填入我们注入的命令,如:

tbus call netapi init {“data”:”ad$(reboot)min”}

数据包解析

由于我们没有想到把tbus程序拉出来执行,因此只在程序内部执行,并抓TCP 784端口包并复现流量,实现命令注入。在做题时并没有时间仔细分析数据格式,现在可根据本章第三节的部分对比二进制数据,研究各字段含义。现场抓包时,TCP会话中发送方一共发送了两个数据包,由第三节的描述可知,这两个数据包分别为object lookup和message消息发送,其中object lookup是为了向服务端查询object id并返回。

object lookup数据包解析

二进制内容为:

0004010000000000000000100200000b6e65746170690000

对比object id请求时的blob数据格式:

mi-netapi-lookupmsg

其中第一个id_len中len为16,但是总长度其实只有15(4+4+len(“netapi”)+1),这其实是因为blob中的数据需要4字节对齐。

message消息发送时数据包解析

二进制内容为:

00050200 + [object id (reverse)] + 0000003403000008 + [object id] + 04000009696e6974000000000700001c830000150004646174610000746573742031323300000000

对比message发送时blob数据格式:

mi-netapi-sendmsg

5. 思考

与上述MQTT漏洞类似,该漏洞也是由于设备开放了TCP端口,接收总线数据,而漏洞点并不位于监听端口的程序,而是总线客户端。对于攻击者来说,只需要知道消息接收端程序所注册的object、method以及消息策略(类似MQTT中的topic),即可伪造sender程序向端口发送恶意消息,从而触发消息接收端的漏洞。ubus使用的是Unix socket,本不会开放任何TCP端口,然而由于开发人员为了实现设备间的通信,将端口开放在外,给了外部攻击者可乘之机。

尽管ubus消息发送端和接收端使用了Unix socket进行通信,但是ubus消息发送端所发送的消息并不一定是完全不可控的。当其它接口的处理程序(如处理HTTP数据的cgi)调用了ubus命令或者ubus API发送它们从外部接口(如HTTP端口)所接收到的数据,就可能带来新的安全问题。攻击者可以将恶意消息注入至外部接口,使这些程序将恶意消息传递到ubus的消息接收端,从而触发漏洞。

0x03 总结


消息总线机制极大方便了开发人员实现进程间和设备间通信,但是由于消息的接收端和发送端在一定程度上被解耦合,对于消息接收端来说,很难知道消息发送者的真实身份,一旦总线服务端缺乏鉴权机制,攻击者就可以伪造消息发送端发送恶意消息,触发接收端的解析漏洞。因此,设备开发者在开发程序时,应避免将总线服务端接口暴露在外,并尽可能减少敏感信息(如系统命令)的传输,保证总线系统中传输的数据安全可靠。

This post is licensed under CC BY 4.0 by the author.