C、C++ 和 Java安全编码实践提示与技巧
文 / Gwyn Fisher 对于所有类型环境中的开发人员来说,安全性正成为一个越来越重要的主题,即便过去一直认为安全性不成问题的嵌入式系统也是如此。本文将介绍几种类型的编码漏洞,指出漏洞是什么、如何降低代码被攻击的风险、如何更好地找出代码中的此类缺陷。注入攻击 通过将信息注入正在运行的流程,攻击者可以危害进程的运行状态,以反射到开发人员无法保护的某种最终目标。例如,
·
文 / Gwyn Fisher
对于所有类型环境中的开发人员来说,安全性正成为一个越来越重要的主题,即便过去一直认为安全性不成问题的嵌入式系统也是如此。本文将介绍几种类型的编码漏洞,指出漏洞是什么、如何降低代码被攻击的风险、如何更好地找出代码中的此类缺陷。
注入攻击
通过将信息注入正在运行的流程,攻击者可以危害进程的运行状态,以反射到开发人员无法保护的某种最终目标。例如,攻击者可能会通过堆栈溢出(stack corruption)将代码注入进程,从而执行攻击者选定的代码。此外,攻击者也可能尝试将数据注入数据库,供将来使用;或将未受保护的字符串注入数据库查询,获取比开发人员更多的信息。无论出于怎样的目的,注入总是一件坏事,总是需要谨慎对待的。
最恶劣的注入攻击形式也许是代码注入——将新代码置入正在运行的进程的内存空间,随后指示正在运行的进程执行这些代码。此类攻击如果成功,则几乎可以进行任何操作,因为正在运行的进程完全被劫持,可执行攻击者希望执行的任何代码。
此类攻击最著名的示例之一就是 Windows 动画光标攻击,这正是本文要讨论的模式。攻击者利用一个简单的 Web 页面将形式不当的动画光标文件下载到查看者的 PC 中,导致浏览器调用此动画光标,动画光标调用时可能发生任意代码的注入。实际上,这是一个完美的攻击载体:因为它不要求对被攻击机器的任何实际访问、最终用户根本意识不到任何可能发生的麻烦;此外,如果攻击效果的恶意也是适度的,则对最终用户的外部影响几乎是零。
考虑示例 1(a),当然,这改写自 Windows 攻击,它构成了此类攻击载体的基础。这里的开发人员对于传入流的可靠性做出了基本的假设。信任流和并相信一切都没问题。使用基于堆栈的将被非串形化(deserialized)的类型调用函数,未知数据流和代码注入肯定会在某个时间点出现。
(a)
void LoadTypeFromStream(unsigned char* stream, SOMETYPE* typtr)
{
int len;
// Get the size of our type's serialized form
memcpy(&len, stream, sizeof(int));
// De-serialize the type
memcpy(typtr, stream + sizeof(int), len);
}
void LoadTypeFromStream(unsigned char* stream, SOMETYPE* typtr)
{
int len;
// Get the size of our type's serialized form
memcpy(&len, stream, sizeof(int));
// De-serialize the type
memcpy(typtr, stream + sizeof(int), len);
}
(b)
void foo(unsigned char* stream)
{
SOMETYPE ty;
LoadTypeFromStream(stream, &ty);
}
{
SOMETYPE ty;
LoadTypeFromStream(stream, &ty);
}
(c)
void LoadTypeFromStream
(unsigned char* stream, SOMETYPE* typtr)
{
int len;
// Get the size of our type's serialized form
memcpy(&len, stream, sizeof(int));
// GUARD
if( len < 0 || len > sizeof(SOMETYPE) )
throw TaintedDataException();
// De-serialize the type
memcpy(typtr, stream + sizeof(int), len);
}
void LoadTypeFromStream
(unsigned char* stream, SOMETYPE* typtr)
{
int len;
// Get the size of our type's serialized form
memcpy(&len, stream, sizeof(int));
// GUARD
if( len < 0 || len > sizeof(SOMETYPE) )
throw TaintedDataException();
// De-serialize the type
memcpy(typtr, stream + sizeof(int), len);
}
示例1 注入攻击。
这是怎样发生的?假设您调用示例 1(b)中的函数。我们就得到了一个易于利用的攻击载体。这里的问题在于 SOMETYPE 在编译时的大小是固定的。假设此类型在内存中使用 128 个字节表示。再假设您构建传入流时,使前 4 个字节(要非串形化的内容的长度)的读数为 256。现在,您没有检查正在处理的内容的有效性,而是将 256 个字节复制到了仅为 128 个字节的保留堆栈空间内。
考虑到发布模式堆栈的典型布局,您显然遇到了麻烦。查看堆栈,了解原因所在。每个被调用的函数都会将其本地数据布设到堆栈的一个帧内,通常是通过在输入时从堆栈指针减去本地数据的已知大小(加上处理调用链本身所需的任何管理数据)实现的。编译器发出的理想函数 prolog(伪代码)如下所示:
这是怎样发生的?假设您调用示例 1(b)中的函数。我们就得到了一个易于利用的攻击载体。这里的问题在于 SOMETYPE 在编译时的大小是固定的。假设此类型在内存中使用 128 个字节表示。再假设您构建传入流时,使前 4 个字节(要非串形化的内容的长度)的读数为 256。现在,您没有检查正在处理的内容的有效性,而是将 256 个字节复制到了仅为 128 个字节的保留堆栈空间内。
考虑到发布模式堆栈的典型布局,您显然遇到了麻烦。查看堆栈,了解原因所在。每个被调用的函数都会将其本地数据布设到堆栈的一个帧内,通常是通过在输入时从堆栈指针减去本地数据的已知大小(加上处理调用链本身所需的任何管理数据)实现的。编译器发出的理想函数 prolog(伪代码)如下所示:
.foo
sub sp, 128 ; sizeof SOMETYPE
sub sp, 128 ; sizeof SOMETYPE
随后,对可利用函数的调用应如下所示:
push sp ; push the SOMETYPE
local variable
push ap ; push the stream
pointer (comes from 1st argument)
call LoadTypeFromStream
ret
local variable
push ap ; push the stream
pointer (comes from 1st argument)
call LoadTypeFromStream
ret
在调用 foo() 时,调用方将流地址以及返回地址(作为使用调用指令或平台上可用的同等部分的隐式效果)压入堆栈,使堆栈内容中有 128 个字节是为我们的类型保留的,且紧邻返回给 foo() 调用方的返回地址,参见图 1。
现在,LoadTypeFromStream 执行,并将 256 个字节写入所提供的地址,也就是在我们调用函数之前堆栈指针(SP)的值。这会覆盖应该使用的 128 个字节(本例中位于地址 0x1000 处),加上随后的 128 个字节,包括传入的参数指针、返回地址以及堆栈中随后 128 个字节内存储的其他任何信息。
那么攻击者怎样利用这样的漏洞呢?并不简单,需要经过反复的试错。实际上,攻击者要安排攻击,使覆盖的返回地址将控制权移交给攻击者,而非预期调用方函数。因而,攻击者需要准确了解要利用哪些数据结构,这样的数据结构在要攻击的任意版本的操作系统或应用程序上有多大、周边有哪些内容(以便正确设定伪造的返回地址)、如何有意义地插入足够的信息以使返回地址和其他效果能够实现某种恶意操作。
这一切做起来并不简单,但多种多样的攻击表明,总是有人有太多的空闲时间。
应如何防范此类攻击?这是一次攻击还是多重攻击?所写入的代码是否真的像这里所显示的这样笨拙?现代编译器是否会对堆栈帧布局做一些特殊处理,以避免此类问题?
总而言之,模糊处理就等于没有防御。我们都认识到,程序员将攻击预想得越简单,攻击出现的可能性就越高。然而,即便是复杂的代码,若未进行合理防御,也迟早会受到攻击。这种利用被污染的数据流和非常基本的缓冲溢出漏洞的攻击,多年以来这一直是热门的研究课题,但每年仍然会出现大量此类攻击。
防范此类攻击的效果甚微,因为攻击形式复杂——注意您的数据假设。只要在示例1(a)中添加一行简单的代码,就会使其更加安全,参见示例1(c)。显然,随着流交互变得更加复杂,保护的要求也随之复杂化,但基本上说代码注入是编码中“不可饶恕”的过失,因为防范它的方法是那样普及和简单。
现在,LoadTypeFromStream 执行,并将 256 个字节写入所提供的地址,也就是在我们调用函数之前堆栈指针(SP)的值。这会覆盖应该使用的 128 个字节(本例中位于地址 0x1000 处),加上随后的 128 个字节,包括传入的参数指针、返回地址以及堆栈中随后 128 个字节内存储的其他任何信息。
那么攻击者怎样利用这样的漏洞呢?并不简单,需要经过反复的试错。实际上,攻击者要安排攻击,使覆盖的返回地址将控制权移交给攻击者,而非预期调用方函数。因而,攻击者需要准确了解要利用哪些数据结构,这样的数据结构在要攻击的任意版本的操作系统或应用程序上有多大、周边有哪些内容(以便正确设定伪造的返回地址)、如何有意义地插入足够的信息以使返回地址和其他效果能够实现某种恶意操作。
这一切做起来并不简单,但多种多样的攻击表明,总是有人有太多的空闲时间。
应如何防范此类攻击?这是一次攻击还是多重攻击?所写入的代码是否真的像这里所显示的这样笨拙?现代编译器是否会对堆栈帧布局做一些特殊处理,以避免此类问题?
总而言之,模糊处理就等于没有防御。我们都认识到,程序员将攻击预想得越简单,攻击出现的可能性就越高。然而,即便是复杂的代码,若未进行合理防御,也迟早会受到攻击。这种利用被污染的数据流和非常基本的缓冲溢出漏洞的攻击,多年以来这一直是热门的研究课题,但每年仍然会出现大量此类攻击。
防范此类攻击的效果甚微,因为攻击形式复杂——注意您的数据假设。只要在示例1(a)中添加一行简单的代码,就会使其更加安全,参见示例1(c)。显然,随着流交互变得更加复杂,保护的要求也随之复杂化,但基本上说代码注入是编码中“不可饶恕”的过失,因为防范它的方法是那样普及和简单。
SQL 注入
此外还存在其他一些类型的 SQL 注入,可能会给以数据库为中心的应用程序造成严重的问题。在某些情况下,攻击者只是尝试访问更多的内容。在另一些情况下,攻击者关注的则是在数据库中存储新信息,以便使应用程序此后在不知情的前提下使用此类信息,入侵最终用户的会话。
基于查询的攻击关注的是一种普遍应用的反模式,使用字符串串联构建查询。这种类型的漏洞常常出现在面向 Web 的应用程序中,在所有常用页面产品——包括 PHP、ASP、JSP 等及其后备控制器逻辑中同样常见。
这种漏洞的核心是开发人员使用直接查询执行,而非利用查询准备来运行数据库交互。考虑以下登录验证查询示例:
SELECT ID FROM USERS WHERE NAME= 'user' AND PWD='password'
用户将看到一个简单的 HTML 表单,该表单包含两个输入框并使用了这种反模式。从表单传入的参数(无论所讨论的页面产品是怎样接收到这些参数的)都将通过串联直接代入查询的字符串形式。
考虑攻击者提供的一组参数:
考虑攻击者提供的一组参数:
NAME: x
PWD: x' OR '1' = '1
PWD: x' OR '1' = '1
运行串联,结果将得到被利用的查询:
SELECT ID FROM USERS WHERE NAME=
'x' AND PWD='x' OR '1' = '1'
'x' AND PWD='x' OR '1' = '1'
如果登录仅检查该语句执行成功与否(而未考虑结果行),攻击者即可迅速获得该应用程序所处理的任意用户记录可提供的任意访问权限。很多应用程序的用户表的第一行都是为超级用户保留的,攻击此类应用程序轻而易举。
利用未谨慎处理数据库语句内代入字符串的应用程序,攻击者可实现多种其他形式的攻击。这种反模式极为常见(参见最近的 Microsoft 公告和其他内容了解其普遍性),缓解方法也同样简单,并可置于基本数据库 API 之中:使用准备好的语句而非字符串串联。
例如,考虑示例2 中的错误实现。此函数严格遵循反模式,还通过抛出包含传入(未过滤)数据(即用户名)的异常而执行了另外一项重要的 no-no 操作。如果以响应的形式为用户呈现此数据,您就很可能遇到某些恶意利用,特别是可能遭遇跨站脚本攻击。
利用未谨慎处理数据库语句内代入字符串的应用程序,攻击者可实现多种其他形式的攻击。这种反模式极为常见(参见最近的 Microsoft 公告和其他内容了解其普遍性),缓解方法也同样简单,并可置于基本数据库 API 之中:使用准备好的语句而非字符串串联。
例如,考虑示例2 中的错误实现。此函数严格遵循反模式,还通过抛出包含传入(未过滤)数据(即用户名)的异常而执行了另外一项重要的 no-no 操作。如果以响应的形式为用户呈现此数据,您就很可能遇到某些恶意利用,特别是可能遭遇跨站脚本攻击。
public void validateUser(String user, String pwd, Connection db)
throws InvalidUserException
{
Statement stmt = null;
ResultSet rs = null;
try
{
// Create the statement
stmt = db.createStatement();
String sql = "select id from users where user='" + user +
"' and pwd='" + pwd + "'";
// Execute it, process the result
rs = stmt.executeQuery(sql);
if( rs == null || rs.next() == null )
throw new InvalidUserException(user);
}
catch( SQLException e )
{
throw new InvalidUserException(user);
}
finally
{
try { if( rs != null ) rs.close(); } catch( Exception e ) { }
try { if( stmt != null ) stmt.close(); } catch( Exception e ) { }
}
}
throws InvalidUserException
{
Statement stmt = null;
ResultSet rs = null;
try
{
// Create the statement
stmt = db.createStatement();
String sql = "select id from users where user='" + user +
"' and pwd='" + pwd + "'";
// Execute it, process the result
rs = stmt.executeQuery(sql);
if( rs == null || rs.next() == null )
throw new InvalidUserException(user);
}
catch( SQLException e )
{
throw new InvalidUserException(user);
}
finally
{
try { if( rs != null ) rs.close(); } catch( Exception e ) { }
try { if( stmt != null ) stmt.close(); } catch( Exception e ) { }
}
}
示例2 错误实现。
为了修正此代码,不应动态构建 SQL 查询,而是直接构建准备好的语句,并使用它来代替传入参数。
我们将准备的语句会为参数保留空间,并且不易受此类攻击利用,原因就在于它的词汇方面并不像字符串串联那样脆弱。
考虑以下语句(准备该语句的目的与前面提到的串联字符串相同):
为了修正此代码,不应动态构建 SQL 查询,而是直接构建准备好的语句,并使用它来代替传入参数。
我们将准备的语句会为参数保留空间,并且不易受此类攻击利用,原因就在于它的词汇方面并不像字符串串联那样脆弱。
考虑以下语句(准备该语句的目的与前面提到的串联字符串相同):
SELECT ID FROM USERS WHERE USER=?
AND PWD=?
AND PWD=?
我使用这个准备好的语句代入了 user 和 pwd 参数的传入数据。如果我们将之前被利用的字符串作为输入,结果将是查询代入过程出错,因为不能将包含单引号等特殊字符的参数提供给准备好的查询。
其他可能出现的利用也能在不同阶段捕捉到,但如示例3 所示,新实现的创建与原实现一样简单,但安全性要高得多(我们也从抛出的异常中删除了用户名,这样可以避免在未经过滤的情况下将其公开给调用方的危险)。
其他可能出现的利用也能在不同阶段捕捉到,但如示例3 所示,新实现的创建与原实现一样简单,但安全性要高得多(我们也从抛出的异常中删除了用户名,这样可以避免在未经过滤的情况下将其公开给调用方的危险)。
public void validateUser(String user, String pwd, Connection db)
throws InvalidUserException
{
PreparedStatement stmt = null;
ResultSet rs = null;
throws InvalidUserException
{
PreparedStatement stmt = null;
ResultSet rs = null;
try
{
// Prepare the statement, rather than concatenating it
String sql = "select id from users where user=? and pwd=?");
stmt = db.prepareStatement(sql);
{
// Prepare the statement, rather than concatenating it
String sql = "select id from users where user=? and pwd=?");
stmt = db.prepareStatement(sql);
// Substitute our incoming parameters into the query
stmt.setString(1, user);
stmt.setString(2, pwd);
stmt.setString(1, user);
stmt.setString(2, pwd);
// Execute the query and process the results as before
rs = stmt.executeQuery();
if( rs == null || rs.next() == null )
throw new InvalidUserException();
}
catch( SQLException e )
{
throw new InvalidUserException();
}
finally
{
try { if( rs != null ) rs.close(); } catch( Exception e ) { }
try { if( stmt != null ) stmt.close(); } catch( Exception e ) { }
}
}
rs = stmt.executeQuery();
if( rs == null || rs.next() == null )
throw new InvalidUserException();
}
catch( SQLException e )
{
throw new InvalidUserException();
}
finally
{
try { if( rs != null ) rs.close(); } catch( Exception e ) { }
try { if( stmt != null ) stmt.close(); } catch( Exception e ) { }
}
}
示例3 示例2 的较为安全的版本。
总体而言,无论是处理查询还是DML,在处理来自最终用户的数据时,始终应使用准备好的语句来利用数据库本身内置的过滤和解析功能。
总体而言,无论是处理查询还是DML,在处理来自最终用户的数据时,始终应使用准备好的语句来利用数据库本身内置的过滤和解析功能。
跨站点脚本攻击(XSS)
在早期的浏览器版本中,对于JavaScript 施加的第一项限制就是为页面内容建立一种边界,使一个站点提供的一个框架内执行的脚本无法访问其他站点提供的框架中的内容。因而,跨站点脚本攻击这种攻击模式关注的是使来自一个站点(攻击者站点)的脚本能够访问其他站点的内容(例如,用户的银行账户站点)。
为此,用户通常必然要访问一个恶意或不可信的网站,而社会工程的众多试验已经显示,用户可能会被最古怪的站点吸引。
在此类漏洞中,最常见的形式就是简单的反射漏洞,在一次服务器请求中将未经过滤的 HTML 参数(通常是表单参数)反射给用户。这种攻击载体的标准形式首先是通过搜索引擎结果页面显示出来的,通常会在页面标题中反射用户的查询关键词。如果未经过滤,这种反射回的查询关键词很可能包含一些编码不当的 HTML 标记,但可被接收方浏览器解释为有效的 HTML。
实际上,未经过滤的传入数据的任何反射都会造成问题,因为 XSS数量和种类始终在增加,参见示例4。
public void doGet(HttpServletRequest req, HttpServletResponse res)
{
string title = req.getParameter("searchTerm");
res.getOutputStream().write(title.getBytes("UTF-8"));
}
{
string title = req.getParameter("searchTerm");
res.getOutputStream().write(title.getBytes("UTF-8"));
}
示例4 未经过滤的传入数据本身就存在问题。
XSS反射的表现十分简单,而解决此问题的方法也极为简单——将从传入请求中读取的一切内容编码,之后再回发给浏览器即可。尽管我们在这里的示例中使用了Java,但包括HTML编码机制的所有常见页面产品均可用以避免此类漏洞。例如,下面这条 ASP 语句就可能被利用:
XSS反射的表现十分简单,而解决此问题的方法也极为简单——将从传入请求中读取的一切内容编码,之后再回发给浏览器即可。尽管我们在这里的示例中使用了Java,但包括HTML编码机制的所有常见页面产品均可用以避免此类漏洞。例如,下面这条 ASP 语句就可能被利用:
Response.Write Request.Form("username""
反之,以下语句则不能被利用:
Response.Write Server.HTMLEncode( Request.Form("username"))
尽管仍然没有内置对象可用于执行标准转换,但也可在 Java 中进行类似转换,以避免此类利用。也就是说,可轻松编写一个类似的 String 转换程序。对于寻找“现成”产品包的用户,JTidy 项目(jtidy.sourceforge.net)是一个理想的起点。
其他更加复杂的 XSS 表现形式以未过滤用户输入的持久存储为中心,此类输入内容会在随后用于提供响应内容。这是一类更难以诊断的 XSS,因为攻击模式不仅依赖于所存储的未经过滤的用户输入,还依赖于此后对其他用户可用的存储数据。
在早期Web发展阶段,不可信任的论坛提供的软件包特别易受此类攻击模式的影响。即便是现在,在数据库(或文件)中存储未经过滤的传入数据并随后将所存储的数据发送给用户的应用程序也易于受到此类持久形式的 XSS 的攻击。
同样,解决方法非常简单,只需通过编程,在存储信息之前将信息编码或在将信息从持久存储发送给用户之前编码即可。总而言之,在存储之前编码数据总是更加安全,这种方式可以保证未来对此类数据的使用免遭XSS 攻击。
尽管仍然没有内置对象可用于执行标准转换,但也可在 Java 中进行类似转换,以避免此类利用。也就是说,可轻松编写一个类似的 String 转换程序。对于寻找“现成”产品包的用户,JTidy 项目(jtidy.sourceforge.net)是一个理想的起点。
其他更加复杂的 XSS 表现形式以未过滤用户输入的持久存储为中心,此类输入内容会在随后用于提供响应内容。这是一类更难以诊断的 XSS,因为攻击模式不仅依赖于所存储的未经过滤的用户输入,还依赖于此后对其他用户可用的存储数据。
在早期Web发展阶段,不可信任的论坛提供的软件包特别易受此类攻击模式的影响。即便是现在,在数据库(或文件)中存储未经过滤的传入数据并随后将所存储的数据发送给用户的应用程序也易于受到此类持久形式的 XSS 的攻击。
同样,解决方法非常简单,只需通过编程,在存储信息之前将信息编码或在将信息从持久存储发送给用户之前编码即可。总而言之,在存储之前编码数据总是更加安全,这种方式可以保证未来对此类数据的使用免遭XSS 攻击。
查找漏洞
本文介绍的问题的规避方法易于实现,但对于尝试控制现有代码库或新建代码库的安全性的开发人员或开发组织而言,所面临的最大挑战就是找到漏洞所在。毫无疑问,可以利用手动代码检查的方法,但我可以确定地说,围坐在桌边、查看大量代码并尝试找出可能成为漏洞的内容绝非乐事。
静态源代码分析为此类问题提供了一种可行的解决方案,这种方法关注代码中现有的潜在漏洞或弱点,而不是像传统安全性应用程序或渗透测试工具那样尝试找到现有漏洞或攻击载体 。利用 SCA 工具可显著减少查找并缓解此类问题所需的时间和工作量。
目前有多种开源和商业工具可用,分别具有不同的功能。Klocwork(我目前效力的企业)就提供了这样一种商业静态源代码分析产品套件,主要关注 C、C++ 和 Java,为开发人员提供了快速、准确的运行缺陷和安全漏洞分析,并且能够集成在您所选择的 IDE 之中。■
原文链接: http://www.ddj.com/cpp/210602504
作者简介:
Gwyn是Klocwork的首席技术官,他有着20多年的全球技术经验。在Klocwork,Gwyn从事的工作仍然围绕他最初的爱好——编译器理论,他致力于将静态源代码分析推向更高级别。
(本文来自《程序员》0902期)
更多推荐
所有评论(0)