yarr RSS 阅读器中的 XSS 漏洞

Lobsters Hottest 新闻

摘要

一位安全研究人员详细介绍了 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)速查表

Lobsters Hottest

本文是跨站脚本攻击(XSS)速查表,提供了执行XSS攻击的各种技术和代码示例,以及浏览器兼容性信息,来源为PortSwigger。

标签名中的奥秘?原来是JavaScript

Lobsters Hottest

PortSwigger的研究文章展示了利用HTML标签名中的JavaScript属性进行新的XSS攻击向量,使WAF绕过在不同浏览器中成为可能。

可扩展矢量图形的烦恼

Lobsters Hottest

PortSwigger 研究员 Gareth Heyes 测试了八款流行的 SVG 清洗器,成功绕过其中六款,演示了通过命名空间混淆、SMIL 动画、Firefox 中的响应头以及 Safari 中以 GIF 伪装 SVG 导入等方式实现 XSS 攻击。