文章总结: 本文介绍了一种利用多语言文件(同时是JPEG和JavaScript)绕过CSP实现XSS的技术。通过构造特殊字节序列,使图片在浏览器中作为脚本执行,前提是上传功能、同域托管及CSP信任self。文章详细展示了构造过程、浏览器兼容性及6KB大小限制的优化,并给出了防守方的五条建议,包括独立域名托管、重写图片头部、删除注释段、细化CSP策略及设置正确响应头。
综合评分: 85
文章分类: web安全,渗透测试,漏洞分析,红队
我上传的头像,在 CSP 眼里都是自己人
原创
升斗安全XiuXiu
升斗安全XiuXiu
升斗安全
2026年9月21日 07:58
广东
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
【文章说明】
- 目的:本文内容仅为网络安全技术研究与教育目的而创作。
- 红线:严禁将本文知识用于任何未授权的非法活动。使用者必须遵守《网络安全法》等相关法律。
- 责任:任何对本文技术的滥用所引发的后果自负,与本公众号及作者无关。
- 免责:内容仅供参考,作者不对其准确性、完整性作任何担保。
阅读即代表您同意以上条款。
📌 这篇讲什么:你有没有想过,一张普普通通的图片,可以同时是一段能执行的 JavaScript?这篇文章我带你手搓一个”双面文件”——它在图片查看器里是一张正常的 JPEG,在浏览器眼里却是一段脚本。只要目标网站允许用户上传图片、图片又和应用同在一个域名、CSP 又恰好信任自家域名,那么一个 XSS 就成立。不需要任何高深技术,全文跟着做,小白也能看懂每一步。文末附完整的攻防清单,开发者和安全同学都建议看完。
一、一个赌局 🎯
故事的开头是 James 给我下的战书:你有没有本事造出一个既是 JPEG、又是 JavaScript的文件?
赌注很实在——如果做成了,就能绕过几乎所有”把用户上传图片托管在自家域名下”的网站的 CSP。
我接了。接完之后的第一步,是把 JPEG 这个格式从头到尾扒了一遍。
在开干之前,先给不太熟的朋友补两块钱的底子,不然后面会有点飘。
小白补一句|CSP 是什么?
CSP(内容安全策略)是网站给浏览器下的一张”白名单”:这些地方来的脚本才能执行,其他一律不许。它是挡 XSS 的主力防线之一。很多站点的策略里都会写上 'self'——意思是”我自己域名下的脚本,我信”。
小白补一句|那图片怎么跟 CSP 扯上关系?
关键就在那个 'self'。如果用户上传的头像、附件,也存放在同一个域名下,那么在 CSP 眼里,那张图片就属于”自己人”。这时候你只要能让浏览器把这张图片当成脚本去执行,CSP 不但拦不住你,还会亲自给你放行。
一句话总结这套攻击成立的前提:上传功能 + 同域托管 + CSP 信任 self。三个条件凑齐,就是白给。
二、什么叫”多语言文件” 🧩
先解释标题里这个词。
多语言文件(polyglot file),指的是一份文件,同时满足两种以上格式的规范。它像一张双面纸:正着看是一幅画,换个角度读是一封信。
你要”同时骗过两类程序”:一类是宽容的(比如图片解析器,它往往跳过不认识的块继续读),另一类是严格的(比如 JS 引擎,多一个字符就报错)。
我们要做的就是:在 JPEG 的”注释区”里藏 JS 代码。
JPEG 天生就给了我们这个空间——它的结构里允许插入一段”注释段”(COM,标记是 FF FE),这段东西图片解析器完全不care,你写什么它都跳过。这就好比一本书的页边距,读者无视,但你能在上面写字。
三、开工:让 JPEG 的头,同时也是 JS 的头 🔧
JPEG 文件开头是这样的:
FF D8 FF E0 2F 2A 4A 46 49 46 00 01 01 01 00 48 00 48 00 00 00 ...
逐个说,这里每一个字节都在”一鱼两吃”。
第一组:FF D8 FF E0
这是 JPEG 的标准起手式——FF D8 表示”图片开始”,FF E0 表示”接下来是 JFIF 头”。
而在 JavaScript 眼里呢?如果浏览器用ISO-8859-1(Latin-1)去读,这四个字节分别是 ÿ、Ø、ÿ、à——四个货真价实的字母。
而 JS 是允许用 Unicode 字母当变量名的(就像你可以写 var 变量 = 1)。所以浏览器看到的,是一个长得有点奇怪、但完全合法的变量名。
第二组:2F 2A——全文最妙的一笔
这两个字节,同时干了两件事:
- 作为 JPEG 的”头部长度”字段:0x2F2A = 12074,告诉解析器” JFIF 头有 12074 字节那么长”;
- 作为字符:
/和*,也就是 JS 里多行注释的开始符/*。
同一个两字节,一种读法是数字,另一种读法是注释符。这就是多语言文件的精髓——每个字节都在同时给两个观众讲不同的故事。
第三组:后面的4A 46 49 46 00 …
这是 JFIF\0,JPEG 的标识符。但因为它处于 /* 之后,JS 引擎会把它当成注释内容,直接无视。
接下来的事就很机械了:因为我们把头部长度谎报成了 12074,就得真的用空字节把这段填满,否则图片解析器会当场翻脸。
于是这段看起来是这样:
FF D8 FF E0 2F 2A 4A 46 49 46 00 01 01 01 00 48 00 48 00 00 ...(后面全是 00) └─ 图片开头 ─┘ └/* ─┘ └─ JFIF ─────┘ └─ 各种参数,然后一堆空字节填充 ─┘
四、正文:把 payload 塞进图片的”页边距” 💥
头部填完,接下来轮到 JPEG 的注释段。这是我们的自留地。
结构是这样的:
FF FE 00 1C 2A 2F 3D 61 6C 65 72 74 28 22 42 75 72 70 20 72 6F 63 6B 73 2E 22 29 3B 2F 2A
拆开:
| 字节 | 身份①:JPEG | 身份②:JavaScript |
| — | — | — |
| FF FE | 注释段的开始标记 | (在注释里,不可见) |
| 00 1C | 注释长度 28 字节(注意:JPEG 的长度字段把自己这两个字节也算进去了,所以内容实际是 26 字节) | 同上 |
| 2A 2F | 注释内容 | */ —— 关闭上面的那个多行注释! |
| 3D | 注释内容 | = —— 赋值符号 |
| 61 6C 65 ... | 注释内容 | alert("Burp rocks.") |
| 3B | 注释内容 | ; 语句结束 |
| 2F 2A | 注释内容 | /* —— 重新打开注释,后面那些二进制数据就不会报错了 |
看懂了吗?整个流程是:关闭注释 → 给那个乱码变量赋值(顺便执行 payload)→ 再打开注释把后面的垃圾数据全吃掉。
图片解析器从头到尾只是跳过一段注释,一张完美的 JPEG。而 JS 引擎读到这里,会老老实实地弹窗。
五、收尾:给注释画个句号 🏁
文件最后,我们要把注释安全地关掉,否则 JS 引擎读到底会报语法错误。
在图像结束标记之前,最后几个字节是这样的:
2A 2F 2F 2F FF D9
2A 2F = */,关掉多行注释;2F 2F = //,万一后面还有碎片,再补一个单行注释兜底;FF D9 = JPEG 的图像结束标记,图片正常收尾。
到此为止,一个双身份文件诞生了。
六、Firefox 给的一记闷棍 🥊
本以为收工了,Firefox 跳出来说:不行。
具体来说:如果不显式指定字符集,这个多语言文件在多数情况下跑得好好的;但在 Firefox 上,当宿主文档使用 UTF-8 字符集时,把它当脚本加载就会直接崩。
原因不难理解:前面那四个”字母”(ÿ Ø ÿ à)是靠ISO-8859-1编码才成立的一一对应关系。一旦浏览器用 UTF-8 去解读这些非法 UTF-8 字节,文件名就变成一堆无效字符,语法当场错误。
解决办法很有意思:MDN 上压根没写 script 标签支持 charset 属性,但它就是支持。
七、浏览器众生相 🌐
实测结果:
| 浏览器 | 表现 |
| — | — |
| Safari | ✅ 成功执行 |
| Firefox | ✅ 成功(需指定 charset="ISO-8859-1") |
| Edge | ✅ 成功 |
| IE11 | ✅ 成功 |
| Chrome | ❌ 不执行——它明智地拒绝把图片当 JavaScript 跑 |
必须给 Chrome 鼓个掌。这也提醒我们一件很重要的事:同一个 payload,在不同浏览器上的命运天差地别。写报告的时候别只说自己测的环境。
八、第二个挑战:6KB 的大小限制 📏
技术成了,现实的巴掌又来了。
我兴冲冲拿这张图去当 phpBB 论坛的头像上传——结果被拦:文件不能超过 6KB,尺寸不能超过 90×90。
问题出在哪?就在我们那个”神来之笔”上:0x2F2A = 12074。
12074 字节的头部,光填充就撑爆了 6KB 限额。我们得把谎报的长度往小了改。
所以现在变成了这样一道题:找一对字节,既能让 JPEG 认它是合法头部长度(越小越好),又能让 JS 认它是合法语法。
翻 ASCII 表翻了半天,我找到的最小组合是:
09 3A
- 作为数字:0x093A = 2362,从 12074 砍到 2362,省下来将近 1 万字节;
- 作为字符:制表符 + 冒号
:。
在 JS 里,变量名: 是一个合法的标签语句(label statement)。于是新的开头变成:
FF D8 FF E0 09 3A 4A 46 49 46 2F 2A └─图片开头──┘ └ 标签 ┘ └ JFIF ─┘ └ /* ─┘
这里还有个小改动:原本 JFIF 后面跟的是 NULL 字符,我把那个 NULL 换成了正斜杠2F(/),再把版本号的位置放一个星号2A()——两个字节凑一对,/ 又一次打开了注释。
完整结构:
FF D8 FF E0 09 3A 4A 46 49 46 2F 2A 01 01 00 48 00 48 00 00 ...(NULL 填充) 2A 2F 3D 61 6C 65 72 74 28 22 42 75 72 70 20 72 6F 63 6B 73 2E 22 29 3B 2F 2A
一张体积小得多、能当头像上传的多语言 JPEG,搞定。
我特别喜欢这一段。它说明了一件事:工程上的限制,往往才是技术真正的考场。原理通了只是开始,把它塞进 6KB 里,才算真的赢。
九、到底能打多大的伤害 🎯
把条件重述一遍,这是判断你的目标是否存在问题的检查清单:
- 网站允许用户上传 JPEG(头像、附件、相册都算);
- 上传的文件托管在应用同一个域名下(很多站点就是这么干的);
- CSP 策略里允许来自 ‘self’ 的脚本(这是绝大多数策略的写法);
而那个受害站点做了什么”错事”呢?它只是允许用户传了张头像而已。
十、防守方该怎么改 🛡️
结论其实很短,但每一条都值得抄进规范里:
-
用户上传的资源,单独放一个域名。这是釜底抽薪的一招。头像、附件、用户生成的一切内容,都应该托管在独立的、最好是无 Cookie 的域上。这样即使它是个”双面人”,也不在 CSP 的 ‘self’ 信任圈里。
-
校验 JPEG 时,重写它的头部。收到一个图片,不要原样落盘。提取像素数据重新编码输出,我们精心构造的那些头部废话、注释段,会在重编码过程中被清洗掉。
-
删除所有 JPEG 注释段。注释段本来就不是图片需要的东西,它在艺术创作阶段就没用,留着只有坏处没有好处。
-
CSP 的白名单里,不要把图片 resource 域名放进去。策略要写得细:script-src 只放真正需要提供 JS 的域,图片 CDN 坚决不进这张名单。别嫌麻烦,这一行省下的,可能是一次事故。
-
顺手补一刀:给所有上传响应带上正确的Content-Type和X-Content-Type-Options: nosniff。虽然它挡不住 这种显式引用,但能堵掉一大批”浏览器自作主张猜类型”的场景。
十一、写在最后 🧠
给看到这里的朋友留三句话:
- 综合利用往往发生在”边界”上。 图片 + 脚本、上传 + CSP、编码 + 语义,每个单点都没问题,拼起来才是漏洞。
- 别低估”同域”这两个字的分量。 无数次事故的根因,都是把不可信内容和自己的应用放在了同一个屋檐下。
- 工程限制才是真考场。 原理跑通只是六十分,能塞进 6KB 里,才是一百分。
写到这里,照例求个三连。
这篇从把 JPEG 格式图翻烂、到把 6KB 那道坎磨过去,花了我一整个周末。如果它让你下次看到”用户上传的图片放在 /uploads/ 下”的时候,会下意识皱一下眉——那这几个小时就没白花。这一个皱眉,可能就是别人漏掉、而你捡到的那个洞。
- 觉得有用,帮我点个**「赞」和「在看」**,让更多挖洞的朋友刷到;
- 顺手**「转发」**给群里那几个做上传功能的开发兄弟,第十节的五条清单直接抄给他们;
- 还没**「关注」**的朋友点个关注,压箱底的笔记我会陆续整理发出来,只发在这里;
- 也欢迎**「推荐」**给身边做安全、做开发的朋友,一起把用户上传这块地方,守得再牢一点。
#CSP绕过 #多语言文件 #渗透测试 #Web安全 #赏金猎人 #XSS #文件上传 #漏洞挖掘 #经验分享 #浏览器安全
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:升斗安全 升斗安全XiuXiu
升斗安全XiuXiu《我上传的头像,在 CSP 眼里都是自己人》