Arya

Getting Coverage(一)
[+] 新的一天,新的噩梦[+] 今天一定要买帽子[+] 还要去买坚果,这样看剧的时候就可以继续剥着吃[+] 今天...
扫描右侧二维码阅读全文
18
2019/01

Getting Coverage(一)

[+] 新的一天,新的噩梦
[+] 今天一定要买帽子
[+] 还要去买坚果,这样看剧的时候就可以继续剥着吃
[+] 今天是认真学习的一天

0x01 代码覆盖

前一章介绍了基本的模糊化——生成测试程序的随机输入
那么如何衡量这些测试的有效性?一种方法是检查经过测试发现的bug的数量(和严重性),但如果bug数量很少,我们便需要一个代理来反应测试的用例被我们所写的代码覆盖的程度——代码覆盖率(Code Coverage),所以这一节说的便是代码覆盖。

首先要实现一个 CGI解码器

CGI:通用网关接口(Common Gateway Interface)是一个Web服务器主机提供信息服务的标准接口。CGI 应用程序能与浏览器进行交互,还可通过数据库API 与数据库服务器等外部数据源进行通信,从数据库服务器中获取数据。格式化为HTML文档后,发送给浏览器,也可以将从浏览器获得的数据放到数据库中。几乎所有服务器都支持CGI,可用任何语言编写CGI,包括流行的C、C ++、VB 和Delphi 等。

这是网上的一些官方解释,没事反正我也看不懂(滑稽,毕竟很多情况下,读懂人类的意思可比读懂计算机的意思难多了,那就上代码吧~

首先介绍一个简单的python函数,他对CGI编码的字符串进行解码。首先要知道,CGI编码用于url时,会对url中的无效字符进行编码,无效字符比如空格和一些标点,比如:
将空格替换成'+'
将其他无效字符替换为"%xx",其中xx是梁文十六进制等小字符

是不是很难懂?
这么解释一下吧,所谓的CGI编码字符,利用web中的常用语言就是最常说的URL编码(没错,就是它,哈哈哈

所以接下来就是模拟写一个CGI解码器,即模拟写一个URL解码器

def cgi_decode(s):
    """Decode the CGI-encoded string `s`:       
    * replace "+" by " "       
    * replace "%xx" by the character with hex number xx.       
    Return the decoded string.  Raise `ValueError` for invalid inputs."""

    # Mapping of hex digits to their integer values
    hex_values = {
        '0': 0, '1': 1, '2': 2, '3': 3, '4': 4,
        '5': 5, '6': 6, '7': 7, '8': 8, '9': 9,
        'a': 10, 'b': 11, 'c': 12, 'd': 13, 'e': 14, 'f': 15,
        'A': 10, 'B': 11, 'C': 12, 'D': 13, 'E': 14, 'F': 15,
    }

    t = ""#利用t来生成新的字符串
    i = 0
    while i < len(s):
        c = s[i] #依次遍历url中的每个字符
        if c == '+': #如果读取的字符是'+',那么转为' '
            t += ' '
        elif c == '%':#如果读取的是百分号
            digit_high, digit_low = s[i + 1], s[i + 2]向后继续读取两个字符
            i += 2
            if digit_high in hex_values and digit_low in hex_values:
                v = hex_values[digit_high] * 16 + hex_values[digit_low]#16进制转码
                t += chr(v)
            else:
                raise ValueError("Invalid encoding")
        else:
            t += c
        i += 1
    return t

打印输出

print(cgi_decode("Hello+world"))

Hello world

这样我们就获得了一个CGI解码器
如果需要系统的测试cgi_decode()函数,我们要怎么进行呢?

于是引入了黑盒测试与白盒测试两种概念

0x02 黑盒测试

黑盒测试是一种规范的功能性测试,因此我们需要引入assert来测试cgi_decode()的执行效果,是否与预期一致,如
可以正确的将'+'替换为' '
可以正确的替换"%xx"
可以正确的读取不需要替换的其他字符
可以识别出非法的输入

代码如下

assert cgi_decode('+') == ' '
assert cgi_decode('%20') == ' '
assert cgi_decode('abc') == 'abc'

try:
    cgi_decode('%?a')
    assert False
except ValueError:
    pass

可以正确运行,证明缩写的代码可以通过测试

而黑盒测试的优点也是可以发现指定行为中的错误,允许在运行之前创建,测试人员不需要了解实现得细节,且测试人员和编程人员是相互独立的;
而缺点则是,黑盒测试并不能测试程序内部的特定功能的代码,也无法发现程序未执行的代码,即测试不能覆盖所有代码,比如并没有触发报错的语句的情况

所以就要引入白盒测试

0x03 白盒测试

与黑盒测试不同,白盒测试一般是可以直接在对开源代码进行测试,如果将黑盒测试比作是web中的漏洞扫描器的功能,那么白盒测试就是代码审计

白盒测试会尽可能实现对每一行语句的测试,所以白盒测试引入了一系列的代码覆盖标准,而被测试程序也必须达到这些标准才被认为是完成了测试

最常用的覆盖标准是
语句覆盖:代码中的每个语句都必须经过至少一次的输入测试

分支覆盖:对代码中的每一个存在分支的语句,其每个分支都要覆盖(比如if-else均要覆盖)
当然除此以外,还有更多的覆盖标准,包括对执行的循环迭代、变量定义等一系列代码的测试覆盖

所以,为了对cgi_decode()实现白盒测试,那么我们需要将代码中的每个语句都至少执行一次,我们需要对
if c == 0 中的语句块进行测试
if c == '%'中的情况进行测试,如一种是正确输入,一种是非法输入
要对最后一个else语句中的所有可能出现的字符情况进行测试

当然,这是与黑盒测试相同的条件,但是在大多数情况下的黑盒测试,并不能达到以上需要的左右条件,仅仅是最理想的情况下,才能和白盒测试有相同的成果

白盒测试的优点是,可以帮助软件测试人员增大代码的覆盖率。 提供代码的质量,发现代码中隐藏的问题,但缺点是并不能知道是否存在一些未实现的功能需求(当然,作为安全研究员,你不需要考虑这些

0x04 跟踪执行

白盒测试的一个很好的特性是可以自动评估是否所有的程序特性均被覆盖之子那个,所以接下来是模拟跟踪程序执行的每一步

引入python中的sys库中的sys.settrace(f)方法,允许定义一个跟踪函数f(),该函数可以对代码执行的每一行进行调用,并且他可以访问当前函数以及其名称,变量内容等,可以说是动态分析的理想工具

sys.settrace(f)有三个参数:
frame参数获取当前函数结构,允许访问当前运行的位置和变量:
frame.f_code是当前的函数执行的代码,frame.f_code.co_name可以获得函数的名称,
frame.f_lineno指向当前运行的函数每一行对应的行号
frame.f_locals表示当前函数内部的的局部变量和参数

even参数是一个字符串,包括line(已经到达的新一行)以及call(一个正在被调用的函数)

arg参数返回的是一些事件的附加参数,对于return返回的参数,arg会保存这个返回值

我们使用这个追踪函数简单的跟踪并汇报当前执行的行的相关信息,我们可以使用frame参数访问该行

coverage = []

def traceit(frame, event, arg):
    if event == "line":
        global coverage
        function_name = frame.f_code.co_name
        lineno = frame.f_lineno 
        coverage.append(lineno)
    return traceit

我们现在使用sys.settrace()开始或者结束追踪

import sys

def cgi_decode_traced(s):
    global coverage
    coverage = []
    sys.settrace(traceit)  # Turn on
    cgi_decode(s) #对传入的url进行解码
    sys.settrace(None)    # Turn off

我们先执行

cgi_decode("a+b")

a b
看不出中间执行的每一步

现在我们再执行跟踪a+b的解析过程,

cgi_decode_traced("a+b")
print(coverage)

接下来有趣的事情发生了,输出的是coverage的值
[8, 9, 10, 11, 14, 15, 16, 17, 18, 20, 28, 29, 16, 17, 18, 19, 29, 16, 17, 18, 20, 28, 29, 16, 30]

打印出了执行的每一行的行号,可以看出
16, 17, 18, 20, 28, 29,
重复出现了3次
对应的即为代码中的每一行,也就是整个while循环
Image4-1.png
跟踪并打印出了cgi_decode()函数的执行过程

当然,有些时候在linux内部直接编辑代码,未必可以清楚地知道每一行对应的是什么,或者说,代码过长,一行一行去搜索太麻烦,可以直接将源代码按行作为一个数组读入

cgi_decode_code = 
"""def cgi_decode(s):
         hex_values={
               '0': 0, '1': 1, '2': 2, '3': 3, '4': 4,
               '5': 5, '6': 6, '7': 7, '8': 8, '9': 9,
               'a': 10, 'b': 11, 'c': 12, 'd': 13, 'e': 14, 'f': 15,
               'A': 10, 'B': 11, 'C': 12, 'D': 13, 'E': 14, 'F': 15,   
               }    
          t=""
          i=0
          while i<len(s):
               c=s[i] 
               if c=='+': 
                   t+=' ' 
              elif c=='%':
                   if s[i+1] in hex_values and s[i+2] in hex_values:
                      v=hex_values[s[i+1]]*16+hex_values[s[i+2]]*16       
                     t+=chr(v)
                 else:
                   raise ValueError("Invalid encoding")
            i += 2
            else:
                t+=c
          i+=1
      return t
"""
cgi_decode_lines = cgi_decode_code.splitlines()#将代码按行拆分

你可以输入对应的行号,查看相对的代码
比如

print(cgi_decode_lines[3:7])

["'0': 0, '1': 1, '2': 2, '3': 3, '4': 4,", "'5': 5, '6': 6, '7': 7, '8': 8, '9': 9,", "'a': 10, 'b': 11, 'c': 12, 'd': 13, 'e': 14, 'f': 15,", "'A': 10, 'B': 11, 'C': 12, 'D': 13, 'E': 14, 'F': 15,"]


[+] 这里有个坑,作者并没有把代码写全,一开始我一度以为,直接运行print(cgi_decode_lines[3:7]),可以得到函数的整个循环体,实际不是,如果想要知道得到和原来代码行号完全对应的行数,需要将所有代码全部写入字符串内部,
否则cgi_decode_lines[x:y])的对应的行号所获得的结果和cgi_decode()函数的获得的行号对应的结果一定是不一致的,简而言之,
在此之前,我们写的代码中

cgi_decode_traced("a+b")
print(coverage) #打印出来的是,所覆盖的代码在全局代码中的绝对行号,而并非函数体内部的相对行号

如果想看到哪些行至少被运行了一次,那么可以将coverage转换为集合,删除重复的输出

covered_lines = set(coverage)
print(covered_lines)

运行结果
{8, 9, 10, 11, 13, 14, 15, 16, 17, 18, 19, 27, 28, 29}

接下来可以将函数体打印出来,并将未被覆盖的代码前置#标记,然后输出
代码如下

for lineno in range(1, len(cgi_decode_lines)):
    if lineno not in covered_lines:
        print("# ", end="")
    else:
        print("  ", end="")#为了对齐没有被覆盖的行输出的"#",利用空格补全
    print("%2d" % lineno, cgi_decode_lines[lineno])

输出

#  1 def cgi_decode(s):
#  2     hex_values={
#  3         '0': 0, '1': 1, '2': 2, '3': 3, '4': 4,
#  4         '5': 5, '6': 6, '7': 7, '8': 8, '9': 9,
#  5         'a': 10, 'b': 11, 'c': 12, 'd': 13, 'e': 14, 'f': 15,
#  6         'A': 10, 'B': 11, 'C': 12, 'D': 13, 'E': 14, 'F': 15,
#  7     }
   8 
   9     t=""
  10     i=0
  11     while i<len(s):
# 12         c=s[i]
  13         if c=='+':
  14             t+=' '
  15         elif c=='%':
  16             if s[i+1] in hex_values and s[i+2] in hex_values:
  17                 v=hex_values[s[i+1]]*16+hex_values[s[i+2]]*16
  18                 t+=chr(v)
  19             else:
# 20                 raise ValueError("Invalid encoding")
# 21             i += 2
# 22         else:
# 23             t+=c
# 24         i+=1
# 25     return t

0x05 一个覆盖类

接下来作者介绍了一个覆盖类,把原本写为作为全局函数的追踪函数封装起来,方便接下来调用

为了更直观的获得所运行函数的相对行号,引入了一个局部的思想,这里作者引入了with-as语句

with OBJECT [as VARIABLE]:
    BODY

我就不解释作者的解释了,还不如看官方文档,

基本思想是with所求值的对象必须有一个__enter__()方法,一个__exit__()方法。
紧跟with后面的语句被求值后,返回对象的__enter__()方法被调用,这个方法的返回值将被赋值给as后面的变量。当with后面的代码块全部被执行完之后,将调用前面返回对象的__exit__()方法。

附上一份我觉得不错的解释:with-as语句简单用法

基于此,我们可以定义一个Coverage对象,使用Coverage.__enter__()自动开始追踪函数,Coverage.__exit__()自动终止追踪函数,追中后,我们可以用特殊方法来获得覆盖的语句

当然,作者自己也说了,下面这个类,并不需要完全读懂,但一定要知道怎么去用

class Coverage(object):
    # Trace function 追踪体
    def traceit(self, frame, event, arg):
        if self.original_trace_function is not None:
            self.original_trace_function(frame, event, arg)

        if event == "line":
            function_name = frame.f_code.co_name
            lineno = frame.f_lineno
            self._trace.append((function_name, lineno))#获取当前函数名称以及行号

        return self.traceit 

    def __init__(self):
        self._trace = []

    # Start of `with` block,with开始后,执行本句
    def __enter__(self):
        self.original_trace_function = sys.gettrace()
        sys.settrace(self.traceit) #开始追踪函数
        return self  #返回值赋值给as后面的变量cov

    # End of `with` block
    def __exit__(self, exc_type, exc_value, tb):
        sys.settrace(self.original_trace_function)

    def trace(self):
        """The list of executed lines, as (function_name, line_number) pairs"""
        return self._trace #获取返回的(函数名称,行号)

    def coverage(self):
        """The set of executed lines, as (function_name, line_number) pairs"""
        return set(self.trace())  #对返回的(函数名称,行号)组成的列表中相同的部分进行删除

这里是我们调用的方法

先执行__enter__(),
并将返回值赋给cov,然后执行cgi_decode("a+b"),
最后执行 __exit__()
with Coverage() as cov: 
    cgi_decode("a+b")

print(cov.coverage())

输出结果
{('cgi_decode', 18), ('cgi_decode', 17), ('cgi_decode', 11), ('cgi_decode', 16), ('cgi_decode', 29), ('cgi_decode', 10), ('cgi_decode', 28), ('cgi_decode', 9), ('cgi_decode', 8), ('__exit__', 122), ('cgi_decode', 15), ('cgi_decode', 14), ('cgi_decode', 27), ('cgi_decode', 13), ('cgi_decode', 19)}

0x05 比较不同输入值的覆盖率

上面已经把Coverage类创建完毕,现在可以开始比较不同输入(如a+babc)所运行的代码范围有什么不同

我们不需要全部输出然后使用肉眼观察法(虽然我很喜欢这个方法,大笑)进行对比,可以直接获得运行中的不同部分

with Coverage() as cov_plus:
      cgi_decode("a+b")
with Coverage() as cov_standard:
     cgi_decode("abc") 
print(cov_plus.coverage() - cov_standard.coverage())

输出结果
{('cgi_decode', 18)}
可以得出第18行(t+=' ')只有输入a+b时执行了

那么有意思的事情来了,我们可以自己定义一个cov_max,即对代码中的每一行都执行的全覆盖,类似于白盒测试中的覆盖思想

with Coverage() as cov_max:
    cgi_decode('+') 会调用第一个if
    cgi_decode('%20')#elif中的if
    cgi_decode('abc')#调用else
    try: #调用elif中的else并捕获异常
        cgi_decode('%?a')
    except:
        pass

得出结果
{('cgi_decode', 22), ('cgi_decode', 25), ('cgi_decode', 24), ('cgi_decode', 21), ('cgi_decode', 20)}

Last modification:January 19th, 2019 at 10:20 pm
If you think my article is useful to you, please feel free to appreciate

Leave a Comment