自動化漏洞挖掘:靜態程式分析入門
關於作者: 《青藤智庫》是青藤2022年成立的網路安全智庫,旨在透過追蹤國際網路安全動態,分析和研究技術及行業發展趨勢,持續跟蹤分析國內外網路安全建設動向,為網路安全人員提供全面的網路安全洞察。內容涵蓋違反較廣泛,包括國內和國外,包括政策分析和事件研判,以期成為網安人的智囊團。
文章目錄如下:
前言
靜態程式分析相關概念快速瞭解
IR(Intermediate Representation)
SSA(Static Single Assignment)
CFG(Control Flow Graph)
Data Flow Analysis
Input and Output States
Transfer Function’s Constraints
Control Flow’s Constraints
Interprocedural Analysis
Caller & Callee & Receiver
Call Graph
Method Calls(Invocations) in Java
Method Dispatch of Virtual Calls
CHA(Class Hierarchy Analysis)
Call Graph Construction
Pointer Analysis
Pointer Analysis and Alias Analysis
Key Factors in Pointer Analysis
Heap Abstraction
Allocation-Site Abstraction
Context Sensitivity
Flow Sensitivity
Analysis Scope
Concerned Statement
Datalog
Predicates
Atoms
Datalog Rules
Souffle簡單教程
註釋和前處理器
關係宣告
I/O指令
語法糖
原始型別
算術表示式
數字編碼
簡單例子
輔助工具
soot-fact-generator
ByteCodeDL
inputDeclaration.dl
utils.dl
cha.dl
rta.dl
pt-noctx.dl
ptaint.dl
GadgetInsepctor
JVM棧禎
區域性變數表
運算元棧
舉個例子
GadgetInspector基本原理
GadgetInspector具體實現
初始化ClassLoader
MethodDiscovery
PassthroughDiscovery
CallGraphDiscovery
SourceDiscovery
GadgetChainDiscovery
GadgetInspector邊界
GadgetInspector實戰效果
總結
前言
l 本文所描述的靜態程式分析是基於Java來描述的;
l 有現成的Fortify等工具可以使用,為啥還要學習這個?因為想要“ 知其然 ”,而且挖洞過程中,很多時候需要自己挖Gadget,Fortify是滿足不了滴;
l 本文主要略的講解了靜態程式分析的概念,最後透過兩個開源專案(GI 和 ByteCodeDL)講解靜態程式分析理論的落地實踐;
l 本文假設讀者對Java有一定的熟悉;
l 本文目的:幫助對靜態程式分析(或者說自動化程式碼審計)感興趣的小夥伴快速入門;
l 文筆粗糙,學識短淺,如有遺漏或者錯誤,煩請多多指教。
靜態程式分析相關概念快速瞭解
IR(Intermediate Representation)
IR即“中間表示”,通常IR的表現形式為三地址碼(3AC,3 Adress Code),靜態程式分析通常會將IR轉換為CFG(Controll Flow Graph)進行下一步分析;
三地址碼(3AC)的特徵:一條指令的右側最多有一個運算子,每一個3AC最多包含3個address,每種型別的指令都有對應的3AC;
例如如下語句:
t2 = a + b + 3
可以轉換成對應的三地址碼:
t1 = a + b
t2 = t1 + 3
通常IR就是把原始碼轉換成對應的3AC,例如如下程式碼:
do i = i+1; while(a[i]<v);
將上面的程式碼轉換成對應的IR,具體形式如下:
IR的特點如下:
l low-level並且貼合機器碼(和彙編很像);
l 通常不依賴於語言(由此便可以使用不同的前端,把不同的Source Code翻譯成統一的IR);
l 結構比較緊湊和均勻;
l 包含控制流資訊;
l 通常被認為是靜態程式分析的基礎;
在實際應用中,我們常見到的IR有soot生成的Jimple,也是3AC的格式,例如如下Java程式碼:
public static void main( String[] args )throws Exception{
App app = new App();
// source, new taint
String exp = app.source();
// taint arg -> ret
exp = app.addSuffix(exp);
// transform taint arg to ret
exp = app.transform(exp);
app.sink(exp);
app.sink(app.bak);
}
使用soot生成Jimple如下:
public static void main(java.lang.String[]) throws java.lang.Exception
{
org.example.App $stack3, app#_10;
java.lang.String $stack7, exp#_12, exp_$$A_1#_14, exp_$$A_2#_17;
java.lang.String[] args#_0;
args#_0 := @parameter0: java.lang.String[];
$stack3 = new org.example.App;
specialinvoke $stack3.<org.example.App: void <init>()>();
app#_10 = $stack3;
exp#_12 = specialinvoke app#_10.<org.example.App: java.lang.String source()>();
exp_$$A_1#_14 = specialinvoke app#_10.<org.example.App: java.lang.String addSuffix(java.lang.String)>(exp#_12);
exp_$$A_2#_17 = specialinvoke app#_10.<org.example.App: java.lang.String transform(java.lang.String)>(exp_$$A_1#_14);
specialinvoke app#_10.<org.example.App: void sink(java.lang.String)>(exp_$$A_2#_17);
$stack7 = app#_10.<org.example.App: java.lang.String bak>;
specialinvoke app#_10.<org.example.App: void sink(java.lang.String)>($stack7);
return;
}
下文,我們的程式分析基本都是圍繞Jimple這種3AC來進行分析。
SSA(Static Single Assignment)
SSA即為“靜態單一分配”,SSA中的所有賦值都有不同名稱的變數,詳細解釋如下:
l 每個定義需要給定一個新的名字;
l 將新名稱傳播給後續使用;
l 每個變數都只有一個定義。
例如如下即為3AC和SSA的對比:
一般生成SSA要比3AC慢很多(慢是真的慢,而且吃記憶體,類似泛微這種專案,一天一夜都生成不完),但是有時可以利用SSA來提高非敏感資料流分析的精度。
CFG(Control Flow Graph)
通常所有的控制流分析(Control Flow Analysis)指的就是建立控制流圖(Control Flow Graph);
l CFG是靜態程式分析的基本結構;
l CGF中的節點可以是單獨的3AC,或者(通常)是基本塊(BB,Basic Block);
一個將3AC轉換成CFG的示例如下:
上面的CFG中,存在兩個特殊的節點:Entry和Exit,這兩個節點的性質如下:
它們不對應於可執行的 IR;
l Entry存在一條Edge指向一個BB,這個BB包含整個IR的第一條指令,也就是這個BB是整個程式的第一個BB;
l 某個BB存在一條Edge指向Exit,這個BB包含整個IR的最後一條指令,也就是這個BB是整個程式的最後一個BB。
Data Flow Analysis
Data Flow Analysis即資料流分析,資料流分析可以概括為:How Data Flows on CFG?也就是:How Application-specific Data Flows through the Nodes(BBs/statements) and Edges(control flows) of CFG(a program)?
l 這裡的Application-specific Data指的就是我們靜態分析時關注的抽象(Abstraction)資料,例如進行汙點分析時,我們關注的就是汙點物件;
l Node通常透過轉換函式(Transfer functions)進行分析處理,例如函式呼叫(Method Call),形參到返回值的轉換處理;
l Edge的分析也就是Control-flow處理,例如GOTO等指令的處理;
l 不同的資料流分析存在不同的抽象資料(data abstraction)、不同的safe-approximation策略、不同的tranfer functions以及不同的control-flow handings。
例如,如果我們關注程式變數的正負等狀態,那麼此時的Application-specific Data指的就是表示變數狀態的一些抽象符號;Transfer functions指的就是各種加減乘除運算;Control-flow handing指的就是merges處的符號合併。
Input and Output States
l 每一個IR的執行,都會將input state轉換成output state;
l input(output) state和statement之前(之後)的program point相關;
l 在每個資料流分析應用中,我們在每個program point處都關聯了一個data-flow value,這個資料流值代表了可以在該點觀察到的所有可能程式狀態的集合的抽象;
l 資料流分析就是,對於程式中的所有IN[s]和OUT[s],需要找到一個方法去解析一系列的safe-approximation約束規則;這些約束規則基於語句的語義(transfer functions)或者控制流(flows of control)。
圖示如下:
Transfer Function’s Constraints
l Transfer Function’s Constraints即基於轉換函式的約束規則,主要分為兩種,一種是Forward Analysis,另外一種就是Backward Analysis;
l 對於Forward Analysis來講,IN[s]經過轉換函式fs的處理,可以得到OUT[s];
l 對於Backward Analysis來講,OUT[s]經過轉換函式fs的處理,可以得到IN[s]。
圖示如下:
Control Flow’s Constraints
l Control Flow’s Constraints即基於控制流的約束規則,主要體現在BB之間以及BB之內;
l 對於 IN[Si+1] = OUT[Si] ,要說明的含義其實就是,對於每一個statement,後一個statement的輸入就是前一個statement的輸出;因為BB中的statement不能存在分叉啥的,所以能這麼認為;
l 對於 IN[B] = IN[S1] 以及 OUT[B] = OUT[Sn] ,要說明的含義其實就是,對於每一個BB,其輸入就是第一個statement的輸入,其輸出就是最後一個statement的輸出。
Interprocedural Analysis
Interprocedural Analysis即為過程間(程式間)分析,前面講到資料流分析等都是程式內的分析,是不處理方法呼叫的,如果遇到了函式呼叫,對於過程間的分析如何處理?過程間分析會沿著過程間的控制流edges進行資料流傳播。
一個簡單的圖示如下:
為了更方便的進行過程間分析,我們通常還需要構造Call Graph。
Caller & Callee & Receiver
為了方便後續的講解,先在此處說明下caller、callee和receiver的概念,它們的具體圖示如下:
Call Graph
Call Graph即為呼叫圖,也就是程式中呼叫關係的表示。本質上,call graph是一組從call-sites到他們的目標方法的呼叫邊(call edges),call-sites的目標方法稱為(callees)。
一個Call Graph圖示如下:
call graph是過程間分析的基礎,對於建立call graph的幾種比較有代表性的演算法如下,越往上,速度越快,但是精度越低,越往下速度越慢,但是精度越高。
Method Calls(Invocations) in Java
對於Java程式而言,總共分為三種函式呼叫:Static Call、Special Call、Virtual Call;其中主要關注的就是Virtual Call,Virtual Call也是Java多型的關鍵體現,對於Virtual Call,呼叫的目標方法(callee)只能在執行時確定,對於靜態程式分析而言,確定callee就成了一個難點。
Method Dispatch of Virtual Calls
對於Virtual Call,其callee只能在執行時才能確定,callee的確定(或者說Dispatch)取決於 :
1. receiver object的true type;
2. call site的方法描述(Descriptor);
我們暫且認為方法簽名和方法描述是由如下部分組成:
Signature = class type + method name + descriptor
Descriptor = return type + parameter types
圖示如下(實際上Doop中的soot-fact-generator生成的方法前面就是這種格式):
最後我們可以定義一個 Dispatch(c, m) 方法來模擬執行時call-site具體方法的呼叫;演算法如下:
大概含義就是,尋找true type為c,呼叫的方法為m的真實目標方法(因為Java多型問題,Virtual Call需要計算執行時真實呼叫的方法),如果c類中存在一個非抽象的方法m’,其方法名和方法簽名和要尋找的m一樣,則m’即為我們需要找的真實方法;否則從類c的父類中去尋找m;
一個示例如下:
CHA(Class Hierarchy Analysis)
l CHA即為類層次結構分析演算法,對於這種演算法,它需要知道程式中類的繼承結構資訊(hierarchy information),例如需要知道某個類的父類和子類有哪些;
l CHA的主要思想就是在一個call site中,根據receiver variable的desclared type計算virtual call;
l CHA假設變數a可以指向類A以及類A的所有子類的物件,所以CHA計算目標方法的過程就是查詢類A的整個繼承結構來查詢目標方法;
l CHA演算法是1995年在ECOOP會議上首次發表出來的。
CHA中推導實際呼叫的目標方法的演算法如下,其中的cs指的是call site:
一個計算案例如下:
CHA的優勢是速度快,原因如下:
l 只考慮call site中receiver variable的declared type和它的繼承結構;
l 忽略資料流和控制流資訊。
CHA的劣勢是精度較低,原因如下:
l 容易引入虛假目標方法;
l 沒有使用指標分析。
Call Graph Construction
即透過CHA演算法生成Call Graph,步驟如下:
l 從入口方法開始(例如對於Java而言的main方法);
l 對於每一個可達方法m,在方法m中透過CHA演算法為每一個call site計算目標方法;
l 重複這個過程直到沒有新的方法被發現。
圖示如下:
具體的演算法如下:
一個使用CHA演算法構建Call Graph的圖示如下:
Pointer Analysis
Pointer Analysis即指標分析;如果我們使用CHA建立CallGraph,我們知道CHA僅僅考慮Class Hierarchy(類繼承關係),那麼正對如下程式分析,因為Number存在三個子類,那麼呼叫 n.get() 方法的時候,就會產生三條呼叫邊,其中有兩條是假的呼叫邊,導致最終分析的結果是一個不準確的結果:
而如果透過指標分析,那麼就可以清楚的知道變數n指向的物件,由此只會產生一條呼叫邊,此時分析的結果就是一個precise(精確)的結果:
l 指標分析是靜態程式分析的基礎,用於計算一個指標可以指向的記憶體;
l 對於OO語言而言,用於計算一個變數或者物件的field可以指向哪個物件;
l 可被認為是一個may-analysis:在over-approximation的約束下,計算一個指標可能指向的物件集合;
l 擁有超過40多年的研究歷史,並且至今還是一個熱門研究領域。
Pointer Analysis and Alias Analysis
兩者很相關,但是又有如下不同:
l Pointer Analysis:分析一個指標可以(may)指向那些物件;
l Alias Analysis:分析兩個指標是否能指向同一個物件;
l Aliase資訊可以從指標指向關係中推匯出。
如下程式中,變數p和q指向相同的物件,則稱p和q是aliase,但是x和y不是aliase:
p = new C();
q = p;
x = new X();
y = new Y();
Key Factors in Pointer Analysis
指標分析是一個複雜的系統,許多因素會影響指標分析的精度和效率,各種影響因素如下:
Heap Abstraction
l Heap Abstraction即堆抽象,需要解決的一個問題就是:如何對程式中的Heap Memory進行建模;
l 在動態執行時,程式會因為迴圈和遞迴等產生無窮無盡的物件(heap object);
l 使用靜態程式分析時,因為模擬動態執行會產生無窮無盡的物件,程式分析無法終止,所以就引出一種堆抽象技術,把無窮無盡的物件抽象成有限個數的物件;
例如使用堆抽象技術,把左邊無窮無盡的物件,抽象成右邊有限個數的物件:
堆抽象有很多分支,下文會詳細講解Allocation sites(呼叫點的抽象)這個分支:
Allocation-Site Abstraction
l 是目前使用最廣泛的堆抽象技術;
l 透過程式中的allocation sites進行物件建模;
l 透過程式中的每個建立點(allocation site)構建一個抽象物件。
如下示例中,透過建立點構建堆抽象,把三次迴圈的物件抽象成一個allocation site抽象物件:
Context Sensitivity
Context Sensitivity即上下文敏感,需要解決的一個問題就是:如何構建呼叫構建上下文(calling context);
當使用上下文不敏感的指標分析時,不同位置的相同函式呼叫,會被merge成一個資料流,從而造成精度丟失,但是如果使用上下文敏感的指標分析,對於不同位置的相同函式呼叫會根據不同的呼叫上下文(calling context)進行區分,然後分別分析每個context,圖示如下:
Flow Sensitivity
Flow Sensitivity即流敏感,需要解決的一個問題就是:如何構建控制流;
所謂流敏感,就是在分析過程中,會遵循程式的執行流,即會遵循程式中語句的執行順序,在程式的每一個statement都會維護當前程式指標指向的關係對映,而流非敏感則是相反的;如下圖所示,左邊藍色的是流敏感分析的結果,右邊是流非敏感的分析結果,會產生一個false positive:
Analysis Scope
Analysis Scope即分析範圍,需要解決的一個問題就是:分析程式中的哪一部分;
這個概念比較好理解,要麼分析全部,要麼分析程式中我們感興趣的部分。
Concerned Statement
Concerned Statement即需要關心的Statement,現代程式語言中,有很多型別的statements,比如 if-else , switch-case , for/while/do-while 等等,但是對於指標分析,我們並不用關心全部型別的statements,那些不會直接影響指標的statements會被忽略;我們只關注那些影響指標的statements;
值得一提是,對於陣列元素的指標處理,我們一般會忽略陣列的索引,把整個陣列抽象為一個整體的、單獨的物件;
Java中可以影響指標的5種Statement如下,後續的很多指標分析演算法都是圍繞著這幾個Statement進行處理:
Datalog
現階段常見的靜態程式分析流程:soot生成fact→編寫datalog規則→datalog引擎(常用souffle)執行datalog規則得出結果;
所以在進入程式分析演算法這步之前,還需要了解Datalog;
Datalog主要用於描述關係,是一種 宣告式邏輯程式語言 ,目前最常見的一種實現Souffle: 。
Predicates
l 本質上,謂詞就是一個資料表;
l 一個Fact代表一個特定的元組(也就是資料表的某一行)屬於某一個Relation(也就是某一個表),即它代表一個謂詞對於特定的值組合為真;
l 在datalog中,一個謂詞(或者說關係,Relation)是一系列特定值組合的集合;
例如如下表中,Age就是一個謂詞(或者說關係),它表明了所有person的age,從而得出 Age("C0m3ct",3) 是真,而 Age("P1n93r",6) 為假。
Atoms
l 主要分為兩種原子:關係型原子(Relational atom)和算術型原子(arithmetic atom);
l 例如 Age("C0m3ct",3) 就是一個關係型原子,而 age>18 則是一個算術型原子。
Datalog Rules
l Rule是邏輯表示式推理的一種方式;
l Rule還用於指定如何推斷事實(fact);
l Rule的格式為: H :- B1,B2,…,Bn. ;這個Rule表明,如果 H 為真,則 B1,B2,…,Bn 也必須為真;
l 如果一條Rule為: H :- B1,B2,…,Bn. ,那麼我們稱 H 為header,則 B1,B2,…,Bn 稱為body;
l Rule的body中,逗號 , 表示邏輯與的關係,封號 ; 表示邏輯或;
l 例如透過前面的 Age(person,age) 關係可以推匯出 Audit(person) :- Age(person,age),age >= 18.
Souffle簡單教程
Souffle是一款常用的datalog的引擎,這裡主要簡單介紹一下souffle。
註釋和前處理器
souffle支援兩種註釋:
// 這是註釋
/* 這是註釋 */
souffle 支援 C 前處理器(比如定義宏):
#include "myprog.dl"
#denfine MYPLUS(a,b) (a+b)
關係宣告
關係的宣告就是前面說到的datalog的謂詞,用於定義一系列特定的元組存在的某種關係,例如如下的關係定義/宣告:
// 定義了一個關係edge,代表一系列特定元組 (a, b) 存在edge這個關係,a和b的型別都是symbol,這是一種類似strings的型別
.decl edge(a:symbol, b:symbol)
I/O指令
編寫dl檔案的時候,我們可以使用IO指令進行fact的載入和輸出,這些指令分別為:
l .input <relation-name> 指令:從 <relation-name>.facts 中載入facts,預設使用tab符號進行資料分隔;
l .out <relation-name> 指令:預設情況下,將分析得出的facts寫出到 <relation-name>.csv 檔案中;
l .printsize <relation-name> 指令:在控制檯中列印給定關係的facts數量。
語法糖
為了減少程式碼編寫工作量,可以在一條規則中編寫多個head,如下所示,左邊是使用了語法糖的情況,右邊是沒有使用語法糖的情況:
類似的,也可以在規則體中使用析取(disjunction),如下所示,左邊是使用了語法糖的情況,右邊是沒有使用語法糖的情況(規則推導中的封號代表邏輯或):
原始型別
souffle存在兩種原始資料型別:
l symbol:它包含所有的字串,它的內部實現是一個ordinal number;
l number:和 int 類似;
算術表示式
souffle支援的算術函子如下:
l 加法: x+y ;
l 減法: x-y ;
l 除法: x/y ;
l 乘法: x*y ;
l 模數: a%b ;
l 冪運算: a^b ;
l 計數器: autonic() ;
l 位操作: x band y 、 x bor y 、 x bxor y 和 bnot x ;
l 邏輯操作: x land y 、 x lor y 和 lnot x ;
souffle支援如下算術約束:
l 小於: a < b ;
l 小於或等於: a <= b ;
l 等於: a = b ;
l 不等於: a != b ;
l 大於或等於: a >= b ;
l 大於: a > b ;
數字編碼
寫規則的時候,可以在原始碼中使用十進位制、二進位制和十六進位制,但是 在facts檔案中,只支援十進位制 :
.decl A(x:number)
A(4711).
A(0b101).
A(0xaffe).
簡單例子
例如如下 example.dl 檔案內容:
// 宣告一個 Predicate: edge
.decl edge(x:number, y:number)
// 表明需要從磁碟中讀取一個 edge.facts 檔案,這裡是從檔案中讀取 facts ,也可以直接在本 dl 中定義 facts
.input edge
// 宣告一個 Predicate: path
.decl path(x:number, y:number)
// 表明執行結束會生成一個 path.facts 檔案
.output path
// rule 推導
path(x, y) :- edge(x, y).
path(x, y) :- path(x, z), edge(z, y).
然後建立一個 edge.facts 檔案,內容如下,這個就表明了edge 這個 Relation :
1 2
2 3
然後直接執行如下命令,得到推匯出來的path 結果,其中, -F 指定了facts 所在的目錄,而 -D 制定了輸出目錄, example.dl 為datalog 檔案:
P1n93r@bogon example % ll
total 16
drwxr-xr-x 4 P1n93r staff 128B 4 28 19:20 .
drwxr-xr-x 3 P1n93r staff 96B 4 28 19:15 ..
-rw-r--r-- 1 P1n93r staff 8B 4 28 19:20 edge.facts
-rw-r--r-- 1 P1n93r staff 336B 4 28 19:19 example.dl
P1n93r@bogon example % souffle -F. -D. example.dl
P1n93r@bogon example % ll
total 24
drwxr-xr-x 5 P1n93r staff 160B 4 28 19:20 .
drwxr-xr-x 3 P1n93r staff 96B 4 28 19:15 ..
-rw-r--r-- 1 P1n93r staff 8B 4 28 19:20 edge.facts
-rw-r--r-- 1 P1n93r staff 336B 4 28 19:19 example.dl
-rw-r--r-- 1 P1n93r staff 12B 4 28 19:20 path.csv
P1n93r@bogon example % cat path.csv
1 2
1 3
2 3
---------------------------------------------未完待續------------------------------------由於本文包含程式碼過多,內容過長,小編將文章整理成冊,掃描下方二維碼,可下載完整手冊。
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69941982/viewspace-2927141/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- 漏洞挖掘的藝術-面向原始碼的靜態漏洞挖掘原始碼
- Java自學入門之靜態變數Java變數
- 藉助 Webpack 靜態分析能力實現程式碼動態載入Web
- 漏洞銀行公開課整理--家用路由器漏洞挖掘入門路由器
- 【自動化測試入門】自動化測試思維
- 前端自動化測試入門前端
- webpack自動化架構入門Web架構
- ansible自動化運維入門運維
- Helix QAC—原始碼級靜態自動化測試工具原始碼
- Jest前端自動化測試入門前端
- Hades:移動端靜態分析框架框架
- [Kyana]使用FlowUS+elog+Hexo+GithubAction自動化靜態部落格HexoGithub
- .NET Emit 入門教程:第二部分:構建動態程式集(追加構建靜態程式集教程)MIT
- airtest自動化測試工具快速入門AI
- 自動化整合:Docker容器入門簡介Docker
- Spring入門(二):自動化裝配beanSpringBean
- 什麼情況下需要進行靜態程式分析?常用Java靜態程式碼分析工具的優勢Java
- 靜態程式碼檢測工具Wukong對log4J中的漏洞檢測、分析及漏洞修復
- Klocwork—符合功能安全要求的自動化靜態測試工具
- Klocwork — 符合功能安全要求的自動化靜態測試工具
- 獻禮網安周 | 極光無限AI自動化漏洞挖掘平臺正式釋出AI
- [譯] 用 Workers 讓靜態網站動態化網站
- sqlmap支援自動偽靜態批次檢測SQL
- Asp.Net Core入門之靜態檔案ASP.NET
- UI自動化測試介紹及入門UI
- docker入門到自動化搭建php環境DockerPHP
- [Linux]Ansible自動化運維① - 入門知識Linux運維
- SRC漏洞挖掘之偏門資產收集篇
- QQ盜號木馬動靜態分析流程
- 自動化測試 RobotFramework自定義靜態測試類庫總結Framework
- gulp之自動化靜態資源壓縮合並加版本號
- Google自動程式設計框架AutoML入門指南Go程式設計框架TOML
- 漏洞挖掘基礎之格式化字串字串
- 偽靜態、靜態和動態的區別
- 自動部署基於issues的靜態部落格
- 《Flask 入門教程》第 4 章:使用靜態檔案Flask
- 動態圖和靜態圖的程式碼區別
- Appium自動化(9) - appium元素定位的快速入門APP