文章讲述的所有功能已经合并进入https://github.com/KSwordDEV/KSword/,欢迎体验
用户启动文件夹与公共启动文件夹的双重枚举
Startup 文件夹中的目录项会在用户登录后由 Windows 外壳处理。枚举时要先用已知文件夹 ID 定位当前用户目录与公共目录,再分别读取两个目录的一级条目。路径、属性、文件身份和签名结果需要分开保存。
一、对象关系和完整流程
系统处理流程从用户登录开始。Windows 建立用户会话并启动桌面外壳。桌面外壳通常是explorer.exe,负责提供桌面、开始菜单,并在相应阶段处理 Startup 目录项。
用户登录 -> Windows 建立用户会话 -> 启动桌面外壳 -> 处理当前用户 Startup 目录 -> 处理公共 Startup 目录 -> 按目录项类型继续处理枚举程序只观察目录内容。它不会触发目录项,也不能证明目标程序已经运行。
解析已知文件夹 ID -> 得到实际绝对路径 -> 枚举目录一级条目 -> 记录名称和属性 -> 打开普通文件 -> 读取文件身份 -> 保存范围、路径和错误状态1. Startup 文件夹是文件系统中的两个登录范围
Startup 文件夹包含当前用户范围和公共范围。Windows 把每个用户的配置与文件放在各自的用户配置文件目录中,同时提供由本机用户共享的公共目录。FOLDERID_Startup指向当前登录用户的启动文件夹。FOLDERID_CommonStartup指向公共启动文件夹。两者都表示文件系统目录。
当前用户 Startup 目录的常见显示路径为%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup。%APPDATA%会因用户配置文件位置、文件夹重定向和企业策略而变化。公共 Startup 目录的常见显示路径为%PROGRAMDATA%\Microsoft\Windows\Start Menu\Programs\StartUp,它面向本机多个用户,写入通常需要更高权限。
登录范围决定记录含义。只出现在当前用户目录中的条目,仅与该用户的登录环境有关。公共目录中的条目可能影响本机上多个交互式用户。读取结果要明确保存范围名称,不能只给出路径文本。
Windows 文件夹逻辑关系 当前登录用户 用户配置文件 AppData\Roaming\...\Programs\Startup FOLDERID_Startup 本机所有用户 ProgramData\...\Programs\StartUp FOLDERID_CommonStartup2. “已知文件夹”使用稳定的逻辑标识符
目录位置要通过已知文件夹标识符获取。Windows 为常用目录分配了 GUID(Globally Unique Identifier,全局唯一标识符),例如FOLDERID_Startup。系统结合用户配置、重定向规则和当前令牌解析标识符,然后返回实际的绝对路径。
已知文件夹 API 返回的路径指针带有明确的内存所有权。调用者拿到的PWSTR由 COM 任务分配器分配,读取完文本后必须调用CoTaskMemFree。把这一指针直接交给delete[]、free或LocalFree都会破坏分配器配对关系。
目录尚未创建也是一种有效状态。全新的用户配置文件、精简系统映像或管理员设置的重定向策略,都可能让目标路径暂时不存在。读取程序要分别记录“路径解析成功”“路径存在”和“能够列出目录”。
3. 外壳处理的是目录项,目录项的种类并不相同
目录项可能是文件、快捷方式或子目录。Startup 文件夹经常包含.lnk快捷方式,也可以包含可执行文件、批处理文件、Internet 快捷方式或目录。目录项名称只表示文件系统中的条目。外壳怎样处理它,还取决于条目类型、文件关联、快捷方式目标和登录环境。
.lnk文件保存 Shell Link 数据,其中可能包含目标路径、工作目录、参数和图标信息。Shell Link 是 Windows 外壳定义的快捷方式格式。枚举 Startup 目录时,先把.lnk当作普通文件记录。需要分析目标时,再通过 Shell Link 的 COM 接口解析。快捷方式文件名不能直接当成目标路径。
文件夹内的子目录也应与直接文件分开保留。直接枚举可以完整回答“这个 Startup 文件夹当前有哪些一级条目”,递归遍历回答的是另一个问题,二者混在同一个列表里会掩盖真实的目录层级。
4. 登录、Explorer 初始化与目录枚举有不同的时间边界
目录枚举不代表执行记录。用户完成交互式登录后,Windows 为该用户建立会话并启动桌面外壳。外壳随后在相应阶段处理 Startup 目录内容。目录枚举只能说明某个时刻有哪些一级条目,无法证明外壳已经处理它们,也无法证明目标程序成功运行。
完整观察分为三层。
- 解析已知文件夹 ID,得到当前用户目录或公共目录的实际路径。
- 读取一级目录项、属性和文件身份。
- 按需要解析快捷方式目标、检查文件是否存在,或结合进程与事件记录。
每层都要保存自己的时间和错误状态。
5. Shell Link、文件路径和文件对象身份需要分别记录
快捷方式文件和链接目标是两个对象。Startup 目录中的.lnk文件是一个文件系统条目。链接目标是另一个对象,可能不存在,也可能已经被替换。快捷方式文件名、快捷方式完整路径、解析出的目标文本和目标文件身份需要分别记录。
文件身份通常由卷序列号和文件索引组合表示。路径可能被改名、重解析或替换。文件索引可以在同一卷上辅助识别本次打开的文件对象。这个身份仍然与读取时间有关。文件被删除或替换后,旧身份不能继续代表新路径。读取失败时要保留文件路径和GetLastError,然后继续枚举其它条目。
6. 文件查找句柄、文件句柄和 COM 分配内存的释放规则不同
每类资源都有对应的释放 API。SHGetKnownFolderPath返回的PWSTR由 COM 任务分配器管理,要用CoTaskMemFree释放。FindFirstFileW返回搜索句柄,要用FindClose释放。CreateFileW返回普通HANDLE,要用CloseHandle释放。三种释放函数不能互换。
资源所有者应在同一段逻辑中保持清晰。路径文本复制到std::wstring后,再释放PWSTR。目录遍历结束后关闭搜索句柄。每个成功打开的文件都在读取身份后关闭。某个条目访问失败时,前一层资源仍能正常释放,另一个 Startup 范围也能继续枚举。
7. 一条目录项记录需要同时保存范围、名称、属性和读取结果
每条结果都要能回到对应目录。记录至少包含范围标签、已知文件夹 API 返回的目录路径、相对名称、完整路径、目录属性、目录标志、文件身份读取状态和采样时间。范围标签说明条目来自当前用户目录还是公共目录。.lnk、.exe、.cmd、.bat或未知扩展名只表示名称或文件类型,不能代替路径来源。
目录可能在枚举期间变化。某个名称由FindFirstFileW返回后,文件可能立即被删除,后续CreateFileW就会失败。文件也可能在打开后被其它进程替换。输出应保留“目录项当时返回的名称”和“文件身份读取失败码”,不能直接丢弃失败条目。
当前用户目录和公共目录要独立读取。当前用户目录解析失败时,继续解析公共目录。公共目录不可访问时,仍然输出当前用户条目。每个错误都要保留对应的范围,避免把一个范围的问题写成另一个范围的空目录。
目录路径、目录项和文件身份要按同一采样时间组合输出。后续差异比较才能对应到明确范围。
路径比较时,可以用完整绝对路径作为展示键,用文件身份辅助复核。重定向、重解析点、大小写显示和目录改名都可能改变路径文本。文件索引读取失败时,要保留失败状态,不能伪造身份字段。
完整的状态字段可以分别解释两次枚举之间的新增、删除、替换和不可读条目。
输出中的失败码要和目录范围一同保存。记录完成后再进行汇总展示,避免丢失单个范围的错误状态。
二、先定位两个不同范围的 Startup 文件夹
1. 已知文件夹 ID 比拼接环境变量更稳定
目录定位要区分当前用户与公共范围。FOLDERID_Startup对应当前用户的 Startup 文件夹,常见位置为%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup。FOLDERID_CommonStartup对应公共 Startup 文件夹,常见位置为%PROGRAMDATA%\Microsoft\Windows\Start Menu\Programs\StartUp。
实际路径要使用已知文件夹 API 获取。API 会根据当前用户配置、重定向设置和系统版本返回结果,不需要手工替换%APPDATA%或%PROGRAMDATA%。
// 意义:释放 COM 任务分配器分配的内存。// 返回:无。参数必须来自 CoTaskMemAlloc 或约定使用同一分配器的 API。voidCoTaskMemFree(LPVOID pv// 输入:SHGetKnownFolderPath 成功返回的路径指针。);// 意义:获取 folderId 指定的已知文件夹绝对路径。// 返回:S_OK 表示成功。失败 HRESULT 表示已知文件夹不可用或访问状态异常。HRESULTSHGetKnownFolderPath(REFKNOWNFOLDERID rfid,// 输入:FOLDERID_Startup、FOLDERID_CommonStartup 等 ID。DWORD dwFlags,// 输入:KF_FLAG_DEFAULT 使用默认解析方式。HANDLE hToken,// 输入:nullptr 表示当前用户令牌。PWSTR*ppszPath// 输出:CoTaskMemAlloc 分配的宽字符串,调用方必须 CoTaskMemFree。);正确做法:复制 API 分配的文本,再释放原始内存。输出指针来自 COM 任务分配器,只能由CoTaskMemFree释放。
PWSTR allocatedPath=nullptr;constHRESULT result=SHGetKnownFolderPath(FOLDERID_Startup,KF_FLAG_DEFAULT,nullptr,&allocatedPath);if(SUCCEEDED(result)&&allocatedPath!=nullptr){std::wstringstartupPath(allocatedPath);CoTaskMemFree(allocatedPath);// startupPath 现在拥有独立副本。}错误示例:混用分配器。下面的delete[]不匹配 API 的分配方式,可能破坏堆内存。
// 错误:SHGetKnownFolderPath 的输出必须用 CoTaskMemFree 释放。delete[]allocatedPath;三、枚举目录时只读取一层
2. 文件查找 API 返回目录项属性
第一次查找文件的宽字符 API 返回搜索句柄和第一条目录项。返回INVALID_HANDLE_VALUE表示失败。搜索句柄必须由FindClose关闭,不能使用CloseHandle。
// 意义:按 lpFileName 的通配符查找第一条目录项。// 返回:有效搜索句柄表示成功。INVALID_HANDLE_VALUE 表示失败。HANDLEFindFirstFileW(LPCWSTR lpFileName,// 输入:目录路径加 \\* 通配符。LPWIN32_FIND_DATAW lpFindFileData// 输出:第一条名称、属性和时间信息。);// 意义:读取同一搜索句柄的下一条目录项。// 返回:非零表示成功。返回 0 且错误码为 ERROR_NO_MORE_FILES 表示正常结束。BOOLFindNextFileW(HANDLE hFindFile,LPWIN32_FIND_DATAW lpFindFileData);// 意义:关闭 FindFirstFileW 返回的搜索句柄。// 返回:非零表示成功。参数只能是搜索句柄。BOOLFindClose(HANDLE hFindFile);正确做法:跳过.与..,只处理目录第一层。Startup 文件夹中的普通文件、快捷方式和子目录都是不同目录项。递归进入子目录会把非直接内容混入结果。
WIN32_FIND_DATAW data{};constHANDLE findHandle=FindFirstFileW((startupPath+L"\\*").c_str(),&data);if(findHandle!=INVALID_HANDLE_VALUE){do{if(wcscmp(data.cFileName,L".")==0||wcscmp(data.cFileName,L"..")==0){continue;}constboolisDirectory=(data.dwFileAttributes&FILE_ATTRIBUTE_DIRECTORY)!=0;// 记录完整路径、目录标志和属性。普通文件再读取文件身份。}while(FindNextFileW(findHandle,&data)!=FALSE);FindClose(findHandle);}错误示例:把FindFirstFileW返回的句柄交给CloseHandle。搜索句柄有专用关闭 API,混用关闭函数会留下资源或产生错误状态。
// 错误:findHandle 应由 FindClose 关闭。CloseHandle(findHandle);四、用文件对象身份辅助路径比对
3. 路径文本与文件身份是两类信息
文件打开的宽字符 API 返回普通文件句柄。FILE_READ_ATTRIBUTES只请求元数据读取,文件共享标志允许并发读取、写入和删除,避免因另一个进程占用文件而不必要失败。
// 意义:打开已有文件并获得文件对象句柄。// 返回:有效 HANDLE 表示成功。INVALID_HANDLE_VALUE 表示失败。HANDLECreateFileW(LPCWSTR lpFileName,DWORD dwDesiredAccess,DWORD dwShareMode,LPSECURITY_ATTRIBUTES lpSecurityAttributes,DWORD dwCreationDisposition,DWORD dwFlagsAndAttributes,HANDLE hTemplateFile);// 意义:关闭 CreateFileW 成功返回的普通文件句柄。// 返回:非零表示成功。搜索句柄和 SC_HANDLE 不使用此函数关闭。BOOLCloseHandle(HANDLE hObject);// 意义:读取文件卷序列号、文件索引、属性与时间等身份信息。// 返回:非零表示成功。返回 0 表示失败。BOOLGetFileInformationByHandle(HANDLE hFile,// 输入:CreateFileW 成功返回的文件句柄。LPBY_HANDLE_FILE_INFORMATION lpInfo// 输出:文件对象信息。);正确做法:同时保存完整路径、卷序列号与文件索引。路径表示 Startup 位置。卷序列号和文件索引可以辅助识别同一路径被替换后的不同文件对象。
constHANDLE file=CreateFileW(path.c_str(),FILE_READ_ATTRIBUTES,FILE_SHARE_READ|FILE_SHARE_WRITE|FILE_SHARE_DELETE,nullptr,OPEN_EXISTING,FILE_ATTRIBUTE_NORMAL,nullptr);if(file!=INVALID_HANDLE_VALUE){BY_HANDLE_FILE_INFORMATION info{};if(GetFileInformationByHandle(file,&info)!=FALSE){constunsignedlonglongfileIndex=(static_cast<unsignedlonglong>(info.nFileIndexHigh)<<32)|info.nFileIndexLow;// 保存 info.dwVolumeSerialNumber 与 fileIndex。}CloseHandle(file);}错误示例:用扩展名判断文件是否可信。.lnk、.exe、.cmd、.bat和.url都可能出现在 Startup 目录。扩展名只描述名称形式。签名验证、签名主体、目标解析和文件身份都需要独立信息。
五、保持目录范围和签名结论独立
4. 签名信息不能替代路径事实
文件位置与签名结果要分开记录。文件位于用户 Startup 或公共 Startup,属于目录事实。签名是否有效、证书主体是谁、证书链是否可信,属于独立的密码学检查结果。这两类结论不能互相替代。
目录枚举由多次文件系统调用组成。文件可能在查找期间被创建、删除或替换。打开文件身份失败时,记录路径和错误状态,再继续读取其它目录项。
完整可运行程序在附件。
https://wangweicm.lanzouu.com/ijfMn3wwjebc