賀江敏 相里朋
(工業和信息化部電子第五研究所 廣州 510610)
我們知道,軟件從誕生的一刻起就伴隨著各種各樣的缺陷,這是因為軟件都是由人開發的,只要是有人參與的活動就不可避免會引入缺陷,而原因也是五花八門,如軟件需求不明確、開發人員技術水平不一致、團隊協作缺乏默契等.軟件中的缺陷可能會導致嚴重的后果,如系統崩潰、財產損失甚至是人員傷亡.于是,人們又研究出了軟件測試方法來發現軟件中存在的缺陷.
軟件測試從測試類型上分為功能測試、性能測試、可靠性測試、接口測試、界面測試等;從開發過程來看,可分為單元測試、集成測試、系統測試等;從是否執行代碼來看,可分為靜態測試和動態測試;從軟件的內部結構又可分為白盒測試、黑盒測試和灰盒測試.在實踐中,人們根據測試的目的選擇不同的測試類型及測試方法.對于一些非常重要的軟件或者軟件中存在的缺陷可能帶來十分嚴重的危害時,為了更加徹底地發現軟件中存在的缺陷,往往需要對軟件的源代碼進行分析,一般采用的是白盒的靜態測試方法,代碼審查就是其中一種常見的方法.
代碼審查以軟件源代碼為輸入,是采用人工的方式,對其中可能存在的缺陷、違反開發標準及其他可能存在的問題進行審查,一般采用的方法有代碼檢查單、代碼走查、軟件開發規范一致性檢查、工具靜態分析等[1].代碼審查的目的是檢查代碼和設計的一致性、代碼執行標準的情況、代碼邏輯表達的正確性、代碼結構的合理性以及代碼的可讀性[2].
代碼審查依據其目的可以分為代碼質量審查和代碼安全性審查.代碼質量審查主要對代碼的質量進行度量,包括編程規則的違反情況、代碼的圈復雜度、節點數、注釋行、運行時錯誤等.代碼質量審查主要用于對實時性、可用性要求較高的軟件或模塊,這類軟件失效時可能造成嚴重的經濟損失或人員傷害,多用于嵌入式軟件;代碼安全性審查主要對代碼中存在的安全漏洞進行分析,如SQL注入、跨站腳本、緩沖區溢出等.軟件中的安全漏洞一旦被非法利用可能導致敏感信息泄露、數據篡改、拒絕服務攻擊等,多針對信息安全要求高的軟件或系統,以信息系統居多.
代碼審查依據審查方法又可分為人工走查及工具靜態分析.人工走查就是采用人工的方式對源代碼逐行進行審查,人工走查可以最大限度地發現軟件中存在的問題,但是費時費力,同時又和代碼審查人員的技術水平相關.若軟件的代碼量十分巨大,對所有代碼進行人工走查往往是不現實的,這時往往對系統中十分重要的核心模塊進行人工走查.人工走查主要采用檢查單的形式,就是對照一份錯誤列表來檢查代碼是否存在常見錯誤[3].代碼質量審查和代碼安全性審查由于其目的不同,審查的重點往往不一樣,表1以對比的方式列出了部分審查內容:

表1 代碼走查檢查單
工具靜態分析就是采用專門的代碼審查工具對軟件源代碼進行掃描,并對掃描的結果進行人工分析.靜態分析的目的是通過對源程序分析、目測,但不執行程序,找出源代碼中可能的錯誤和缺陷[4].工具靜態分析可以代替人工發現代碼中存在的通用性缺陷,并且速度快、效率高,但是針對特定領域相關的邏輯缺陷是無法發現的,比如嵌入式軟件對寄存器的賦值錯誤、軟件運行狀態錯誤等.因此即使采用工具對源代碼進行分析具有如此多的優點,但也不能完全取代代碼人工走查.在實際的工程中,往往是這2種測試方法相結合,以達到更好的效果.目前市面上的代碼靜態分析工具也主要分成2類:針對代碼質量的靜態分析工具和針對代碼安全性的靜態分析工具.針對代碼質量的靜態分析工具如testbed,logiscope,C++test等,其主要功能有復雜度分析、靜態數據流分析、交叉索引分析、信息流和數據對象分析、運行時錯誤檢測等.針對代碼安全性的靜態分析工具如Fortify SCA,Checkmarx CxSuite,Armorize CodeSecure等.有的靜態分析工具2種缺陷都可以檢測,如Klocwork在對代碼質量進行分析的同時也可以檢測一些諸如注入、緩沖區溢出、拒絕服務等常見的安全問題.每種代碼靜態分析工具都有自己的特點,有的測試類型比較廣泛,有的針對某個領域做得比較完善,在實際中,可以根據測試的內容、關心的方向等有目的的選擇.
隨著網絡上每年爆出大量的安全事件,如軟件漏洞、蠕蟲病毒、木馬、黑客攻擊、用戶信息泄露等,人們開始越來越重視軟件的安全性.在《GBT 16260.1—2006 軟件工程 產品質量 第1部分:質量模型》中,軟件的安全保密性是屬于軟件6性(功能性、可靠性、易用性、效率、維護性、可移植性)中功能性的子特性,而在《GBT 25000.10—2016 系統與軟件工程 系統與軟件質量要求和評價(SQuaRE) 第10部分:系統與軟件質量模型》中,軟件的產品質量特性被劃分為8個特性:功能性、性能效率、兼容性、易用性、可靠性、信息安全性、維護性和可移植性,信息安全性已被單獨提出成為軟件質量特性之一,并劃分為保密性、完整性、抗抵賴性、可核查性、真實性、信息安全的依從性6個子特性.可見軟件的安全性測試已成為軟件測試中非常重要的部分.對于軟件的安全性可以通過黑盒的測試方法,如安全功能測試、滲透測試等,也可以通過白盒的測試方法如代碼安全性審查進行評估.前面已經對代碼安全性審查的概念作了描述,下面分別從人工走查和工具靜態分析量2方面對代碼安全性審查的方法進行研究.
本文主要針對目前常見的軟件安全漏洞進行分析.
2.1.1SQL注入
SQL注入(SQL injection)是一種代碼注入(code injection)攻擊,其根源是用戶輸入等不可信數據未經充分凈化、過濾就被數據庫引擎當作SQL代碼片段執行[5],這樣攻擊者就可以注入任何SQL語句實現數據庫的查詢、修改,甚至是通過存儲過程或調用外部命令實現對操作系統的操作.以下就是一個存在SQL注入漏洞代碼的例子:
public class test{
public ResultSet getArticleData(ServletRequest req, Connection con) throws SQLException {
String id=request.getParameter(″id″);
String query=″SELECT * FROM articles WHERE id=′″+id+″′″;
Statement stmt=con.createStatement();
ResultSet rs=stmt.execute(query);
return rs;
}
}
這段代碼的功能為:服務器接收客戶端瀏覽器通過post或get方法傳遞過來的id參數,以id參數為輸入,形成查詢字符串,查找數據庫articles表中以id為指定值的文章相關內容并返回.可以看出,代碼中的查詢字符串是由一個基本的查詢語句和用戶輸入的字符串采用拼接字符串的方式組成的,如果攻擊者為id輸入字符串“100′ OR ′a′=′a”那么構建的查詢語句就變成:
SELECT * FROM articles WHERE id=′100′ OR ′a′=′a′;
由于OR ′a′=′a′是恒成立的,于是構建的查詢語句就等效為
SELECT * FROM articles;
這時返回的是articles表中的所有條目,而不是指定id的條目,當然也可以通過構建的SQL語句來執行更加復雜的操作,比如在支持采用分割符一次性執行多條SQL語句的數據庫中,若攻擊者為id輸入字符串“100′; DELETE FROM articles; --”那么構建的查詢語句就變成:
SELECT * FROM articles WHERE id=′100′;
DELETE FROM articles;
查詢語句變為2個,在執行完第1個查詢語句后會執行第2個查詢語句,直接刪除articles表.
具有安全意識的程序員會采用參數化的SQL指令來進行SQL查詢,通過占位符進行參數捆綁,以便區分哪些是命令的一部分哪些只是輸入的數據,捆綁的參數只會當作輸入的數據,即使里面帶有SQL指令也不會執行.因此,參數化SQL可以防止篡改上下文,有效避免SQL注入攻擊.在Java語言中,采用PreparedStatement進行預編譯,提供占位符實現參數化功能,如下所示:
public class test {
public ResultSet getArticleData(ServletRequest req, Connection con) throws SQLException {
String id=request.getParameter(″id″);
String query=″SELECT * FROM articles WHERE id=?″;
PreparedStatement stmt=
con.prepareStatement (query);
stmt.setString(1, id);
ResultSet rs=stmt.execute();
return rs;
}
}
當以上這種方式進行數據庫查詢時便不會產生SQL注入的問題.
2.1.2緩沖區溢出
緩沖區溢出是一種十分危險的漏洞,這是由于向內存區塊中填寫的數據超過了區塊本身的大小,導致數據覆蓋了指定區域之外的內存區域,經過精心構建的填充數據可以覆蓋并重寫函數的返回地址,當函數返回時直接跳轉到攻擊者指定的緩沖區,并執行攻擊者想要執行的任意代碼.即使是任意填充的隨機數據也會使函數返回到未知的地址,最終導致程序的崩潰,造成拒絕服務攻擊.緩沖區溢出漏洞常出現在采用C語言編寫的代碼中,經常與危險的字符串函數的使用相關,標準C庫中有很多不進行自變量檢查的字符串操作函數,在使用這類函數時一定要對操作字符串的數目進行限制[6],如gets(),scanf(),strcpy(),sprintf().內存分配函數malloc()的使用也要十分謹慎,若沒有對分配的內存大小進行判斷,很可能會引起緩沖區溢出漏洞,在進行代碼人工走查時,以上都是需要重點關注的地方.下面是一個緩沖區溢出的例子:
void test()
{
…
char a[10]=″Hello Tom″;
char b[20]=″This is a example″;
strcpy(a,b);
…
}
這段代碼中,字符數組變量a的長度為10,字符數組變量b的長度為20,通過函數strcpy將b的內容覆蓋變量a,由于變量b中的內容長度大于變量a的長度,當變量a的10個字節覆蓋完成后會繼續覆蓋其分配的內存空間之外的地址,這就形成了緩沖區溢出.
針對緩沖區溢出漏洞有以下2種解決辦法:
1) 人工通過代碼對上限進行判斷,如下所示:
void test()
{
…
char a[10]=″Hello Tom″;
char b[20]=″This is a example″;
if (strlen(b) > (sizeof(a)-1)){
print(″error ″);
return;
}
strcpy(a,b);
…
}
2) 將無界字符串操作函數strcpy(dest,src),替換成對應的有界字符串操作函數strncpy(dest,src,n),strncpy將src中的內容復制到dest,復制的大小由n決定,如下所示:
void test()
{
…
char a[10]=″Hello Tom″;
char b[20]=″This is a example″;
strncpy(a,b,sizeof(a)-1);
…
}
這樣就可以避免緩沖區溢出的問題.
2.1.3資源未釋放
資源未釋放一般分為2種情況:一種是文件流資源未釋放;另一種是數據庫連接資源未釋放.文件流資源未釋放就是當打開一個文件流,對文件進行讀寫操作后卻忘記了釋放這個文件流.數據庫連接資源未釋放就是建立了一個數據庫連接,對數據庫進行增、刪、改、查操作后忘記釋放數據庫連接.資源未釋放會被惡意攻擊者利用,大批量并發的資源打開操作而又不釋放資源,很容易導致資源的耗盡從而引發拒絕服務攻擊.一個典型的文件流資源未釋放的例子如下:
private void test(String fileName) throws IOException {
try
{
Int len=0;
FileInputStream fis=new FileInputStream(fileName);
byte[] Array=new byte[SIZE];
while((len=fis.read(Array))!=-1){
System.out.println(new String(Array,0,len));
}
}
catch (IOException e)
{
e.printStackTrace();
}
}
該代碼建立一個文件流,讀取文件中的內容并打印出來,最后卻沒有關閉該文件流.雖然Java有垃圾回收機制,但是垃圾收集器需要確認對象是否符合回收條件,而且什么時候回收是由系統自動判斷的,這就不能保證資源的釋放,導致內存使用過大.
有的程序員會在try模塊中釋放資源,如下所示:
private void test(String fileName) throws IOException {
try
{
Int len=0;
FileInputStream fis=new FileInputStream(fileName);
byte[] Array=new byte[SIZE];
while((len=fis.read(Array))!=-1){
System.out.println(new String(Array,0,len));
}
if(fis!=null)
{
fis.close();
}
}
catch (IOException e)
{
e.printStackTrace();
}
}
這在大多數情況下沒有什么問題,但是一旦程序發生異常則會跳過后續代碼的執行,資源便得不到釋放.正確的做法是在finally模塊中釋放資源,這樣,即使發生了異常也能保證資源可以得到釋放,如下所示:
private void test(String fileName) throws IOException {
try
{
Int len=0;
FileInputStream fis=new FileInputStream(fileName);
byte[] Array=new byte[SIZE];
while((len=fis.read(Array))!=-1){
System.out.println(new String(Array,0,len));
}
}
catch (IOException e);
{
e.printStackTrace();
}
finally
{
if(fis!=null)
{
fis.close();
}
}
}
2.1.4路徑操縱
當對文件進行操作時,若文件路徑由用戶可以操作的變量組成,而程序又沒有對用戶提交的參數進行過濾就會導致路徑操縱漏洞,通過路徑操縱漏洞,惡意用戶可能訪問操作系統中任意文件[7].
String fileName=request.getParameter(″fileName″);
test(fileName);
public void test(String filename){
try
{
…
amt=fis.read(Array);
out.println(Array);
…
}
catch(…){
…
}
}
因此需要限制用戶輸入的字符,只允許輸入規定的字符,如下所示:
String fileName=request.getParameter(″fileName″);
test(fileName);
public void test(String filename){
try
{
…
String regex=″∧[A-Za-z0-9]+.[a-z]+$″;
if(filename.matches(regex)){
red=fis.read(Array);
out.println(Array);
}
…
}
catch(…){
…
}
}
這樣若用戶提交的文件名和正則表達式不匹配則不能進行文件讀取的操作,防止了用戶讀取操作系統上的任意文件.
2.1.5不安全的密碼算法
一些密碼算法在其誕生之初是安全的,但隨著技術的發展現在已經不再安全,比如DES算法,其密鑰長度只有56 b,在云計算、并行計算及計算機運算速度發展的今天,破解其密鑰只需要很短的時間.對于散列算法,像MD5,SHA-1都已經實現了碰撞,不再安全,應使用現在公認還比較安全的散列算法如SHA-256,SM3等.
public void encrypt(String str){
…
Cipher encryptCipher=
Cipher.getInstance(″DES″);
KeyGenerator keygen=
KeyGenerator.getInstance(″DES″);
SecretKey deskey=keygen.generateKey();
encryptCipher.init(Cipher.ENCRYPT_MODE, deskey);
byte[] src=str.getBytes();
byte[] cipherByte=
encryptCipher.doFinal(src);
…
}
以上代碼采用DES加密方式對輸入的字符串進行加密,由于DES加密算法已經不安全,需要將其改為AES加密算法,AES加密算法可以使用256 b長度的密鑰,以現在的技術在可承受的時間范圍內破解是不可能的.當然隨著技術的發展,如量子計算,將來的某一天,也許AES加密算法也不再安全,但至少現在暫時還是安全的,為了修復該問題,只需要將以上代碼修改為如下代碼:
public void encrypt(String str, String
password){
…
Cipher encryptCipher=Cipher.getInstance(″AES″);
KeyGenerator keygen=
KeyGenerator.getInstance(″AES″);
keygen.init(256, new
SecureRandom(password.getBytes()));
SecretKey orikey=keygen.generateKey();
byte [] raw=orikey.getEncoded();
SecretKey deskey=new SecretKeySpec(raw, ″AES″);
encryptCipher.init(Cipher.ENCRYPT_MODE, deskey);
byte[] src=str.getBytes();
byte[] cipherByte=encryptCipher.doFinal(src);
…
}
通過工具對源代碼的安全性進行靜態分析并不是用工具跑一遍得出結果就可以了,我們知道只要是采用工具就有2個不可避免的問題:漏報率和誤報率.漏報率可以通過人工走查的方式降低,要降低誤報率,采用靜態分析工具對源程序進行編碼規則檢查,對于工具報出的問題再由人工進行進一步的分析以確認軟件問題,是一種比較有效的方法[8].
目前各種代碼安全性靜態分析工具都比較成熟,所采用的分析方法一般有:數據流分析、語義分析、結構分析、控制流分析、配置分析等.
2.2.1數據流分析
數據流分析就是跟蹤程序中數據的傳遞,從而發現存在的安全問題.比如數據從一個變量傳遞給另一個變量,或者數據通過調用函數傳遞到函數內部,處理后再返回給另一個變量等.下面舉一個SQL注入漏洞數據流分析的例子,如圖1所示:

圖1 數據流傳遞示意圖
圖1在getRawParameter()函數中用戶提交的數據通過request.getParameterValues()傳遞到服務器,接著數據返回到createContent()函數內部,并形成SQL語句,執行查詢操作.數據流在從用戶提交到SQL語句執行的整個傳遞過程都可以很清楚看到.經過分析,在數據流的整個傳遞過程中都沒有對數據進行過濾,在最終的執行階段也沒有采用預編譯的方式通過占位符參數綁定防止SQL注入,因此,這是一個SQL注入漏洞.
通過數據流分析可以很容易發現通過數據的傳遞引發的安全漏洞,但是由于其只對數據流進行跟蹤,對于數據流之外的防護手段是無法發現的,這就可能出現誤報,因此還需要進行額外的人工分析以消除這些誤報.比如采用了全局過濾器,這時在web.xml配置文件中會有如下代碼:
同時,我們還需要檢查其對應的過濾函數是否有效,查看classescomfilter目錄下的SqlInjectionfilter.java文件
public class SqlInjectionfilter implements Filter {
public void destroy() {
…
}
public void init(FilterConfig arg0) throws ServletException {
…
}
public void doFilter(ServletRequest args0, ServletResponse args1, FilterChain chain) throws IOException, ServletException {
過濾代碼
…
}
}
對于asp代碼,可以通過在文件頭引用具有SQL過濾功能的代碼對提交的數據進行過濾,如下所示:
SqlInjectionfilter.asp文件是對提交數據進行過濾的代碼,如:
<%
If Request.QueryString<>″″ Then
For Each Get_Data In Request.QueryString
對通過GET方式提交的參數進行過濾…
Next
End If
If Request.Form<>″″ Then
For Each Post_Data In Request.Form
對通過POST方式提交的參數進行過濾…
next
end if
%>
這種過濾方法同樣是在數據流之外,通過數據流分析的方法無法識別,因此需要人工審查并進行剔除.
2.2.2語義分析
語義分析就是分析代碼中不安全函數、API或不安全方法的使用,這對一些緩沖區溢出及格式化字符串問題十分有效,比如下面這段代碼:
char a[10]″;
char b[10]″;
…
strcat(a,b);
由于使用了危險的函數strcat(),可能造成緩沖區溢出漏洞.
再舉一例,比如以下代碼:
public final static String
DATABASE_PASSWORD=″cp8gc6ka″;
將密碼直接寫到源代碼中,這是一種不安全的做法,稱為密碼硬編碼.因為一旦軟件發布以后就不能修改這些密碼,除非發布新的版本,而通過對軟件進行動態調試或二進制分析也可能找到這些密碼,因此應對密碼進行加密并存儲在外部的配置文件中.
2.2.3結構分析
結構分析就是通過對程序結構的上下文進行分析,并找出其中的安全問題,比如下面的代碼:
public class test extends HttpServlet {
String name;
protected void doPost (HttpServletRequest req,HttpServletResponse res) {
username=req.getParameter(″username″);
…
out.println(″Hello″+username);
}
}
該段代碼將用戶名變量放置在成員變量中,從結構上看,這個變量在“類”中,“方法”外.當一個用戶進行訪問時,這段代碼是沒有什么問題的,但當2個用戶同時訪問時,由于Servlet是單實例多線程的并發處理模式,會導致第2個用戶的用戶名覆蓋第1個用戶的用戶名,從而在執行到顯示用戶名的代碼時,將第2個用戶的用戶名顯示給第1個用戶,形成競爭條件問題.
另外一個典型的結構問題是函數在finally中返回,如下所示:
public int test(int Num) {
int rt;
try
{
…
}
catch (Exception e)
{
…
}
finally
{
…
return rt;
}
}
函數在finally中返回會導致從try塊中拋出的異常丟失,這樣便無法處理可能出現的異常情況.
2.2.4控制流分析
控制流分析根據指令的執行可定義多個不同的狀態,不同的狀態通過控制流的路徑連接起來,主要是在代碼解析的基礎上,分析過程內語句之間的控制依賴關系,提取程序的控制流信息[9].基本的控制語法,如if,while,case等根據狀態的不同引導控制流的走向,如果某一條控制流最后的狀態是一個錯誤狀態,那么這就有可能是一個漏洞.
State state=null;
switch (Fg) {
case 1:
state=state1;
break;
case 2:
state=state2;
case 3:
state=state3;
}
state.dosomething ();
在以上代碼中,若Fg不為1,2,3中的任何一個值,那么state就不會賦值,仍為null狀態,這時若對state進行操作就有可能觸發null引用問題,導致程序崩潰,當然若state在賦值狀態,程序是沒有問題的.
2.2.5配置分析
配置分析就是對配置文件的內容進行分析,找出其中可能存在的安全問題,如敏感信息、不安全的配置等.比如在application.properties配置文件中有如下配置:
…
jdbc.driver=
oracle.jdbc.driver.OracleDriver
jdbc.url=jdbc:oracle:thin:@127.0.0.1:1521:orcl
jdbc.username=test
jdbc.password=uuikx0k6
…
該配置文件描述了應用程序通過jdbc連接oracle數據庫的連接字符串,包括連接的用戶名和密碼,可以看到密碼采用了明文方式存儲,任何可以訪問該配置文件(包括使用非法手段)的人都可以獲得數據庫的訪問密碼,從而訪問數據庫,因此需要對配置文件中存儲的密碼進行加密.
需要注意的是,工具并不能區分出密碼字符串是否經過加密,比如下面的配置:
zJQDik2PenVdnh6IZA0cqW9kX7Nz53oc=
工具仍可能會認為這可能是一個存儲在配置文件中的未加密的密碼,因此需要對工具分析出的結果進行人工分析.
另外一些看上去好像是加密的密碼實際上并沒有加密,如下所示:
jdbc.password=MTIzNDU2Nzg5MA==
這實際上只是對密碼明文進行了Base64的編碼,并不是加密,可以很容易還原成明文,以上都是在對工具靜態分析結果進行確認時需要注意的地方.
在軟件安全測試領域,經過多年的發展,測試方法和測試手段已經多元化,有的側重于安全功能的實現,有的側重于外部環境的影響,還有的需要從整個系統層面進行考量.對于特別重要的軟件系統,如涉及人民生命財產安全、重要數據、甚至可能影響到國家安全的軟件及信息系統,代碼安全性審查就是一個十分必要的手段.雖然目前還存在人工成本大、耗時長、工具分析存在誤報和漏報等不足,但是隨著云計算、大數據、人工智能等技術的發展,若將其應用到對軟件源代碼的安全分析方面,代碼安全性審查的效率將大大提高,時間和成本也會大幅度降低,成本的降低將帶來技術的普及,這將為更多、更廣范圍的軟件帶來安全性的保障.