yarr RSS 阅读器中的 XSS 漏洞
摘要
一位安全研究人员详细介绍了 yarr(一款用 Go 编写的开源 RSS 阅读器)中的一个跨站脚本(XSS)漏洞,该漏洞源于过于宽松的 iframe src 过滤逻辑以及可被绕过的同源域名检查。
<p>注:本站的深色模式有点难以阅读。</p>
<p><a href="https://lobste.rs/s/1r7enm/xss_yarr_rss_reader">评论</a></p>
查看缓存全文
缓存时间: 2026/10/30 03:19
# yarr 中的 XSS 漏洞
来源:https://0x67673068.me/xss-in-yarr/
*2026年10月20日*
yarr (https://github.com/nkanaev/yarr) 是一个用 Go 编写的开源 RSS 阅读器。我自己的家庭实验室(homelab)中就在使用它,某天我好奇 yarr 是如何加载和渲染这些内容的。查看了它的源码之后,我找到了答案。
当你添加一个 RSS 订阅源时,订阅源中的每篇文章都会被视为 yarr 对象模型中的一个"item"。这些 item 由名为 `handleItem` 的函数进行处理。
```
func (s *Server) handleItem(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
...
switch r.Method {
case http.MethodGet:
item := s.db(r).GetItem(id)
...
item.Content = sanitizer.Sanitize(item.Link, item.Content)
for i, link := range item.MediaLinks {
item.MediaLinks[i].Description = sanitizer.Sanitize(item.Link, link.Description)
}
...
```
当从远程 URL 加载文章时,内容首先会经过 `Sanitize` 函数的处理。这个函数顾名思义,作用非常直观。
```
// Sanitize 返回安全的 HTML。
func Sanitize(baseURL, input string) string {
tokenizer := html.NewTokenizer(bytes.NewBufferString(input))
...
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
```
经过净化的内容会在序列化为 JSON 后,作为响应中的 `"content"` 值返回。然后通过动态渲染插入 DOM 中。
了解了这个模型之后,就很清楚:如果净化逻辑中存在漏洞,就会导致 XSS。于是我更仔细地查看了这个函数的实现。
这部分的详细说明超出了本文的范围,因为其中大部分内容与该漏洞关系不大,所以我会简要总结:它本质上是对 HTML 节点进行递归解析,从子节点开始进行净化(实际上我认为这里面很可能还潜伏着其他缺陷……)。
但回到我们关注的漏洞本身。首先我们在 `getTagAllowList` 中观察到 `iframe` 是允许的标签,`src` 是允许的属性。
```
func getTagAllowList() map[string][]string {
...
whitelist["iframe"] = []string{"width", "height", "frameborder", "src", "allowfullscreen"}
return whitelist
}
```
此外,在负责净化属性的函数中,我们观察到对 iframe 的 `src` 属性有一个特殊的处理逻辑,位于 `isValidIframeSource` 中:
```
func sanitizeAttributes(baseURL string, node *html.Node) []string {
var attrNames []string
var attrs []html.Attribute
tagName := node.Data
for _, attribute := range node.Attr {
// 属性名不区分大小写;解析器可能保留原始大小写,
// 而白名单是小写的。
key := strings.ToLower(attribute.Key)
value := attribute.Val
if !isValidAttribute(tagName, key) {
continue
}
if (tagName == "img" || tagName == "source") && key == "srcset" {
value = sanitizeSrcsetAttr(baseURL, value)
}
if isExternalResourceAttribute(key) {
if tagName == "iframe" {
if isValidIframeSource(baseURL, attribute.Val) {
// 在这里!!!
value = attribute.Val
} else {
continue
}
} else if tagName == "img" && key == "src" && isValidDataAttribute(attribute.Val) {
value = attribute.Val
} else {
value = htmlutil.AbsoluteUrl(value, baseURL)
if value == "" {
continue
}
if !hasValidURIScheme(value) || isBlockedResource(value) {
continue
}
}
}
```
该函数的内容如下:
```
func isValidIframeSource(baseURL, src string) bool {
whitelist := []string{
"bandcamp.com",
"cdn.embedly.com",
"invidio.us",
"player.bilibili.com",
"player.vimeo.com",
"soundcloud.com",
"vk.com",
"w.soundcloud.com",
"www.dailymotion.com",
"www.youtube-nocookie.com",
"www.youtube.com",
}
domain := htmlutil.URLDomain(src)
// 允许来自同源的 iframe
if htmlutil.URLDomain(baseURL) == domain {
return true
}
return slices.Contains(whitelist, domain)
}
...
func URLDomain(val string) string {
if u, err := url.Parse(val); err == nil {
return u.Host
}
return val
}
```
简单来说,它接受提供的 URL(即 iframe 的 src),提取 URL 的主机部分,检查其是否与 yarr 的基础 URL 或白名单中的条目匹配,如果都不匹配则拒绝。URL 通过 `net/url` 包进行解析。该包的文档中提到:
> 包 url 解析 URL 并实现查询转义。参见 RFC 3986。该包总体上遵循 RFC 3986,但出于兼容性原因在某些地方有所偏离。对于 IPv6 zone 字面量,遵循 RFC 6874。
正是这一点使得多语言(polyglot)payload 得以实现。在 RFC 3986 中,字符序列 `//` 标记了权限部分(authority section)的开始(`URLDomain` 提取的就是这部分)。而在 RFC 6874 中,IPv6 zone 标识符使用 `%25`(即 `%` 的百分号编码)进行转义。
我们将这一点与 iframe 会欣然处理 `src` 中的 `javascript:` URI 的事实结合起来,就能构造出:
```
javascript://www.youtube.com/%0a
```
`net/url` 将 `www.youtube.com` 视为权限部分,提取主机(这里整个权限部分就是主机),与白名单匹配并通过。然而在浏览器中,当这被处理为 `javascript:` URI 时,冒号之后的所有内容都被视为 JavaScript 代码(经过一轮 URL 解码后)。
因此我们得到了:
```
javascript://www.youtube.com/%0a
```
被解析为:
```
//www.youtube.com/
```
请注意 `//` 在这里恰好也是 JavaScript 的行注释(而 `%0a` 解码后是一个换行符)——在不同的语法中具有不同的含义。
最后一块拼图是默认对 iframe 应用的宽松沙箱策略:
```
func extraAttributes(tagName string) ([]string, []html.Attribute) {
switch tagName {
...
case "iframe":
return []string{"sandbox", "loading"}, []html.Attribute{
{Key: "sandbox", Val: "allow-scripts allow-same-origin allow-popups"},
{Key: "loading", Val: "lazy"},
}
...
default:
return nil, nil
}
}
```
将所有这些组合在一起,一个恶意的 RSS 订阅源就可以对任何加载其文章的 yarr 用户实施 XSS 攻击。下面是一个简单的演示(重写父级 DOM,因为在那个沙箱策略下无法弹出 alert :):
<img src="https://raw.githubusercontent.com/0x67673068/blog/main/static/images/2026-10-20-xss-in-yarr/yarr_xss.png" alt="XSS 演示截图">
我已经将这个发现通知了 yarr 的维护者,但没有收到回复。话虽如此,考虑到 yarr 没有账号体系,我认为这个漏洞没有太大的即时影响——最坏的情况也就是窥探你的订阅者在关注哪些 RSS 订阅源。不过,这仍然是一个有趣的小发现,感谢阅读。
相似文章
跨站脚本攻击(XSS)速查表
本文是跨站脚本攻击(XSS)速查表,提供了执行XSS攻击的各种技术和代码示例,以及浏览器兼容性信息,来源为PortSwigger。
CVE-2026-46529: 存在10年之久的Linux PDF查看器远程代码执行漏洞(XReader/Evince/Atril)
某安全研究人员发现了CVE-2026-46529,这是Linux PDF查看器XReader、Evince和Atril中一个存在10年之久的远程代码执行漏洞,原因是在生成子进程以打开远程文档链接时,参数引用不充分。
标签名中的奥秘?原来是JavaScript
PortSwigger的研究文章展示了利用HTML标签名中的JavaScript属性进行新的XSS攻击向量,使WAF绕过在不同浏览器中成为可能。
通过构建日志接管SourceHut账户(ansi2html.py中的XSS)
研究人员在SourceHut使用的ansi2html库中发现了一个XSS漏洞,该漏洞可能允许通过构建日志接管账户,并描述了发现和修复过程。
可扩展矢量图形的烦恼
PortSwigger 研究员 Gareth Heyes 测试了八款流行的 SVG 清洗器,成功绕过其中六款,演示了通过命名空间混淆、SMIL 动画、Firefox 中的响应头以及 Safari 中以 GIF 伪装 SVG 导入等方式实现 XSS 攻击。