1晤碘、起因
從今年1月1日開始涨醋,打算選擇一個(gè)自己一直想做的韭山,又不那么容易完成的事情郁季,于是想到了以前多次動(dòng)手,但是從未完成的軟件“任意文件格式的文件結(jié)構(gòu)分析工具”钱磅。
2009年:
- 計(jì)劃解析swf文件內(nèi)的圖片梦裂,音頻資源,手工分析了20%左右续搀,沒有時(shí)間繼續(xù)分析
- 分析了不少音視頻格式與它的封裝 (RTMP, mp3, mp4...)
- FFmpeg中有個(gè)缺陷塞琼,aiff音頻文件的總時(shí)長沒有寫入文件,任何播放器都無法拖動(dòng)進(jìn)度條禁舷,需要在生成的文件中打補(bǔ)丁寫入總時(shí)長
- 看到CSDN上有人做過類似的軟件彪杉,能分析固定大小毅往,順序的的結(jié)構(gòu)體,功能相對單一派近,還不能滿足需求
2014年(約)
- iOS系統(tǒng)解析m4a文件攀唯,有時(shí)不能及時(shí)獲得總時(shí)長與seek點(diǎn),需要手工分析渴丸。
2016年
- 搜索到了一款軟件 Synalyze It!侯嘀,這就是我想要的完美軟件,憑著愛心我購買了谱轨,這個(gè)軟件的核心是利用固定的分析引擎戒幔,把語法文件(.grammar)和被分析文件(.mp3, .mp4)*粘合在一起,生成一個(gè)新的文件結(jié)構(gòu)樹土童,每一個(gè)數(shù)的枝葉對應(yīng)到被分析文件的具體某個(gè)字節(jié)诗茎,并標(biāo)明含義,該軟件特別之處包含:
- 支持Python和lua腳本献汗,方便在復(fù)雜的條件下進(jìn)行具體分析敢订,比如zip文件需要先分析文件末尾,PE文件包含結(jié)構(gòu)體循環(huán)嵌套罢吃。
- 部分屬性楚午,比如結(jié)構(gòu)體長度,循環(huán)次數(shù)支持表達(dá)式尿招,這就為大部分通用容器類文件提供了很好的支持矾柜,因?yàn)榻Y(jié)構(gòu)體的大小依賴于結(jié)構(gòu)體內(nèi)第一個(gè)或者第二個(gè)元素的值。
- 支持結(jié)構(gòu)體派生泊业,減少了重復(fù)勞動(dòng)把沼,比如PNG文件的結(jié)構(gòu),都是存在基類吁伺,根據(jù)基類中的第一個(gè)字段來決定具體是用哪一個(gè)類
- 支持分析某一個(gè)bit的值
2017年
- 多次想動(dòng)手寫饮睬,一共寫不超過100句話,總是放棄了篮奄,突破不了自己的思維局限捆愁,也沒有時(shí)間
- 因?yàn)槭謾C(jī)需要支持wma,分析了一遍wma文件格式窟却,分析過程也花了不少時(shí)間
2019年
- 分析pdf文件
2021年
- 計(jì)劃做一個(gè)和Synalyze It!一模一樣的軟件昼丑,不能馬馬虎虎的做,做之前就打算再做好之后要回憶做的過程中的思考夸赫,也能預(yù)測到做完之后就不想回憶了菩帝,這是很矛盾的。
- 不要自己創(chuàng)新,完全抄襲呼奢,我不可能做的比它更好
2宜雀、現(xiàn)狀
經(jīng)過一個(gè)來月的努力,每一次技術(shù)的選擇都很精心準(zhǔn)備握础,已經(jīng)完美支持Synalyze It!軟件中核心功能辐董,不過還是控制臺的,沒有GUI禀综,如下的截圖简烘。
3、過程
3.1 技術(shù)選型
主要語言:
常用的可以發(fā)布到Store的語言包含C/C++定枷,C#孤澎,OC,Swift依鸥,因?yàn)镃/C++太啰嗦亥至,OC和Swift在Windows平臺上沒有優(yōu)秀的IDE支持,所以選擇C#語言贱迟,C#語言聽說也可以編譯成Apple Store的包,自己曾經(jīng)試過的確可以開發(fā)Mac App應(yīng)用絮供。
腳本語言:
程序中有三個(gè)地方用到了可能是腳本的地方
- 結(jié)構(gòu)體或者結(jié)構(gòu)體內(nèi)成員的屬性衣吠,比如占用字節(jié)長度,重復(fù)次數(shù)壤靶,可能是表達(dá)式比如缚俏,根據(jù)Synalyze It!的文檔,沒有說明用的是什么語法贮乳,只是說明支持的表達(dá)式很多忧换,但都是一句話表達(dá)式
// 各種表達(dá)式
used_bytes_count + 5
ceil(sqr(used_bytes_count)) + offset
- 結(jié)構(gòu)體內(nèi)可能包含一個(gè)script代碼,該script只解析該結(jié)構(gòu)體內(nèi)的一個(gè)內(nèi)容向拆,不解析結(jié)構(gòu)體外的內(nèi)容亚茬,比如把剛解析出來的unix time轉(zhuǎn)換成 UTC 字符串作為結(jié)果顯示到結(jié)果樹中, 這一段代碼能用到的函數(shù)比較多浓恳,都在幫助文檔中說明刹缝,能用到的全局變量并沒有說明,需要閱讀代碼颈将,比如:
# 復(fù)雜的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有固定的函數(shù)申明梢夯,用于處理指定區(qū)間內(nèi)的數(shù)據(jù)
# 解析指定區(qū)間內(nèi)的數(shù)據(jù)
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多個(gè)和40多個(gè),那么現(xiàn)在要選擇什么作為腳本語言呢?有幾種選擇
- C# 非常容易和主要語言C#集成人乓,可惜Store版本中不能把C#作為腳本語言梗醇,因?yàn)椴恢С謩?dòng)態(tài)生成可執(zhí)行代碼
- Javascript 已知的實(shí)現(xiàn)JINT庫,這個(gè)庫的代碼寫的非常漂亮撒蟀,沒有任何使用特殊權(quán)限的地方叙谨,所以不會和Store的限制相互沖突,也很方便自定義新的函數(shù)來滿足2中提到的表達(dá)式保屯,唯一的缺陷是需要把現(xiàn)有的150個(gè)腳本手工寫成Javascript的手负,寫代碼不會花費(fèi)很長時(shí)間,問題在于很多文件格式?jīng)]有現(xiàn)成的姑尺,難以把握質(zhì)量
- Python C#語言庫中成熟的Python庫是IronPython竟终,需要測試是否和Store限制沖突,因?yàn)槲覀冎繮ython中有很多Process切蟋,Event相關(guān)的內(nèi)容统捶,肯定是無法提交到Store的,于是找到了IronPython源代碼柄粹,嘗試去掉那些功能喘鸟,這里花了不少時(shí)間,具體過程如下
- 現(xiàn)在源代碼編譯驻右,編譯不過什黑,不像Javascript的JINT庫,沒有其他依賴堪夭,IronPython依賴Microsoft DLR庫愕把,這個(gè)DIR庫目前只給IronPython和IronLua用,還不清楚DLR庫離開了這兩個(gè)實(shí)現(xiàn)要怎么用
- 測試是否可以自定義模塊森爽,自定義函數(shù)
- 編譯通過后恨豁,找到啟用新進(jìn)程部分,發(fā)現(xiàn)屏蔽方式是依靠條件編譯爬迟,找到了條件編譯文件是在.sln目錄下的一個(gè)固定文件名叫做Directory.Build.props橘蜜,找這個(gè)文件花了一些時(shí)間,因?yàn)檫@個(gè)文件不在解決方案目錄樹中雕旨,無法在IDE中搜索到扮匠,說明了一切隱含的東西,都會讓人迷糊凡涩,比如C++的那么多奇怪的隱藏行為
- 不斷的調(diào)整條件編譯和編譯Store包棒搜,用測試程序自檢
- Lua 前期并沒有打算實(shí)現(xiàn)Lua腳本部分,后來也是懶得寫40多個(gè)腳本活箕,Lua的C#實(shí)現(xiàn)比較好找便斥,為了方便查找Exception,也是找了源碼來參與編譯佛吓。
所以腳本最后的選擇是Python+Lua
3.2 主要難點(diǎn)
3.2.1 結(jié)構(gòu)體的派生
問題:
已知兩個(gè)結(jié)構(gòu)體parent和child,child從parent派生闺鲸,parent定義了確定的元素和順序a,b,c, child定義可能是以下情況
- 只包含parent中的元素,元素的順序保持不變
- 只包含parent中的元素埃叭,元素的順序和parent中不同[難]
- 不包含parent中任何元素
- 即包含parent中的元素摸恍,又包含新元素,而且parent中元素在先定義赤屋,且順序保持不變
- 即包含parent中的元素立镶,又包含新元素,而且parent中元素在先定義类早,順序發(fā)生變化[難]
- 即包含parent中的元素媚媒,又包含新元素,而且parent中元素定義位置不確定涩僻,順序也不確定[難]
想起6那種復(fù)雜的場景缭召,雖然人工可以簡單的感覺到答案,但是代碼實(shí)現(xiàn)優(yōu)雅逆日,不容易想到嵌巷,于是先寫好了測試用例
// 派生關(guān)系的測試用例
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]);
}
}
}
最后總結(jié)出還算合理的規(guī)則,如下
// 派生關(guān)系的合并
while (parent.length > 0 && child.length > 0)
{
if (parent或者child其中一個(gè)長度為0)
{
把另外一個(gè)全部添加到結(jié)果列表
退出循環(huán)
}
if (parent.first == child.first)
{
則加入到結(jié)果屏富,同時(shí)parent和child都刪除第一個(gè)元素
繼續(xù)循環(huán)
}
else
{
if (child.contains(parent.first))
{
則說明child已經(jīng)開始自定義剩下的部分了晴竞,把child剩下部分都加入到結(jié)果列表
退出循環(huán)
}
else
{
則說明child沒有覆蓋定義parent中該元素定義,把parent中的第一個(gè)元素加入結(jié)果列表
繼續(xù)循環(huán)
}
}
}
3.2.2 結(jié)構(gòu)體占用空間的計(jì)算
結(jié)構(gòu)體占用空間的定義有三種
- 寫明了多少字節(jié)狠半,比如12字節(jié),或者0x22字節(jié)
- 沒有定義颤难,說明是根據(jù)元素占用來計(jì)算神年,比如子元素一共占用12字節(jié),說明結(jié)構(gòu)體一共占用12字節(jié)
- 是一個(gè)表達(dá)式行嗤,表達(dá)式可能是之前已經(jīng)計(jì)算過的其他元素的值已日,也可能是該結(jié)構(gòu)體子元素的值,也就意味著他的值需要等他的子元素解析后才知道結(jié)果栅屏。
前兩者的實(shí)現(xiàn)比較容易飘千,第3種中,首先需要分析出表達(dá)式中所有的變量栈雳,變量是否已經(jīng)定義护奈,行為有所不同,如果遇到子元素名稱和以往解析過的名稱相同哥纫,該使用哪一個(gè)值呢霉旗?因此,我解析出了Synalyze It!中所有的表達(dá)式1000多個(gè),利用簡單而實(shí)用的函數(shù)解析出所有的變量
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;
}
檢查每一個(gè)變量如果同時(shí)出現(xiàn)在子元素和其他結(jié)構(gòu)體的行為厌秒,具體分析后得出結(jié)論:如果變量存在于子元素读拆,則一定意味著子元素,不意味著其他結(jié)構(gòu)體中已解析變量鸵闪。
那么該如何寫代碼呢檐晕?難道每解析一個(gè)子元素就嘗試計(jì)算一次表達(dá)式,如果該有的變量還沒有定義蚌讼,解析程序會拋出異常辟灰,異常內(nèi)容并非一個(gè)結(jié)構(gòu)化數(shù)據(jù),不方便知道是不是那個(gè)變量沒有定義引起啦逆。
定義集合伞矩,集合包含了需要解析的子元素列表,當(dāng)最后一個(gè)需要解析的子元素解析了之后夏志,再計(jì)算結(jié)構(gòu)體長度乃坤,這里遇到一個(gè)另外一個(gè)問題,解析出來的長度可能小于已經(jīng)解析過的子元素需要的長度沟蔑,或者長度大于該結(jié)構(gòu)體所可能的最大空間湿诊,這時(shí),整個(gè)結(jié)構(gòu)體都是需要拋棄的瘦材。
// 檢查需要解析的子元素是否都解析好了
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
{
// 繼續(xù)等待
return;
}
3.2.3 獲取從任意bit開始厅须,小于64bit長度的數(shù)值
因?yàn)樵氐拈L度可能只有1bit,而不是1byte食棕,所以整個(gè)程序的度量單位必須定義成bit.
如何獲取數(shù)值呢朗和?
首先找到這么多長度的bit,如果bit的長度剛好是8的倍數(shù)簿晓,還比較好處理眶拉,如果不是8的倍數(shù),需要考慮填充bit憔儿,使得取得的長度是8的整數(shù)倍忆植,這些0填在哪里呢?是填入到最前面谒臼,還是最后面朝刊,依賴于數(shù)值的Endian類型,還有一個(gè)特例蜈缤,如果總位數(shù)小于8拾氓,則總是左側(cè)補(bǔ)0, 代碼來說大概是這樣
// bit長度補(bǔ)齊為8的倍數(shù)
if (bit length不是8的倍數(shù))
{
if (是大端 or bit length < 8)
{
左側(cè)補(bǔ)0
}
else
{
右側(cè)補(bǔ)0
}
}
根據(jù)Endian類型轉(zhuǎn)成數(shù)值
由于年事已高劫樟,很難一次性推理出是左側(cè)補(bǔ)0還是右側(cè)補(bǔ)0痪枫,答案都是實(shí)驗(yàn)出來的织堂。
3.2.4 越界保護(hù)
程序中的主要越界保護(hù)用于兩個(gè)地方
- 已確定占用空間的某個(gè)元素,如果想獲取它的值(數(shù)值奶陈,字符串)易阳,需要遍歷,寫for語句容易訪問到界外吃粒,比如原本只占用2個(gè)字節(jié)的元素潦俺,想獲取它的Int32值,容易多訪問兩個(gè)字節(jié)
- 當(dāng)結(jié)構(gòu)體大小不確定的時(shí)候徐勃,需要盡可能的給結(jié)構(gòu)體分配空間事示,這時(shí)會假定結(jié)構(gòu)體大小是Int64.Max, 如果把Int64.Max用于數(shù)值下標(biāo),會出現(xiàn)異常僻肖。
還好強(qiáng)大的C#可以一句話解決肖爵,那就是array.skip(m).take(n), 經(jīng)過測試無論m和n怎么越界都不會出現(xiàn)異常,如果越界會盡可能的獲取臀脏,或者返回空的數(shù)組劝堪,這也是為什么我選擇C/C++的一個(gè)重要原因,需要寫太多的保護(hù)邏輯了
// 越界的簡單處理揉稚,一定不會越界
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的集成主要準(zhǔn)備好script環(huán)境秒啦,把script可能要使用的變量準(zhǔn)備好,這需要多看script代碼才知道要在程序中定義哪些變量搀玖,答案是
- 所有已經(jīng)解析過的number類型字段的值需要定義到script環(huán)境中
- 需要定義變量currentOffset余境,表明當(dāng)前分析到哪個(gè)字節(jié)了
這兩種的定義方法如下
// 為腳本定義變量
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工程內(nèi)添加代碼文件,如下
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腳本添加了全局函數(shù)logMessage
lua腳本中添加ENDIAN_LITTLE的方式暫時(shí)沒有實(shí)現(xiàn)宋雏,等需要的時(shí)候再實(shí)現(xiàn)。
3.2.6 調(diào)試
整個(gè)調(diào)試過程务豺,都是先運(yùn)行一遍磨总,看結(jié)果和Synalyze It!從哪個(gè)地方開始不一樣的,不一樣的地方笼沥,增加條件斷點(diǎn)蚪燕,因?yàn)檎麄€(gè)引擎的核心地方可能就是1-2個(gè)循環(huán)娶牌,檢查的地方就是那1-2個(gè)函數(shù)對于結(jié)構(gòu)體復(fù)雜的文件,那簡直就是圓環(huán)套圓環(huán)馆纳,頭很暈诗良,,只能用結(jié)構(gòu)體id或者文件偏移當(dāng)做斷點(diǎn)的條件鲁驶,一旦有修改鉴裹,則把之前已經(jīng)測試過的文件重新運(yùn)行一遍,對關(guān)鍵地方進(jìn)行重新檢查钥弯。
// 很多測試用例
static void Main(string[] args)
{
ElementStructure.TestJoinKeys(); // 結(jié)構(gòu)體派生
test_bit_padding(); // bit位數(shù)不是8倍數(shù)時(shí)候的填充
test_all_element_express_is_number(); // 所有表達(dá)式是否能被識別
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"); // 復(fù)雜的結(jié)構(gòu)體
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"); // 復(fù)雜的結(jié)構(gòu)體
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 總結(jié)
- 要想做的心里踏實(shí)径荔,把事情調(diào)查清楚,的確需要花費(fèi)很多時(shí)間
- 測試用例還是需要寫脆霎,多多的寫
- 對于可能發(fā)生的总处,但是不應(yīng)該發(fā)生的,多多寫斷言
- 今年心愿已了睛蛛,明年還有機(jī)會的話再寫另外一個(gè)心愿
5 擔(dān)憂
版權(quán)