前几天朋友的电脑遇到这样一个问题,他想要运行某个jar文件,不小心点击了右键选项中的“使用notepad打开”,这里的notepad指的是windows11自带文件编辑器,他的jar文件快将近200MB,这导致notepad直接卡死。原本以为关掉后就无事发生了,但事实并非如此,此后他每次打开notepad都会默认去打开那个文件,导致进程崩溃。
他第一时间的想法是删除这个文件,这样即使打开notepad也会因为找不到文件而跳过这个步骤。事实并非如此吗?当他这样操作后,notepad仍会打开缓存中的这个文件数据的备份,还是会卡死(大家可以试一试)。
这个问题其实在2023年11月份开始就已经在互联网上出现了,应该只局限于win11更新的notepad的特性,如果大家遇到类似问题的话具体的解决方案可以参考下面的文章:
how-to-delete-notepad-cache-in-windows-11-pro
记事本打开文件总是会自动打开之前打开过的文件
win11记事本因打开文件过大而持续无响应卡顿问题的解决方案
当然,我们在这里讨论这个特性的原因并不是为了帮大家解决这个问题,是否我们可以利用这个特性做一些好玩的事情呢?目前网上的相关资料较少,windows也没有相关的文档可供参考,下面让我们来分析一下是否存在利用的可能。
本文相关项目已经开源,欢迎大家star和提出bug。
操作系统:windows 11
使用软件:010editor,notepad
首先我们查看本地数据,在资源管理器中输入%LOCALAPPDATA%
转到我们已安装应用的本地数据中:
接着进入Packages
目录下,我们可以看到noepad对应的文件夹:
我们进入这个文件夹后,会看到有一个LocalState
文件夹,里面存放着tab的数据:
对应目录的含义为:
_8wekyb3d8bbwe
: 这个是固定的后缀,因此后续我们查找文件路径可以直接去这个路径下查找。LocalState
:这个文件夹用于存储应用的本地状态信息。TabState
:这个文件夹用于存储有关记事本应用中打开的标签页的状态信息。Windows的最新版本允许记事本应用支持多标签页,这个文件夹可能包含了用户当前打开的标签页的信息,比如哪些文件被打开了,它们的显示状态等。总之,这个目录用于存储和管理Windows记事本应用中的标签页状态,以便于应用可以恢复到用户上次使用的状态。
这里面的bin文件的文件名貌似是符合GUID的规则的,这也很正常,随机生成的GUID来防止生成文件时文件名重复。既然是个bin文件,那我们就能用010editor来进行分析。在这之前,我们可以使用strings.exe来看看bin文件中都有什么字符串。strings.exe是Sysinternals的套件工具之一,可以在这里进行下载。运行这个程序后可以看到bin文件中确实包含了字符串,其中test.txt是我当前测试文件的文件名:
我再新建一个文件test2.txt,然后往里面写点数据保存后再对比看看会看到什么:
# 写入数据保存为test2.txt
123456789
Mode LastWriteTime Length Name
---- ------------- ------ ----
----- 2021-06-22 2:57 PM 7490 Eula.txt
----- 2021-06-22 2:58 PM 370056 strings.exe
----- 2021-06-22 2:58 PM 478088 strings64.exe
----- 2021-06-22 2:58 PM 525704 strings64a.exe
接着使用strings.exe再看一下新多出来的文件,可以看到我们的数据都包含在了这个bin文件中:
这种情况是我们保存了文件后查看到的bin文件的数据,假设我们在notepad中写入数据后不保存,看看会发生什么事情:
写入数据后直接关掉notepad,可以看到新出现了一个bin文件,新出现的文件:
查看对应字符串,可以看到即使我们没有保存文件,也会出现在notepad中:
感觉这个地方确实可以被利用,比如说我们随手在notepad中写入了账号密码,虽然没有保存,但是仍然存在被取证的可能性。我们接着用010editor分析一下这个“未保存而产生”的bin文件:
可以看到包含了我们的数据,与之对比的就是之前保存了的test2.txt对应的bin文件,数据中还会包含文件路径:
我们对比“未保存”和“保存”的bin文件的前四个字节,可以看到第四个字节有所不同,我推断这个应该判断文件保存状态的,当为1时则为保存的文件,至于前面的4E 50
应该是这个bin文件的魔数:
第五个字节对应路径的长度,在未保存的文件中为01
,在已保存的文件中为69
,0x69对应十进制105,如下图所示符合我们的计算规则:
我们接着观察已保存文件的文件名后面的字节是做什么的(为了方便观察,我将文本中的内容都填充为1234567890):
这一部分的前面是文件路径,后面是文件内容。当前文本内容长度为400,我们创建一个短内容(10)并保存文件,来看看保存的文件之间有什么区别。
短内容(10)的中间字节如下图所示:
两者对比,可以看到在05 01
之前的两个字节有所不同:
十六进制的05 01
通常代表的是回车符号(\r
),在Windows系统中,回车符通常与换行符(\n
,也称为 Line Feed)一起使用 (\r\n
) 来表示新的一行的开始。
前面的两个字节代表的实际上是整个文件内容的长度,如果当前文件内容小于128(0x80),就只需要一个字节表示就可以了,就像上图的00 0A
,也就是长度为10,符合我们内容的长度。
但是长内容(400)用到了两个字节来表示长度,很明显跟短内容有所区别,长内容使用90 03
,计算内容的方法为:
我们设0x90为a,0x03为b,0x80为c,计算公式为a-c + b*c
:
>>> 0x90 - 0x80 + 0x03*0x80
400
计算结果为400,符合我们的设计。
至于文件最后的六个字节,应该是对当前文件做的什么签名,保存的文件和未保存的文件末尾都会有这个,忽略掉即可:
我们是不是可以写一个工具来抓取那些已经保存和还未保存的notepad数据呢?这里我打算使用C#进行实现,这样CS可以进行内存加载。
下面我将一步步领大家完成这个工具的demo版本。
首先检测第四个字节,判断当前文件是已保存还是未保存:
已保存:
05 01
前面的两个字节判断文本长度(如果其中一个字节为0x00,则不带入等式中)未保存:
4E 50 00 00 01 0F 0F 01 00 00 00 0F
,共12字节核心代码实现如下:
// 使用FileStream打开文件
using (FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
{
// 移动到第四个字节的位置
fileStream.Seek(3, SeekOrigin.Begin);
// 读取第四个字节
int fourthByte = fileStream.ReadByte();
// 根据第四个字节的值输出结果
if (fourthByte == 1)
{
Console.WriteLine("File State: saved file\n");
}
else
{
Console.WriteLine("File State: unsaved file\n");
}
}
根据第五个字节判断文件名长度并打印文件名:
// 读取第五个字节作为长度
int length = fileStream.ReadByte();
Console.WriteLine("FilePath Length: " + length);
length = length * 2 + 1;
// 读取指定长度的字节
byte[] buffer = new byte[length];
int bytesRead = fileStream.Read(buffer, 0, length);
if (bytesRead < length)
{
throw new Exception("File too short to read expected content.");
}
// 将字节转换为字符串
string content = Encoding.UTF8.GetString(buffer);
Console.WriteLine("File Name: " + content);
当前运行结果如下:
此处注意length = length * 2 + 1;
,文件保存文件名单字符是按照两个字节进行保存的,因此需要乘2,加一算上后面的空字节,这样最终的length+1就是0x90了,也就到了我们判断文本长度的地方。
根据length定位(05 01
前面的两个字节)文本内容长度,核心代码实现如下:
这里我们涉及了一个方程计算,因此我们需要写一个函数来计算:
// 计算表达式 a-c + b*c
public static int CalculateExpression(int a, int b, int c)
{
return (a - c) + (b * c);
}
下面调用:
// 读取关于内容长度的两个字节,作为函数参数进行运算
fileStream.Seek(5 + length, SeekOrigin.Begin);
int a = fileStream.ReadByte();
int b = fileStream.ReadByte();
int contentLength;
#if DEBUG
Console.WriteLine($"a: {a}, b: {b}");
#endif
if (b == 5)
{
Console.WriteLine("Content Length < 0x80");
contentLength = a;
Console.WriteLine("Result of Content Length: " + contentLength);
}
else
{
Console.WriteLine("Content Length > 0x80");
int c = 0x80;
int result = CalculateExpression(a, b, c);
contentLength = result;
Console.WriteLine("Result of Content Length: " + contentLength);
}
接着我们实现Main Content的代码,我们从总长度里把最后6个字节和上面代码得到的contentLength*2
一起减去:
// 计算起始位置并读取内容
long startPosition = fileStream.Length - 6 - contentLength * 2;
if (startPosition < 0)
{
Console.WriteLine("Invalid content length, unable to read from specified position.");
}
else
{
fileStream.Seek(startPosition, SeekOrigin.Begin);
byte[] headerBytes = new byte[contentLength * 2];
int headerBytesRead = fileStream.Read(headerBytes, 0, headerBytes.Length);
if (headerBytesRead < headerBytes.Length)
{
throw new Exception("File too short to read expected header.");
}
// 将字节转换为字符串并打印
string mainContent = Encoding.UTF8.GetString(headerBytes);
Console.WriteLine("Main Content: " + mainContent);
}
当前已保存的文件读取程序基本完成,大于0x80和小于0x80的情况都已进行了处理:
把上面的代码封装成函数:
public static void savedFile(string filePath)
{
// 指定要读取的文件路径
try
{
// 使用FileStream打开文件
using (FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
{
// 移动到第四个字节的位置
fileStream.Seek(3, SeekOrigin.Begin);
// 读取第四个字节
int fourthByte = fileStream.ReadByte();
// 根据第四个字节的值输出结果
if (fourthByte == 1)
{
Console.WriteLine("File State: Saved file");
}
else
{
Console.WriteLine("File State: Unsaved file");
}
// 读取第五个字节作为文件路径长度
int length = fileStream.ReadByte();
Console.WriteLine("FilePath Length: " + length);
length = length * 2;
// 读取指定长度的字节
byte[] filePath_buffer = new byte[length];
int bytesRead = fileStream.Read(filePath_buffer, 0, length);
if (bytesRead < length)
{
throw new Exception("File too short to read expected content.");
}
// 将字节转换为字符串
string filePath_content = Encoding.UTF8.GetString(filePath_buffer);
Console.WriteLine("File Name: " + filePath_content);
// 读取关于内容长度的两个字节,作为函数参数进行运算
fileStream.Seek(5 + length, SeekOrigin.Begin);
int a = fileStream.ReadByte();
int b = fileStream.ReadByte();
int contentLength;
#if DEBUG
Console.WriteLine($"a: {a}, b: {b}");
#endif
if (b == 5)
{
Console.WriteLine("Content Length < 0x80");
contentLength = a;
Console.WriteLine("Content Length: " + contentLength);
}
else
{
Console.WriteLine("Content Length > 0x80");
int c = 0x80;
int result = CalculateExpression(a, b, c);
contentLength = result;
Console.WriteLine("Content Length: " + contentLength);
}
// 计算起始位置并读取内容
long startPosition = fileStream.Length - 6 - contentLength * 2;
if (startPosition < 0)
{
Console.WriteLine("Invalid content length, unable to read from specified position.");
}
else
{
fileStream.Seek(startPosition, SeekOrigin.Begin);
byte[] headerBytes = new byte[contentLength * 2];
int headerBytesRead = fileStream.Read(headerBytes, 0, headerBytes.Length);
if (headerBytesRead < headerBytes.Length)
{
throw new Exception("File too short to read expected header.");
}
// 将字节转换为字符串并打印
string mainContent = Encoding.UTF8.GetString(headerBytes);
Console.WriteLine("Main Content: " + mainContent);
}
}
}
catch (Exception ex)
{
// 输出错误信息
Console.WriteLine("Error reading file: " + ex.Message);
}
}
未保存文件的处理方式要比已保存的好解决得多,因为未保存文件前12字节和后6字节固定,我们可以直接进行字符串转换。
public static void unsavedFile(FileStream fileStreamInput)
{
try
{
using (FileStream fileStream = fileStreamInput)
{
// 确保文件长度足以进行读取
if (fileStream.Length < 20) // 至少需要13 + 7 = 20个字节
{
Console.WriteLine("File is too short.");
return;
}
// 设置起始位置,从第13个字节开始读取(索引从0开始,所以是12)
fileStream.Seek(12, SeekOrigin.Begin);
// 计算要读取的字节数
int count = (int)fileStream.Length - 12 - 7;
// 创建缓冲区并读取数据
byte[] bytes = new byte[count];
int bytesRead = fileStream.Read(bytes, 0, count);
// 将字节转换为字符串
string content = Encoding.UTF8.GetString(bytes, 0, bytesRead);
// 打印结果
Console.WriteLine(content);
}
}
catch (Exception ex)
{
Console.WriteLine("Error reading file: " + ex.Message);
}
}
我们需要判断保存和未保存的状态分类处理,此处把函数独立出来在主函数中再进行调用:
public static void dealFileType(string filePath)
{
// 使用FileStream打开文件
using (FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
{
// 移动到第四个字节的位置
fileStream.Seek(3, SeekOrigin.Begin);
// 读取第四个字节
int fourthByte = fileStream.ReadByte();
// 根据第四个字节的值输出结果
if (fourthByte == 1)
{
Console.WriteLine("File State: Saved file");
savedFile(fileStream);
}
else
{
Console.WriteLine("File State: Unsaved file");
unsavedFile(fileStream);
}
}
}
主函数中我们需要利用正则提取出我们需要使用的bin文件,因为符合GUID的命名规则,所以还是很好实现的:
static void Main(string[] args)
{
// 指定要遍历的文件夹路径
string directoryPath = @"C:\Users\root\AppData\Local\Packages\Microsoft.WindowsNotepad_8wekyb3d8bbwe\LocalState\TabState";
// 获取目录中所有的.bin文件
string[] filePaths = Directory.GetFiles(directoryPath, "*.bin");
// 定义一个GUID的正则表达式
Regex guidRegex = new Regex(@"^[{(]?[0-9A-Fa-f]{8}[-]([0-9A-Fa-f]{4}[-]){3}[0-9A-Fa-f]{12}[)}]?$");
// 遍历所有文件
foreach (string filePath in filePaths)
{
// 获取文件名(不包括路径)
string fileName = Path.GetFileNameWithoutExtension(filePath);
// 检查文件名是否符合GUID格式
if (guidRegex.IsMatch(fileName))
{
Console.WriteLine($"================================================");
Console.WriteLine($"Processing file: {filePath}");
dealFileType(filePath);
}
}
}
整体代码运行结果如下,这个比较适合红队进行后渗透使用,有很多人喜欢用notepad临时保存个密码之类的,例如我q.q,这种情况下即使没有保存到本地依然存在被抓取的可能性:
2024.04.23 更新
上面的项目在国内环境的话解析会遇到若干问题,因为我们默认使用的是UTF-8编码进行的处理,实际上需要使用UTF-16处理才能正确读取文件。上文我们通过0x05 0x01
前面的两个字节判断我们文件中文本内容的大小,但是在存在中文文本的文件中计算是存在问题的:中文字符会按照三个字节来进行计算,而保存在文件中的中文字符却是按照两个字节进行保存的,所以我们直接读取代表文本内容大小的字节是会出现错误的。我这里使用了另一个方法:
我们计算出文本头和文本尾部,然后直接去读中间的部分就可以了。
我这里直接把数据结构总结好了供大家参考:
/*
已保存文件结构:
大于0x80的数据:[Magic Header(3bytes)] [4th byte: unsaved/saved file] [5th byte: filePathStr length] [filePath string] [content length(1 or 2 bytes)] [05 01] [padding(53 bytes)] [content] [6 bytes]
小于0x80的数据:[Magic Header(3bytes)] [4th byte: unsaved/saved file] [5th byte: filePathStr length] [filePath string] [content length(1 or 2 bytes)] [05 01] [padding(50 bytes)] [content] [6 bytes]
文件存在中英混合的情况,需要额外处理中文(因为notepad的字符统计把中文按照3个字节来进行计算,但是保存是按照2字节进行保存的),我们需要自己手动计算[content length]:
1. 确定[content]的字节区域范围:
开始:3 + 1 + 1 + [filePathStr length] + [1 or 2 bytes] + 2 + [padding]
结束:去掉倒数六个字节
2. 确定[content]中包含多少个中文字节
3. 求出真正的[content length]
*/
除此之外,我将打印数据的部分进行了函数封装:
public static void PrintFileContent(FileStream fileStreamInput, int header, int ender)
{
if (fileStreamInput == null)
throw new ArgumentNullException(nameof(fileStreamInput));
if (header < 0 || ender < 0)
throw new ArgumentException("Header and ender must be non-negative.");
if (header + ender >= fileStreamInput.Length)
throw new ArgumentException("Header and ender combined are larger than the file length.");
// 定位到 header 之后的开始位置
header++;
ender--;
fileStreamInput.Seek(header, SeekOrigin.Begin);
// 计算需要读取的有效字节数
int effectiveLength = (int)(fileStreamInput.Length - header - ender);
Console.WriteLine("PrintFileContent-effectiveLength: " + effectiveLength);
if (effectiveLength <= 0)
{
Console.WriteLine("No data to read after adjusting for header and ender.");
return;
}
// 读取有效字节
byte[] buffer = new byte[effectiveLength];
int bytesRead = fileStreamInput.Read(buffer, 0, effectiveLength);
if (bytesRead < effectiveLength)
throw new Exception("Could not read the expected amount of bytes.");
// 将字节转换为 Unicode 字符串
string mainContent = Encoding.Unicode.GetString(buffer);
// 替换 CR 字符为 \r\n
mainContent = mainContent.Replace("\u000d", "\r\n");
// 打印内容到控制台
Console.WriteLine("Main Content: " + mainContent);
#if DEBUG
// 打印主内容的头字节以16进制形式
string mainContentHex = BitConverter.ToString(buffer);
Console.WriteLine("Main Content Bytes (Hex): " + mainContentHex);
#endif
}
读上面的代码,大家应该能看到其中有一个mainContent = mainContent.Replace("\u000d", "\r\n");
,这个地方是用来处理换行的,如果没有这部分的处理数据不会完整的打印出来。
其他的实现流程大家可以参考这个项目,后续我看看notepad++是否也可以进行实现。
这就是本文的全部内容了,下篇见。
12 篇文章
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!