摘要
2021 HCTF WEB HCTF INTERNAL SYSTEM 复现(失而复得的 MD)
题目已上线 [BUUCTF](https://buuoj.cn/challenges#[虎符CTF 2021]Internal System)
打开环境,直接是一个登陆页面:
随便测试一下,这里看到,如果密码错误,还会返回登陆页面,并且登陆信息是通过 GET 请求上传的:
测试无果,查看源代码,发现注释:
进入/source
, 发现 nodejs 代码;
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 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 const express = require ('express' )const router = express.Router ()const axios = require ('axios' )const isIp = require ('is-ip' )const IP = require ('ip' )const UrlParse = require ('url-parse' )const {sha256, hint} = require ('./utils' )const salt = 'nooooooooodejssssssssss8_issssss_beeeeest' const adminHash = sha256 (sha256 (salt + 'admin' ) + sha256 (salt + 'admin' ))const port = process.env .PORT || 3000 function formatResopnse (response ) { if (typeof (response) !== typeof ('' )) { return JSON .stringify (response) } else { return response } }function SSRF_WAF (url ) { const host = new UrlParse (url).hostname .replace (/\[|\]/g , '' ) return isIp (host) && IP .isPublic (host) }function FLAG_WAF (url ) { const pathname = new UrlParse (url).pathname return !pathname.startsWith ('/flag' ) }function OTHER_WAF (url ) { return true ; }const WAF_LISTS = [OTHER_WAF , SSRF_WAF , FLAG_WAF ] router.get ('/' , (req, res, next ) => { if (req.session .admin === undefined || req.session .admin === null ) { res.redirect ('/login' ) } else { res.redirect ('/index' ) } }) router.get ('/login' , (req, res, next ) => { const {username, password} = req.query ; if (!username || !password || username === password || username.length === password.length || username === 'admin' ) { res.render ('login' ) } else { const hash = sha256 (sha256 (salt + username) + sha256 (salt + password)) req.session .admin = hash === adminHash res.redirect ('/index' ) } }) router.get ('/index' , (req, res, next ) => { if (req.session .admin === undefined || req.session .admin === null ) { res.redirect ('/login' ) } else { res.render ('index' , {admin : req.session .admin , network : JSON .stringify (require ('os' ).networkInterfaces ())}) } }) router.get ('/proxy' , async (req, res, next) => { if (!req.session .admin ) { return res.redirect ('/index' ) } const url = decodeURI (req.query .url ); console .log (url) const status = WAF_LISTS .map ((waf )=> waf (url)).reduce ((a,b )=> a&&b) if (!status) { res.render ('base' , {title : 'WAF' , content : "Here is the waf..." }) } else { try { const response = await axios.get (`http://127.0.0.1:${port} /search?url=${url} ` ) res.render ('base' , response.data ) } catch (error) { res.render ('base' , error.message ) } } }) router.post ('/proxy' , async (req, res, next) => { if (!req.session .admin ) { return res.redirect ('/index' ) } const url = "https://postman-echo.com/post" await axios.post (`http://127.0.0.1:${port} /search?url=${url} ` ) res.render ('base' , "Something needs to be implemented" ) }) router.all ('/search' , async (req, res, next) => { if (!/127\.0\.0\.1/ .test (req.ip )){ return res.send ({title : 'Error' , content : 'You can only use proxy to aceess here!' }) } const result = {title : 'Search Success' , content : '' } const method = req.method .toLowerCase () const url = decodeURI (req.query .url ) const data = req.body try { if (method == 'get' ) { const response = await axios.get (url) result.content = formatResopnse (response.data ) } else if (method == 'post' ) { const response = await axios.post (url, data) result.content = formatResopnse (response.data ) } else { result.title = 'Error' result.content = 'Unsupported Method' } } catch (error) { result.title = 'Error' result.content = error.message } return res.json (result) }) router.get ('/source' , (req, res, next )=> { res.sendFile ( __dirname + "/" + "index.js" ); }) router.get ('/flag' , (req, res, next ) => { if (!/127\.0\.0\.1/ .test (req.ip )){ return res.send ({title : 'Error' , content : 'No Flag For You!' }) } return res.json ({hint : hint}) })module .exports = router
目前对 nodejs 知之甚少,于是先慢慢看这段代码。
url.parse()
函数返回一个 url 对象:
好了,重新回到题目,从/login
下手
要想进入/index
页面,账号密码符合的条件是:账号密码都存在 ,账号密码不相同 且长度也不相同 ,并且账户名不能是 字符串admin .
感谢诸位大佬, 一个新知识点出现了。在 js 中:
1 'admin' + ['admin' ] == 'adminadmin'
所以观察 adminHash 的构造,可以很容易的绕过登录判断:
1 2 username = ['admin' ] salt + username == salt + 'admin'
于是就可以构造 payload:
1 http://94bc9026-2036-4ad9-b9ba-bea8f7bcc17a.node3.buuoj.cn/login?username[]=admin&password=admin
成功登录!!!
观察一下 /index
是什么鬼东西。这里的 URL 搜索框提交的数据直接去 /proxy
来进行处理,有 post 和 get 两种方法。
观察一下 post 方法的处理过程,不难发现无论提交了什么样的 url 最后的代理请求 url 都会变成 http://127.0.0.1:3000/search?url=https://postman-echo.com/post
,所以这里其实是没有操作空间的,因此只能寄希望于 get 请求。
当向 /proxy
发送了 get 请求后,后台首先判断你是不是 admin,只能 admin 才能提交请求。然后后端会解析提交的 url 值,对这个 url 进行三次 waf 检测,只有全部通过才能继续接下来的操作。
对于 SSRF_WAF 函数,首先将要搜索的 url 中的 hostname 提取出来,然后删掉所有的方括号(我猜是为了防止使用 MAC 地址?)。举个例子,如果说 url=http://127.0.0.2:9080/api/xxx
,那么最后的结果就是 host = 127.0.0.2
。然后如果 host 是 IP 地址 并且不是内网地址,才能返回 true
对于 FLAG_WAF 函数,会检测 url 的 pathname,对于上一段的例子 pathname 就是 /api/xxx
,如果 pathname 不是以 /flag
开头就返回 true
最后一个 OTHER_WAF 函数,存在意义不大哈哈哈
就这三个 waf 如果全部通过,才能执行后续代码。
如果检测全部通过,我们提交的 url 会转发到 /search
页面,也就是http://127.0.0.1:3000/search?url=${url}
,那么接下来再看看 /search
的代码。
ummmmm,首先检测发起请求的 IP,必须得从内网 127.0.0.1
来发起请求才能继续执行代码,所以我们直接访问这个页面是无效的:
接下来,常规解析url 参数。然后对请求方式进行判断,如果对/search
发起的是 post 请求,那么后台就会同样用 post 的方式去请求我们要的 url,同样的如果是 get 请求,那么后台就会同样用 get 的方式去请求我们要的 url 。回到 /index
页面的代码,发现我们对 /proxy
发起了 post 请求,那么/search
发起的也是 post 请求,对 /proxy
发起了 get 请求,那么/search
发起的就是 get 请求。BUT 显然 post 对我们毫无用处,因此 和 post 请求有关的代码都不用看。
随后后台会直接访问由 /proxy
转发来的 url 地址,并把得到的响应展示出来。
比方说我来搜索我自己服务器IP地址:
得到了正确的响应,和直接浏览器里访问一模一样:
当然如果搜索像 http://127.0.0.1/
, http://127.0.0.1/flag
这样的地址是肯定要被 waf 的。
but 目标很明确我们得去/flag
页面,因为那有 hint:
同样的,这里也必须是内网来访问。
思考一下,前面的 waf 好像有漏洞,如果参数 url 来个套娃,其实waf是检测不到套娃里有没有内网地址或者flag的 。
但是又出现了新问题:如果要实现套娃来获得 /flag
的内容,必要条件是对 /proxy
发起的搜索 url 必须是以 内网地址开头,但显然内网 ip 会被 waf。
在实际应用中,一般我们在服务端绑定端口的时候可以选择绑定到 0.0.0.0,这样我的服务访问方就可以通过我的多个ip地址访问我的服务
而 isPublic(“0.0.0.0”) //true
所以直接可以搜索 http://0.0.0.0:3000
,这样子就成功绕过了 内网waf 检测:
于是我们就可以去访问有内网限制的地址了,直接构造 http://0.0.0.0:3000/search?url=http://127.0.0.1:3000/flag
成功获得了 /flag
中的 hint,然而并没有 flag…
hint 是 内网里还有个 Netflix 服务器。
那么下一步我们要找到服务器在内网的地址。想起来刚刚登陆成功的时候,显示了几行字没有用上:
忙猜应该是在 10.0.130.8 ~ 10.0.130.24 或者是 10.128.0.99 ~ 10.128.0.99,这得扫描一波。但是并不知道具体端口号,就很烦。
去网上看了些 Netflix 的安装教程,发现 Netflix 默认安装在 8080 端口,那么就写个脚本试试吧:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import requests login = "http://3c04bee4-a46a-495f-ab54-0b66e43c16d1.node3.buuoj.cn/login?username[]=admin&password=admin" url = "http://3c04bee4-a46a-495f-ab54-0b66e43c16d1.node3.buuoj.cn/proxy?url=" targe1 = "http://0.0.0.0:3000/search?url=http://10.0.130." targe2 = "http://0.0.0.0:3000/search?url=http://10.128.0." session = requests.session() res = session.get(url=login)for i in range (9 , 25 ): t = url + targe1 + str (i) + ":8080" r = session.get(url=t) if "Error" not in r.text: print (url + targe1 + str (i))for i in range (16 , 100 ): t = url + targe2 + str (i) + ":8080" r = session.get(url=t) if "Error" not in r.text: print (url + targe2 + str (i))
找到了找到了,Netflix 服务器再 10.0.130.14
这就是 Netflix 的文档页。
下一步应该是查看版本,经过了解,版本号应该是藏在/api/admin/config
那就去看看:
版本2.26.0
去看了下网上的
参考文章:参考链接