0x01 Office RTF 檔案介紹
- RTF 檔案也稱富文本格式(Rich Text Format, 一般簡稱為 RTF),意為多文本格式是由微軟公司開發的跨平台文檔格式。大多數的文字處理軟體都能讀取和儲存 RTF 文檔。RTF 是一種非常流行的檔案結構,很多文字編輯器都支援它,vb 等開發工具甚至還提供了 richtxtbox 的控件。
- RTF 和 DOC 檔案一樣,都屬于 Microsoft Office 的範疇,和 DOC 檔案類似,RTF 檔案也可以進行文字編輯操作,甚至是插入 OLE 對象來增強檔案的互操作性,例如公式編輯器、嵌入式 PPT 檔案、嵌入式 DOC 文檔、WordPad Document 以及位圖檔案等等。之後為了友善對 DOC 的相容性操作,提供了将 RTF 檔案嵌入 DOC 文檔的功能。
- 在 RTF 檔案解析和 OLE 對象互動的安全性方面,一直是安全行業以及 APT 組織關注的重點,屬于 Office 檔案二進制安全。OLE 全稱嵌入式對象,是微軟為了提升程式互操作性而研究出的成果,當然了其他廠商比如 Apple 和 Mozilla 也有自己的 OLE 嵌入式架構。是以基于這幾點準備開發一個 Python 小腳本來解析 RTF 檔案以及其中的 OLE 對象,但是網上有很多專門提供對 RTF 文檔解析的庫(Python 和 C/C++ 都有),為什麼還要大費周章的重新寫一個呢,因為不想用大炮打蒼蠅,而且隻是自己用而已。
0x02 根據 RTF Specification Version 1.7 分析 RTF 檔案
- RTF Specification Version 1.7 是 RTF 的檔案規範,就如下圖所示:
- 這個版本是 1.7 版本,最新的版本好像是 1.9,按理說 RTF 檔案如今的利用已經很廣泛了,但是微軟的最新的 RTF 檔案規範就是搜不到。故使用較為舊的 1.7 版本,因為版本較舊在解析較新的文檔時,還是有一些控制字解析不出來。
- 廢話不多扯了,下面來分析 RTF 檔案的解析方法。首先建立一個 RTF 檔案,注意需要使用文檔打開最好添加一些任意字元,不然檔案的大小為 0(處于未初始化狀态),之後使用 16 進制編輯器檢視檔案二進制内容。如下圖所示,左邊是 16 進制格式,右邊是 ASCII 格式。從 ASCII 格式可以看出這個 RTF 文檔除了字母還有一些字元,例如 "
“、”\\
“、”{
“、”*
",暫且将它們定為特殊字元。;
- 這些特殊字元遵從 RTF 檔案文法規範,RTF 文法規範有這幾種:控制字、控制符、未格式化文本群組。未格式化文本就不用說了,就是文本文檔。控制字是 RTF 用來标記列印控制符和管理文檔資訊的一種特殊格式的指令,一個控制字最長 32 個字元。控制字的使用格式如下
,比如上圖中的\字母序列<分隔符>
和\rtf1
就是控制字;而組就更簡單了,文法格式為\ansi
,功能是文本、控制字、控制符的集合。最後是控制符,控制符由一個反斜線{ }
跟随單個非字母字元組成,例如,\
代表一個不換行空格,控制符不需要分隔符。某些控制字, 稱為引用,用于标記可能在同一檔案中的其他位置出現的相關文本集合或者其他引用的開始位置。引用也可以是被使用的但是未必出現于文檔中的文本。格式:\~
。\*\
注:(1)組和控制字是解析 RTF 檔案的十分重要的部分,起着架構和支撐作用,在指定算法時需要尤其小心,不然會導緻後面的解析出現意想不到的 Bug(2)控制字、控制符、未格式化文本群組是排列組合或者順序排列的關系,在沒有專門說明的情況下不存在附屬關系,是以解析時需要注意 (3)對于文檔中的控制字在 RTF 檔案規範中基本上都會有詳細的解釋
- 除了 RTF 文法規範還有就是 RTF 文法形式,這個比較易懂,如下圖所示:
0x03 制定解析算法
- 解析 RTF 檔案之前所必須要考慮的就是算法問題,好的算法能夠精确對 RTF 文法進行分割和識别。
a) 組平衡算法
- 在解析正常的 RTF 檔案時組一定是平衡的,舉個例子:
,最外面的組嵌套着兩個小組,第一個組包含文本字元 1,第二個組裡面又包含一個組并且這個組包含文本字元 2。雖然說嵌套關系比較複雜,但是組的{ { 1 } { { 2 } } }
和{
始終是一樣多的,因為始終需要完成包裹。當然了,這個是正常的檔案,誰也不敢保證損壞的 RTF 文檔當中的組也是平衡的,是以需要組平衡算法來判斷組是否有損壞,代碼如下圖所示:}
# 組平衡判斷
def balance(data, seek):
local_1 = 0;
for i in data:
if(i == '{'):
local_1 += 1;
if(i == '}'):
local_1 -= 1;
local_1 += seek;
if(local_1 == 0):
return True;
return False;
- 算法很簡單,循環周遊傳入的資料 data,假如碰到
就将 local_1 變量減去 1;同樣的假如碰到{
就将 local_1 變量加上 1,最後通過判斷 local_1 是否為 0 來判斷 data 中的組是否平衡。算法中的 seek 起着控制作用,一般情況下傳入的是 0。}
注:相比于正常的檔案,其實損壞的檔案也可以進行強制的解析,主要是通過算法修複損壞的組或者直接将損壞的組丢棄掉。由于組修複超出了文章的讨論範圍,是以不在多述
b) 控制字群組分離算法
- 在組平衡的前提下可以開展組解析,組解析算法的目的就是将組分離開并儲存在變量當中,由于組與組是嵌套關系是以解析時需要确定解析等級,比如
,在一級解析的情況下為{ { 1 } { 2 { 3 } } { 4 } }
,二級解析為{ { 1 } { 2 { 3 } } { 4 } }
,三級解析為{ { 1 } { 2 { 3 } } { 4 } },{ 1 },{ 2 { 3 } },{ 4 }
,解析算法如下:{ { 1 } { 2 { 3 } } { 4 } },{ 1 },{ 2 { 3 } },{ 3 },{ 4 }
# 核心函數, 将資料解析, 得出控制字群組
def rtfAnalysis(data):
# 對資料進行組平衡判斷
if(balance(data, 0) == False):
print("[-] 解析出錯, RTF 組格式損壞");
exit(0);
# - 組控制字解析算法:
# 根據 RTF 1.7 文檔規範以及解析的功能, 對 RTF 資料中的 \ 、 \\ 、 { 、 } 字元對檔案進行分割, 達到分離
# 控制字群組的目的, 之後使用 python 獨有的切片操作, 将分割後的字元儲存在集合中, 最後傳回集合, 達到分離
# 并解析 RTF 資料的目的, 本算法可能在效率上不及壓入彈出算法
local_1 = 0; local_2 = 0; local_3 = 0; listgroup1 = []; listgroup2 = [];
for i in data:
if(i == '\\' and local_3 == 0):
if(data[local_1 + 1] != '*' and data[local_1 - 1] != '*' ):
listgroup1.append(i); listgroup2.append(local_1);
elif(data[local_1 + 1] == '*'):
listgroup1.append(i); listgroup2.append(local_1);
if(i == '{' or i == '}'):
local_3 = 1;
if(i == '{'):
if(local_2 == 0):
listgroup1.append(i); listgroup2.append(local_1);
local_2 += 1;
if(i == '}'):
local_2 -= 1;
if(local_2 == 0):
listgroup1.append(i); listgroup2.append(local_1);
local_3 = 0;
local_1 += 1;
local_1 = 0; local_2 = 0; local_3 = 0; listgroup3 = {};
for list1 in listgroup1:
if(local_1 < len(listgroup2) - 1):
if(list1 == '\\'):
listgroup3[listgroup2[local_1]] = data[listgroup2[local_1]:listgroup2[local_1 + 1]].strip();
elif(list1 == '{'):
listgroup3[listgroup2[local_1]] = data[listgroup2[local_1]:listgroup2[local_1 + 1] + 1];
else:
if(list1 != '}'):
listgroup3[listgroup2[local_1]] = data[listgroup2[local_1]:len(data) + 1].strip();
local_1 += 1;
return listgroup3;
- 上述算法首先使用第一個循環對 data 資料進行判斷,如果遇到
}
{
字元,就将它們的位置放入\
數組。之後使用第二個循環對listgroup1
數組進行周遊,利用 python 特有的切片操作分離出完整的控制字、控制符群組,并且将結果放入listgroup1
中。最後傳回listgroup3
。listgroup3
注:需要注意的是在第一個循環中的控制字判斷的時候需要注意引用控制字,也就是 。同樣的在進行組操作的時候需要判斷哪一個是組開頭,并且哪一個是組結尾,這裡使用了
\*\
和
local2
兩個變量幫助進行判斷。在第二個循環當中的切片操作需要注意切片時不能大于整個 data 資料的大小,否則會發生數組越界異常
local3
c) 組嵌套和控制字查找算法
- 組嵌套算法群組分離算法不同,組嵌套算法主要是為了識别組與組之間的嵌套關系的,比如分析一個 OLE 嵌入式對象,該對象可能在檔案的任何位置(可能和 \rtf 控制字在同一級,也可能在文檔區的某個組段落之中),這時就需要确定 OLE 對象所在的組是什麼樣的嵌套關系。控制字查找算法借鑒了移動視窗算法,使用移動視窗進行查找。代碼如下:
# 查找特定的組, 記錄其組嵌套關系, 并傳回完整的組
def searchGroup(data, findStr):
WSize = len(findStr); WSPosition = [];
for i in range(len(data)+1):
if(i + WSize == len(data) - 3):
break;
CWData = data[i:i + WSize];
if(CWData == findStr):
WSPosition.append(i);
local_1 = 0; local_2 = 0; dict1 = {};
for m in WSPosition:
grouplist = [];
for n in data[0:m]:
if(n == '{'):
grouplist.append(local_1);
if(n == '}'):
grouplist.pop();
local_1 += 1;
dict1[m] = grouplist;
local_1 = 0;
return dict1;
- 上述代碼當中的第一個循環用于查找特定的控制字,比如
對象控制字,首先計算出對象的大小/object
,之後使用WSize
切片操作将 data 資料中的data[i:i + WSize]
查找出來,并且将它的位置儲存在/object
中。而第二個循環用于确定組嵌套關系,使用的模拟堆棧壓入和彈出操作,比如WSPosition
這個例子,{ { } { {\object} } }
被包裹了兩個組,因為上面的查找控制字算法得出了控制字的位置,是以進行切片操作,切片完成之後就會變成這個樣子\object
,之後進行循環周遊操作,如果遇到{ { } { {
就将其位置壓入堆棧,如果遇到{
就将最近壓入的}
的位置彈出來,是以就可以成功的避開完整的組,同時也得出了外面包裹的組的頭位置,最後将這些資訊儲存在 dict1 中并傳回。{
注: 需要注意的是,第一個循環的滑動視窗不能越出 data 資料的最大大小,不然會引發數組越界異常
0x04 制定腳本功能
- 既然解析算法都已經定下來了,那麼下面就需要考慮功能性的問題了。RTF 文檔中的解析主要是對控制字進行解析,而各個控制字的解釋在 RTF 文檔格式規範中基本都有所提及,隻是格式各不一樣。考慮到本腳本主要是簡單分析 RTF 檔案格式和提取其中的 OLE 對象(如果有的話),是以不必将文檔中的所有的控制字都給解析出來。基于此目的得出這樣幾個功能:(1) -l : 列出所有組和控制字元 (2) -i : 對檔案進行基本解析 (3) -o : 解析檔案中的 ole 對象。
a) 列出所有組和控制字元
- 基于控制字群組分離算法,将所有的控制字群組列印出來,并根據傳入的參數制定列印的深度,也就是循環解析嵌套組的深度,目前支援 1 和 2 兩個等級的深度。
b) 對檔案進行基本解析
- 基本解析包括對 RTF 檔案頭和文檔區頭的基本解析,解析出諸如 RTF 版本資訊、字元集、Unicode RTF 版本、建立時間、作者資訊以及列印出各個控制字。
c) 解析檔案中的 ole 對象
- 基于控制字查找算法,查找出本文檔是否含有 OLE 嵌入式對象格式,如果含有的話,提煉出完整的 OLE 對象,并且列印出對象的基本資訊和輸入輸出流。
0x05 編寫腳本
- 首先在腳本的頭部指定
,防止亂碼。然後按照基本流程指定腳本的 main 函數。之後使用#-*-coding:utf-8-*-
庫對傳入的參數進行識别和控制,并且更具傳入的參數轉移到相應的函數進行處理,如下圖所示:OptionParser
- 需要注意的是在使用函數進行處理之前,首先會使用
函數對使用 -f 參數傳入的檔案路徑做基本判斷,判斷是否是一個标準的 RTF 檔案,并且判斷此檔案是否存在。判斷方法如下圖所示:init()
- 在檔案判斷成功之後就會進入檔案處理環結:
a) 使用 printAllobj() 函數列印所有的組和控制字元
- 代碼如下所示,根據解析的深度使用 rtfAnalysis 函數擷取組和控制字的集合,并依次将它們列印出來。
# 根據解析的結果列印組和控制字
def printAllobj(data, level):
# 該函數主要為了列印出二級和三級清單下的組或控制字, 完成對 RTF 檔案的基本解析
print(" [+] 開始解析: ");
print(" [*] 注: \">\" 代表一級組清單下的或控制字 ; \">>\" 代表二級清單下的組或控制字 ; \">>>\" 代表三級清單下的組或控制字");
print(" ")
group = rtfAnalysis(data);
local_1 = 0; local_2 = 0; local_3 = 0;
for i in group:
local_1 += 1;
if(group[i][0] == '{'):
print(" >>>", group[i][0:30], "......", group[i][-30:]);
listgroup = rtfAnalysis(group[i][1:-1]);
for m in listgroup:
local_2 += 1;
if(listgroup[m][0] == '{'):
print(" >>", listgroup[m][0:30], "......", listgroup[m][-30:]);
if(level == "2"):
listgroup1 = rtfAnalysis(listgroup[m][1:-1]);
for n in listgroup1:
local_3 += 1;
if(listgroup1[n][0] == '{'):
print(" >", listgroup1[n][0:30], "......", listgroup1[n][-30:]);
else:
if(listgroup1[n][1] == '\''):
pass
else:
print(" >", listgroup1[n]);
else:
if(listgroup[m][1] == '\''):
pass
else:
print(" >>", listgroup[m]);
else:
if(group[i][1] == '\''):
pass
else:
print(" >>>", group[i]);
print("\n [+] 解析完畢");
print(" [+] 結果:");
print(" [>] 一級對象或控制字總個數:", local_1, "|", "二級對象或控制字總個數:", local_2, "|", "三級對象或控制字總個數:", local_3);
print(" [>] RTF 對象格式完好, 不存在缺失狀況\n");
print(" [+] 如需要更詳細的了解某些控制字群組, 可以參照 RTF 規範文檔");
b) 使用 printInfo() 函數對文檔進行基本解析
- 代碼如下所示:
# 列印 RTF 檔案基本資訊
def printInfo(data):
group1 = rtfAnalysis(data);
if(group1[0][0:5] != "{\\rtf"):
return False;
group2 = rtfAnalysis(group1[0][1:-1]);
group1 == {};
for i in group2:
if(group2[i][0] == '{'):
break;
group1[i] = group2[i];
RTF_characterSet = {"\\ansi":"ANSI (預設)", "\\mac":"Apple Macintosh", "\\pc":"IBM PC code page 437", "\\pca":"IBM PC code page 850"};
RTF_deffont = {"\\stshfdbch":"遠東字元", "\\stshfloch":"ASCII字元", "\\stshfhich":"High-ANSI字元", "\\stshfbi":"Complex Scripts (BiDi)字元"};
RTF_group = {"\\fonttbl":"字型表", "\\filetbl":"檔案表", "\\colortbl":"顔色表", "\\stylesheet":"樣式表", "\\listtable":"編目表", " \\*\\rsidtbl":"RSID", "\\*\\generator":"生成器"};
# 開始解析 RTF 頭部資訊
print(" > 開始解析:\n\n # 檔案頭部資訊:\n");
# 解析 RTF 版本
list1 = [];
for i in group1:
list1.append(group1[i]);
rtfN = list1[0];
rtfVersion = rtfN[rtfN.find("\\rtf") + len("\\rtf"):];
print(" [+] RTF 版本号:", rtfVersion);
# 解析字元集
local_1 = 0;
for m in list1:
for n in RTF_characterSet:
if(m == n):
print(" [+] 字元集:", n[1:]);
local_1 = 1;
if(local_1 == 0):
print(" [+] RTF 檔案缺少字元集");
# 解析預設 Unicode RTF
for m in list1:
if(m.find("\\ansicpg") != -1):
print(" [+] Unicode RTF(\\ansicpg):", m[m.find("\\ansicpg") + len("\\ansicpg"):]);
# 解析控制字 deffont
local_1 = 0;
for m in list1:
for n in RTF_deffont:
if(m.find(n) != -1):
print(" [*]", n[1:], ":", m[m.find(n) + len(n):]);
local_1 = 1;
if(local_1 == 0):
print(" [+] RTF 檔案缺少 deffont 控制字");
# 解析其他檔案頭部中存在的可被解析的組對象
print("\n [+] RTF 檔案頭部被解析出的其他對象: \n");
for m in group2:
for n in RTF_group:
if(group2[m].find(n) != -1):
print(" [+]", n,"(", RTF_group[n], ")");
print(" ");
# 開始解析 RTF 文檔區資訊
c) 使用 printOLE() 函數解析 RTF 檔案的 OLE 對象
- 代碼如下所示:
# 解析 RTF 檔案的 OLE 對象
def printOLE(data):
olestr = "\\object";
if(balance(data, 0) == False):
print("[-] RTF 檔案中的組不平衡, 檔案可能損壞, 但不影響分析");
dict1 = searchGroup(data, olestr);
# 開始解析 RTF 檔案中的 OLE 對象
print("# 開始解析 RTF 檔案中的 OLE 對象...\n");
# 列印 RTF 檔案中的對象個數
count = 0;
for i in dict1:
count += 1;
print(" [+] RTF 檔案中的 OLE 對象個數:", count);
# 根據對象的首位置取出完整的對象
count = 1; count1 = 0; ole = {};
for m in dict1:
olestart = dict1[m][-1];
olend = olestart;
for n in data[olestart:]:
if(n == '{' or n == '}'):
if(n == '{'):
count += 1;
if(n == '}'):
count -= 1;
if(count == 1):
break;
olend += 1;
ole[m] = data[olestart:olend + 1];
# 列印對象的嵌套關系和基本資訊
obj_objtype = {"\\objemb":"OLE嵌入對象類型", "\\objlink":"OLE連結對象類型", "\\objautlink":"OLE自動連結對象類型", "\\objsub":"Macintosh版管理簽署對象類型", "\\objpub":"Macintosh版管理釋出對象類型", "\\objicemb":"MS Word for Macintosh可安裝指令(IC)嵌入對象類型", "\\objhtml":"超文本标記語言(HTML)控件對象類型", "\\objocx":"OLE控件類型"};
obj_objmod = {"\\linkself":"該對象為同一文檔中的另一部分", "\\objlock":"鎖定對象的所有更新操作", "\\objupdate":"強制對象在顯示之前更新", "\\*\\objclass":"表示該對象類的文本參數", "\\objname":"表示該對象名稱的文本參數", "\\objtime":"列出對象最後更新的時間"};
obj_objinfo = {"\\objhN":"N是以缇表示的對象的原始高度, 假定該對象具有圖形表示特性", "\\objwN":"N是以缇表示的對象的原始寬度, 假定該對象具有圖形表示特性", "\\objsetsize":"強制對象伺服器将對象尺寸設定為用戶端給出的尺寸", "\\objalignN":"N是以缇表示的應該對齊制表位的對象的左縮進距離, 用于正确放置公式編輯器方程", "\\objtransyN":"是以缇表示的對象應該參考于基線垂直移動的距離,用于正确放置數學方程式", "\\objcroptN":"N是以缇表示的頂端裁剪值", "\\objcropbN":"N是以缇表示的底端裁剪值", "\\objcroplN":"N是以缇表示的左裁剪值", "\\objcroprN":"N是以缇表示的右裁剪值", "\\objscalexN":"N是水準縮放百分比", "\\objscaleyN":"N是垂直縮放百分比"};
obj_objdata = {"\\objdata":"該子引用包含了特定格式表示的對象資料,OLE對象采用OLESaveToStream結構,這是一個引用控制字", "\\objalias":"該子引用包含了Macintosh編輯管理器發行對象的别名記錄,這是一個引用控制字", "\\objsect":"該子引用包含了Macintosh編輯管理器發行對象的域記錄,這是一個引用控制字"};
obj_objres = {"\\rsltrtf":"如果可能, 強制結果為RTF", "\\rsltpict":"如果可能, 強制結果為一個Windows圖元檔案或者MacPict圖檔格式", "\\rsltbmp":"如果可能, 強制結果為一個位圖", "\\rslttxt":"如果可能, 強制結果為純文字", "\\rslthtml":"如果可能, 強制結果為HTML", "\\rsltmerge":"無論擷取任何新的結果均使用目前結果格式", "\\result":"結果目标在\\object目标引用中可選"};
count = 1; count2 = 1;
for k in dict1:
print("\n ======================================================================================================================= \n");
print(" [*] 開始解析第", count, "個對象 (對象處于檔案的位置:", k,") : ");
print(" [1] 對象的嵌套關系:");
print(" [!] 一級對象嵌套二級對象, 二級對象嵌套三級對象, 以此類推");
for i in dict1[k]:
print(" ", count2, "級對象 ", data[i:i + 40]);
count2 += 1;
print("\n [+] 目前 OLE 對象處于", count2 - 1, "級嵌套中");
count2 = 0;
# 列印對象基本資訊
print("\n [*] 對象的基本資訊:");
CWord = rtfAnalysis(ole[k][1:-1]);
for m in CWord:
# 解析對象類型
for n in obj_objtype:
if(CWord[m] == n):
print(" [+] 對象類型:", n, "-", obj_objtype[n]);
# 解析對象資訊
for n in obj_objmod:
if(CWord[m].find(n) != -1):
if(CWord[m].find("\\*\\") != -1):
print("\n [!] 對象資訊:", n, CWord[m].split()[1][:-1], "-", obj_objmod[n]);
else:
print("\n [!] 對象資訊:", n, "-", obj_objmod[n]);
# 解析對象尺寸、位置、裁剪與縮放
for n in obj_objinfo:
if(n.find("N") != -1):
ki = n[0:-1];
seek = CWord[m].find(ki);
if(seek != -1):
if(n.find("N") != -1):
print(" [+] 對象尺寸、位置、裁剪與縮放:", n, " N =", CWord[m][len(n):], "(", obj_objinfo[n], ")");
else:
print(" [+] 對象尺寸、位置、裁剪與縮放:", n, CWord[m][len(n):], "-", obj_objinfo[n]);
# 對象資料
for n in obj_objdata:
if(CWord[m].find(n) != -1 and CWord[m][0] == "{"):
print(" [!] 對象資料:", n, "-", obj_objdata[n]);
# 對象結果
for n in obj_objres:
if(CWord[m].find(n) != -1):
print(" [!] 對象結果:", n, "-", obj_objres[n]);
# 列印對象資料
length = 0;
print("\n [*] 對象輸入資料(\\objdata):");
for i in CWord:
if(CWord[i].find("\\objdata") != -1):
length = len(CWord[i]);
print(" ", CWord[i][0:400], "......", CWord[i][-400:]);
print("\n [*] 資料大小: ", length, " 檔案中的位置: ", i);
length = 0;
print("\n [*] 對象輸出資料(\\result):");
for i in CWord:
if(CWord[i].find("\\result") != -1):
length = len(CWord[i]);
print(" ", CWord[i][0:400], "......", CWord[i][-400:]);
print("\n [*] 資料大小: ", length, " 檔案中的位置: ", i);
count += 1;
0x05 腳本測試
- 測試文檔以及内容:
a) 列印所有組和控制字
- 測試列出所有的組和控制字,檢視是否正常:
- 如下圖所示成功列印出檔案的所有組和控制字:
b) 對檔案進行基本解析
- 測試使用 -i 參數對檔案進行基本解析:
- 解析正常。
c) 解析檔案中的 ole 對象
- 測試文檔 2 及其内容:
- 如圖所示,在測試文檔 2 中插入了 7 個 OLE 對象。
- 測試使用 -o 參數解析 OLE 對象:
- 第一個對象解析正常。
- - 第七個對象(最後一個)解析正常。
其實腳本測試的時候不一定使用正常的 RTF 文檔進行解析,也可以使用各種畸形的檔案進行測試
- python 腳本共享位址(百度雲):https://pan.baidu.com/s/1andozoFEqiwKS5sGJc3ttA(提取碼:k1lt)
- RTF 檔案規範 v1.7:https://pan.baidu.com/s/11w-xGVoguP-jtpJ6ocpwSA(提取碼:9xar)
- 測試文檔:https://pan.baidu.com/s/1KfEwC5UPmQCLtxJ8wzSlvg(提取碼:gz84)