1、起因
从今年1月1日开始,打算选择一个自己一直想做的,又不那么容易完成的事情,于是想到了以前多次动手,但是从未完成的软件“任意文件格式的文件结构分析工具”。
2009年:
- 计划解析swf文件内的图片,音频资源,手工分析了20%左右,没有时间继续分析
- 分析了不少音视频格式与它的封装 (RTMP, mp3, mp4...)
- FFmpeg中有个缺陷,aiff音频文件的总时长没有写入文件,任何播放器都无法拖动进度条,需要在生成的文件中打补丁写入总时长
- 看到CSDN上有人做过类似的软件,能分析固定大小,顺序的的结构体,功能相对单一,还不能满足需求
2014年(约)
- iOS系统解析m4a文件,有时不能及时获得总时长与seek点,需要手工分析。
2016年
- 搜索到了一款软件 Synalyze It!,这就是我想要的完美软件,凭着爱心我购买了,这个软件的核心是利用固定的分析引擎,把语法文件(.grammar)和被分析文件(.mp3, .mp4)*粘合在一起,生成一个新的文件结构树,每一个数的枝叶对应到被分析文件的具体某个字节,并标明含义,该软件特别之处包含:
- 支持Python和lua脚本,方便在复杂的条件下进行具体分析,比如zip文件需要先分析文件末尾,PE文件包含结构体循环嵌套。
- 部分属性,比如结构体长度,循环次数支持表达式,这就为大部分通用容器类文件提供了很好的支持,因为结构体的大小依赖于结构体内第一个或者第二个元素的值。
- 支持结构体派生,减少了重复劳动,比如PNG文件的结构,都是存在基类,根据基类中的第一个字段来决定具体是用哪一个类
- 支持分析某一个bit的值
2017年
- 多次想动手写,一共写不超过100句话,总是放弃了,突破不了自己的思维局限,也没有时间
- 因为手机需要支持wma,分析了一遍wma文件格式,分析过程也花了不少时间
2019年
- 分析pdf文件
2021年
- 计划做一个和Synalyze It!一模一样的软件,不能马马虎虎的做,做之前就打算再做好之后要回忆做的过程中的思考,也能预测到做完之后就不想回忆了,这是很矛盾的。
- 不要自己创新,完全抄袭,我不可能做的比它更好
2、现状
经过一个来月的努力,每一次技术的选择都很精心准备,已经完美支持Synalyze It!软件中核心功能,不过还是控制台的,没有GUI,如下的截图。
3、过程
3.1 技术选型
主要语言:
常用的可以发布到Store的语言包含C/C++,C#,OC,Swift,因为C/C++太啰嗦,OC和Swift在Windows平台上没有优秀的IDE支持,所以选择C#语言,C#语言听说也可以编译成Apple Store的包,自己曾经试过的确可以开发Mac App应用。
脚本语言:
程序中有三个地方用到了可能是脚本的地方
- 结构体或者结构体内成员的属性,比如占用字节长度,重复次数,可能是表达式比如,根据Synalyze It!的文档,没有说明用的是什么语法,只是说明支持的表达式很多,但都是一句话表达式
// 各种表达式
used_bytes_count + 5
ceil(sqr(used_bytes_count)) + offset
- 结构体内可能包含一个script代码,该script只解析该结构体内的一个内容,不解析结构体外的内容,比如把刚解析出来的unix time转换成 UTC 字符串作为结果显示到结果树中, 这一段代码能用到的函数比较多,都在帮助文档中说明,能用到的全局变量并没有说明,需要阅读代码,比如:
# 复杂的script代码
endFound = False
theOffset = currentOffset
byteView = currentMapper.getCurrentByteView()
results = currentMapper.getCurrentResults()
grammar = currentMapper.getCurrentGrammar()
while not endFound:
theByte = byteView.readByte(theOffset);
if (theByte == 0xff):
theSecondByte = byteView.readByte(theOffset + 1);
if ((theSecondByte > 0) and (theSecondByte < 0xFF)):
endFound = True
theValue = Value();
theValue.setString("EOF");
struct = grammar.getStructureByName("ImageBytes")
element = struct.getElementByName("ImageBytes")
length = theOffset - currentOffset
currentMapper.mapElementWithSize(element, length);
theOffset = theOffset + 1;
该代码中currentOffset的含义需要多阅读积分代码确定
- 作为文件级别通用的script代码, 这类script有固定的函数申明,用于处理指定区间内的数据
# 解析指定区间内的数据
from datetime import datetime, timedelta
def parseByteRange(element, byteView, bitPos, bitLength, results):
timeStamp = byteView.readUnsignedInt(bitPos/8, 4, ENDIAN_LITTLE)
value = Value()
if (timeStamp != 0):
dt = datetime.fromtimestamp(timeStamp)
dtAdjusted = dt - timedelta(hours=1)
dateString = dtAdjusted.strftime("%Y-%m-%d %H:%M:%S")
value.setString(dateString)
else:
value.setString("<not set>")
results.addElement(element, 4, 0, value)
return 4
对于2,3的script代码,已知的语法有python和lua,分别有110多个和40多个,那么现在要选择什么作为脚本语言呢?有几种选择
- C# 非常容易和主要语言C#集成,可惜Store版本中不能把C#作为脚本语言,因为不支持动态生成可执行代码
- Javascript 已知的实现JINT库,这个库的代码写的非常漂亮,没有任何使用特殊权限的地方,所以不会和Store的限制相互冲突,也很方便自定义新的函数来满足2中提到的表达式,唯一的缺陷是需要把现有的150个脚本手工写成Javascript的,写代码不会花费很长时间,问题在于很多文件格式没有现成的,难以把握质量
- Python C#语言库中成熟的Python库是IronPython,需要测试是否和Store限制冲突,因为我们知道Python中有很多Process,Event相关的内容,肯定是无法提交到Store的,于是找到了IronPython源代码,尝试去掉那些功能,这里花了不少时间,具体过程如下
- 现在源代码编译,编译不过,不像Javascript的JINT库,没有其他依赖,IronPython依赖Microsoft DLR库,这个DIR库目前只给IronPython和IronLua用,还不清楚DLR库离开了这两个实现要怎么用
- 测试是否可以自定义模块,自定义函数
- 编译通过后,找到启用新进程部分,发现屏蔽方式是依靠条件编译,找到了条件编译文件是在.sln目录下的一个固定文件名叫做Directory.Build.props,找这个文件花了一些时间,因为这个文件不在解决方案目录树中,无法在IDE中搜索到,说明了一切隐含的东西,都会让人迷糊,比如C++的那么多奇怪的隐藏行为
- 不断的调整条件编译和编译Store包,用测试程序自检
- Lua 前期并没有打算实现Lua脚本部分,后来也是懒得写40多个脚本,Lua的C#实现比较好找,为了方便查找Exception,也是找了源码来参与编译。
所以脚本最后的选择是Python+Lua
3.2 主要难点
3.2.1 结构体的派生
问题:
已知两个结构体parent和child,child从parent派生,parent定义了确定的元素和顺序a,b,c, child定义可能是以下情况
- 只包含parent中的元素,元素的顺序保持不变
- 只包含parent中的元素,元素的顺序和parent中不同[难]
- 不包含parent中任何元素
- 即包含parent中的元素,又包含新元素,而且parent中元素在先定义,且顺序保持不变
- 即包含parent中的元素,又包含新元素,而且parent中元素在先定义,顺序发生变化[难]
- 即包含parent中的元素,又包含新元素,而且parent中元素定义位置不确定,顺序也不确定[难]
想起6那种复杂的场景,虽然人工可以简单的感觉到答案,但是代码实现优雅,不容易想到,于是先写好了测试用例
// 派生关系的测试用例
public static void TestJoinKeys()
{
List<(string, string, string)> data = new List<(string, string, string)>()
{
("","",""),
("1","","1"),
("1,2,3","","1,2,3"),
("1,2,3","1,2,3","1,2,3"),
("1,2,3","1,2,3,4","1,2,3,4"),
("1,2,3","2","1,2,3"),
("1,2,3","2,3","1,2,3"),
("1,2,3","2,3,4","1,2,3,4"),
("","1,2,3","1,2,3"),
("1,2,3","1,3","1,2,3"),
("1,2,3,4,5","2,4","1,2,3,4,5"),
("1,2,3,4,5","1","1,2,3,4,5"),
("1,2,3,4,5","1,2","1,2,3,4,5"),
("1,2,3,4,5","5","1,2,3,4,5"),
("1,2,3,4,5","7,8","1,2,3,4,5,7,8"),
("1,2,3,4,5","1,2,7,8","1,2,3,4,5,7,8"),
("1,2,3,4,5","1,3,2","1,3,2"),
("1,2,3,4,5","3,2","1,3,2"),
("1,2,3,4,5,6,7,8","3,5,7","1,2,3,4,5,6,7,8"),
("1,2,3,4,5,6,7,8","2,4,9,10,11,12","1,2,3,4,5,6,7,8,9,10,11,12"),
};
foreach(var dataItem in data)
{
var parent = dataItem.Item1.Split(',').Where(s => s.Length > 0).ToList();
var current = dataItem.Item2.Split(',').Where(s => s.Length > 0).ToList();
var result = dataItem.Item3.Split(',').Where(s => s.Length > 0).ToList();
var joined = JoinKeys(parent, current);
Debug.Assert(joined.Count == result.Count);
foreach(var i in Enumerable.Range(0, joined.Count))
{
Debug.Assert(joined[i] == result[i]);
}
}
}
最后总结出还算合理的规则,如下
// 派生关系的合并
while (parent.length > 0 && child.length > 0)
{
if (parent或者child其中一个长度为0)
{
把另外一个全部添加到结果列表
退出循环
}
if (parent.first == child.first)
{
则加入到结果,同时parent和child都删除第一个元素
继续循环
}
else
{
if (child.contains(parent.first))
{
则说明child已经开始自定义剩下的部分了,把child剩下部分都加入到结果列表
退出循环
}
else
{
则说明child没有覆盖定义parent中该元素定义,把parent中的第一个元素加入结果列表
继续循环
}
}
}
3.2.2 结构体占用空间的计算
结构体占用空间的定义有三种
- 写明了多少字节,比如12字节,或者0x22字节
- 没有定义,说明是根据元素占用来计算,比如子元素一共占用12字节,说明结构体一共占用12字节
- 是一个表达式,表达式可能是之前已经计算过的其他元素的值,也可能是该结构体子元素的值,也就意味着他的值需要等他的子元素解析后才知道结果。
前两者的实现比较容易,第3种中,首先需要分析出表达式中所有的变量,变量是否已经定义,行为有所不同,如果遇到子元素名称和以往解析过的名称相同,该使用哪一个值呢?因此,我解析出了Synalyze It!中所有的表达式1000多个,利用简单而实用的函数解析出所有的变量
static List<String> GetVarsFromExpression(string expression)
{
HashSet<string> functionNames = new HashSet<string>() { "ceil", "pow", "mod", "select", "if", "abs", "prev", "this", "Math.", "this." };
List<String> r = expression.Split(new char[] { '+', '-', '*', '/', '(', ')', ',', '^', '.' })
.Select(item => item.Trim())
.Distinct()
.Where(item => item.Length > 0)
.Where(item => functionNames.Contains(item) == false)
.Where(item => Char.IsDigit(item[0]) == false).ToList();
return r;
}
检查每一个变量如果同时出现在子元素和其他结构体的行为,具体分析后得出结论:如果变量存在于子元素,则一定意味着子元素,不意味着其他结构体中已解析变量。
那么该如何写代码呢?难道每解析一个子元素就尝试计算一次表达式,如果该有的变量还没有定义,解析程序会抛出异常,异常内容并非一个结构化数据,不方便知道是不是那个变量没有定义引起。
定义集合,集合包含了需要解析的子元素列表,当最后一个需要解析的子元素解析了之后,再计算结构体长度,这里遇到一个另外一个问题,解析出来的长度可能小于已经解析过的子元素需要的长度,或者长度大于该结构体所可能的最大空间,这时,整个结构体都是需要抛弃的。
// 检查需要解析的子元素是否都解析好了
HashSet<string> depends = current_result.dic_attribute_depends_map[elementKey];
depends.ExceptWith(lst_just_finished_sub_element_name);
if (depends.Count == 0)
{
long? tmp = mapContext.scriptInstance.GetScript(ScriptEnv.python).EvalExpression(s);
Debug.Assert(tmp != null, "全局变量缺失");
SetDefinedLength(tmp.GetValueOrDefault(0));
continue_get = false;
return;
}
else
{
// 继续等待
return;
}
3.2.3 获取从任意bit开始,小于64bit长度的数值
因为元素的长度可能只有1bit,而不是1byte,所以整个程序的度量单位必须定义成bit.
如何获取数值呢?
首先找到这么多长度的bit,如果bit的长度刚好是8的倍数,还比较好处理,如果不是8的倍数,需要考虑填充bit,使得取得的长度是8的整数倍,这些0填在哪里呢?是填入到最前面,还是最后面,依赖于数值的Endian类型,还有一个特例,如果总位数小于8,则总是左侧补0, 代码来说大概是这样
// bit长度补齐为8的倍数
if (bit length不是8的倍数)
{
if (是大端 or bit length < 8)
{
左侧补0
}
else
{
右侧补0
}
}
根据Endian类型转成数值
由于年事已高,很难一次性推理出是左侧补0还是右侧补0,答案都是实验出来的。
3.2.4 越界保护
程序中的主要越界保护用于两个地方
- 已确定占用空间的某个元素,如果想获取它的值(数值,字符串),需要遍历,写for语句容易访问到界外,比如原本只占用2个字节的元素,想获取它的Int32值,容易多访问两个字节
- 当结构体大小不确定的时候,需要尽可能的给结构体分配空间,这时会假定结构体大小是Int64.Max, 如果把Int64.Max用于数值下标,会出现异常。
还好强大的C#可以一句话解决,那就是array.skip(m).take(n), 经过测试无论m和n怎么越界都不会出现异常,如果越界会尽可能的获取,或者返回空的数组,这也是为什么我选择C/C++的一个重要原因,需要写太多的保护逻辑了
// 越界的简单处理,一定不会越界
byte[] bytes = byteView.Take(8).ToArray();
if (endianType == ENDIAN_TYPE.ENDIAN_LITTLE)
{
Array.Reverse(bytes);
}
Int64 v = 0;
foreach (byte b in bytes.Take(8))
{
Int64 t = b;
v = v * 256 + t;
}
return v;
3.2.5 Script的集成
script的集成主要准备好script环境,把script可能要使用的变量准备好,这需要多看script代码才知道要在程序中定义哪些变量,答案是
- 所有已经解析过的number类型字段的值需要定义到script环境中
- 需要定义变量currentOffset,表明当前分析到哪个字节了
这两种的定义方法如下
// 为脚本定义变量
IScript script = mapContext.scriptInstance.GetScript(this.scriptLanguage.ToLower() == "python"
? ScriptEnv.python
: ScriptEnv.lua);
script.SetValue("currentOffset", currentOffset);
- 需要定义一些全局变量,比如ENDIAN_LITTLE,有两种定义办法,一种是定义ENDIAN_LITTLE为string类型,一种是定义为枚举类型,如果定义成枚举类型,script中需要增加枚举类型前缀变成ENDIAN_TYPE.ENDIAN_LITTLE,为了不修改script,定义方法如下
// 在IronPython.Modules工程内添加代码文件,如下
namespace IronPython.Modules
{
public partial class file_structure_plugin
{
public static readonly string ENDIAN_LITTLE = "ENDIAN_LITTLE";
public static void logMessage(String module, int messageID, string severity, String message)
{
// TODO
}
...
}
}
通过上面的代码可以看到,我们也为Python脚本添加了全局函数logMessage
lua脚本中添加ENDIAN_LITTLE的方式暂时没有实现,等需要的时候再实现。
3.2.6 调试
整个调试过程,都是先运行一遍,看结果和Synalyze It!从哪个地方开始不一样的,不一样的地方,增加条件断点,因为整个引擎的核心地方可能就是1-2个循环,检查的地方就是那1-2个函数对于结构体复杂的文件,那简直就是圆环套圆环,头很晕,,只能用结构体id或者文件偏移当做断点的条件,一旦有修改,则把之前已经测试过的文件重新运行一遍,对关键地方进行重新检查。
// 很多测试用例
static void Main(string[] args)
{
ElementStructure.TestJoinKeys(); // 结构体派生
test_bit_padding(); // bit位数不是8倍数时候的填充
test_all_element_express_is_number(); // 所有表达式是否能被识别
test_all_script_content(); // 所有的脚本的类型
test_offset_elements(); // Offset类型的元素
testIronPython(); // IronPython是否支持import语法
test_parse_one_file("jpeg.grammar", "1.jpg");
test_parse_one_file("pe.grammar", "1.dll"); // 复杂的结构体
test_parse_one_file("gzip.grammar", "1.gz"); // custom script
test_parse_one_file("png.grammar", "1.png"); // "must_match == true"
test_parse_one_file("wav.grammar", "1.wav");
test_parse_one_file("mp3.grammar", "1.mp3"); // python script
test_parse_one_file("qt.grammar", "1.mp4"); // 复杂的结构体
test_parse_one_file("flac.grammar", "1.flac"); // 解析bit
test_parse_one_file("zip.grammar", "1.zip"); // lua script
test_parse_one_file("test_padding.grammar", "1.flac"); // alignment
test_parse_one_file("bitmap.grammar", "1.bmp"); // offset
Debug.WriteLine("Hello World!");
}
4 总结
- 要想做的心里踏实,把事情调查清楚,的确需要花费很多时间
- 测试用例还是需要写,多多的写
- 对于可能发生的,但是不应该发生的,多多写断言
- 今年心愿已了,明年还有机会的话再写另外一个心愿
5 担忧
版权