NTFS Change Journal(USN Journal)详解

还是那个文件监控的应用,发现使用Windows API(ReadDirectoryChangesW)还是不能满足要求,如果变化量大又密集时,丢失通知现象很严重。好在需要监控的大部分的Windows用户都转到NTFS系统,所以打算采用分析NTFS的Change Journal(更改日志)的方法实现监控功能。

Change Journal这名字挺直白。

很火的桌面搜索程序:Everything就是利用了NTFS系统的这个特性,通过读取和监控USN(后面会讲)而不是扫描文件来构建索引,所以搜索速度飞快,看来这个东西很好用。但是,相关的资料却是很少,特别是系统级介绍,而且一会USN,一会Change Journal,晕啊,找到代码都不敢用,还是老老实实做功课吧,找到了微软原始的两篇论文,来好好研究一下这个Change Journal。

Change Journal有什么用

NTFS是Windows 2000及其他基于Windows NT系统的标准文件系统,提供很多新特性(与FAT32比),而Change Journal是一个存储所有NTFS 5.0标卷(Volume)上文件和目录变化信息的数据库。每个标卷都有自己的Change Journal数据库,这些监控这些信息可以用来实现数据恢复,防止系统文件被篡改等系统级功能。在Windows NT 4.0中,这些功能,都是由之前两篇译文:[译]理解ReadDirectoryChangeW (理论部分)和(实现部分)中提到的Windows API来完成监控的,难用的程度真是谁用谁知道。一般的非系统级应用(如杀毒软件)也可以使用Change Journal,可以避免程序扫描整个硬盘,提高效率。

Change Journal工作原理

事实上Change Journal是标卷上一个特殊的文件,系统将其隐藏,所以用资源管理器或者CMD Shell都看不到,当文件系统中的文件或者目录发生改变时,就会向日志中追加记录。记录一般包括:文件名,变化时间,变化类型,而实际的数据不会记录,这样也可以保持记录文件足够小。

最开始的时候,日志文件时一个磁盘标卷上的空文件,随着改变的发生,记录不断被追写进日志。每条日志有个64-bit标识,即USN(Update Sequence Number),这个USN是自增的,所以你可以通过比较USN来,找到事件发生的顺序(号码越小,事件越早),但不一定连续,有可能第一个USN是0,而第二个是128。

微软最开始构建Change Journal时,称其为USN Journal,所以winioctl.h头文里的结构定义都是这个命名,写程序的时候也将大量使用这个名词,所以下面不区分,Change Journal=USN Journal。

由于总是向文件末端添加记录,所以采用文件偏移的形式来存储USN,这样查询时只需要计算即可定位。但记录中的文件名是变长的,所以每条USN大小也不一定相同。考虑到性能问题,系统会将记录以4KB(可以参看winioctl.h中的USN_PAGE_SIZE宏)为块大小存放,每块通常会包含三四十条记录。操作系统不允许单条记录横跨两个块页,所以有时候会发生USN为空,用来填充块间隙。

在NTFS标卷上,文件和目录信息存储于Master File Table(MFT)中,其中的记录都描述了文件或目录名,位置,大小,属性等。NTFS 5.0中,每个MFT记录项都保存了该文件或者目录最后的USN记录。当Change Journal记录时,文件系统更新被更改的MFT中最后的USN值。

如果日志文件过大(大于定义的MaximumSize参数),系统将会清理掉文件开始部位较早的数据,通常截断开始数据需要大量的I/O操作,文件末端必须要被拷贝到新位置,这是一个耗时的过程。幸运的是,NTFS 5.0支持稀疏文件,这种机制允许删除文件中不需要的部分,而保留其余数据的逻辑偏移。所以Change Journal就是一个稀疏文件,允许清除早期记录,而不会损失太多性能,也不影响原先的文件偏移访问。更多关于稀疏文件信息可以参考A File System for the 21st Century: Previewing the Windows NT 5.0 File System。

标卷上的Change Journal功能可以关闭,这样系统就不会记录变化信息,默认情况下,NTFS标卷上的Change Journal功能是关闭的,必须明确的开启才能使用,开启和关闭可以由任意程序,任意时间完成。问题来了,如果两个程序操作时发生冲突怎么办?当一个程序禁用标卷的Change Journal,系统会清理所有先前的记录,以防止其他程序读取不可靠的数据。总的来说,Change Journal启用时会创建日志文件,禁用时会删除日志文件。

每一个Change Journal会被分配一个唯一的64-bit标识(与USN标识不同)USNJournalID,系统将会在禁用/启用之后改变这个标识,这样程序可以通过读取这个标识,来确定读取信息的可靠性。这个标识在重启后也不会变化,换句话说,如果标识不变,Change Journal会记录开机后所有文件的变化。其实这个标识是一个UTC时间戳,但是程序员不应该利用这个语义,万一微软有一点变了咋办

所有Change Journal操作都可以通过下面函数完成:

BOOL DeviceIoControl(
HANDLE hDevice,          // handle to device/file/
// directory
DWORD dwIoControlCode,   // control code of operation
// to perform
LPVOID lpInBuffer,       // pointer to buffer of
// input data
DWORD nInBufferSize,     // size, in bytes, of input
// buffer
LPVOID lpOutBuffer,      // pointer to buffer for
// output data
DWORD nOutBufferSize,    // size, in bytes, of output
// buffer
LPDWORD lpBytesReturned, // receives number of bytes
// written to lpOutBuffer
LPOVERLAPPED lpOverlapped// for asynchronous
// operation
);
  • 第一个参数是通过CreateFile获得的文件/目录/设备的句柄;DeviceIoControl是用来请求驱动对设备进行操作的常用方法
  • 参数dwIoControlCode即指定执行什么操作并定义I/O缓冲区的结构;如果CreateFile使用FILE_FLAG_OVERLAPPED调用,DeviceIoControl将会异步操作,如果ReadFile/WriteFile一样。Change Journal由NTFS驱动管理,为了与之通信,需要获得标卷的句柄:

C++:

// Get a handle to access the Change Journal on the
// 'C' volume
HANDLE hcj = CreateFile("\.C:", GENERIC_READ,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL, OPEN_EXISTING, 0, NULL);

访问标卷句柄必须具有管理员权限,所以普通用户无法运行涉及Change Journal操作的程序,更具体的操作可以查询MSDN。

程序可以通过调用DeviceIoControl传递FSCTL_QUERY_USN_JOURNAL,来查询特定的数据,如果DeviceIoControl返回TRUE,则USN_JOURNAL_DATA结构会被填充;如果返回FALSE可以利用GetLastError(具体查MSDN)获得错误信息。

C++:

typedef struct {
DWORDLONG UsnJournalID;  //64-bit标识。
USN FirstUsn;            //第一条记录,所有比它还小的ID,都会被清理。
USN NextUsn;             //下一条会被写入的记录。
USN LowestValidUsn;      //这个日志中最小的USN,不一定是零。
USN MaxUsn;              //最大日志,根据最大大小算出,NextUsn比它还大,那就要清理记录。
DWORDLONG MaximumSize;   //最大大小
DWORDLONG AllocationDelta; //增长大小,如果增长超过MaximuSize,开始清理记录。
} USN_JOURNAL_DATA, *PUSN_JOURNAL_DATA;

=USN记录=

下面是USN记录结构,注意磁盘具体数据并不是这样存储的,所以永远都是由系统来填充这个结构,下面一一解释各个成员:

C++:

// Version 2.0 USN_RECORD structure
typedef struct {
DWORD         RecordLength;
WORD          MajorVersion;
WORD          MinorVersion;
DWORDLONG     FileReferenceNumber;
DWORDLONG     ParentFileReferenceNumber;
USN Usn;
LARGE_INTEGER TimeStamp;
DWORD         Reason;
DWORD         SourceInfo;
DWORD         SecurityId;
DWORD         FileAttributes;
WORD          FileNameLength;
WORD          FileNameOffset;
WCHAR         FileName[1];
} USN_RECORD, *PUSN_RECORD;

系统会一次读出多条记录缓存,RecordLength是记录总长度,包括文件名。所以利用长度来计算下一条记录位置。

C++:

PUSN_RECORD pNext;
pNext = (PUSN_RECORD) (((PBYTE) pRecord) +
pRecord->RecordLength);

请不要忽视MajorVersion和MinorVersion这两个参数,毕竟NTFS也在不断演化,Change Journal有自己版本控制,要知道最新的结构,不妨参看winioctl.h中的声明,而且可能要在程序中判断版本,区分处理,以免出错。瞧瞧,2.3版本是这个样子的:

C++:

// HYPOTHETICAL Version 2.3 USN_RECORD structure
typedef struct {
DWORD RecordLength;
WORD   MajorVersion;
WORD   MinorVersion;
DWORDLONG FileReferenceNumber;
DWORDLONG ParentFileReferenceNumber;
USN Usn;
LARGE_INTEGER TimeStamp;
DWORD Reason;
DWORD SourceInfo;
DWORD SecurityId;
DWORD FileAttributes;
WORD  FileNameLength;
WORD  FileNameOffset;  // penultimate of original version 2.0
DWORD ExtraInfo1;      // Hypothetically added in version 2.1
DWORD ExtraInfo2;      // Hypothetically added in version 2.2
DWORD ExtraInfo3;      // Hypothetically added in version 2.3
WCHAR FileName[1];     // variable length always at the end
} USN_RECORD, *PUSN_RECORD;

记录本身并不记录文件或者目录的全路径,而文件名由上面结构中的三个参数确定,FileNameOffset文件名偏移,FileNameLength文件名长度,FileName这个不能直接使用。

C++:

WCHAR szName[MAX_PATH];
CopyMemory(szName,
((PBYTE) pRecord) + pRecord->FileNameOffset,
pRecord->FileNameLength);
// Let's zero-terminate it
szName[pRecord->FileNameLength/sizeof(WCHAR)] = 0;

File Reference Number(FRN)是文件和目录在NTFS标卷上唯一的标识,可以通过ParentFileReferenceNumber获得全路径。

C++:

TCHAR szFullPath[MAX_PATH];
// Fill in the path of the parent directory
PathFromParentFRN(pRecord->ParentFileReferenceNumber,
szFullPath);// Append name to path using the Win32 function PathAppend
PathAppend(szFullPath, szName);

很遗憾没有一个API叫PathFromParentFRN,不然就可以直接读出目录名。现在你可能会奇怪,FileReferenceNumber是干什么的,如果我们能通过FRN得到全路径信息,那就不用上面的偏移+长度获得文件名了。事实上,找到一个目录的FRN比文件容易得多,FileReferenceNumber不一定是个文件还是目录,但是ParentFileReferenceNumber一定是个目录,所以采用偏移+长度的方式得到本名,再用Parent得到目录,这样就可以组合出全路径了。

没错,Usn就是记录标识了;TimeStamp是一个64bit,UTC时间戳;Reason成员表示文件或者目录发生了何种变化,一个文件打开后,系统将Reason变量置零,但不写入USN记录,当变化动作发生时,如果这是一个新的Reason Code,就设置Reason变量并向日志中写入记录。如果有多个程序同时操作同一个文件,也可能会发生同一条记录的Reason有多个Reason Code,直到USN_REASON_CLOSE被设置,文件被关闭。

C++:还可以通过调用DeviceIoControl传入FSCTL_WRITE_USN_CLOSE_RECORD,使得系统在打开文件时清理Reason变量为0。

DWORD cb;
USN usn;
// Force a close record for
// the open file specified
// by 'hFile'
DeviceIoControl(hFile, FSCTL_WRITE_USN_CLOSE_RECORD,
NULL, 0, &usn, sizeof(usn), &cb, NULL);

唯一特别的的一个Reason Code是USN_REASON_RENAME_OLD_NAME,当一个文件重命名,将会有两条记录被写入日志,分别一条记录老的文件/目录名,另一条记录新的文件/目录名,当然其Reason Code是USN_REASON_RENAME_NEW_NAME。

如果SourceInfo成员非零,说明文件发生了改变,那这与Season有什么区别呢。比如“杀毒软件删除了一个你文档里面的病毒”,杀毒软件需要打开文件并覆盖受感染的部分。这会产生一个Reason=USN_REASON_DATA_OVERWRITE的记录,记录会因为一个数据覆盖操作(Reason),而完成这个工作是为了杀毒(SourceInfo)。也就是说SourceInfo更具有逻辑意义,这个信息并不是系统指出的,而是由操作文件的程序设置。

SecurityId是系统用来描述文件安全性的成员,与设备I/O控制FSCTL_SECURITY_ID_CHECK仪器使用;FileAttributes可以通过GetFileAttributes调用获得文件/目录的属性。

=读取记录=

有了上面对记录结构的认识,下面来读取Change Journal记录。首先准备两个变量,分别是标卷句柄,与日志结构(通过FSCTL_QUERY_USN_JOURNAL获得):

C++:

HANDLE hcj;
USN_JOURNAL_DATA ujd;

再通过调用FSCTL_READ_USN_JOURNAL调用DeviceIoControl,下面这个结构需要填充后作为参数输入:

C++:

typedef struct {
USN StartUsn;
DWORD ReasonMask;
DWORD ReturnOnlyOnClose;
DWORDLONG Timeout;
DWORDLONG BytesToWaitFor;
DWORDLONG UsnJournalID;
} READ_USN_JOURNAL_DATA, *PREAD_USN_JOURNAL_DATA;

StartUsn,第一条你想访问的Usn,如果标识存在就返回,否则返回下一条,如果StartUsn为0,系统将会返回最开始的记录。 ReasonMask和ReturnOnlyOnClose可以按照字面理解(后面会解释),StartUsn并不能保证时候满足这两个条件,所以需要调用者自己验证。系统是以4KB为一块(USN_PAGE_SIZE)写入日志,所有ujd.FirstUsn到ujd.NextUsn都会依据4KB对齐。

系统只会返回满足ReasonMask条件的记录,换句话说,你可以指定自己关心的Reason Code,不符合条件的记录不会包含在缓冲区中。ReturnOnlyOnClose是另一可以过滤记录的成员,如果其值非零,只有Reason=USN_REASON_CLOSE记录才会被返回,这个条件需要与ReasonMask相一致才行。

Timeout与BytesToWaitFor一起使用,作为查询时间的限制。并不是说明DeviceIoControl在指定的超时时间内返回,而是用来指定系统检查请求数据是否可用的周期。这个成员不像其他win32超时参数采用毫秒计时,而是使用FILETIME结构。当设置Timeout为0,即不指定超时时间;使用一个负数来指定超时时间,例如一个25秒的超时可以表示为-2500000000。如果是异步调用DeviceIoControl则超时成员被忽略。

不要混淆BytesToWaitFor成员和输出缓冲区大小,或者DeviceIoControl的返回值,若置零,则表示函数立即返回,即使没有找打匹配的日志,如果非零,至少找到一条匹配数据然后返回。BytesToWaitFor定义了系统检查是否匹配数据创建的周期,例如,如果定义16384,系统将会在新建16KB数据后验证,这样可以防止一个进程读取记录时使用太多资源。Timeout/BytesToWaitFor只有在使用ReasonMask/ReturnOnlyOnClose但没有找到数据时才有效果。

UsnJournalID应该被设为ujd.UsnJournalID,如果日志ID已经被改变,DeviceIoControl调用会失败(前面说过,禁用后数据都会删除,重启后会改变这个ID)。

调用FSCTL_READ_USN_JOURNAL是为了填充输出缓冲。

C++:

DeviceIOControl(hcj, FSCTL_READ_USN_JOURNAL, &InBuf,
sizeof(InBuf), pOut, cbOut, &cbReturned, NULL);

但却无法知道具体填充了几条数据,具体排列形式是这样:

下面的代码利用usnStart和usnEnd判断数据合法性:

C++:

// Read the raw data for USNs from usnStart up to but not including usnEnd
// This can be used to read all available records by using
// the USN_JOURNAL_DATA members FirstUsn and NextUsn
void GetRawRecordData(HANDLE hcj, DWORDLONG journalId,
USN usnStart, USN usnEnd){
READ_USN_JOURNAL_DATA rujd;
rujd.StartUsn          = usnStart;
rujd.ReasonMask        = 0xFFFFFFFF;  // All bits
rujd.ReturnOnlyOnClose = FALSE;       // All entries
rujd.Timeout           = 0;           // No timeout
rujd.BytesToWaitFor    = 0;           // Do not wait if no records
rujd.UsnJournalID      = journalId;   // The journal we expect to read fromwhile (rujd.StartUsn <usnEnd) {
DWORD cbRead;
BYTE pData[8192 + sizeof(USN)]; // read in 8 KB chunks
BOOL fOk = DeviceIoControl(hcj, FSCTL_READ_USN_JOURNAL,
&rujd, sizeof(rujd), pData, sizeof(pdata), &cbRead, NULL);
if (!fOk)
break; // handle error
// Get first USN to request next time
rujd.StartUsn = * ((PUSN) pData);
PUSN_RECORD pRecord = (PUSN_RECORD) &pData[sizeof(USN)];
while ((PBYTE) pRecord < (pData + cbRead)) {
// … do something with the record …
pRecord = (PUSN_RECORD)((PBYTE) pRecord + pRecord->RecordLength)
}
}}

==参考==

  1. Keeping an Eye on Your NTFS Drives: the Windows 2000 Change Journal Explained

  2. Keeping an Eye on Your NTFS Drives, Part II: Building a Change Journal Application

  3. Wikipedia: USN_Journal

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349