Tattoo的文章

  • 共识算法:实用拜占庭容错算法(PBFT)


    本文原作为:fatcat22,如需转载请注明来源。原文地址:https://yangzhe.me/2019/11/25/pbft/

    最近在学习区块链方面的知识,看到这篇博文讲得很好,故转载分享!
    引言在之前的文章中,我们介绍了什么是「拜占庭将军问题」,也了解了原论文中的解决方案。但那些方案只是理论上的解决办法,若真的在现实中使用就会有各种各样的问题。因此在那之后,又提出了一些可在真实生产环境中使用的解决拜占庭将军问题的方法,本文将要介绍的「实用拜占庭容错算法」(Practical Byzantine Fault Tolerance, 简称 PBFT)就是其中一种。
    从名称上就可以看出,PBFT 就是冲着可在实际应用中使用而发明的。这篇文章里,我们将详细了解一下 PBFT 算法的相关细节。文章中的多数讨论来自于原始论文,不过这篇文章写得也很不错,给了我一些启发。
    系统模型在介绍 PBFT 的算法步骤之前,我们先了解一下使用 PBFT 算法的分布式系统的概念,和算法对系统的一些要求。

    首先,PBFT 对系统内的结点数量是有要求的。与拜占庭将军问题类似,PBFT 要求系统内的结点数量 nnn 不小于 3f+13f+13f+1,其中 fff 为「恶意结点」的数量。这里的「恶意结点」可以是故意作恶的结点,也可以是被攻击被控制的结点,甚至是失去响应的结点,总之只要是不正常的,都可以认为是恶意的。
    其次,PBFT 将系统内的每个结点分成了两类:主结点和从结点。任一时刻内,只有一个主结点,其它结点都是从结点。但主结点是可以被更换的(更换主结点被称为「域转换」(View Change))。无论是主结点还是从结点,他们都使用状态机机制记录自己的操作。如果各结点的操作是一致的,那么它们的状态机的状态会一直保持一致。
    再次,向 PBFT 系统发送请求的端叫做「客户端」。当某个客户端想要向系统发送请求时,一般情况下,它会将请求发给当前的主结点;非一般情况下,它会将请求广播给所有结点。无论哪种情况,客户端都直接从各个结点(包括主结点)接收请求返回的数据。
    第四,在论文中,作者假设客户端等待上一个请求完成以后,才会发送下一个请求。也就是说主结点和从结点们在某一时刻只会处理一个请求。这是一种同步发送请求的方式。如果客户端不等上一个请求返回就再次发送请求(即异步发送请求),那么请求的响应顺序可能不会是客户端发送的顺序。
    第五,PBFT 中有一个「域」( view )的概念(一般翻译成「视图」,但我觉得「视图」这个词并不能表达原术语的意思,所以我大胆将它翻译成「域」)。某个结点担任主结点的过程,就是一个域。如果担任主结点的结点发生了变化,就是发生了「域转换」(View Change)。域是有编号的,每发生一次域转换,域编号就递增一次。如果将每个结点从 0 开始编号,那么我们可以通过算式 i=v mod ∣R∣i=v \space mod \space |R|i=v mod ∣R∣得到当前主结点的编号 iii:其中 vvv 为当前的域编号, ∣R∣|R|∣R∣ 为结点数量。(如果把「域」比作「朝代」,可能会比较好理解一些:一个结点开始担任主结点,表示一个朝代的开始;主结点发生变更时,表示一个朝代的变更,朝代号就是加 1)
    最后,PBFT 中各结点通信信息是经过签名的。也就是说,任何一个结点都无法修改别的结点发送的消息,只能原封不动的拷贝、转发或存储。所以可以想象一下, PBFT 算法与介绍拜占庭将军问题的文章中的 SMSMSM 算法应该是有相同的地方的。

    以上就是 PBFT 系统模型的要点。看完这些你可能似懂非懂,心中有很多疑问题。比如为什么需要 3f+13f+13f+1 个结点?域到底起什么作用?这些问题我们会在后面作解答。这里只需了解这些概念和限制,相信在后面理解算法的过程中,很多问题自然就会消散了。

    在这里谈下我自己的理解:为什么n>3f?(n是节点总数,f是恶意节点)
    我们要在收到n-f条消息的时候,就必须做出决定,因为若有f个节点可以不发消息,这时节点最多能接收到n-f条消息。所以,我们必须要在接收到n-f条消息时做出决定。
    当在通信网络中,节点接收到n-f条消息时,最坏的情况是未接收到的消息都是诚实节点发的,而接收到的消息中有f条消息正好是恶意节点发的,那么这时要想做出少数服从多数的正确决定,诚实节点消息必须多于恶意节点消息:(n-f)-f>f。

    如果我来发明这个算法…我发现很多「高大上」的东西,其底层的逻辑通常都很朴素,并没有复杂到像我这样的普通人永远也想不出来的程度。所以我很喜欢在理解了某问题的解决方法以后,再假设我不知道这个方法但仍然遇到了问题,然后把解决方法「自己想出来」。所以这里我决定在文章里来这么一次。
    前面我们已经给了应用 PBFT 的系统的一些概念和限制了,总结一下就是:正常情况下,客户端发送请求给主节点,然后等待各个从节点返回数据。而我们要做的是,保证多数节点(无论是主结点还是从结点)返回一致的数据给客户端。
    OK,现在就让我们来想象一下这个过程。现在客户端发送数据给主结点了,主结点该怎么办呢?它不但要自己响应这一请求,还要把这一请求发送给所有从结点,让所有从结点进行响应。所以这里主结点做了两件事:

    一是自己响应客户端的请求
    二是将客户端的请求转发给所有从节点

    现在每个从结点都收到了主结点转发的请求,那么从结点们应该开始响应这个请求吗?注意这是一个无法信任某个人的世界,所以从结点们不知道主结点是不是可信,它们不会直接执行收到的请求的。那从结点们该怎么办呢?
    答案就是「少数服从多数」:如果从结点可以确定其它大多数从结点收到的请求与自己收到的请求是一样的,那么就可以响应这个请求。所以从结点们一边将自己收到的请求广播给其它从结点,一边收取并记录其它从结点广播过来的请求。当某个从结点收集到了足够多的从结点广播的请求,并且这些请求与自己从主结点那里收到的一致,它就认为主结点是可信的、这个请求是可以响应的。(这一过程与拜占庭将军问题的论文中的 SMSMSM 算法很像,具体可参考之前的文章)。
    现在收集到足够多的从结点可以确定主结点是可信的,那么它是否可以立即执行这个请求呢(在 SMSMSM 算法中确实是立即执行的)?答案是不可以。虽然某个从结点确认了请求是可以响应的,但它并不能确定别的从结点也确认了。所以如果此时立即执行请求,我们并不能保证结点间的状态一致。举个例子,比如可能有某个从结点由于暂时的网络问题,只能向外广播消息,却收不到其它结点的消息。因此这个结点就收不到足够多的其它从结点广播的请求,因而也不会认为这个请求是可以响应的。最终的结果是有的结点响应了这个请求,有的没有响应,无法保证结点间的状态是一致的。
    那怎么办呢?既然无法确定别的结点是否确认了这个消息是可响应的,那就确定一下呗。所以从结点需要多做一步,从结点此时并不马上响应请求,而是给所有其它结点广播一条消息:「我认可了某某请求」。然后它开始等待其它从结点的同样的消息。如果某从结点发现大多数结点都发出了这样一条消息,它就确定大多数结点认可这一请求是可以响应的(而不像刚才那样只知道自己认可是可响应的,别人是否认可它不知道)。所以现在它可以愉快的执行并响应这一请求。如果所有正常的结点都这样做,那么所有正常的结点都知道自己可以响应这一请求,也知道其他多数结点也同意响应这个请求,那么最后大多结点的状态在响应完这个请求后,仍然是一致的。
    稍微总结一下这一过程,你会发现各结点先是做了两步:

    第一步是广播「认可当前请求」的消息、并从其它结点那接收同样的消息
    第二步是广播「我认可了某某请求」的消息、并从其它结点那接收同样的消息。然后当接收到多数结点发送的「我认可了某某请求」消息时,才真正执行和响应请求

    这就是我能想像出来的保证多数结点状态一致的过程,也是白话版的 PBFT 算法的主要过程。当然刚才描述的这个过程仍有不少问题需要解决(比如当前主结点是恶意的怎么办?再比如即使收到了大多数结点发送的「我认可了某某请求」,但因为一些原因仍未执行请求怎么办?),但主要的流程就是刚才描述的那样,其它问题只不过是对一些异常状态的处理而已。
    其实各结点「两步走」的想法,和建立 TCP 连接的「三次握手」是非常相似的:虽然我能确定对方是正常的,但我确定不了对方对我的看法(对方是否认为我是正常的),所以只得再增加一步,以完成这一确定。
    说完了我自己的朴素的 PBFT 的逻辑,下面我们就来看看真正的 PBFT 是什么样子的。
    PBFT 算法下面我们就来看看真正的 PBFT 算法是什么样子的。我们首先了解一下在正常情况下,PBFT 是如何执行的;然后介绍一下 检查点(checkpoint)的概念;最后学习一下异常发生时的域转换(View Changes)的逻辑,和主结点不响应这种异常情况的处理方法。(其实检查点不算是 PBFT 的一个重点的概念,并且也很好理解。但要想理解域转换,就需要先理解检查点的概)
    正常情况下的流程这一小节里我们来了解一下在正常情况下 PBFT 算法的流程。但在这之前,我们需要先介绍一下后面用到的一些符号的意义。

    首先,我们在文章的开头提到过,PBFT 系统中各结点的消息是经过签名的,所以在下面的描述中,我们使用 <m>σi<m>_{\sigma i}<m>​σi​​ 代表由结点 iii 签名的消息 mmm;使用 D(m)D(m)D(m) 或 ddd 代表消息 mmm 哈希。
    其次,前面也说过,在 PBFT 中每一个域都有一个编号,后面我们记为 vvv。当发生域转换时,vvv 会递增加 1。每个结点也有一个编号,一般记为 iii,结点的编号从 0 开始,直至结点总数 ∣R∣−1|R|-1∣R∣−1。所以我们可以使用 i=v mod ∣R∣i=v \space mod \space |R|i=v mod ∣R∣ 来计算当前哪个结点是主结点。

    PBFT 的核心是三个阶段:pre-prepare、prepare、commit。所以这里我们先单独介绍一下这三个阶段,然后再看一下整体的正常流程。
    PBFT 三阶段PBFT 的三个阶段按执行顺序是 pre-prepare 阶段、 prepare 阶段、 commit 阶段。pre-prepare 阶段起始于主结点收到客户端的请求以后。下面我们详细了解一下每个阶段的细节。小节的最后我们会放上原始论文中的示意图,在经过说明之后,这个示意图会更加容易明白。
    pre-prepare主结点收到客户端发送的请求之后,开始 pre-prepare 阶段。首先主结点ppp 为收到的请求分配一个序号,记为 nnn(nnn 必须未被分配给其它请求,且比最近一次的请求的序号大)。然后广播消息 <<PRE−PREPARE,v,n,d>σp,m> <<PRE-PREPARE,v,n,d>_{\sigma p},m><<PRE−PREPARE,v,n,d>​σp​​,m> 给所有从结点。其 mmm 为结点收到的客户端请求; vvv 代表当前的域编号(view number);nnn 为刚才分配给 mmm 的序号;ddd 为 mmm 的哈希值。
    注意这里的参与签名的 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息中,并不包含 mmm 本身。这是因为一方面 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息可能在域转换时还会被用到,而那时并不需要重新发送 mmm 本身;另一方面,将 mmm 与 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息分开,可以更方便针对某些情况做优化,比如在 mmm 较大时使用更好的方式发送 mmm 本身。
    当从结点收到 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息后,会对其进行验证,满足以下条件才会通过验证:

    mmm 的签名和 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息的签名都是正确的,且 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息中的 ddd 确是 mmm 的哈希
    从结点当前的域编号与 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息中的一致,都为 vvv
    从结点之前没收到过相同的 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息
    在 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息中的 nnn 在区间 [h, H] 内

    最后一条可以避免恶意的主结点滥用序号值,比如将序号值设置得非常大。我们在介绍 checkpoint 时会说明如何设置 hhh 和 HHH 的值。
    prepare如果某个从结点验证通过了某条 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息,那么它将进入 prepare 阶段,并广播消息 <PREPARE,v,n,d,i>σi <PREPARE,v,n,d, i>_{\sigma i}<PREPARE,v,n,d,i>​σi​​ 。(如果 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息验证不通过,就忽略它,什么也不做)
    在从结点发出 <PREPARE><PREPARE><PREPARE> 消息的同时,它也会接收别人广播过来的 <PREPARE><PREPARE><PREPARE> 消息,并对其进行验证。满足以下条件才会通过验证:

    <PREPARE><PREPARE><PREPARE> 消息的签名是正确的
    从结点当前的域编号与 <PREPARE><PREPARE><PREPARE> 消息中的一致,都为 vvv
    在 <PREPARE><PREPARE><PREPARE> 消息中的 nnn 在区间 [h, H] 内

    这里我们需要定义一个函数 prepared(m,v,n,d,i)prepared(m,v,n,d,i)prepared(m,v,n,d,i):如果某结点 iii 验证通过了以下数据:

    mmm 本身
    关于 mmm 的 <PRE−PREPARE,v,n,d> <PRE-PREPARE,v,n,d> <PRE−PREPARE,v,n,d> 消息
    2f 条其它结点(不包含自己)发送过来的、与 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息相匹配的 <PREPARE><PREPARE><PREPARE> 消息(匹配的定义是,它们的vvv、nnn 以及 mmm 的哈希 ddd 是完全一样的)

    我们就定义 prepared(m,v,n,d,i)prepared(m,v,n,d,i)prepared(m,v,n,d,i) 的值为 truetruetrue,否则为falsefalsefalse。
    commit如果对于某个结点 iii ,prepared(m,v,n,d,i)prepared(m,v,n,d,i)prepared(m,v,n,d,i) 为 truetruetrue,那么这个结点将进入commit 阶段,并广播消息 <COMMIT,v,n,i>σi <COMMIT,v,n,i>_{\sigma i}<COMMIT,v,n,i>​σi​​ 。
    类似于 prepare 阶段,从结点发送 <COMMIT><COMMIT><COMMIT> 消息的同时,也会接收别的结点广播过来的 <COMMIT><COMMIT><COMMIT> 消息,并对其进行验证。满足以下条件才会通过验证:

    <COMMIT><COMMIT><COMMIT> 消息的签名是正确的
    从结点当前的域编号与 <COMMIT><COMMIT><COMMIT> 消息中的一致,都为 vvv
    在 <COMMIT><COMMIT><COMMIT> 消息中的 nnn 在区间 [h, H] 内

    类似于 prepare 阶段,我们这里也要定义一个函数 committed−local(m,v,n,d,i) committed-local(m,v,n,d,i) committed−local(m,v,n,d,i):如果某结点 iii 满足以下条件:

    要求 prepared(m,v,n,d,i) prepared(m,v,n,d,i) prepared(m,v,n,d,i) 的值为 truetruetrue
    验证通过了 2f 条其它结点(不包括自己)发送过来的、与 <PREPARE><PREPARE><PREPARE> 消息相匹配的 <COMMIT><COMMIT><COMMIT> 消息(匹配的定义是,它们的vvv、nnn 以及 mmm 的哈希 ddd 是完全一样的)

    我们就定义 committed−local(m,v,n,d,i) committed-local(m,v,n,d,i) committed−local(m,v,n,d,i) 为 truetruetrue,否则为 falsefalsefalse。
    如果某结点 iii 的 committed−local(m,v,n,d,i) committed-local(m,v,n,d,i) committed−local(m,v,n,d,i) 为 truetruetrue,那么它就可以响应请求 mmm、将结果更新到自己的状态机中、并返回结果给客户端了。
    下面就是原始论文中使用的三阶段的示意图,其中 0 是主结点,3号是恶意结点不对请求进行响应(我一开始看这个图是有些懵的,但明白了整个过程以后再看,就很清楚了):

    正常情况下的完整流程介绍完了最核心的三阶段,我们将其放在整个 PBFT 的流程中,看一下在不出意外的正常情况下,PBFT 的完整流程:

    客户端向主结点发起请求,记为 <REQUEST,o,t,c>σc <REQUEST,o,t,c>_{\sigma c} <REQUEST,o,t,c>​σc​​ 。其中 ooo 代表客户端请求的操作( operation );ttt 代表时间戳;ccc 为客户端自己的标识。这里通过 ttt 来保证同一请求只会被发送和处理一次:如果主结点收到两个完全一样的请求,它将丢弃重复的请求;如果同一操作需要先后执行两次,客户端应该先后构造两个请求,且这两个请求的时间戳是不一样的。
    主结点收到请求后,立即启动三阶段的共识过程,让所有从结点参与请求的处理。三阶段的共识过程就是我们前面介绍的 pre-prepare、prepare、commit。
    三阶段执行完成后,如果对于某一结点 iii, committed−local(m,v,n,d,i)committed-local(m,v,n,d,i)committed−local(m,v,n,d,i) 的值为 truetruetrue,则结点开始执行请求 mmm。执行成功后更改自己本地状态机的状态,并将结点直接返回给客户端。
    针对同一请求,如果客户端收到了 f+1 个相同的返回结果,那么它就把这个结点作为最终的结果。

    这就是 PBFT 在正常情况下的完整流程。
    在第 3 步中,你可能会有疑问,虽然对于结点 iii, committed−local(m,v,n,d,i)committed-local(m,v,n,d,i)committed−local(m,v,n,d,i) 为 truetruetrue,即结点 iii 确实可以响应 mmm 了,但结点 iii 如何确定其它结点的 committed−locallocalcommitted-locallocalcommitted−locallocal 为 truetruetrue 了呢?如果大多数其它结点的 committed−locallocalcommitted-locallocalcommitted−locallocal 不为 truetruetrue,它们就不会响应 mmm,那么结点 iii 的状态岂不是与其它结点的状态不一致了吗?
    确实,在第 3 步中,某个正常结点的 committed−locallocalcommitted-locallocalcommitted−locallocal 为 truetruetrue,并不真正代表其它所有正常结点的 committed−locallocalcommitted-locallocalcommitted−locallocal 都为 truetruetrue,但这其实是一种特殊情况,而不是正常情况。正常情况下,某结点发送了 <COMMIT><COMMIT><COMMIT> 消息且验证通过了 2f 条其它结点发送的 <COMMIT><COMMIT><COMMIT> 消息(因而它的 committed−locallocalcommitted-locallocalcommitted−locallocal 为 truetruetrue),其它正常结点应该也可以接收到 2f 条 <COMMIT><COMMIT><COMMIT> 消息、因而也可以达到 committed−locallocalcommitted-locallocalcommitted−locallocal 为 truetruetrue 的状态。
    这一小节里我们并没有讨论「特殊情况」下的解决方法(所以小节的名字才叫「正常情况下的完整流程」)。那么要解决刚才的疑问中的特殊情况(包括其它特殊情况,比如主结点是恶意的),需要靠后面的小节介绍的域转换才行。
    检查点( checkpoints )在介绍域转换之前,我们首先要介绍一下 checkpoint 的概念。一般情况下,每个结点需要保存所有的历史数据和状态机的每个历史状态,以便在需要的时候可以复查。但随着系统运行的时间越来越长,数据就会越来越多,最终肯定有存不下的情况。所以我们需要一种机制,可以放心的丢弃以前的历史数据,又不致于影响系统的运行和结点间的信任。
    原始论文中给出的解决方法就是检查点(checkpoints)机制。所谓「检查点」,其实就是某一时刻结点状态机的状态。系统中的每个结点都会按相同的时期间隔(比如每隔 100 个请求序号)将状态机的状态创建为检查点。
    如果某结点自顾自的创建的一个检查点,那么这个检查点是没有可信度的,只有它自己才相信这个检查点的正确性。因此如果一个结点想要创建一个别人都信服的检查点,除了创建检查点本身,还要创建这个检查点的「证明」,这个证明说明了大多数结点都认可了这个检查点。一个有证明的检查点,被称为「稳定检查点」(stable checkpoint)。只有稳定检查点之前的历史数据,才是可以放心删除的。
    那么如何创建稳定检查点呢?假设一个结点 iii 最新处理的请求序号为 nnn,此时状态机的状态的哈希值为 ddd。它想在此时创建一个稳定的检查点,那么它将创建并广播消息 <CHECKPOINT,n,d,i>σi <CHECKPOINT,n,d,i>_{\sigma i} <CHECKPOINT,n,d,i>​σi​​ 。同时它将收集其它结点广播的 CHECKPOINTCHECKPOINTCHECKPOINT 消息,如果它收到 2f 条不同结点广播出来的、序号为 nnn、状态哈希为 ddd 的 CHECKPOINTCHECKPOINTCHECKPOINT 消息,那么它就创建了一个稳定检查点,这 2f+1 条消息(包含自己的消息)就是这个稳定检查点的证明。
    前面我们也提到了一个区间 [h, H] ,这个区间主要是为了防止恶意主结点滥用序号值。但我们没提过如何设置 h 和 H 的值。有了稳定检查点,这两个值就好设置了。论文中提出可以将 h 设置为当前最新的稳定检查点的序号值,而 H 设置一个相对较大的值,保证正常情况下在创建下一个稳定检查点时,其序号值也不会超过 H。比如,如果系统正常情况下每隔 100 个请求序号就创建一个检查点,那么 H 可以设置为 200。
    了解了检查点的概念,下面我们再来看看域转换时会发生什么。
    域转换(View Changes)前面我们提到过,所谓「域」,就是某个结点作为主结点的整个过程(就像中国古代「朝代」的概念)。每个域都有一个编号。每当主结点发生一次变换,域编号就递增加 1,这个过程也就是所谓的「域转换」。可以看到,其实域转换主要是主结点的变换。
    为什么要设计「域」这个概念呢?这主要是为了保持整个系统的「活性」(liveness)。前面我们说过, PBFT 的系统模型就是主结点、从结点的模式,这种模式严重依赖主结点的正确性:如果主结点是正常的,那么它会给客户端请求分配正确的序号,然后将正确的 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息广播给所有从结点,各结点才能最终达成一致共识;如果主结点是恶意的,它就可能给各从结点发送各不相同的 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息,那么从结点肯定无法达成共识。可见如果没有机制可以将这样的恶意主结点换掉,那么不管有多少个正常的从结点,这个系统依然是废的,永远无法处理任何请求。所以在设计 PBFT 更换主结点的能力时,作者定义了「域」的概念,并将更换主结点的过程定义为「域转换」。
    那么什么时候会发生域转换呢?其实任何会发生异常的地方,都有可能导致发生域转换,总结下来,主要有以下情况可能会发生异常:

    从结点发送 <PREPARE><PREPARE><PREPARE> 消息后,在一定时间内没有收到 2f 条其它结点广播的同样的 <PREPARE><PREPARE><PREPARE> 消息
    从结点发送 <COMMIT><COMMIT><COMMIT> 消息以后,在一定时间内没有收到 2f 条其它结点广播的同样的 <COMMIT><COMMIT><COMMIT> 消息
    主结点不响应客户端的请求

    上面三种情况总结起来,其实都是「超时」(第三种情况其实是主结点不响应的情况,我们会在下一小节详细讨论)。可以说任何该发生却没有发生的情况,都会引起域转换。
    下面我们来看看如何进行域转换。对于任一结点 iii,假设它当前的域编号是 vvv。如果发生了前面提到的超时情况,则结点 iii 就会发起域转换,具体步骤如下:

    结点 iii 暂时停止接收和处理消息(除了 <CHECKPOINT><CHECKPOINT><CHECKPOINT>、<VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE>、<NEW−VIEW><NEW-VIEW><NEW−VIEW> 三种消息),并广播消息 <VIEW−CHANGE,v+1,n,C,P,i>σi <VIEW-CHANGE,v+1,n,C,P,i>_{\sigma i}<VIEW−CHANGE,v+1,n,C,P,i>​σi​​ 。其中 nnn 是结点 iii 最新的稳定检查点的请求序号;CCC 是 nnn 对应的稳定检查点的证明(2f+1 条 <CHECKPOINT><CHECKPOINT><CHECKPOINT> 消息);PPP 是一个集合,集合中的每一项为某结点 iii 中序号大于 nnn 且处于 preparedpreparedprepared 状态的请求的信息 PmP_mP​m​​。这里 PmP_mP​m​​ 其实就是 mmm 对应的 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息和 2f 条相匹配的 <PREPARE><PREPARE><PREPARE> 消息。
    当域编号为 v+1v+1v+1 的主结点 ppp(此时它其实还不是主结点)收到 2f 条步骤 1 中发送的 <VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE> 消息,则它广播一条消息 <NEW−VIEW,v+1,V,O>σp <NEW-VIEW,v+1,V,O>_{\sigma p}<NEW−VIEW,v+1,V,O>​σp​​ 给所有其它结点。
    其中 VVV 是新主结点 ppp 收到的 2f+1 条(包括自己的) <VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE> 消息的集合;OOO 是一些 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息的集合。集合 OOO 是这样计算出来的:

    1.主结点 ppp 计算出一个请求的序号范围。其中最小值我们记为 min−smin-smin−s,它的值为 VVV 中所有稳定检查点的请求序号的最大值;最小值我们记为 max−smax-smax−s,它的值为 VVV 中所有处于 preparedpreparedprepared 状态的请求的序号的最大值。
    2.主结点 ppp 使用新的域编号 v+1v+1v+1 为每一个序号位于 [min−s,max−s][min-s,max-s][min−s,max−s] 范围内的请求创建一个 PRE−PREPAREPRE-PREPAREPRE−PREPARE 消息,并将其加入到集合 OOO 中。创建 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息时,假设当前的序号为 nnn,有两种情况:

    (1).序号 nnn 对应的请求信息存在于集合 PPP 中。此时主结点 ppp 创建如下消息:<PRE−PREPARE,v+1,n,d>σp <PRE-PREPARE,v+1,n,d>_{\sigma p}<PRE−PREPARE,v+1,n,d>​σp​​。其中 ddd 是集合 VVV 中序号为 nnn 且域编号最大的 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息对应的请求的哈希。
    (2).序号 nnn 对应的请求信息不在 PPP 中,这表明此消息已被成功处理。此时主结点 ppp 创建如下消息:<PRE−PREPARE,v+1,n,dnull>σp <PRE-PREPARE,v+1,n,d_{null}>_{\sigma p}<PRE−PREPARE,v+1,n,d​null​​>​σp​​。其中 dnulld_{null}d​null​​ 是一个特定的、空请求的哈希。(空请求在系统内的处理方式和正常请求一样,但它的操作为「无操作」,不会改变状态机的状态)


    对于新的主结点 ppp,在它发送 NEW−VIEWNEW-VIEWNEW−VIEW 后,就正常进入 v+1v+1v+1 域。对于其它从结点,在收到 v+1v+1v+1 域的 <NEW−VIEW><NEW-VIEW><NEW−VIEW> 消息后,会首先检查这个消息的正确性。如果正确,才会正式进入域 v+1v+1v+1。然后无论是主结点还是从结点,都会像前面描述的正常流程一样,开始处理集合 OOO 中的所有 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息。
    从结点通过检查 <NEW−VIEW><NEW-VIEW><NEW−VIEW> 的签名、集合 VVV 和 集合 OOO 是否正确,来判断 <NEW−VIEW><NEW-VIEW><NEW−VIEW> 消息是否正确(检查集合 OOO 的方法与创建的方法一样)。
    另外,如果各结点发现自己的最新稳定检查点落后于集合 VVV 中的最新稳定检查点,那么它也会保存集合 VVV 中的最新检查点,并将其作为自己最新的检查点,丢弃旧的数据。或者如果发现自己缺少请求 mm 本身的数据(还记得请求数据和 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 不是放在一起传输的吗),它可以从别的结点那里获取到 mmm 数据。

    经过这三步以后,域转换就完成了,从这之后所有结点就进入了新的域时代了。
    主结点不响应前面我们提到了域转换发生的条件,其中一条就是主结点不响应客户端请求的情况。试想如果一个主结点是恶意的,但它不是通过给不同从结点发送不同消息来作恶,而是不响应客户端的请求、不将请求发送给其它从结点。如果没有一种机制来应对这种情况,那么从结点永远也不知道客户端曾经发送过请求,也就永远不会发起域转换,这个系统也就永远瘫痪了。
    处理这种情况需要客户端的配合。如果客户端发现自己发出的请求没有或很少有结点返回数据,那么客户端可以认为主结点不响应。这时客户端就会将请求广播给所有从结点。每个从结点从客户端那直接收到 <REQUEST><REQUEST><REQUEST> 请求后,并不会马上处理,而是将请求转发给主结点,并等待与这个请求对应的、主结点发送的 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息。如果在一定时间内没有收到主结点的 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息,那么从结点就认为主结点没有响应,从而发送 <VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE> 消息,提议进行域转换;如果有足够多(2f+1)个结点提议进行域转换,就会真正进入域转换,从而将不响应的主结点换掉。
    (原论文中并没有讨论在主结点不响应时、进入域转换流程后,客户端当初广播给所有结点的请求是如何被处理的。依算法的情况来看,这个请求应该是被忽略了。客户端在发现仍然没有结点返回请求后,需要再次广播请求,直到结点们域转换完成,且换到了一个正常的结点当主结点)。
    关键问题分析前面我们详细的说明了 PBFT 算法的流程,但也只是说明了流程,很少提到为什么要这样做。这一小节里,我们就针对一些关键问题,聊一聊「为什么」。
    三阶段的作用其实 PBFT 的整个流程还是非常容易理解的(我个人认为比「拜占庭将军问题」论文里给出的方法好理解多了),但我看完这个算法的第一个反应是:为什么要分三个阶段呢?一个阶段不行吗?两个阶段不行吗?
    其实我们在之前的小节「如果我来发明这个算法」里已经提到过三阶段的原因了,但这里我们要说得更正式一些。
    首先再次提一下我自己的认识。pre-prepare 阶段,是主结点分发请求给所有从结点的阶段,这个过程必不可少,也很好理解。prepare 阶段,是各从结点「商量」达成一致的阶段,大家把自己的收到的消息公布出来,看看自己是不是属于「大多数」,如果是,理论上讲其实就可以放心的响应请求啦。commit 阶段,是确定别人的 prepare 阶段是否成功的阶段,这样就可以避免自己通过了 prepare 阶段响应了请求,而别人并没有通过 prepare 阶段、也就没有响应请求,从而造成了状态不一致的状况。
    这是我对三阶段中每阶段的作用的理解。若以原论文的描述为准,我感觉这些理解都不是很「正式」(但我自己觉得也没有错)下面我们看看原论文中的说法。
    其实仔细琢磨一下 PBFT,就会发现只要正常结点间执行请求的顺序是一致的,它们的状态就能保持一致。因此保持请求的执行顺序的一致性就很重要。而保持一致性的关键,是序号不会被重复使用,即如果某些正常结点认为请求 mmm 的序号是 nnn,那么其它正常结点就必须不能认为另一个请求 m′m^{\prime}m​′​​ 的序号是 nnn。pre-prepare 和 prepare 阶段的作用,就是在同一域内保证了这一点。比如说,在域 vvv 中,某一个或多个正常结点使用序号 nnn 来执行请求 mmm,即 prepared(m,v,n,i)prepared(m,v,n,i)prepared(m,v,n,i) 为 truetruetrue;那么可以确定,其它的正常结点一定不会使用序号 nnn 来执行另一个不同的请求 m′m^{\prime}m​′​​。
    为什么可以这么肯定呢?为了证明,我们先假设这种情况会发生,即存在一些正常结点 jjj 使用序号 nnn 执行另一个不同的请求 m′m^{\prime}m​′​​,即 prepared(m′,v,n,j)prepared(m^{\prime},v,n,j)prepared(m​′​​,v,n,j) 为 truetruetrue。根据我们前面了解的 PBFT 三阶段的算法,preparedpreparedprepared 为 truetruetrue 代表有 2f+1 个结点对某一消息的序号进行了相同的确认。这就是说,prepared(m,v,n,i)prepared(m,v,n,i)prepared(m,v,n,i) 为 truetruetrue 代表有 2f+1 个结点确认了 mmm 的序号是 nnn;prepared(m′,v,n,j)prepared(m^{\prime},v,n,j)prepared(m​′​​,v,n,j) 为 truetruetrue 代表有 2f+1 个结点确认了 m′m^{\prime}m​′​​ 的序号是 nnn。而结点的总数量为 3f+1,那么必定有 f+1 个结点对这两种情况都进行了确认。恶意结点最多有 f 个,那么对两种情况都进行了确认的 f+1 个结点中,至少有 1 个是正常结点,而这是不可能的。所以假设不成立,肯定不会存在其它正常结点使用序号 nnn 执行另一个不同的请求 m′m^{\prime}m​′​​ 的情况。
    但是正如我们所说,pre-prepare 和 prepare 仅能保证在同一域内请求顺序的共识。并不能保证跨域的情况下所有结点对请求的顺序仍能达成共识。假如某结点 iii 的 preparedpreparedprepared 函数为 truetruetrue 后立即执行了请求(即没有 commit 阶段),但其它结点的 preparedpreparedprepared 函数并不是 truetruetrue,因而它们发起了域转换,那么结果是虽然结点 iii 也被迫转到了新的域,但只有它使用序号 nnn 执行了请求 mmm ;对于新主结点来说,序号 nnn 可能还是未被分配的,所以当有新的请求时,新主结点就可能会将序号 nnn 分配给新的请求,结果就是仍然出现了我们刚才讨论的问题,即结点 iii 使用序号 nnn 执行了请求 mmm,但其它结点却使用序号 nnn 执行了新的请求 m′m^{\prime}m​′​​。
    这里你可能会说,域转换时会将 preparedpreparedprepared 为 truetruetrue 的请求 mmm 及序号 nnn 放到 <VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE> 中啊,这样根据前面讲的域转换的流程,新的主结点就会将这个消息的 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息再广播一遍,从而使这个请求再次正常执行一遍整个流程,序号 nnn 也不会被复用了。但这里执行了请求 mmm 的结点 iii 并没有发送 <VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE> 消息,它是被迫进行域转换的,而其它主动提议域转换的结点的 mmm 的 preparedpreparedprepared 函数并不为 truetruetrue,因此 <VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE> 消息中并不会包含请求 mmm 及序号 nnn 等信息。
    所以为了避免上面说的情况,需要加上 commit 阶段。commit 阶段和域转换算法一起,保证了达到了 committed−localcommitted-localcommitted−local 条件的请求,即使在发生域转换以后,在新的域里各结点依然能对消息的顺序达成共识。还是考虑刚才的情况,在结点 iii 内,committed−local(m,v,n,i)committed-local(m,v,n,i)committed−local(m,v,n,i) 为 truetruetrue ,然后结点 iii 执行了这个请求。但此时其它结点 committed−localcommitted-localcommitted−local 并不是 truetruetrue(因而肯定还没有执行这个请求),而是超时发起了域转换。只要发起域转换的结点中至少有一个结点的 prepared(m,v,n,i)prepared(m,v,n,i)prepared(m,v,n,i) 为 truetruetrue,那么 <NEW−VIEW><NEW-VIEW><NEW−VIEW> 中肯定包含了消息 mmm 和序号 nnn 的 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息,因而在新的域里面会再次使用序号 nnn 执行一遍关于请求 mmm 的正常流程,此时除了刚才结点 iii 之外的结点就会仍然使用序号 nnn 执行请求 mmm,从而达到与结点 iii 一致的状态(虽然不在一个域中)。
    我们如何能确定发起域转换的结点中至少有一个结点 prepared(m,v,n,i)prepared(m,v,n,i)prepared(m,v,n,i) 为 truetruetrue 呢?一方面,结点 iii 的 committed−local(m,v,n,i)committed-local(m,v,n,i)committed−local(m,v,n,i) 已经是 truetruetrue,也就是说它肯定收到了至少 2f 条其它结点的 <COMMIT><COMMIT><COMMIT> 消息,这也说明至少有 2f 个结点的 preparedpreparedprepared 为 truetruetrue。另一方面,如果域转换想要成功,必定至少有 2f 个结点主动发送 <VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE> 消息。总结点数量为 3f+1,所以 2f 个 preparedpreparedprepared 为 truetruetrue 的结点和 2f 个发送 <VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE> 消息的结点中,必须有重合的。也就是说,肯定有结点它的 preparedpreparedprepared 为 truetruetrue 并且发送了 <VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE> 消息。
    以上就是三阶段的作用的分析。总得来说,pre-prepare 和 prepare 阶段保证了在同一域内请求的执行顺序;commit 阶段和域转换规则保证了在不同域内,请求的执行顺序仍然是不变的。
    相信经过上面一段详细的说明,读者对三个阶段的作用会有更加深刻的认识。
    安全性(Safety)和活性(Liveness)之前我们在讨论 PBFT 的各个方面时,其实已经涉及到安全性和活性的问题,但没有特意指出这俩特性。在这一小节里,我们再将安全性和活性相关的问题讨论一遍。
    我们先说安全性。安全性是指系统内所有正常结点可以达成「正确的一致性」,而不会受内部恶意结点的干扰。「正确的一致性」是我自己发明的,这里不仅指达成一致的决定,而且这个决定需要是客户端的原始请求。比如客户端请求计算 10 的平方,那么各正常结点必须计算 10 的平方,而不是每个结点收到多个数值(如 10, 20, 30, …)然后计算这些数值的平均值(或中位数)的平方(拜占庭将军问题中的 SMSMSM 算法就可能会再现这种情况)。对于后一种情况,虽然每个结点最终参加计算的值和结果都是一致的,但并不是原来客户端的请求。
    由于 PBFT 系统中的所有消息都有签名,我们不用担心客户端的请求被篡改。所以只要每个正常的结点能拿到客户端的请求并只执行原始的请求,就不必担心刚才提到的后一种情况。需要重点关注的仍是如何达成一致。可以确定,只要所有正常的结点始终以同样的顺序处理请求,那么这些正常结点的状态始终是一致的,也就是达成一致了。因而总得来说,就可以保证安全性。那算法是如何保证所有正常结点以相同的顺序处理请求呢?正是前面我们刚才讨论的三阶段和域转换的作用:pre-prepare 和 prepare 可以保证在同一域内请求的执行顺序达成一致;commit 阶段和域转换规则可以保证在不同域内这一执行顺序也不会发生改变。详细信息上一小节已作了详细的说明,这里不再重复了。因此可以说,签名机制和整个 PBFT 的算法设计提供了安全性。
    不过论文中也提到一点,就是这里的安全性仅仅是指系统内部的安全性,它只能使系统内的恶意结点无法干扰正常的工作,但并不能阻止外部如客户端作恶(比如向整个系统写入垃圾数据)。
    我们再来说说活性。前面说过,所谓活性就是整个系统持续处理请求的能力。如果主结点是一个恶意结点,而没有机制可以更换这个主结点,那么这个系统就没有活性。前面我们介绍过,域转换其实就是更改主结点,因此可以说,PBFT 中的活性主要是由域转换机制保证的。具体的域转换规则前面已经详细介绍过,这里不再重复。但这里我们要说一下保证活性的三个细节。
    第一个细节,就是要避免频繁地进行域转换。这主要是通过线性增加超时时间来完成的。比如一个结点在开始处理一个请求时,它设置一个时长为 T1T_1T​1​​ 的计时器,如果计时器超时仍没有完成这个请求的 commit 阶段,那么除了发起域转换,它还会把 T1T_1T​1​​ 设置得更大一些(比如 2T12T_12T​1​​),以保证下次可以容忍更久的请求处理时间(当然也需要在请求处理得比较快的时候减小 T1T_1T​1​​,或设置 T1T_1T​1​​ 的最大值,防止超时时间无限增大);再比如一个结点为了更新到域 v+1v+1v+1 而发送 <VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE> 消息后,设置一个时长为 T2T_2T​2​​ 的计时器,如果计时器超时仍没有收到 <NEW−VIEW><NEW-VIEW><NEW−VIEW> 消息,那么这个结点可以再次为更新到域 v+2v+2v+2 而发送 <VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE> 消息,并这次计时器的时间间隔会设置得更大,比如 2T22T_22T​2​​。
    第二个细节,如果一个结点累计收到了超过(包含) f+1 条域编号比结点自己当前域编号大的 <VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE> 消息,那么该结点需要立即使用这些消息中的域编号最小的编号,重新发送一遍 <VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE> 消息。这样有助于及时的进入到新的域中。(原因如下:假设某结点 iii 收到了 f+1f+1f+1 个其它结点的 <VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE> 消息,说明至少有一个正常结点发现了异常并发起了域转换。但若结点 iii 非常等待自己发现异常(比如共识三阶段的超时)才去发起域转换,会多浪费一些时间。反正已经可以确认有正常结点发现异常了,不如直接跟随马上发起域转换。)
    第三个细节是自然而然的,即除了恶意的主结点,其它恶意结点无法强制频繁地进行域转换。因为域转换算法规定需要有 2f+1 个结点发送 <VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE> 消息才可能进行域转换,而系统中最多有 f 个恶意结点,显然达不到域转换成功的要求(事实上,只要发送 <VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE> 消息的结点数量小于(不包含) f+1 ,就肯定不会进行域转换;但超过或等于 f+1 个结点发送 <VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE> 消息,就肯定会进行域转换。因为这 f+1 个结点中,必定至少有一个正常结点,而一个正常结点一旦发送了 <VIEW−CHANGE><VIEW-CHANGE><VIEW−CHANGE> 消息,就不会处理其它消息。这会导致其它正常处理请求的结点因无法收到 2f 条 <PREPARE><PREPARE><PREPARE> 或 <COMMIT><COMMIT><COMMIT> 消息而超时,从而也发起域转换)。虽然恶意主结点可以通过不响应或发送错误消息强制发起域转换,但因为最多只有 f 个恶意结点,所以最多连续强迫系统发生 f 次域转换,这是可以接受的。
    PBFT 与 $$SM$$ 算法的比较这一小节里我们将 PBFT 与拜占庭将军问题的文章中的 SMSMSM 算法放在一起,进行一下比较,从而可以更加深刻的理解这两种算法。
    其实 PBFT 与 SMSMSM 有一定的相似性,但 PBFT 更进一步,考虑和解决了很多现实的问题,所以才可以应用于实际环境中。下面我们先聊聊它们之间的共同点,再看看 PBFT 比 SMSMSM 「更进一步」在哪些地方。
    共同点相比起不同点,PBFT 与 SMSMSM 的共同点较少,我认为主要在两个方面:一是系统模型相同,都是主从结构;二是从结点拿到主结点消息后,都会进行「沟通」与汇总。
    首先在SMSMSM 算法中,有一个司令官,其它人都是副官,接受并执行司令官的命令;而 PBFT 算法中,也分主结点和从结点,从结点也接受主结点的消息并执行。这一点上,它们是一样的。
    其次在两种算法中,所有副官(从结点)收到司令官(主结点)的消息后,都不会着急着去立刻执行,而是将自己收到的消息广播给其它结点,并接收其它结点广播的消息。这就像一个沟通的过程:我告诉别人我收到了什么消息,别人也告诉我他收到了什么消息。最终每个结点会在这些消息的基础上汇总一个结果。这一过程在 PBFT 中并不那么明显,但 prepare 阶段本质上也是这么一个过程。
    我觉得在相同点上,两种算法主要就是这两方面的相同,这两方面也都是基础。下面我们来看看在这两个基础之上的不同,这些不同点也是让 PBFT 的应用性强于 SMSMSM 的地方。
    不同点由于 PBFT 与 SMSMSM 的不同点稍多且需要较多的说明,因此我们分小节来看这些不同点。
    「一致性」与「正确的一致性」在 SMSMSM 算法中只要求一致性,即只要最终所有副官能达成一致的结果就可以了,至于这个结果是什么并不关心。比如如果司令官是叛徒,他给有的副官发送「进攻」、给有的副官发送「撤退」,只要最终忠诚的副官们行动一致,就算是正确的,不管是「一致进攻」还是「一致撤退」。
    但这在 PBFT 中是不行的。在 PBFT 中,客户端请求什么,所有结点就计算什么。如果从结点们发现彼此请求的数据不一致,它们就拒绝执行,而不是像 SMSMSM 那样取一个中间状态去执行。这就是通过对 <PRE−PREPARE><PRE-PREPARE><PRE−PREPARE> 消息的检验和必须收到 2f 条 <PREPARE><PREPARE><PREPARE> 消息这两方面来保证的。
    外部可识别正确结果在 SMSMSM 中,最终所有副官的行动肯定是一致的。但作为一个外部成员,却无法知道哪些人的行动是可信的。比如存在 5 个将军,其中只有 2 个是忠诚的,3 个是叛徒。并且司令官是忠诚的。因此在发送命令时,忠诚的司令官给每个人发送了「进攻」的命令。根据 SMSMSM 算法,另一个忠诚的副官肯定也会执行「进攻」。但另外 3 个叛徒的行为就不一定了,他们可能都执行的是「撤退」。那么在一个外人看来,你该相信哪个行动是正确的呢?
    PBFT 中的客户端其实就相当于这样一个「外人」。但与 SMSMSM 不同的是,在 PBFT 中客户端是能够知道哪些结果是正确的。这是它们之间很大的不同。
    那么 PBFT 是如何做到这一点的呢?其实主要是结点数量的差别。
    仔细研究 SMSMSM 算法就会发现,虽然 f+2 个结点也可以让正常结点(哪怕只有 2 个)达成一致,但外部(比如客户端)并不知道哪个结点的结果是正确的,正如刚才的例子显示的,如果 f>=2 且 f 个结点的结果是一致的(但是错误的),那么外部(比如客户端)是无法确定应该相信哪个结果的。比如若客户端根据「数量最多的结果就是正确的」的原则,那么它就会采用 f 个恶意结点返回的结果。这显然是不对的。因此在 PBFT 中,除了要求正确的结点达成一致外,还要有数量上的优势,让客户端知道应该采用谁的结果。所以 PBFT 要求 3f+1 个结点。由于只有 f 个恶意结点,那么客户端只要收到 f+1 个相同的结果,就可以认为这是正常的结果。
    既然只要有数量上的优势就可以,为什么 2f+1 个结点不行呢?根据 PBFT 论文中的描述,PBFT 可以用于存在恶意结点的情况,那么除了有 f 个恶意结点,还可能有 f 个正确结点「临时出点状况」,比如网络偶尔故障,因此 3f+1 个结点可以最大程序的确保整个系统正常运行。
    (事实上我对论文中这个解释是有点疑问的。客户端只要 f+1 个结点返回一样的结果,就可以认为这个结果就是正确的结果。但在系统内部处理请求的时候,每个结点仍然需要等待 2f 条 <PREPARE><PREPARE><PREPARE> 和 <COMMIT><COMMIT><COMMIT>,因此只要有 1 个正常结点「临时出点状况」,出状况其间请求是无法处理的。所以这难道不是要求所有正常结点都不能出状况吗?)
    那么 4f+1 个结点行不行呢?也不是不可以,只是这么多结点拖慢整个系统处理请求的速度,却没带来其它额外好处。
    活性是否考虑了活性,是 SMSMSM 与 PBFT 非常明显也是非常重要的区别。PBFT 考虑到了活性问题,所以它是「实用的」;而 SMSMSM 根据没有考虑更换司令官的问题,因而也就无法在实际应用中使用。
    总结PBFT 是一个比较重要的共识算法,是许多其它共识算法的基础。在这篇文章里,我们详细介绍了 PBFT 的相关知识,包括主从模式,pre-prepare / prepare / commit 三阶段及其作用,域和域转换等。pre-prepare 和 prepare 阶段让所有正常结点对请求的处理顺序达成一致;而 commit 和域转换规则保证了在发生域转换时,也能保持这个处理顺序。
    0  留言 2020-08-09 19:11:09
  • 共识算法:拜占庭将军问题


    本文原作为:fatcat22,如需转载请注明来源。原文地址:https://yangzhe.me/2019/11/06/byzantine-generals-problem/

    最近在学习区块链方面的知识,看到这篇博文讲得很好,故转载分享!
    引言接触区块链,经常会听到有人提到「拜占庭将军问题」(The Byzantine Generals Problem),所以这篇文章里,我们就详细探讨一下这个「问题」。
    本篇文章的讨论多数来自于「拜占庭将军问题」的原始论文,有一些细节参考了网上这篇文章。
    什么是拜占庭将军问题「拜占庭将军问题」来源于这样一个场景:
    拜占庭帝国的军队正在围攻一座城市,这支军队被分成了多支小分队,驻扎在城市周围的不同方位,每支小分队由一个将军领导。这些将军们彼此之间只能依靠信使传递消息(无法聚在一起开个会)。每个将军在观察自己方位的敌情以后,会给出一个各自的行动建议(比如进攻、撤退或按兵不动),但最终的需要将军们达成一致的作战计划并共同执行,否则就会被敌人各个击破。但是,这些将军中可能有叛徒,他们会尝试阻止其他忠诚的将军达成一致的作战计划。
    这就是拜占庭将军的「问题」:只能依靠通信相互交流,又不知道谁是叛徒,怎么能不受叛徒们的影响,让这些忠诚的将军快速的达到一致的作战计划呢?
    很显然,将这一场景套用到计算机系统中也是非常适用的:在一个分布式系统中,针对每个运算,每台独立的机器也需要最终达成一致的结果。但每台计算机之间也只能依靠网络通信(显然它们无法聚在一起开会),每台计算机都有出错的可能(被攻击,或故障),从而变成「叛徒」干扰正常的计算机达成一致。
    这一问题是由 Leslie·Lamport 等人在这篇论文中提出的,并在论文中给出了理论可行的解决方案和证明。不过在学习这些解决方案之前,我们还要对「拜占庭将军问题」作进一步的推导,看一下解决方案只要符合哪些要求,就能解决拜占庭将军问题。
    推导根据刚才的描述可以总结出,忠诚的将军们使用的算法,需要保证以下两点:

    A:所有忠诚的将军可以达成一致的作战计划
    B:少数的叛徒不会导致忠诚的将军们无法达成一致

    (论文中第二条本来是「少数的叛徒不会导致忠诚的将军们采用错误的计划」(A small number of traitors cannot cause the loyal generals to adopt a bad plan),并在接下来对什么是「bad plan」作了一个间接的解释,我的理解是,在这里只要是不一致的行动计划,都是「bad plan」,无论是「进攻」还是「撤退」)
    如果我们假设 v(i)v(i)v(i) 为第 iii 个将军的作战计划,且每个将军使用某种方法将序列 v(1),v(2),..,v(n)v(1),v(2),..,v(n)v(1),v(2),..,v(n) 转换成一个单一的作战计划。那么只要所有的将军使用相同的方法转换 v(1),v(2),..,v(n)v(1),v(2),..,v(n)v(1),v(2),..,v(n) ,条件A就可以被满足。条件B可以通过某种更有弹性的方法来满足,比如获取到消息序列 v(1),v(2),..,v(n)v(1),v(2),..,v(n)v(1),v(2),..,v(n) 以后,通过投票的方式,少数服从多数,得出一个最终的、一致的作战计划。
    问题似乎很简单就被解决了,但我们忽略了一点,想要满足上面两个条件,所有忠诚的将军必须拿到同样的「v(1),v(2),..,v(n) v(1),v(2),..,v(n)v(1),v(2),..,v(n) 」。而叛徒可能给不同的将军发送不同的消息,使这些将军拿到的是不同的消息序列,这种情况下我们刚才的解决方法就无法成立了。所以我们必须想办法保证:
    1. 所有忠诚的将军必须拿到相同的消息序列 v(1),v(2),..,v(n)v(1),v(2),..,v(n)v(1),v(2),..,v(n)
    只要某个算法能保证条件1,那么条件A和条件B也是可以保证的,因而这个算法就可以解决将军们的问题。
    那么如何保证条件1呢?有些将军(叛徒)可能给不同的将军发送不同的值,而要使所有忠诚的将军拿到相同的消息序列,这些忠诚的将军们就不能直接使用自己接收到的值。也就是说,将军们最终使用的 v(i)v(i)v(i) 不一定就是将军 iii 当初发送的原始值,即使将军 iii 是一个忠诚的将军。为什么这么说呢?因为如果让将军们必须直接使用其它将军发送的原始值,那么由于叛徒会给不同将军发送不同值,所以条件1永远也无法达成。所以对于叛徒发出的值,将军们不能直接使用。但将军们无法区分谁是叛徒谁是忠诚的,所以如果不多加注意,就会导致抛弃或修改某个忠诚的将军发出原始值的情况。但这显然是不对的,对于忠诚的将军,我们应该必须使用他发出的原始值,所以我们必须保证:
    2. 如果将军 iii 是忠诚的,那么它发出的原始值 v(i)v(i)v(i) 必须被其他忠诚的将军所采用
    条件2只提到了「如果将军 iii 是忠诚的」的情况,如果将军 iii 是叛徒呢?根据条件1,我们依然要保证所有忠诚的将军拿到相同的值。所以我们可以得出:
    1’. 对于任意一个将军 iii,无论他是忠诚的还是叛徒,当他发出值时,所有的忠诚的将军都将采用相同的值,即 v(i)v(i)v(i)(虽然可能不是将军 iii 发送的原始值)
    至此,只要某个算法能保证条件1’(对任意一个将军发出的值,其他忠诚的将军都将使用相同的值),就能保证条件1(所有忠诚的将军肯定会拿到相同的消息序列)。再加上条件2(忠诚的将军发出的原始值肯定会被其它忠诚的将军采用),就可以保证条件A和条件B的成立,因而这个算法就可以解决将军们的问题。
    注意条件2和条件1’考虑的都是单个的将军发送的值的情况,而只要这两个条件成立,我们就可以保证解决将军们的问题。因此我们将问题的关键转移到了「单个的将军如何发送他的值给其它人」上来。我们把这一问题表达为「司令官发送命令给他的副官们」,得到了如下拜占庭将军问题:

    拜占庭将军问题:一位司令官必须发送命令给他的 n - 1 个副官们,且满足条件:IC1. 所有忠诚的副官获得相同的命令IC2. 如果司令官是忠诚的,那么所有忠诚的副官都会获得司令官发出的原始命令

    这里 IC1 对应着条件1’; IC2 对应着条件2,不同的是 IC2 特别强调了「司令官」,而条件2只说「对任意一个将军」。后面我们会看到,司令官是会轮换的,任意一个将军都可能成为司令官,所以这两个说明其实是一样的。
    至此,我们得出了确定性的拜占庭将军问题的定义。只要能保证 IC1 和 IC2,就可以解决将军们的问题。
    (这里可能会有人疑惑:IC1 和 IC2 到底是「问题」,还是「解决条件」。我觉得「问题」和「解决条件」是一致的。它们构成了问题,也是解决问题的关键,解决了它们,就解决了问题)
    解决方案的不可能性介绍完拜占庭将军问题以后,你可能会觉得其实也挺简单。不过论文中也提出了「解决方案的不可能性」(IMPOSSIBILITY RESULTS),指出在某些条件下,将军们无论如何是无法达成一致的。我们需要先对这个作一个介绍,然后再去介绍解决方案。
    我们这里先说结论:在使用口头消息的情况下,不存在将军总数小于 3m+13m+13m+1 的解决方案,能够在存在 mmm 个叛徒的情况下解决拜占庭将军问题。也就是说,如果有 mmm 个叛徒,则至少需要 3m+13m+13m+1 个将军,才能让忠诚的将军们达成一致。
    什么是口头消息呢?所谓口头消息,是指内容完全由发送者或转发者控制的消息。将军们使用这种消息传递信息时,叛徒可以修改自己收到的任意消息,再转发给其人,而其它人无法识别出这个消息是否被修改过。(与口头消息对应的是签名消息,即消息是经过原始发送者签名的,收到消息的人可以根据签名验证此消息是否被修改过)
    下面我们简单证明一下这个结论。首先来证明最简单的情况,即当叛徒数量 m=1 时,不存在将军总数 <= 3 的解决方案,可以解决拜占庭将军问题。下面这张图我直接截取自原始论文:

    我们先来看 Fig.1 代表的第一种情况。此时司令官是忠诚的,2号副官是叛徒。司令官给所有副官发送了「attack」命令,但副官2是叛徒,所以他欺骗副官1自己收到了「retreat」命令。因为副官1也不知道谁是叛徒,所以此时副官1会很迷茫,不知道谁说的是真的:如果他相信副官2,那么他将不会与忠诚的司令官达成一致;如果他相信司令官,倒是暂时正确了,但事情还未结束。
    我们再来看 Fig.2 代表的第二种情况。此时司令官是叛徒,两个副官是忠诚的。司令官给不同的副官发送了不同的命令,副官2也如实将司令官的命令发给了副官1。但此时副官1依然会迷茫,因为他仍然收到了两个不同的命令。可以看到,对于副官1来说,当前收到的消息与刚才第一种情况下收到的消息是一样的,他依然不知道该相信谁。在第一种情况里,选择相信司令官是暂时正确的,但在当前的情况里却是错误的。
    所以结合这两种情况来看,无论副官1选择相信谁,都有可能是错误的。所以可以得出结论,在只有一个叛徒但将军总量 <= 3 的情况下,无法保证忠诚的将军们达成一致。
    现在我们再来看叛徒数量 m>1 的情况。m>1 时论文的证明很有意思,它将 m=1 时的每一位将军想象成同类将军的代表,比如在 Fig.1 中,司令官和副官1分别代表 m 个忠诚的将军,副官2代表 m 个叛徒;在 Fig.2 中,司令官代表 m 个叛徒,副官1和副官2分别代表 m 个忠诚的将军。因此也可以得出, m>1 但将军总量 <= 3m 时,无法保证忠诚的将军们达成一致。
    以上就是这一「解决方案的不可能性」的证明过程。但我总是感觉这个证明有些「玄乎」,感觉能理解,但好像又不是真的可以理解。论文中也提到了类似的问题,然后将严谨的证明过程指向了另一篇论文。不过我们不是作学术研究,就不继续追究下去了。

    在这里谈下我自己的理解:为什么n>3f?(n是节点总数,f是恶意节点)
    我们要在收到n-f条消息的时候,就必须做出决定,因为若有f个节点可以不发消息,这时节点最多能接收到n-f条消息。所以,我们必须要在接收到n-f条消息时做出决定。
    当在通信网络中,节点接收到n-f条消息时,最坏的情况是未接收到的消息都是诚实节点发的,而接收到的消息中有f条消息正好是恶意节点发的,那么这时要想做出少数服从多数的正确决定,诚实节点消息必须多于恶意节点消息:(n-f)-f>f。

    口头消息的解决方案刚才我们介绍了不可能存在解决方案的情况,那么现在我们就来介绍一下,存在解决方案的时,如何解决拜占庭将军问题。
    在这一小节里我们假设将军们使用「口头消息」(Oral Message)传递消息。前面我们已经介绍过,所谓口头消息,是指内容完全由发送者或转发者控制的消息。将军们使用这种消息传递信息时,叛徒可以修改自己收到的任意消息,再转发给其人,而其它人无法识别出这个消息是否被修改过。另外,我们还要对将军们的消息传递系统作如下的限制:

    A1. 每一个消息都可以在两个将军间正确的传递,传递过程中不会被修改
    A2. 消息的接收者知道这个消息的真正发送者是谁
    A3. 消息的缺失是能够被将军们发现的

    A1 和 A2 可以防止叛徒干扰两个将军之间的通信:A1 让叛徒无法在通信过程中修改信息;A2 让叛徒无法冒充其它将军发送消息。A3 可以防止叛徒通过不发消息的方式影响一致共识的达成。并且如果将军们发现缺少某个消息,他们可以使用统一的默认值(比如 RETREAT)来替代缺失的消息。
    另外,我们还假设每个将军都可以直接发消息给其它任意一个将军,不需要中间有人代理转发。
    下面我们先给出算法的定义,然后再详细解释一下算法过程。
    我们首先要定义一个函数 majoritymajoritymajority,这个函数有这样的功能:如果大多数的 viv_iv​i​​ 等于 vvv,那么 majority(v1,..,vn−1)=v majority(v_1,..,v_{n-1}) = v majority(v​1​​,..,v​n−1​​)=v。比如可以这样实现这个函数:

    1.majoritymajoritymajority 函数可以取出现次数最多的那个值,否则为一个默认值(如 RETREAT)
    2.majoritymajoritymajority 函数可以取所有值的中位数(如果这些值可以排序的话)

    我们定义使用口头消息解决拜占庭将军问题的算法为OM(m)OM(m)OM(m),其中 mmm 代表叛徒的数量且 m>=0m>=0m>=0,nnn 代表将军的总数量且 n>=3m+1n>=3m+1n>=3m+1。 OM(m)OM(m)OM(m) 算法如下:
    OM(0)OM(0)OM(0):

    司令官把他的值发送给每一位副官
    每一位副官使用从司令官那里接收到的值作为共识值。如果没接收到任何值则使用默认值作为共识值

    OM(m),m>0OM(m),m>0OM(m),m>0:

    司令官把它的值发送给每一位副官。对于每一个副官 iii,假设 viv_iv​i​​ 为自己从司令官那里接收到的值(如果没接收到则为默认值)
    每一个副官 iii 作为新的司令官、其它所有副官(当然不包含自己和当前司令官啦)作为新的副官,执行算法 OM(m−1) OM(m-1) OM(m−1)。(在算法 OM(m−1)OM(m-1)OM(m−1) 中新司令官 iii 会将他接收到的值 viv_iv​i​​ 发送他的副官们)
    假设 vi′v^{\prime}_iv​i​′​​ 为第二步中的副官们对第二步中的新司令官 iii 发送的值达成的共识值,则当前副官们的共识值为 majority(v1′,v2′,..,vn−1′) majority(v^{\prime}_1, v^{\prime}_2,.. ,v^{\prime}_{n-1}) majority(v​1​′​​,v​2​′​​,..,v​n−1​′​​)

    这里我们要马上解释一下 共识值 这个概念(这个词是我自己创造的,原论文中没有)。所谓「共识值」,是指副官们一致认同的司令官发送的值。这里的关键是并不是司令官发什么值,副官们就接受并相信什么值;而是副官之间要对司令官发送的值进行「探讨」,最终形成一个一致意见,接受并相信一个相同的值,这个值就是「共识值」。如果司令官是叛徒,那么这个值不一定就是司令官发出的原始值。比如有 4 个将军,司令官是叛徒,他给其他 3 个副官发送的值分别是 a、b、c。那么 3 个副官最终的共识值应该是 majority(a,b,c)majority(a,b,c)majority(a,b,c)。
    OM(m)OM(m)OM(m) 算法使用了递归,这使它非常难以理解。但我们这里先不详细考虑算法本身的流程,而是从更高一层的角度想想,为什么会产生拜占庭将军问题,以及应该怎样应对。
    产生拜占庭将军问题的原因我认为是:每个忠诚的将军不知道谁是叛徒,从而产生了对其他将军的不信任。
    那怎么应对呢?既然是不知道该相信谁且多数将军是忠诚的,那么只能使用 majoritymajoritymajority 函数产生一个「多数人的意见」,并服从这个意见。所以当副官们接收到司令官的命令时,副官们不知道司令官是不是叛徒,从而不能轻易相信司令官,只能依然副官们「相互沟通」后,达成一个可以相信的「共识值」。
    副官们沟通的方法就是将司令官排除出去,每个副官将自己接收到的值发给其他所有人,这样每个副官就能知道司令官发给所有人值了。然后副官们就使用 majoritymajoritymajority 函数从这些值中计算出一个「共识值」。
    但问题是,在这些沟通的副官中,仍可能会有叛徒,他发出来的值可能根本不是从司令官接收到的值,且可能发给其他每个人的值都不一样,这样每个忠诚的副官拿到的就不是一样的序列 v1,..,vn−1v_1,..,v_{n-1}v​1​​,..,v​n−1​​,还是没法达成一致的。
    其实在副官们的「沟通」过程中,每当一位副官向其他人发送自己的值时,其他人也不知道这位副官是否是叛徒,因此也不应该直接相信他,而是应该所有接收他消息的人再次「相互沟通」,以便对这位副官发送的值达成共识。这里就产生了递归:每一位副官向其他人发送自己接收到的值时,也可以看作自己是「司令官」,其他人是自己的副官。其他的副官在彩纳自己发送的值之前,首先要充分的「相互沟通」达成一个共识值。
    以上基本上就是 OM(m)OM(m)OM(m) 算法的要素和递归过程。这么来看,这个算法的逻辑就比较容易理解了。
    那什么时候递归结束呢?或者说,什么时候可以相信司令官发送的值,而不用再「相互沟通」了呢。在 OM(m)OM(m)OM(m) 算法中,每递归一轮,mmm 的值就减 1,当m=0m=0m=0 时,副官们就不用再「相互沟通」了,而是直接将司令官发送的值作为当前的共识值。至于为什么这样是正确的,我们一会再来说明。
    下面我们通过实例,来加深对这一算法的理解。首先看一看论文中比较简单的 m=1m=1m=1 时的例子。
    首先看司令官是忠诚的情况,此时有一个副官是叛徒:

    从图中可以看到,在 OM(1) 的第 (1) 步中,司令官开始给每个副官发送一个值,本例中司令官是忠诚的,所以他给每个副官的值都是一样的,为 a。
    在 OM(1) 的第 (2) 步中,每个副官各自作为司令官,进入到 OM(m−1)OM(m-1)OM(m−1) 算法中,将自己收到的值发给其他副官。此时 m=0m=0m=0,所以其它副官把此时自己收到的值作为这一层次的共识值。这一步执行完以后,每个副官都会收到其它两个副官发送的值,再加上自己在第 (1) 步中收到的值,此时每个副官共收到了 3 个值。然后进入到下一步中。
    在 OM(1) 的第 (3) 步中,每个副官利用 majoritymajoritymajority 函数,计算自己收到的 3 个值。由于 L1 自己从 L0 那里收到了一个 a 值;且在 OM(0) 中从 L2 那里收到的也是 a 值,从 L3 那里收到的是 x,所以 L1 的共识值应该是 a。L2 的情况与 L1 类似,他的共识值也是 a。所以 L0、L1、L2 三个忠诚的将军达成了一致,他们的共识值都是 a。
    我们再来看看司令官是叛徒的情况;

    此时在 OM(1) 的第 (1) 步中,由于司令官 L0 是叛徒,所以他可能发给每个人的值都不一样(以尽最大可能达到干优忠诚将军们的目的),所以我们假设 L1、L2、L3 收到的值各不相同,分别为 a、b、c。
    下面的步骤与刚才类似,我们不再赘述。总之 L1、L2、L3 分别收到了除自己这外的其他两个副官发送的值,所以他们最后每个人收到的三个值其实都是 a、b、c。当他们使用 majoritymajoritymajority 函数计算共识值时,由于输入值是相同的,所以输出的肯定是同一个值。因此最终三个忠诚的将军仍然达成了一致的共识值 t,虽然这个值可能与司令官 L0 发送的值完全不一样。
    m=1m=1m=1 时 OM(m)OM(m)OM(m) 算法很简单,也很容易理解。以此为预热,我们来看一下复杂一些的情况,比如 m=3m=3m=3 时,OM(3)OM(3)OM(3) 是如何执行的。我们下面的示意图使用的图例与刚才完全一致,只是更复杂而已。由于 OM(3)OM(3)OM(3) 的情况实在太复杂,如果全部用图示意出来,图片会非常的大,也会非常复杂,这样反而失去了图片示意的意义。因此在下面这张图中,每一个步骤我只示意了第一种情况和最后一种情况,其它情况都用省略号代替了。另外在乍用这张图理解 OM(3)OM(3)OM(3) 算法时,建议先纵向看,即先沿着某一分支一直向下看,看明白了再看其它分支。最后再横向综合起来看。
    另外,也由于 OM(3)OM(3)OM(3) 太复杂,所以这里也只示意了叛徒作为司令官的情况。忠诚将军作为司令官的情况是类似的(甚至会更简单些)。
    下面就是 OM(3)OM(3)OM(3) 的示意图(如果图片太小,可以尝试点击图片看大图,或在新标签中打开图片,或图片另存为本地图片后查看):

    虽然整个过程变复杂了,但细节上与刚才介绍的 OM(1)OM(1)OM(1) 是相同的,因此就不每一步都说明介绍了。需要特别提出来的是,在 OM(1)OM(1)OM(1) step (1) 中、L9 作为司令官时,最终忠诚的将军们是无法对 L9 发送的信息达成一致的共识值的(OM(1)OM(1)OM(1) step (3) 中),但这并不妨碍忠诚将军们达到最终的一致共识。
    与前面介绍过的 OM(1)OM(1)OM(1) 中判徒作为司令官的情况类似,这里忠诚将军们虽然最终达成了一致(上图中用值 C 表示),但这个值可能与最开始司令官发出来的值不一样。
    在原论文中还给出了这个算法的正确性证明。这里我们就不进行证明了,只是聊一下证明相关的思路,以及为什么当 m=0 时各将军就不再相互确认,而是直接将接收到的值作为当前的共识值。由于在 OMOMOM 算法中,每进行一轮都会忽略当前司令官、剩下的副官们相互发送消息确认。那么我们设想两个极端的情况:

    第一种情况是每次忽略的都是叛徒
    第二种情况是每次忽略的都是忠诚将军

    在第一种情况下,每次忽略的都是叛徒,因此当最后 m=0 时,一共忽略了 m 个叛徒,但总共也就 m 个叛徒。也就是说,在这种情况下当最后 m=0 时,剩下的所有将军都是忠诚的,因此他们肯定可以达成一致的共识值。
    在第二种情况下,每次忽略的都是忠诚将军,因此最后当 m=0 时,一共忽略了 m 个忠诚将军,此时剩下的将军中,有 m 个是叛徒,有 m+1 个是忠诚将军。忠诚将军的数量大于叛徒的数量,因此还是可以达成一致的共识值的。
    因此无论哪种情况下,在 OM(1)OM(1)OM(1) 的第 (3) 步中,忠诚将军们肯定是可以达成一致共识的。论文中证明的关键也是无论什么时候,忠诚将军的数量总是多于叛徒的。
    签名消息的解决方案回看一下口头消息的解决方案,还是很复杂的。其实口头消息的解决方案之所以复杂,就是因为叛徒可以随意改更忠诚将军的消息,而别人无法发现消息被改。如果我们让忠诚将军的消息无法篡改,那么问题就变得简单多了。这就是签名消息的解决方案。
    前面介绍口头消息时,我们对将军们之间的消息传递系统作了一些限制(前面的 A1 - A3)。在使用签名消息时,我们要在之前限制的基础上,再加一条:

    A4.(a) 忠诚将军的签名是无法被修改的;任何改动包含了忠诚将军签名的消息的行为,都可以被发现(b) 任意一个人都可以验证属于某将军的签名是不是真的是他自己签的

    注意这里我们并没有对叛徒的签名作任何的限制。我们可以允许叛徒之间相互修改和伪造彼此的签名,而不被别人发现。
    既然我们现在使用了消息签名,那么之前将军数量必须大于等于 3m+13m+13m+1 才能达成共识的限制就可以去除了。事实上,我们可以让任何数量的将军团体在存在 mmm 个叛徒的情况下达成共识(这里虽然说任意数量,但如果总数小于 m+2m+2m+2 将没有意义。因为「达成共识」意味着两个或两个以上的人,只有一个忠诚将军或跟本没有忠诚将军谈不上「达成共识」)。
    在给出解决方案之前,我们首先要定义一个函数 choicechoicechoice。这个函数输入一个值的集合,输出一个单一值。我们对这个函数有两个要求:

    一是如果集合 VVV 由单个元素 vvv 组成,那么 choice(v)=vchoice(v)=vchoice(v)=v
    二是 choice(∅)choice(\varnothing)choice(∅) 始终为相同的默认值,比如 RETREAT。共中 ∅\varnothing∅ 代表集合为空

    另外在算法中,我们使用 x:ix:ix:i 代表由将军 iii 签名的值 xxx。类似的 v:j:iv:j:iv:j:i 代表值 vvv 先由将军 jjj 签名得到 v:jv:jv:j,然后 v:jv:jv:j 又被将军 iii 签名,得到 v:j:iv:j:iv:j:i。
    我们定义使用签名消息解决拜占庭将军问题的算法为 SM(m)SM(m)SM(m),其中 mmm 代表叛徒的数量且 m>=0m>=0m>=0。 SM(m)SM(m)SM(m) 算法如下:

    (0). 每个将军 i 初始化自己的值集合 Vi=∅V_i=\varnothingV​i​​=∅
    (1). 司令官将要发送的值签名,然后发送签名后的值
    (2). 对于每个副官 iii:

    (A) 如果副官 iii 之前没接收过任何将军发过来的任何值,且值的形式是 v:0v:0v:0,那么:

    (i) 将 vvv 加入到 ViV_iV​i​​ 中(此时 V=vV=vV=v)
    (ii) 将 v:0:iv:0:iv:0:i 发送给其他副官

    (B) 如果副官 iii 接收到了一个形式如 v:0:j1:..:jkv:0:j_1:..:j_kv:0:j​1​​:..:j​k​​ 这样的值,并且 vvv 不在 ViV_iV​i​​ 中,那么:

    (i) 将 vvv 加入到 ViV_iV​i​​ 中
    (ii) 如果 k<mk<mk<m,那么此副官将值 v:0:j1:..:jk:iv:0:j_1:..:j_k:iv:0:j​1​​:..:j​k​​:i 发送给除 j1,..,jkj_1,..,j_kj​1​​,..,j​k​​ 之外的所有其它副官

    (C) 如果副官 iii 接收到一个已经存在于 ViV_iV​i​​ 中的值,则忽略它

    (3). 对于每个副官 iii,当自己不会再接收到更多值的时候,它将 choice(Vi)choice(V_i)choice(V​i​​) 作为最终自己的共识值

    (论文中解释了第 (3) 步中如判断自己不会再接收到更多的值,但我觉得不太靠谱。这里只是算法,不是实现,所以就姑且认为可以判断吧)
    可以看到,SMSMSM 算法要比 OMOMOM 算法详细、简单,尤其是 SMSMSM 算法没有用到递归来解决问题。注意在第 (2) 步中,(A)、(B)、(C) 三种情况是互斥的,即只要执行了某一情况,就不会再去执行另一情况。可以把第 (2) 步理解成编程语言中的 switch/case 语句。
    这个算法的关键是维护一个集合 VVV,如果新收到的值不在这个集合中,就将它加入进去,并对这个值签名后再将其发送给其他人;如果已存在于集合中,就忽略不管了。 仔细理解这一过程你就会发现,SMSMSM 的关键思想是通过消息不可篡改这一特性,让所有忠诚将军成为「一体」,就像同一个人一样。 当某个值第一次被忠诚将军接收到时,无论是第 (2) 步的 情况 (A) 还是 情况 (B),这个忠诚将军都会将这个值发送给其他所有没给这个值签过名的人;由于签名值的不可篡改,最终其他所有忠诚将军的集合 ViV_iV​i​​ 中肯定也会存在这个值。因此除非消息到达不了忠诚将军那里,只要到达,就会「全体忠诚将军都有」。这样看来,无论叛徒怎么干扰,都无济于事了,因为最终忠诚将军们的集合 ViV_iV​i​​ 肯定都是一样的,因此 choicechoicechoice 最终的结果也肯定是一样的。所不同的是,如果司令官是忠诚的,那么最终所有忠诚将军的集合 ViV_iV​i​​ 中肯定只有一个值,就是司令官原始发送的值;而如果司令官是叛徒,他给不同的副官发送了不同的值,那么最终所有忠诚将军的集合 ViV_iV​i​​ 中会有多个值(但整个集合还是相等的)。
    这里还要解释一下为什么在算法的 (2)(B)(ii) 步骤中,只有 k<mk<mk<m 的情况下,才会将值签名后,发给其他副官。因为接收到的值 v:0:j_1:..:j_kv:0:j\_1:..:j\_kv:0:j_1:..:j_k 有 k+1k+1k+1 个签名(注意最前面还有编号为 0 的司令官的签名)。如果 k>=mk>=mk>=m,那么 k+1>=m+1k+1>=m+1k+1>=m+1,即当 k>=mk>=mk>=m 时,至少有 m+1m+1m+1 个将军对这个值签过名了,而这 m+1m+1m+1 个将军中至少有 1 个忠诚的将军。也就是说当 k>=mk>=mk>=m 时至少有 1 个忠诚的将军的集合 VVV 中已经有这个值了。刚才我们说过,忠诚的将军们是「一体的」,只要有一个忠诚的将军有这个值了,其它忠诚将军也肯定会有这个值。所以当 k>=mk>=mk>=m 时就没必要再给别人发送这个值了。
    SMSMSM 算法比较容易理解,所以我们这里只看一下论文中给出的简单例子即可。论文中的例子中总共有 3 个将军,其中有 1 个叛徒,并且司令官就是这个叛徒(注意这个例子并不需要如 OMOMOM 算法那样需要 4 个将军才能达成共识)。如下图(图片来自原论文):

    在上图中,叛徒司令官给两个副官分别发送了不同的值。副官1收到值后发现这是自己第一次收到值且是司令官发来的,于是执行步骤 (2)(A),将 “attack” 加入到了自己的集合 V1V_1V​1​​ 中,然后将 “attack”:0:1 发送给副官2;类似的副官 2 在收到司令官发来的值后,也将 “retreat” 加入到自己的集合 V2V_2V​2​​ 中,然后将 “retreat”:0:2 发送给副官1。
    副官1接收到副官2发来的消息后,发现自己的集合中没有 “retreat” 这个值,因此他将这个值加入到了自己的集合 V1V_1V​1​​ 中。此时副官1的集合为 “attack”,“retreat”。
    副官2接收到副官1发来的消息后,也发现自己的集合中没有 “attack” 这个值,因此他也将这个值加入到了自己的集合 V2V_2V​2​​ 中。此时副官2的集合为 “retreat”,“attack”。
    最后我们可以看到,两个忠诚的副官的集合都是一样的。因此 choice(V1)choice(V_1)choice(V​1​​) 和 choice(V2)choice(V_2)choice(V​2​​) 的值也肯定是一样的(不管这个值是什么)。所以最终忠诚的将军达成了共识。
    总结通过这篇文章,我们详细了解了什么是拜占庭将军问题,以及原论文给出的基于口头消息和签名消息的解决方法。「拜占庭将军问题」是区块链共识的一个基础(我觉得也是各种分布式系统的共识的基础),了解了这个问题,有利于我们以后理解其它很多的共识算法。
    拜占庭将军问题的原论文虽然给出了基于口头消息和签名消息的解决方案,但这些方案并不能很好的应用于实际生产环境中(比如基于口头消息的方法,通信量太大;基于签名消息的方法,如果司令官一直是叛徒,那这个系统虽然可以达成一致,但也干不了什么正事)。因此很多前辈和大牛给出了不少其他可实际应用的解决方案,我们以后的文章会继续学习这些方案。
    0  留言 2020-08-09 17:13:28
  • 以太坊智能合约 OPCODE 逆向之理论基础篇


    本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/640/

    最近在学习 solidity 逆向方面的知识,看到这篇博文讲得很好,故转载分享!
    一、基础二、IO2.1 stack2.2 mem2.3 storage三、变量3.1 全局变量的储存模型3.1.1 定长变量3.1.2 映射变量3.1.3 变长变量3.1.4 结构体四、函数4.1 两种调用函数的方式4.2 调用函数4.3 主函数中的函数4.4 回退函数和payable4.5 函数参数4.6 变量类型的分辨五、智能合约代码结构5.1 部署合约5.2 创建合约代码总结在我们对etherscan等平台上合约进行安全审查时,常常会遇到没有公布Solidity源代码的合约,只能获取到合约的OPCODE,所以一个智能合约的反编译器对审计无源码的智能合约起到了非常重要的作用。
    目前在互联网上常见的反编译工具只有porosity[1],另外在Github上还找到另外的反编译工具ethdasm[2],经过测试发现这两个编译器都有许多bug,无法满足我的工作需求。因此我开始尝试研究并开发能满足我们自己需求的反编译工具,在我看来如果要写出一个优秀的反汇编工具,首先需要有较强的OPCODE逆向能力,本篇Paper将对以太坊智能合约OPCODE的数据结构进行一次深入分析。
    一、基础智能合约的OPCODE是在EVM(Ethereum Virtual Machine)中进行解释执行,OPCODE为1字节,从0x00 - 0xff代表了相对应的指令,但实际有用的指令并没有0xff个,还有一部分未被使用,以便将来的扩展。
    具体指令可参考Github[3]上的OPCODE指令集,每个指令具体含义可以参考相关文档[4]。
    二、IO在EVM中不存在寄存器,也没有网络IO相关的指令,只存在对栈(stack)、内存(mem)、存储(storage)的读写操作。
    2.1 stack使用的push和pop对栈进行存取操作,push后面会带上存入栈数据的长度,最小为1字节,最大为32字节,所以OPCODE从0x60-0x7f分别代表的是push1-push32。
    PUSH1会将OPCODE后面1字节的数据放入栈中,比如字节码是0x6060代表的指令就是PUSH1 0x60。
    除了PUSH指令,其他指令获取参数都是从栈中获取,指令返回的结果也是直接存入栈中。
    2.2 mem内存的存取操作是MSTORE和MLOAD:

    MSTORE(arg0, arg1)从栈中获取两个参数,表示MEM[arg0:arg0+32] = arg1
    MLOAD(arg0)从栈中获取一个参数,表示PUSH32(MEM[arg0:arg0+32])

    因为PUSH指令,最大只能把32字节的数据存入栈中,所以对内存的操作每次只能操作32字节。
    但是还有一个指令MSTORE8,只修改内存的1个字节。

    MSTORE(arg0, arg1)从栈中获取两个参数,表示MEM[arg0] = arg1
    内存的作用一般是用来存储返回值,或者某些指令有处理大于32字节数据的需求。
    比如: SHA3(arg0, arg1)从栈中获取两个参数,表示SHA3(MEM[arg0:arg0+arg1]),SHA3对内存中的数据进行计算sha3哈希值,参数只是用来指定内存的范围。
    2.3 storage上面的stack和mem都是在EVM执行OPCODE的时候初始化,但是storage是存在于区块链中,我们可以类比为计算机的存储磁盘。
    所以,就算不执行智能合约,我们也能获取智能合约storage中的数据:
    eth.getStorageAt(合约地址, slot) # 该函数还有第三个参数,默认为"latest",还可以设置为"earliest"或者"pending",具体作用本文不做分析
    storage用来存储智能合约中所有的全局变量,使用SLOAD和SSTORE进行操作:

    SSTORE(arg0, arg1)从栈中获取两个参数,表示eth.getStorageAt(合约地址, arg0) = arg1
    SLOAD(arg0)从栈中获取一个参数,表示PUSH32(eth.getStorageAt(合约地址, arg0))

    三、变量智能合约的变量从作用域可以分为三种:全局公有变量(public)、全局私有变量(private)和局部变量。

    全局变量和局部变量的区别:全局变量储存在storage中,而局部变量是被编译进OPCODE中,在运行时,被放在stack中,等待后续使用
    公有变量和私有变量的区别:公有变量会被编译成一个constant函数,后面会分析函数之前的区别

    因为私有变量也是储存在storage中,而storage是存在于区块链当中,所以相当于私有变量也是公开的,所以不要想着用私有变量来储存啥不能公开的数据。
    3.1 全局变量的储存模型不同类型的变量在storage中储存的方式也是有区别的,下面对各种类型的变量的储存模型进行分析。
    solidity内存地址结构

    3.1.1 定长变量第一种我们归类为定长变量,所谓的定长变量,也就是该变量在定义的时候,其长度就已经被限制住了。
    比如定长整型(int/uint……)、地址(address)、定长浮点型(fixed/ufixed……)、定长字节数组(bytes1-32)。
    这类的变量在storage中都是按顺序储存:
    uint a; // slot = 0address b; // 1ufixed c; // 2bytes32 d; // 3## a == eth.getStorageAt(contract, 0)d == eth.getStorageAt(contract, 3)
    上面举的例子,除了address的长度是160bits,其他变量的长度都是256bits,而storage是256bits对齐的,所以都是一个变量占着一块storage,但是会存在连续两个变量的长度不足256bits的情况:
    address a; // slot = 0uint8 b; // 0address c; // 1uint16 d; // 1
    在opcode层面:

    获取a的值得操作是:SLOAD(0) & 0xffffffffffffffffffffffffffffffffffffffff
    获取b值得操作是:SLOAD(0) // 0x10000000000000000000000000000000000000000 & 0xff
    获取d值得操作是: SLOAD(1) // 0x10000000000000000000000000000000000000000 & 0xffff

    因为b的长度+a的长度不足256bits,变量a和b是连续的,所以他们在同一块storage中,然后在编译的过程中进行区分变量a和变量b,但是后续在加上变量c,长度就超过了256bits,因此把变量c放到下一块storage中,然后变量d跟在c之后。
    从上面我们可以看出,storage的储存策略一个是256bits对齐,一个是顺序储存。(并没有考虑到充分利用每一字节的储存空间,我觉得可以考虑把d变量放到b变量之后)。
    3.1.2 映射变量mapping(address => uint) a;
    映射变量就没办法想上面的定长变量按顺序储存了,因为这是一个键值对变量,EVM采用的机制是:
    SLOAD(sha3(key.rjust(64, "0")+slot.rjust(64, "0")))
    比如: a["0xd25ed029c093e56bc8911a07c46545000cbf37c6"]首先计算sha3哈希值:
    >>> from sha3 import keccak_256>>> data = "d25ed029c093e56bc8911a07c46545000cbf37c6".rjust(64, "0")>>> data += "00".rjust(64, "0")>>> keccak_256(data.encode()).hexdigest()'739cc24910ff41b372fbcb2294933bdc3108bd86ffd915d64d569c68a85121ec'# a["0xd25ed029c093e56bc8911a07c46545000cbf37c6"] == SLOAD("739cc24910ff41b372fbcb2294933bdc3108bd86ffd915d64d569c68a85121ec")
    我们也可以使用以太坊客户端直接获取:
    > eth.getStorageAt(合约地址, "739cc24910ff41b372fbcb2294933bdc3108bd86ffd915d64d569c68a85121ec")
    还有slot需要注意一下:
    address public a; // slot = 0mapping(address => uint) public b; // slot = 1uint public d; // slot = 1mapping(address => uint) public c; // slot = 3
    根据映射变量的储存模型,或许我们真的可以在智能合约中隐藏私密信息,比如,有一个secret,只有知道key的人才能知道secret的内容,我们可以b[key] = secret, 虽然数据仍然是储存在storage中,但是在不知道key的情况下却无法获取到secret。
    不过,storage是存在于区块链之中,目前我猜测是通过智能合约可以映射到对应的storage,storage不可能会初始化256*256bits的内存空间,那样就太消耗硬盘空间了,所以可以通过解析区块链文件,获取到storage全部的数据。
    上面这些仅仅是个人猜想,会作为之后研究以太坊源码的一个研究方向。
    3.1.3 变长变量变长变量也就是数组,长度不一定,其储存方式有点像上面两种的结合
    uint a; // slot = 0uint[] b; // 1uint c; // 2
    数组任然会占用对应slot的storage,储存数组的长度(b.length == SLOAD(1))。
    比如我们想获取b[1]的值,会把输入的index和SLOAD(1)的值进行比较,防止数组越界访问。
    然后计算slot的sha3哈希值:
    >>> from sha3 import keccak_256>>> slot = "01".rjust(64, "0")>>> keccak_256(slot.encode()).hexdigest()'20ec45d096f1fa2aeff1e3da8a84697d90109524958ed4be9f6d69e37a9140a4'#b[X] == SLOAD('20ec45d096f1fa2aeff1e3da8a84697d90109524958ed4be9f6d69e37a9140a4' + X)# 获取b[2]的值> eth.getStorageAt(合约地址, "20ec45d096f1fa2aeff1e3da8a84697d90109524958ed4be9f6d69e37a9140a6")
    在变长变量中有两个特例:string和bytes。
    字符串可以认为是字符数组,bytes是byte数组,当这两种变量的长度在0-31时,值储存在对应slot的storage上,最后一字节为长度*2|flag, 当flag = 1,表示长度>31,否则长度<=31
    下面进行举例说明:
    uint i; // slot = 0string a = "c"*31; // 1SLOAD(1) == "c*31" + "00" | 31*2 == "636363636363636363636363636363636363636363636363636363636363633e"
    当变量的长度大于31时,SLOAD(slot)储存length*2|flag,把值储存到sha3(slot):
    uint i; // slot = 0string a = "c"*36; // 1SLOAD(1) == 36*2|1 == 0x49SLOAD(SHA3("01".rjust(64, "0"))) == "c"*36
    3.1.4 结构体结构体没有单独特殊的储存模型,结构体相当于变量数组,下面进行举例说明:
    struct test { uint a; uint b; uint c;}address g;Test e;// 上面变量在storage的储存方式等同于address g;uint a;uint b;uint c;
    四、函数4.1 两种调用函数的方式下面是针对两种函数调用方式说明的测试代码,发布在测试网络上: https://ropsten.etherscan.io/address/0xc9fbe313dc1d6a1c542edca21d1104c338676ffd#code
    pragma solidity ^0.4.18;contract Test { address public owner; uint public prize; function Test() { owner = msg.sender; } function test1() constant public returns (address) { return owner; } function test2(uint p) public { prize += p; }}
    整个OPCODE都是在EVM中执行,所以第一个调用函数的方式就是使用EVM进行执行OPCODE:
    # 调用test1> eth.call({to: "0xc9fbe313dc1d6a1c542edca21d1104c338676ffd", data: "0x6b59084d"})"0x0000000000000000000000000109dea8b64d87a26e7fe9af6400375099c78fdd"> eth.getStorageAt("0xc9fbe313dc1d6a1c542edca21d1104c338676ffd", 0)"0x0000000000000000000000000109dea8b64d87a26e7fe9af6400375099c78fdd"
    第二种方式就是通过发送交易:
    # 调用test2> eth.getStorageAt("0xc9fbe313dc1d6a1c542edca21d1104c338676ffd", 1)"0x0000000000000000000000000000000000000000000000000000000000000005"> eth.sendTransaction({from: eth.accounts[0], to: "0xc9fbe313dc1d6a1c542edca21d1104c338676ffd", data: "0xcaf446830000000000000000000000000000000000000000000000000000000000000005"})> eth.getStorageAt("0xc9fbe313dc1d6a1c542edca21d1104c338676ffd", 1)"0x000000000000000000000000000000000000000000000000000000000000000a"
    这两种调用方式的区别有两个:

    使用call调用函数是在本地使用EVM执行合约的OPCODE,所以可以获得返回值
    通过交易调用的函数,能修改区块链上的storage

    一个调用合约函数的交易(比如 https://ropsten.etherscan.io/tx/0xab1040ff9b04f8fc13b12057f9c090e0a9348b7d3e7b4bb09523819e575cf651)的信息中,是不存在返回值的信息,但是却可以修改storage的信息(一个交易是怎么修改对应的storage信息,是之后的一个研究方向)。
    而通过call调用,是在本地使用EVM执行OPCODE,返回值是存在MEM中return,所以可以获取到返回值,虽然也可以修改storage的数据,不过只是修改你本地数据,不通过发起交易,其他节点将不会接受你的更改,所以是一个无效的修改,同时,本地调用函数也不需要消耗gas,所以上面举例中,在调用信息的字典里,不需要from字段,而交易却需要指定(设置from)从哪个账号消耗gas。
    4.2 调用函数EVM是怎么判断调用哪个函数的呢?下面使用OPCODE来进行说明。
    每一个智能合约入口代码是有固定模式的,我们可以称为智能合约的主函数,上面测试合约的主函数如下:
    PS: Github[5]上面有一个EVM反汇编的IDA插件。
    [ 0x0] | PUSH1 | ['0x80'][ 0x2] | PUSH1 | ['0x40'][ 0x4] | MSTORE | None[ 0x5] | PUSH1 | ['0x4'][ 0x7] | CALLDATASIZE | None[ 0x8] | LT | None[ 0x9] | PUSH2 | ['0x61'][ 0xc] | JUMPI | None[ 0xd] | PUSH4 | ['0xffffffff'][ 0x12] | PUSH29 | ['0x100000000000000000000000000000000000000000000000000000000'][ 0x30] | PUSH1 | ['0x0'][ 0x32] | CALLDATALOAD | None[ 0x33] | DIV | None[ 0x34] | AND | None[ 0x35] | PUSH4 | ['0x6b59084d'][ 0x3a] | DUP2 | None[ 0x3b] | EQ | None[ 0x3c] | PUSH2 | ['0x66'][ 0x3f] | JUMPI | None[ 0x40] | DUP1 | None[ 0x41] | PUSH4 | ['0x8da5cb5b'][ 0x46] | EQ | None[ 0x47] | PUSH2 | ['0xa4'][ 0x4a] | JUMPI | None[ 0x4b] | DUP1 | None[ 0x4c] | PUSH4 | ['0xcaf44683'][ 0x51] | EQ | None[ 0x52] | PUSH2 | ['0xb9'][ 0x55] | JUMPI | None[ 0x56] | DUP1 | None[ 0x57] | PUSH4 | ['0xe3ac5d26'][ 0x5c] | EQ | None[ 0x5d] | PUSH2 | ['0xd3'][ 0x60] | JUMPI | None[ 0x61] | JUMPDEST | None[ 0x62] | PUSH1 | ['0x0'][ 0x64] | DUP1 | None[ 0x65] | REVERT | None
    反编译出来的代码就是:
    def main(): if CALLDATASIZE >= 4: data = CALLDATA[:4] if data == 0x6b59084d: test1() elif data == 0x8da5cb5b: owner() elif data == 0xcaf44683: test2() elif data == 0xe3ac5d26: prize() else: pass raise
    PS:因为个人习惯问题,反编译最终输出没有选择对应的Solidity代码,而是使用Python。
    从上面的代码我们就能看出来,EVM是根据CALLDATA的前4字节来确定调用的函数的,这4个字节表示的是函数的sha3哈希值的前4字节:
    > web3.sha3("test1()")"0x6b59084dfb7dcf1c687dd12ad5778be120c9121b21ef90a32ff73565a36c9cd3"> web3.sha3("owner()")"0x8da5cb5b36e7f68c1d2e56001220cdbdd3ba2616072f718acfda4a06441a807d"> web3.sha3("prize()")"0xe3ac5d2656091dd8f25e87b604175717f3442b1e2af8ecd1b1f708bab76d9a91"# 如果该函数有参数,则需要加上各个参数的类型> web3.sha3("test2(uint256)")"0xcaf446833eef44593b83316414b79e98fec092b78e4c1287e6968774e0283444"
    所以可以去网上找个哈希表映射[6],这样有概率可以通过hash值,得到函数名和参数信息,减小逆向的难度。
    4.3 主函数中的函数上面给出的测试智能合约中只有两个函数,但是反编译出来的主函数中,却有4个函数调用,其中两个是公有函数,另两个是公有变量。
    智能合约变量/函数类型只有两种,公有和私有,公有和私有的区别很简单,公有的是能别外部调用访问,私有的只能被本身调用访问。
    对于变量,不管是公有还是私有都能通过getStorageAt访问,但是这是属于以太坊层面的,在智能合约层面,把公有变量给编译成了一个公有函数,在这公有函数中返回SLOAD(slot),而私有函数只能在其他函数中特定的地方调用SLOAD(slot)来访问。
    在上面测试的智能合约中, test1()函数等同于owner(),我们可以来看看各自的OPCODE:
    ; test1(); 0x66: loc_66[ 0x66] | JUMPDEST | None[ 0x67] | CALLVALUE | None[ 0x68] | DUP1 | None[ 0x69] | ISZERO | None[ 0x6a] | PUSH2 | ['0x72'][ 0x6d] | JUMPI | None[ 0x6e] | PUSH1 | ['0x0'][ 0x70] | DUP1 | None[ 0x71] | REVERT | None; 0x72: loc_72[ 0x72] | JUMPDEST | None[ 0x73] | POP | None[ 0x74] | PUSH2 | ['0x7b'][ 0x77] | PUSH2 | ['0xfa'][ 0x7a] | JUMP | None; 0xFA: loc_fa[ 0xfa] | JUMPDEST | None[ 0xfb] | PUSH1 | ['0x0'][ 0xfd] | SLOAD | None[ 0xfe] | PUSH20 | ['0xffffffffffffffffffffffffffffffffffffffff'][ 0x113] | AND | None[ 0x114] | SWAP1 | None[ 0x115] | JUMP | None; 0x7B: loc_7b[ 0x7b] | JUMPDEST | None[ 0x7c] | PUSH1 | ['0x40'][ 0x7e] | DUP1 | None[ 0x7f] | MLOAD | None[ 0x80] | PUSH20 | ['0xffffffffffffffffffffffffffffffffffffffff'][ 0x95] | SWAP1 | None[ 0x96] | SWAP3 | None[ 0x97] | AND | None[ 0x98] | DUP3 | None[ 0x99] | MSTORE | None[ 0x9a] | MLOAD | None[ 0x9b] | SWAP1 | None[ 0x9c] | DUP2 | None[ 0x9d] | SWAP1 | None[ 0x9e] | SUB | None[ 0x9f] | PUSH1 | ['0x20'][ 0xa1] | ADD | None[ 0xa2] | SWAP1 | None[ 0xa3] | RETURN | None
    和owner()函数进行对比:
    ; owner(); 0xA4: loc_a4[ 0xa4] | JUMPDEST | None[ 0xa5] | CALLVALUE | None[ 0xa6] | DUP1 | None[ 0xa7] | ISZERO | None[ 0xa8] | PUSH2 | ['0xb0'][ 0xab] | JUMPI | None[ 0xac] | PUSH1 | ['0x0'][ 0xae] | DUP1 | None[ 0xaf] | REVERT | None; 0xB0: loc_b0[ 0xb0] | JUMPDEST | None[ 0xb1] | POP | None[ 0xb2] | PUSH2 | ['0x7b'][ 0xb5] | PUSH2 | ['0x116'][ 0xb8] | JUMP | None; 0x116: loc_116[ 0x116] | JUMPDEST | None[ 0x117] | PUSH1 | ['0x0'][ 0x119] | SLOAD | None[ 0x11a] | PUSH20 | ['0xffffffffffffffffffffffffffffffffffffffff'][ 0x12f] | AND | None[ 0x130] | DUP2 | None[ 0x131] | JUMP | None; 0x7B: loc_7b[ 0x7b] | JUMPDEST | None[ 0x7c] | PUSH1 | ['0x40'][ 0x7e] | DUP1 | None[ 0x7f] | MLOAD | None[ 0x80] | PUSH20 | ['0xffffffffffffffffffffffffffffffffffffffff'][ 0x95] | SWAP1 | None[ 0x96] | SWAP3 | None[ 0x97] | AND | None[ 0x98] | DUP3 | None[ 0x99] | MSTORE | None[ 0x9a] | MLOAD | None[ 0x9b] | SWAP1 | None[ 0x9c] | DUP2 | None[ 0x9d] | SWAP1 | None[ 0x9e] | SUB | None[ 0x9f] | PUSH1 | ['0x20'][ 0xa1] | ADD | None[ 0xa2] | SWAP1 | None[ 0xa3] | RETURN | None
    所以我们可以得出结论:
    address public a;会被编译成(==)function a() public returns (address) { return a;}#address private a;function c() public returns (address) { return a;}等同于下面的变量定义(≈)address public c;
    公有函数和私有函数的区别也很简单,公有函数会被编译进主函数中,能通过CALLDATA进行调用,而私有函数则只能在其他公有函数中进行调用,无法直接通过设置CALLDATA来调用私有函数。
    4.4 回退函数和payable在智能合约中,函数都能设置一个payable,还有一个特殊的回退函数,下面用实例来介绍回退函数。
    比如之前的测试合约加上了回退函数:
    function() { prize += 1;}则主函数的反编译代码就变成了:
    def main(): if CALLDATASIZE >= 4: data = CALLDATA[:4] if data == 0x6b59084d: return test1() elif data == 0x8da5cb5b: return owner() elif data == 0xcaf44683: return test2() elif data == 0xe3ac5d26: return prize() assert msg.value == 0 prize += 1 exit()当CALLDATA和该合约中的函数匹配失败时,将会从抛异常,表示执行失败退出,变成调用回退函数。
    每一个函数,包括回退函数都可以加一个关键字: payable,表示可以给该函数转帐,从OPCODE层面讲,没有payable关键字的函数比有payable的函数多了一段代码:
    JUMPDEST | NoneCALLVALUE | NoneDUP1 | NoneISZERO | NonePUSH2 | ['0x8e']JUMPI | NonePUSH1 | ['0x0']DUP1 | NoneREVERT | None
    反编译成python,就是:
    assert msg.value == 0
    REVERT是异常退出指令,当交易的金额大于0时,则异常退出,交易失败。
    4.5 函数参数函数获取数据的方式只有两种,一个是从storage中获取数据,另一个就是接受用户传参,当函数hash表匹配成功时,我们可以知道该函数的参数个数,和各个参数的类型,但是当hash表匹配失败时,我们仍然可以获取该函数参数的个数,因为获取参数和主函数、payable检查一样,在OPCODE层面也有固定模型:
    比如上面的测试合约,调动test2函数的固定模型就是:main -> payable check -> get args -> 执行函数代码。
    获取参数的OPCODE如下:
    ; 0xAF: loc_af[ 0xaf] | JUMPDEST | None[ 0xb0] | POP | None[ 0xb1] | PUSH2 | ['0xd1'][ 0xb4] | PUSH20 | ['0xffffffffffffffffffffffffffffffffffffffff'][ 0xc9] | PUSH1 | ['0x4'][ 0xcb] | CALLDATALOAD | None[ 0xcc] | AND | None[ 0xcd] | PUSH2 | ['0x18f'][ 0xd0] | JUMP | None

    函数test2的参数p = CALLDATA[4:4+0x20]
    如果有第二个参数,则是arg2 = CALLDATA[4+0x20:4+0x40],以此类推

    所以智能合约中,调用函数的规则就是data = sha3(func_name)[:4] + *args。
    但是,上面的规则仅限于定长类型的参数,如果参数是string这种不定长的变量类型时,固定模型仍然不变,但是在从calldata获取数据的方法,变得不同了,定长的变量是通过调用CALLDATALOAD,把值存入栈中,而string类型的变量,因为长度不定,会超过256bits的原因,使用的是calldatacopy把参数存入MEM。
    可以看看function test3(string a) public {}函数获取参数的代码:
    ; 0xB2: loc_b2[ 0xb2] | JUMPDEST | None[ 0xb3] | POP | None[ 0xb4] | PUSH1 | ['0x40'][ 0xb6] | DUP1 | None[ 0xb7] | MLOAD | None[ 0xb8] | PUSH1 | ['0x20'][ 0xba] | PUSH1 | ['0x4'][ 0xbc] | DUP1 | None[ 0xbd] | CALLDATALOAD | None[ 0xbe] | DUP1 | None[ 0xbf] | DUP3 | None[ 0xc0] | ADD | None[ 0xc1] | CALLDATALOAD | None[ 0xc2] | PUSH1 | ['0x1f'][ 0xc4] | DUP2 | None[ 0xc5] | ADD | None[ 0xc6] | DUP5 | None[ 0xc7] | SWAP1 | None[ 0xc8] | DIV | None[ 0xc9] | DUP5 | None[ 0xca] | MUL | None[ 0xcb] | DUP6 | None[ 0xcc] | ADD | None[ 0xcd] | DUP5 | None[ 0xce] | ADD | None[ 0xcf] | SWAP1 | None[ 0xd0] | SWAP6 | None[ 0xd1] | MSTORE | None[ 0xd2] | DUP5 | None[ 0xd3] | DUP5 | None[ 0xd4] | MSTORE | None[ 0xd5] | PUSH2 | ['0xff'][ 0xd8] | SWAP5 | None[ 0xd9] | CALLDATASIZE | None[ 0xda] | SWAP5 | None[ 0xdb] | SWAP3 | None[ 0xdc] | SWAP4 | None[ 0xdd] | PUSH1 | ['0x24'][ 0xdf] | SWAP4 | None[ 0xe0] | SWAP3 | None[ 0xe1] | DUP5 | None[ 0xe2] | ADD | None[ 0xe3] | SWAP2 | None[ 0xe4] | SWAP1 | None[ 0xe5] | DUP2 | None[ 0xe6] | SWAP1 | None[ 0xe7] | DUP5 | None[ 0xe8] | ADD | None[ 0xe9] | DUP4 | None[ 0xea] | DUP3 | None[ 0xeb] | DUP1 | None[ 0xec] | DUP3 | None[ 0xed] | DUP5 | None[ 0xee] | CALLDATACOPY | None[ 0xef] | POP | None[ 0xf0] | SWAP5 | None[ 0xf1] | SWAP8 | None[ 0xf2] | POP | None[ 0xf3] | PUSH2 | ['0x166'][ 0xf6] | SWAP7 | None[ 0xf7] | POP | None[ 0xf8] | POP | None[ 0xf9] | POP | None[ 0xfa] | POP | None[ 0xfb] | POP | None[ 0xfc] | POP | None[ 0xfd] | POP | None[ 0xfe] | JUMP | None
    传入的变长参数是一个结构体:
    struct string_arg { uint offset; uint length; string data;}
    offset+4表示的是当前参数的length的偏移,length为data的长度,data就是用户输入的字符串数据。
    当有多个变长参数时: function test3(string a, string b) public {}。
    calldata的格式如下: sha3(func)[:4] + a.offset + b.offset + a.length + a.data + b.length + b.data
    翻译成py代码如下:
    def test3(): offset = data[4:0x24] length = data[offset+4:offset+4+0x20] a = data[offset+4+0x20:length] offset = data[0x24:0x24+0x20] length = data[offset+4:offset+4+0x20] b = data[offset+4+0x20:length]
    因为参数有固定的模型,因此就算没有从hash表中匹配到函数名,也可以判断出函数参数的个数,但是要想知道变量类型,只能区分出定长、变长变量,具体是uint还是address,则需要从函数代码,变量的使用中进行判断。
    4.6 变量类型的分辨在智能合约的OPCDOE中,变量也是有特征的。
    比如一个address变量总会 & 0xffffffffffffffffffffffffffffffffffffffff:
    PUSH1 | ['0x0']SLOAD | NonePUSH20 | ['0xffffffffffffffffffffffffffffffffffffffff']AND | None
    上一篇说的mapping和array的储存模型,可以根据SHA3的计算方式知道是映射变量还是数组变量。
    再比如,uint变量因为等同于uint256,所以使用SLOAD获取以后不会再进行AND计算,但是uint8却会计算& 0xff。
    所以我们可以SLOAD指令的参数和后面紧跟的计算,来判断出变量类型。
    五、智能合约代码结构5.1 部署合约在区块链上,要同步/发布任何信息,都是通过发送交易来进行的,用之前的测试合约来举例,合约地址为:0xc9fbe313dc1d6a1c542edca21d1104c338676ffd,创建合约的交易地址为::0x6cf9d5fe298c7e1b84f4805adddba43e7ffc8d8ffe658b4c3708f42ed94d90ed。
    查看下该交易的相关信息:
    > eth.getTransaction("0x6cf9d5fe298c7e1b84f4805adddba43e7ffc8d8ffe658b4c3708f42ed94d90ed"){ blockHash: "0x7f684a294f39e16ba1e82a3b6d2fc3a1e82ef023b5fb52261f9a89d831a24ed5", blockNumber: 3607048, from: "0x0109dea8b64d87a26e7fe9af6400375099c78fdd", gas: 171331, gasPrice: 1000000000, hash: "0x6cf9d5fe298c7e1b84f4805adddba43e7ffc8d8ffe658b4c3708f42ed94d90ed", input: "0x608060405234801561001057600080fd5b5060008054600160a060020a0319163317905561016f806100326000396000f3006080604052600436106100615763ffffffff7c01000000000000000000000000000000000000000000000000000000006000350416636b59084d81146100665780638da5cb5b146100a4578063caf44683146100b9578063e3ac5d26146100d3575b600080fd5b34801561007257600080fd5b5061007b6100fa565b6040805173ffffffffffffffffffffffffffffffffffffffff9092168252519081900360200190f35b3480156100b057600080fd5b5061007b610116565b3480156100c557600080fd5b506100d1600435610132565b005b3480156100df57600080fd5b506100e861013d565b60408051918252519081900360200190f35b60005473ffffffffffffffffffffffffffffffffffffffff1690565b60005473ffffffffffffffffffffffffffffffffffffffff1681565b600180549091019055565b600154815600a165627a7a7230582040d052fef9322403cb3c1de27683a42a845e091972de4c264134dd575b14ee4e0029", nonce: 228, r: "0xa08f0cd907207af4de54f9f63f3c9a959c3e960ef56f7900d205648edbd848c6", s: "0x5bb99e4ab9fe76371e4d67a30208aeac558b2989a6c783d08b979239c8221a88", to: null, transactionIndex: 4, v: "0x2a", value: 0}
    我们可以看出来,想一个空目标发送OPCODE的交易就是创建合约的交易,但是在交易信息中,却不包含合约地址,那么合约地址是怎么得到的呢?
    function addressFrom(address _origin, uint _nonce) public pure returns (address) { if(_nonce == 0x00) return address(keccak256(byte(0xd6), byte(0x94), _origin, byte(0x80))); if(_nonce <= 0x7f) return address(keccak256(byte(0xd6), byte(0x94), _origin, byte(_nonce))); if(_nonce <= 0xff) return address(keccak256(byte(0xd7), byte(0x94), _origin, byte(0x81), uint8(_nonce))); if(_nonce <= 0xffff) return address(keccak256(byte(0xd8), byte(0x94), _origin, byte(0x82), uint16(_nonce))); if(_nonce <= 0xffffff) return address(keccak256(byte(0xd9), byte(0x94), _origin, byte(0x83), uint24(_nonce))); return address(keccak256(byte(0xda), byte(0x94), _origin, byte(0x84), uint32(_nonce))); // more than 2^32 nonces not realistic }
    智能合约的地址由创建合约的账号和nonce决定,nonce用来记录用户发送的交易个数,在每个交易中都有该字段,现在根据上面的信息来计算下合约地址:
    # 创建合约的账号 from: "0x0109dea8b64d87a26e7fe9af6400375099c78fdd",# nonce: 228 = 0xe4 => 0x7f < 0xe4 < 0xff>>> sha3.keccak_256(binascii.unhexlify("d7" + "94" + "0109dea8b64d87a26e7fe9af6400375099c78fdd" + "81e4")).hexdigest()[-40:]'c9fbe313dc1d6a1c542edca21d1104c338676ffd'
    5.2 创建合约代码一个智能合约的OPCODE分为两种,一个是编译器编译好后的创建合约代码,还是合约部署好以后runtime代码,之前我们看的,研究的都是runtime代码,现在来看看创建合约代码,创建合约代码可以在创建合约交易的input数据总获取,上面已经把数据粘贴出来了,反汇编出指令如下:
    ; 0x0: main[ 0x0] | PUSH1 | ['0x80'][ 0x2] | PUSH1 | ['0x40'][ 0x4] | MSTORE | None[ 0x5] | CALLVALUE | None[ 0x6] | DUP1 | None[ 0x7] | ISZERO | None[ 0x8] | PUSH2 | ['0x10'][ 0xb] | JUMPI | None[ 0xc] | PUSH1 | ['0x0'][ 0xe] | DUP1 | None[ 0xf] | REVERT | None----------------------------------------------------------------; 0x10: loc_10[ 0x10] | JUMPDEST | None[ 0x11] | POP | None[ 0x12] | PUSH1 | ['0x0'][ 0x14] | DUP1 | None[ 0x15] | SLOAD | None[ 0x16] | PUSH1 | ['0x1'][ 0x18] | PUSH1 | ['0xa0'][ 0x1a] | PUSH1 | ['0x2'][ 0x1c] | EXP | None[ 0x1d] | SUB | None[ 0x1e] | NOT | None[ 0x1f] | AND | None[ 0x20] | CALLER | None[ 0x21] | OR | None[ 0x22] | SWAP1 | None[ 0x23] | SSTORE | None[ 0x24] | PUSH2 | ['0x24f'][ 0x27] | DUP1 | None[ 0x28] | PUSH2 | ['0x32'][ 0x2b] | PUSH1 | ['0x0'][ 0x2d] | CODECOPY | None[ 0x2e] | PUSH1 | ['0x0'][ 0x30] | RETURN | None
    代码逻辑很简单,就是执行了合约的构造函数,并且返回了合约的runtime代码,该合约的构造函数为:
    function Test() { owner = msg.sender;}

    因为没有payable关键字,所以开头是一个check代码assert msg.value == 0
    然后就是对owner变量的赋值,当执行完构造函数后,就是把runtime代码复制到内存中:
    CODECOPY(0, 0x32, 0x24f) # mem[0:0+0x24f] = CODE[0x32:0x32+0x24f]
    最后在把runtime代码返回: return mem[0:0x24f]

    在完全了解合约是如何部署的之后,也许可以写一个OPCODE混淆的CTF逆向题。
    总结通过了解EVM的数据结构模型,不仅可以加快对OPCODE的逆向速度,对于编写反编译脚本也有非常大的帮助,可以对反编译出来的代码进行优化,使得更加接近源码。
    0  留言 2020-08-09 12:24:10
  • 从solc编译过程来理解solidity合约结构


    版权声明:本文由伏宸区块链安全实验室原创发布转载,请参考转载声明,注明出处: https://www.anquanke.com/post/id/164567

    最近在学习 solc 编译器方面的知识,看到这篇博文主要介绍得通俗易懂,故转载分享。
    现在以一个最简单的代码来开始我们的逆向旅程,为了方便学习,所有的代码编译和分析都在http://remix.ethereum.org/# 上进行.默认IDE 选项是关闭代码优化(Enable Optimization)和使用0.4.25 版本的solidity 编译器编译。
    pragma solidity ^0.4.24;contract test { function a() { uint a = 123; }}
    在Remix 上输入上述代码,点击Start to Compile 对代码进行编译,可以在Details 按钮里面获取编译出来的结果.结果包含如下:

    METADATA:编译器元数据,包含:编译器版本,编译器设置,源码信息等
    BYTECODE:合约编译完整的字节码结果
    ABI:应用程序接口,用于标识合约提供了哪些函数给外部调用
    WEB3DEPLOY:Web3js版合约部署代码
    METADATAHASH:元数据哈希
    GASESTIMATES:编译器计算函数调用需要消耗的Gas表
    RUNTIME BYTECODE:合约运行时字节码
    ASSEMBLY:字节码反汇编

    读者们会注意到,编译结果里面有 ByteCode 和 Runtime Bytecode, 分别表示什么意思呢?在此先略过这个疑问,我们关注Runtime Bytecode 中返回的字节码.对代码结构分析和字节码分析的标注后所有代码如下:
    000 PUSH1 80 002 PUSH1 40 004 MSTORE ; 把0x80 写到[0x40 ,0x40 + 0x20] 这块内存里面,因为内存是空的,这会创建新的内存 005 PUSH1 04 007 CALLDATASIZE ; 获取CALLDATA 的长度 008 LT ; LT 和PUSH1 0x4 对应,判断CALLDATA 的长度是否小于0x4 009 PUSH1 3f 011 JUMPI ; 如果小于0x4 就往下跳转到0x3F 012 PUSH1 00 014 CALLDATALOAD ; CALLDATALOAD 0x0 ,PUSH1 0x0 是给CALLDATALOAD 的参数,意思要获取CALLDATA 数据的偏移位置 015 PUSH29 0100000000000000000000000000000000000000000000000000000000 045 SWAP1 046 DIV ; DIV 和PUSH29 对应,意思是把上面的数据向左移28 字节,剩下4 字节是调用合约函数名的哈希 047 PUSH4 ffffffff 052 AND ; AND 和PUSH4 0xFFFFFFFF 对应,保留低位4 字节数据,高位去处 053 DUP1 054 PUSH4 0dbe671f ; 这个是合约有的函数名,经过sha3() 精简过的 059 EQ ; 判断传递过来的函数调用名是否相等 060 PUSH1 44 062 JUMPI ; 如果两值相等就往下跳转到0x44 063 JUMPDEST ; 空指令 064 PUSH1 00 066 DUP1 067 REVERT ; 没有匹配到相应的函数就撤销所有操作,Revert(0,0) 068 JUMPDEST 069 CALLVALUE ; 获取用户转帐数额 070 DUP1 071 ISZERO ; 如果用户转帐数额为0 072 PUSH1 4f 074 JUMPI ; 转帐数额不为0 则跳到0x4F,否则就退出 075 PUSH1 00 077 DUP1 078 REVERT ; 因为调用函数a() 是不需要附加转帐金额的,所以检测到带有附加金额的函数调用就退出,参考payable 关键字 079 JUMPDEST 080 POP 081 PUSH1 56 083 PUSH1 58 085 JUMP ; 跳转到地址88 086 JUMPDEST 087 STOP ; 停止执行 088 JUMPDEST 089 PUSH1 00 091 PUSH1 7b 093 SWAP1 094 POP 095 POP 096 JUMP ; 跳转到地址86 097 STOP ---- 合约代码结束分界线 ---- 098 LOG1 099 PUSH6 627a7a723058 106 SHA3 107 MUL 108 PUSH15 5fd8c2f6fe4103dba9baf9c48c052e 124 CALLDATALOAD 125 INVALID 126 PUSH1 d9 128 INVALID 129 INVALID 130 TIMESTAMP 131 STATICCALL 132 INVALID 133 INVALID 134 DUP13 135 INVALID 136 TIMESTAMP 137 INVALID 138 NUMBER 139 STOP 140 INVALID
    标注信息把合约代码的结构和执行过程的思路都理清了,但是读者们会发现以下的问题:

    为什么会有合约代码结束分界线,多出来的代码究竟是什么?
    为什么会有很多多余的跳转?
    JUMPDEST 是无意思的字节码,为什么会多次出现?

    要解决这些疑问,那么就需要深入到solidity 编译器的源码分析.在https://github.com/ethereum/solidity 中找到 solc 编译器的源码,定位到libsolidityCodegenContractCompiler.cpp 文件的ContractCompiler::compileContract() 函数,对该函数的分析如下:
    void ContractCompiler::compileContract( ContractDefinition const& _contract, std::map<const ContractDefinition*, eth::Assembly const*> const& _contracts) // 合约编译{ // ... initializeContext(_contract, _contracts); // 初始化执行环境上下文 appendFunctionSelector(_contract); // 根据合约内使用到的函数进行汇编构造 appendMissingFunctions(); // 链接不公开的函数(非public 声名)}
    initializeContext() 函数主要功能是初始化执行环境上下文,并把初始化的机器码输出到字节码缓存。
    void ContractCompiler::initializeContext( ContractDefinition const& _contract, map<ContractDefinition const*, eth::Assembly const*> const& _compiledContracts){ m_context.setExperimentalFeatures(_contract.sourceUnit().annotation().experimentalFeatures); m_context.setCompiledContracts(_compiledContracts); m_context.setInheritanceHierarchy(_contract.annotation().linearizedBaseContracts); CompilerUtils(m_context).initialiseFreeMemoryPointer(); // 初始化EVM 内存指针 registerStateVariables(_contract); m_context.resetVisitedNodes(&_contract);}const size_t CompilerUtils::freeMemoryPointer = 64;const size_t CompilerUtils::zeroPointer = CompilerUtils::freeMemoryPointer + 32;const size_t CompilerUtils::generalPurposeMemoryStart = CompilerUtils::zeroPointer + 32;void CompilerUtils::initialiseFreeMemoryPointer(){ m_context << u256(generalPurposeMemoryStart); // generalPurposeMemoryStart 的值为0x80,输出0x80 到字节码缓存 storeFreeMemoryPointer();}void CompilerUtils::storeFreeMemoryPointer(){ m_context << u256(freeMemoryPointer) << Instruction::MSTORE; // freeMemoryPointer 的值为0x40 ,输出0x40 和指令MSTORE 到字节码缓存}
    根据上面的代码可以知道,ContractCompiler::initializeContext() 会输出MSTORE 0x40,0x80 到合约字节码的头部,也就是我们常看到合约机器码的开头部分:6080604052 .appendFunctionSelector() 函数是把合约里面编译好的函数和合约初始化检测的代码组合在一起,如果没有深入了解appendFunctionSelector() 的代码生成过程,那就很难理解solc 为什么会这样生成字节码。
    void ContractCompiler::appendFunctionSelector(ContractDefinition const& _contract){ map<FixedHash<4>, FunctionTypePointer> interfaceFunctions = _contract.interfaceFunctions(); // 合约中声名的公开函数列表 map<FixedHash<4>, const eth::AssemblyItem> callDataUnpackerEntryPoints; // 函数代码入口点 if (_contract.isLibrary()) // 判断合约是否为库代码 { solAssert(m_context.stackHeight() == 1, "CALL / DELEGATECALL flag expected."); } FunctionDefinition const* fallback = _contract.fallbackFunction(); eth::AssemblyItem notFound = m_context.newTag(); // 创建新的汇编Tag ,Tag 的意义是用来标注汇编代码块声名和跳转到某一段汇编用的 // directly jump to fallback if the data is too short to contain a function selector // also guards against short data m_context << u256(4) << Instruction::CALLDATASIZE << Instruction::LT; // 判断CALLDATA 内容长度是否大于等于4 字节 m_context.appendConditionalJumpTo(notFound); // 插入条件跳转,LT 判断不通过就跳转到notFound 代码块 // retrieve the function signature hash from the calldata if (!interfaceFunctions.empty()) CompilerUtils(m_context).loadFromMemory(0, IntegerType(CompilerUtils::dataStartOffset * 8), true); // 从CALLDATA 中提取要调用的函数哈希 // 构造代码PUSH 0x00 ,CALLDATALOAD ,PUSH29 0100000000000000000000000000000000000000000000000000000000 ,SWAP1 ,DIV ,PUSH4 0xFFFFFFFF ,AND // stack now is: <can-call-non-view-functions>? <funhash> for (auto const& it: interfaceFunctions) { callDataUnpackerEntryPoints.insert(std::make_pair(it.first, m_context.newTag())); // 对函数入口创建新汇编代码块声名 m_context << dupInstruction(1) << u256(FixedHash<4>::Arith(it.first)) << Instruction::EQ; // 构造代码:DUP1 ,PUSH4 函数哈希 ,EQ m_context.appendConditionalJumpTo(callDataUnpackerEntryPoints.at(it.first)); // 如果数值相同,则跳转到目的函数地址,对应PUSH + JUMPI 指令 } m_context.appendJumpTo(notFound); // 没有找到的话就跳转到notFound 触发revert(0) 退出 m_context << notFound; // 声名notFound 的代码段 if (fallback) { solAssert(!_contract.isLibrary(), ""); if (!fallback->isPayable()) appendCallValueCheck(); solAssert(fallback->isFallback(), ""); solAssert(FunctionType(*fallback).parameterTypes().empty(), ""); solAssert(FunctionType(*fallback).returnParameterTypes().empty(), ""); fallback->accept(*this); m_context << Instruction::STOP; } else // TODO: error message here? m_context.appendRevert(); // 对notFound 的代码进行填充,因为fallback=fakse ,执行m_context.appendRevert() ,所以notFound 的代码序列是 PUSH1 0x00 ,DUP1 ,REVERT .意思是revert(0x0) // 上面是构造合约执行数据检测的代码,下面是对各个公开调用(指public 声名)的函数进行入口点构造 for (auto const& it: interfaceFunctions) { FunctionTypePointer const& functionType = it.second; solAssert(functionType->hasDeclaration(), ""); CompilerContext::LocationSetter locationSetter(m_context, functionType->declaration()); m_context << callDataUnpackerEntryPoints.at(it.first); if (_contract.isLibrary() && functionType->stateMutability() > StateMutability::View) // 库函数且关键字声名不是pure 和view 的函数 { // If the function is not a view function and is called without DELEGATECALL, // we revert. m_context << dupInstruction(2); m_context.appendConditionalRevert(); } m_context.setStackOffset(0); // We have to allow this for libraries, because value of the previous // call is still visible in the delegatecall. if (!functionType->isPayable() && !_contract.isLibrary()) // 如果函数没有启用Payable 关键字或者这是库函数的话,都不支持接收合约调用携带转帐金额 appendCallValueCheck(); // 添加对转帐金额检测代码 // Return tag is used to jump out of the function. eth::AssemblyItem returnTag = m_context.pushNewTag(); // 对函数创建返回代码段声名 if (!functionType->parameterTypes().empty()) // 如果函数有参数的话 { // Parameter for calldataUnpacker m_context << CompilerUtils::dataStartOffset; // CompilerUtils::dataStartOffset 指的是函数参数数据在TXDATA 里的偏移 m_context << Instruction::DUP1 << Instruction::CALLDATASIZE << Instruction::SUB; // 计算函数参数的大小 CompilerUtils(m_context).abiDecode(functionType->parameterTypes()); // 从TXDATA 中获取参数 } m_context.appendJumpTo(m_context.functionEntryLabel(functionType->declaration())); // 调转到函数入口点 m_context << returnTag; // 声名函数返回的代码段 // Return tag and input parameters get consumed. m_context.adjustStackOffset( CompilerUtils(m_context).sizeOnStack(functionType->returnParameterTypes()) - CompilerUtils(m_context).sizeOnStack(functionType->parameterTypes()) - 1 ); // Consumes the return parameters. appendReturnValuePacker(functionType->returnParameterTypes(), _contract.isLibrary()); // 构造函数返回值处理 }}
    上面的代码分析可能看起来有些晦涩难懂,作者把上面分析到的编译过程一一对应到编译结果并标注,汇编代码如下:
    ----- initializeContext() -> initialiseFreeMemoryPointer() ---- 000 PUSH1 80 ; initialiseFreeMemoryPointer() , m_context << u256(0x80) 002 PUSH1 40 ; storeFreeMemoryPointer() 004 MSTORE ; storeFreeMemoryPointer() , m_context << u256(0x40) << Instruction::MSTORE; ----- initialiseFreeMemoryPointer() ----- ----- appandFunctionSelector() Create Code Start --- 005 PUSH1 04 ; /-- 007 CALLDATASIZE ; | 008 LT ; | 检测CallData 是否合法,CallData 会带有4 字节函数哈希 009 PUSH1 3f ; | 011 JUMPI ; -- 012 PUSH1 00 ; /-- 014 CALLDATALOAD ; | 015 PUSH29 0100000000000000000000000000000000000000000000000000000000 045 SWAP1 ; | 046 DIV ; | 处理CallData 里面带入的函数哈希.默认读出来数据是在高位,现在处理成低位 047 PUSH4 ffffffff ; | 052 AND ; -- 053 DUP1 ; /-- 054 PUSH4 0dbe671f ; | 059 EQ ; | 060 PUSH1 44 ; | 根据函数哈希找入口 062 JUMPI ; | 063 JUMPDEST ; -- 064 PUSH1 00 ; /-- 066 DUP1 ; | notFound 代码填充 067 REVERT ; -- ----- appandFunctionSelector() 会给每个函数根据参数调用来分配函数头入口初始化检测代码 --- 068 JUMPDEST ; Address 0x44 , function a() Entry .. 069 CALLVALUE ; /-- 070 DUP1 ; | 071 ISZERO ; | 072 PUSH1 4f ; | 074 JUMPI ; | solc /libsolidity/Codegen/ContractCompiler.cpp:appendCallValueCheck() 075 PUSH1 00 ; | 077 DUP1 ; | 078 REVERT ; -- ----- Tag function_A_pre_JumpTo_function_A_Tag_Code ---- ----- m_context.appendJumpTo(m_context.functionEntryLabel(functionType->declaration())); 079 JUMPDEST ; JUMPDEST 是无意义的代码,它的唯一意义是用来标识这是一个Label 起始头 080 POP 081 PUSH1 56 ; eth::AssemblyItem returnTag = m_context.pushNewTag(); 0x56 is Return Address 083 PUSH1 58 ; / 085 JUMP ; m_context.appendJumpTo(m_context.functionEntryLabel(functionType->declaration())); ----- Tag function_A_return_Code ---- 086 JUMPDEST ; Address 0x56 087 STOP ; ContractCompiler::appendReturnValuePacker(); ----- Tag function_A_Main_Code ---- ----- CompilerContext 是保存每一个函数编译好的代码 088 JUMPDEST ; Address 0x58 , m_context.functionEntryLabel("a"); .. 089 PUSH1 00 091 PUSH1 7b 093 SWAP1 ; 0x7B ,0x00 == SWAP => 0x00 ,0x7B .意义是对栈进行平衡 094 POP 095 POP 096 JUMP ; Jump 0x56 097 STOP ; ----- appandFunctionSelector() Create Code End --- 098 LOG1 099 PUSH6 627a7a723058 106 SHA3 107 MUL 108 PUSH15 5fd8c2f6fe4103dba9baf9c48c052e 124 CALLDATALOAD 125 INVALID 126 PUSH1 d9 128 INVALID 129 INVALID 130 TIMESTAMP 131 STATICCALL 132 INVALID 133 INVALID 134 DUP13 135 INVALID 136 TIMESTAMP 137 INVALID 138 NUMBER 139 STOP 140 INVALID
    结合编译器的编译过程和编译出来的结果来阅读理解代码之后,可以知道合约汇编代码的结构:

    初始化EVM memory
    检测TXDATA 里面是否带有合法的函数哈希
    函数跳转
    函数预校验代码
    函数参数获取代码
    函数返回代码
    函数主体代码

    读者可能会有一个疑问,为什么在汇编代码 097 STOP 的后面还有多余的代码呢,这些代码的意义何在.我们来阅读CompilerStack::compileContract() 的代码:
    void CompilerStack::compileContract( ContractDefinition const& _contract, map<ContractDefinition const*, eth::Assembly const*>& _compiledContracts){ // ... shared_ptr<Compiler> compiler = make_shared<Compiler>(m_evmVersion, m_optimize, m_optimizeRuns); compiledContract.compiler = compiler; string metadata = createMetadata(compiledContract); // 创建元数据 compiledContract.metadata = metadata; bytes cborEncodedMetadata = createCBORMetadata( // 生成CBOR 元数据 metadata, !onlySafeExperimentalFeaturesActivated(_contract.sourceUnit().annotation().experimentalFeatures) ); try { // Run optimiser and compile the contract. compiler->compileContract(_contract, _compiledContracts, cborEncodedMetadata); // 编译合约 } catch(eth::OptimizerException const&) { solAssert(false, "Optimizer exception during compilation"); } // ...}
    可以看到,编译合约的时候 cborEncodedMetadata 的数据也带入 compileContract() ,compileContract() 代码如下:
    void Compiler::compileContract( ContractDefinition const& _contract, std::map<const ContractDefinition*, eth::Assembly const*> const& _contracts, bytes const& _metadata){ ContractCompiler runtimeCompiler(nullptr, m_runtimeContext, m_optimize); // 初始化合约编译类 runtimeCompiler.compileContract(_contract, _contracts); // 编译合约代码 m_runtimeContext.appendAuxiliaryData(_metadata); // 把CBOR 元数据附加在编译之后合约代码末端 // ...}
    那么回来阅读createCBORMetadata() 的代码,发现它其实是使用了元数据来构造出的数据。
    bytes CompilerStack::createCBORMetadata(string _metadata, bool _experimentalMode){ bytes cborEncodedHash = // CBOR-encoding of the key "bzzr0" bytes{0x65, 'b', 'z', 'z', 'r', '0'}+ // CBOR-encoding of the hash bytes{0x58, 0x20} + dev::swarmHash(_metadata).asBytes(); bytes cborEncodedMetadata; if (_experimentalMode) cborEncodedMetadata = // CBOR-encoding of {"bzzr0": dev::swarmHash(metadata), "experimental": true} bytes{0xa2} + cborEncodedHash + bytes{0x6c, 'e', 'x', 'p', 'e', 'r', 'i', 'm', 'e', 'n', 't', 'a', 'l', 0xf5}; else cborEncodedMetadata = // CBOR-encoding of {"bzzr0": dev::swarmHash(metadata)} bytes{0xa1} + cborEncodedHash; solAssert(cborEncodedMetadata.size() <= 0xffff, "Metadata too large"); // 16-bit big endian length cborEncodedMetadata += toCompactBigEndian(cborEncodedMetadata.size(), 2); return cborEncodedMetadata;}
    我们从Bytecode 中提取出CBOR 编码,数据为a165627a7a723058207ba6766efb653d5e4d3b7d5893d345b79718b5513bd5a87d5bf8256fa895c58d0029 ,对它的标注如下:
    a1 ; Flag : experimental = False 65627a7a72305820 ; CBOREncodeHash 7ba6766efb653d5e4d3b7d5893d345b79718b5513bd5a87d5bf8256fa895c58d ; BZZA hash 0029 ; hash 长度
    弄懂solidity 的编译过程和为什么会编译出这样的结果之后,现在回来解答前面提出的问题:

    为什么会有合约代码结束分界线,多出来的代码究竟是什么?

    多出来的代码是CBOR 元数据编码
    为什么会有很多多余的跳转?

    多余的跳转是因为appendFunctionSelector() 会帮助函数去构造预处理代码,参数提取代码和返回代码,最后才跳转到函数的主体代码.solidity 和x86 arm 等的汇编不同,它的对函数的参数和返回值处理都不是由函数主体来完成的。
    JUMPDEST 是无意思的字节码,为什么会多次出现?

    编译器在生成编译代码时,可以看到JUMP 和JUMPI 指令会跳转到JUMPDEST 指令.JUMPDEST 指令是solidity 编译器用来标识汇编代码的区段声名(Tag).所以后面的示例汇编代码都会在JUMPDEST 前记录代码区段的意思标注.

    至此,本文还有一个疑问没有被解决:ByteCode 和Runtime Bytecode 分别表示什么意思呢?我们来回顾Compiler::compileContract() 的完整代码:
    void Compiler::compileContract( ContractDefinition const& _contract, std::map<const ContractDefinition*, eth::Assembly const*> const& _contracts, bytes const& _metadata){ ContractCompiler runtimeCompiler(nullptr, m_runtimeContext, m_optimize); // 编译runtime 代码 runtimeCompiler.compileContract(_contract, _contracts); m_runtimeContext.appendAuxiliaryData(_metadata); // 插入CBOR 编码 // This might modify m_runtimeContext because it can access runtime functions at // creation time. ContractCompiler creationCompiler(&runtimeCompiler, m_context, m_optimize); // 编译creation 代码 m_runtimeSub = creationCompiler.compileConstructor(_contract, _contracts); m_context.optimise(m_optimize, m_optimizeRuns); // 优化汇编代码}
    可以看到 Compiler::compileContract() 里面分两部分来编译合约代码:runtime 的代码指的是合约编写逻辑的代码;creation 的代码指的是constructor() 的代码.我们回来看ByteCode 和RuntimeCode 的汇编代码来做对比。
    ByteCode 汇编:
    ---- Binary ---- 6080604052348015600f57600080fd5b50608d8061001e6000396000f300608060405260043610603f576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff1680630dbe671f146044575b600080fd5b348015604f57600080fd5b5060566058565b005b6000607b9050505600a165627a7a72305820026e5fd8c2f6fe4103dba9baf9c48c052e35ca60d9cdee42faca258c284224430029 ---- Constructor() Code ---- PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH1 0xF JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH1 0x8D DUP1 PUSH2 0x1E PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN STOP ---- Contract Code ---- PUSH1 0x80 PUSH1 0x40 MSTORE PUSH1 0x4 CALLDATASIZE LT PUSH1 0x3F JUMPI PUSH1 0x0 CALLDATALOAD PUSH29 0x100000000000000000000000000000000000000000000000000000000 SWAP1 DIV PUSH4 0xFFFFFFFF AND DUP1 PUSH4 0xDBE671F EQ PUSH1 0x44 JUMPI JUMPDEST PUSH1 0x0 DUP1 REVERT JUMPDEST CALLVALUE DUP1 ISZERO PUSH1 0x4F JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH1 0x56 PUSH1 0x58 JUMP JUMPDEST STOP JUMPDEST PUSH1 0x0 PUSH1 0x7B SWAP1 POP POP JUMP STOP
    RuntimeCode 汇编:
    ---- Binary ---- 608060405260043610603f576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff1680630dbe671f146044575b600080fd5b348015604f57600080fd5b5060566058565b005b6000607b9050505600a165627a7a72305820026e5fd8c2f6fe4103dba9baf9c48c052e35ca60d9cdee42faca258c284224430029 ---- Contract Code ---- PUSH1 0x80 PUSH1 0x40 MSTORE PUSH1 0x4 CALLDATASIZE LT PUSH1 0x3F JUMPI PUSH1 0x0 CALLDATALOAD PUSH29 0x100000000000000000000000000000000000000000000000000000000 SWAP1 DIV PUSH4 0xFFFFFFFF AND DUP1 PUSH4 0xDBE671F EQ PUSH1 0x44 JUMPI JUMPDEST PUSH1 0x0 DUP1 REVERT JUMPDEST CALLVALUE DUP1 ISZERO PUSH1 0x4F JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH1 0x56 PUSH1 0x58 JUMP JUMPDEST STOP JUMPDEST PUSH1 0x0 PUSH1 0x7B SWAP1 POP POP JUMP STOP
    所以,我们可以明白在部署合约的时候,EVM 执行Constructor() 函数的代码初始化合约数据,后续用户通过Web3 调用节点上的合约函数时,是直接在RuntimeCode 中开始执行合约代码.TXDATA 中是带有用户希望调用函数的哈希和函数的参数数据的,接下来合约代码初始EVM Memory 之后,根据TXDATA 里面指向用户希望调用的函数哈希来进行代码跳转.执行函数主体代码前做一些预检测并从TXDATA 中提取函数参数到栈,最后执行函数主体代码并退出。
    0  留言 2020-08-08 17:38:21
  • Solidity字节码Bytecode的理解


    版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明原文链接:https://blog.csdn.net/Programmer_CJC/article/details/80217672https://blog.csdn.net/Programmer_CJC/article/details/80218649https://blog.csdn.net/Programmer_CJC/article/details/80219720

    最近在学习solidity方面的知识,看到这几篇博文主要介绍evm bytecode的,通俗易懂,故转载分享。
    一、从Bytecode角度分析,EVM如何在基本块之间跳转1.1 BasicBlock1.1.1 条件跳转(JUMPI)1.1.2 非条件跳转(JUMP)1.1.3 Fall to二、EVM Bytecode文件结构以及如何执行三、用solc编译smart contract,用evm反编译bytecode一、从Bytecode角度分析,EVM如何在基本块之间跳转1.1 BasicBlock在解释EVM是如何执行之前,先来解释一下BasicBlock(基本块)。一个基本块由一系列的指令构成,有一个入口和一个出口,入口就是第一个指令,出口就是最后一个指令。
    出口的类型有:

    条件跳转(JUMPI)
    非条件跳转(JUMP)
    结束指令(RETURN,REVERT)
    什么都没有,直接fall to下一个block

    1.1.1 条件跳转(JUMPI)EVM中条件跳转的指令是JUMPI,它会从stack中读取2个元素,分别代表跳转的条件和pc(programmer counter)。下面是一个JUMPI的跳转例子:

    Block1: JUMPDEST CALLVALUE ISZERO PUSH2 0x0100 JUMPI
    Block2: PUSH1 0x00 DUP1 REVERT

    Block1由5个指令构成(PUSH2 0x0100是一个), JUMPDEST表明这个Block是一个跳转的起始位置,CALLVALUE代表从transaction中获得的值,比如用户发送的ether额度,就可以用该指令获得。ISZERO判断CALLVALUE获得的值是否为0, PUSH指令向stack中放入了一个值。最后执行到JUMPI,它从条件中读取了两个值:

    ISZERO(CALLVALUE)
    0x0100

    如果满足1,则跳转到0x0100指向的block, 否则继续执行下一个BasicBlock(Block2) (PS: 如果满足条件跳转之后,执行完跳转的Block会继续往下执行Block2,执行的方式是深度优先遍历的方式)。
    1.1.2 非条件跳转(JUMP)EVM中的非条件跳转由JUMP指令触发, 每次执行到JUMP指令时,都会从stack读出1个值,表示要跳转的pc。和JUMPI指令类似,执行完跳转块后,也会继续向下执行,执行方式是深度优先遍历。
    1.1.3 Fall toEVM的某些基本块没有跳转指令也没有结束指令,对于这些指令,执行完最后一个指令后会继续执行下一个指令。当然对于条件跳转来说,也会有fall to的情况。如在条件跳转中举的例子,在执行完Block1之后,会继续执行Block2。或者Block1的JUMPI跳转条件不满足,也会继续执行Block2。
    二、EVM Bytecode文件结构以及如何执行该小节用一个具体的smart contract以及对应的指令来具体解释EVM bytecode的文件结构以及bytecode如何执行。
    pragma solidity ^0.4.22;contract Demo{ uint public value1 = 0; uint public value2 = 0; function A(uint v) public returns(uint){ value1 += v; return value1; } function B(uint v) public{ value2 += A(v); }}
    上面的智能合约来做例子,由于Bytecode过长就不上传,可以将该代码贴到 http://remix.ethereum.org/#optimize=false&version=soljson-v0.4.22+commit.4cb486ee.js ,直接点击右侧的Details来查看Bytecode:

    下面开始解释一下Bytecode的结构:

    从上面的图来看,Bytecode由两部分构成。第一部分的.code包含了一些smart contract初始化的代码,比如构造函数,state variable(全局变量)的赋值等操作。区块链上,这些都是EOA在部署合约时就执行完成的,在区块链浏览器,如Etherscan,都是无法看到这部分的代码的(某些开源合约会公开这部分的信息,默认是没有的)。
    从.data开始,是smart contract的runtime bytecode,也就是在区块链上保存的合约的bytecode。想要获得该部分的bytecode,可以安装solidity( https://github.com/ethereum/solidity ),通过命令 solc —bin-runtime filePath获得。
    Remix的结构有点不太一样,是由若干个tag组成的,每个tag由若干个基本块组成。以JUMPDEST或者结束指令(RETURN,REVERT,STOP)划分。.code部分是Bytecode的入口,这部分的指令包含了所有能够被外部调用的函数的函数签名和跳转pc(programmer counter)值。

    上面的5个框分别是该合约的5个跳转函数。可能会奇怪合约就2个函数,为何会有5个可跳转函数。这5个跳转函数分别是:1. fallback(回退函数),2个public全局变量,2个public函数。
    首先解释一下回退函数,在EVM中,回退函数是唯一一个未命名的函数,可以发现其他4个框前面都有一个函数签名,如第二个框的3033413B,只有fallback function没有。因此如果我们调用了一个合约中没有的函数,没有一个函数签名能满足,接下来的四个框都不会满足跳转条件,因此会通过fall to的形式执行tag 1,tag 1也就是fallback函数的开始位置。
    接下来说一下什么是函数签名。函数签名是一个4byte的hash值,用来唯一标识smart contract中的函数。它是通过sha3(“functionName(type1, type2)”),取前4bytes得到的。也就是说该函数签名只与函数名,函数类型有关。
    总结一下.code部分,该部分包含了合约能调用的所有函数的跳转地址,从上图中体现就是tag1-5. tag 1-5分别是5个函数的起始位置。

    下面用函数B为例,解释一下EVM的bytecode是如何跳转的:

    要调用函数B,首先EVM会接受到函数签名(DAC0EB07),在.code部分中,跳转到tag 5
    tag 5是函数B的开始部分,tag 5中有一个JUMPI,假设跳转条件满足,EVM会跳转到tag 15,如果不满足条件,则会执行PUSH, DUP1, REVERT. REVERT是终止指令,程序终于。该部分通常是用来判断一个函数是否是payable的。比如CALLVALUE指令会得到transacation是否发了Ether,如果发了ether,ISZERO的结果就会是false,因此不会执行跳转
    执行tag 15, 执行到最后有一个JUMP指令,会从EVM stack读出一个值, 上一个push到stack的值是tag 17,因此跳转到tag 17
    执行tag 17,同tag 15,tag17最后的tag 15会使pc跳转到tag14(tag 14也就是函数A的函数体部分)
    执行tag 14,执行到最后有一个JUMP指令,这时JUMP指令读到的是tag 17中push的tag 20
    执行tag 20, tag20最后的JUMP指令,执行的是tag15中的push tag 16, 因此会跳转到tag 16
    执行tag 16,执行到stop指令,程序终止


    以函数A为例:

    要调用函数A,首先EVM会接收到函数签名(A17A9E66),在.code部分中,跳转到tag 4
    tag 4是函数A的开始部分,假设满足JUMPI的跳转条件,则跳转到tag 12,如果不满足,则继续执行下面的三个指令
    tag 12代表函数读取参数的过程,函数B没有参数因此没有这一部分。最后由JUMP指令跳转到tag 14
    执行tag 14,最后的JUMP读取到的是tag 12中的PUSH tag 13
    执行tag 13, tag 13最后的终止指令是RETURN,代表函数执行结束并返回值

    三、用solc编译smart contract,用evm反编译bytecode首先需要安装solc和evm:

    solc: https://github.com/ethereum/solidity/releases
    evm: https://geth.ethereum.org/downloads

    编译一个smart contract可以通过指令来得到bytecode:
    solc --bin-runtime filepath反编译bytecode可以通过:
    evm --dissam bytecodeFilePath反编译以后的文件如下:

    前面的数字就是pc(programmer counter), 以20行的指令为例,0x008d代表21行的JUMPI跳转的pc值是141.
    solc还有下面几个非常好用的指令,可以获得合约的ast,asm(汇编码),opcode,bin,abi,函数签名等:
    0  留言 2020-08-14 14:53:07
  • 【课程笔记】南大软件分析课程12——Soundiness


    最近在看“静态分析”技术相关的文章,看到这个系列的笔记和视频教程,感觉介绍得很好,通俗易懂,而且还比较详细,故转载分享,同时也备份保留下,方便自己今后阅读。(PS:建议大家一边看笔记,一边看视频,加深理解)原作者:bsauce原文链接:https://www.jianshu.com/p/1ca6e11b1e72

    首先非常感谢南京大学李樾和谭添老师的无私分享,之前学习程序分析是看的北大熊英飞老师的ppt,但是很多地方没看懂,正如李樾老师所说的那样,熊英飞老师的授课涵盖非常广,不听课只看ppt的话,理解起来还是很有难度的。但李樾老师的视频就讲解的非常易懂,示例都是精心挑选的,所以墙裂推荐。
    推送门:南大课件 南大视频课程 北大课件

    目录
    Soundness & Soundiness复杂语言特性一:Java Reflection复杂语言特性二:Native Code
    重点理解soundiness的含义,为什么要引入它?
    理解为什么Java reflection和native code分析这么复杂。
    课程就这么结束了,抽象解释可能要在研究生课程再讲。
    分析真实复杂程序时,产生的问题都与Soundiness有关,是最前沿的topic之一。
    1.Soundness & SoundinessSoundness:保守估计,分析结果能包含程序所有可能的执行。学术界和工业界都做不到。
    复杂语言特性:导致分析结果不精确。

    Java:Reflection, native code, dynamic class loading, etc.
    JavaScript:eval(执行任意命令), document object model (DOM,和DOM加护), etc.
    C/C++:Pointer arithmetic(指针地址+1或乘以), function pointers, etc.

    现状:有些文章不提这类问题,或者随意一提(如eval)。极具误导性,导致相信该工具很sound,且影响专家的评判。
    Soundiness:直观相信的”truth”,但没有任何事实和证据。
    词语对比:

    sound analysis:能捕捉所有动态运行行为,理想化。
    soundy analysis:目标是捕捉所有动态行为,但对于某些复杂语言特性可以unsound。
    unsound analysis:为了效率、精度,故意不处理某些行为。

    2.复杂语言特性一:Java Reflection—反射(1)介绍Java反射Java Reflection:反射机制很重要的一点就是“运行时”,其使得我们可以在程序运行时加载、探索以及使用编译期间完全未知的 .class 文件。换句话说,Java 程序可以加载一个运行时才得知名称的 .class 文件,然后获悉其完整构造,并生成其对象实体、或对其 fields(变量)设值、或调用其 methods(方法)。

    说明:无反射代码在编译时就能确定对象;反射代码在运行时才确定对象,如c指向什么,”Person”也可能是的字符串指针,很难静态分析。分析该类代码很有必要,如弄清对象到底调用了哪个目标函数、对象域的指向关系等。
    (2)分析方法分析方法:String Contant analysis + Pointer Analysis(Reflection Analysis for Java——APLAS 2005)。
    示例:目标是分析m调用的目标函数。

    找到m的定义点,即Method m = c.getMethod(mName, ...);
    通过String Contant analysis找到mName指向内容
    通过指针分析找到c指向内容
    通过String Contant analysis找到cName指向内容
    知道了是调用Person类的setName函数


    问题:若字符串的值无法通过静态分析得到,则反射目标不能求解。Eg,字符串来自配置文件、网络、命令行、复杂字符串处理、动态生成、加密。
    (3)改进解决方法:Type Inference + String analysis + Pointer Analysis(Self-Inferencing Reflection Resolution for Java——ECOOP 2014,李樾,谭添老师的成果)。在创造点不可推,但在使用点可推。
    示例:类名依赖cmd参数,解不出来;但在调用点,通过Java的类型系统推导parameters,发现parameters是this指针。推出结论就是,175行的目标函数有1个参数,且声明类型要么是FrameworkCommandInterpreter要么是其子类。结果推断出50个反射目标函数,48个为true。

    最新工作:Understanding and Analyzing Java Reflection (TOSEM 2019) Yue Li, Tian Tan, Jingling Xue。不仅求解反射对象更准确更多,而且能说出哪里解的不准。
    常用方法:Taming reflection: Aiding static analysis in the presence of reflection and custom class loaders (ICSE 2011)。利用动态分析来解,缺点是测试用例的覆盖路径有限,优点是只要解出来,结果都为真。
    3.复杂语言特性二:Native CodeNative Code:一个Native Method就是一个java调用非java代码的接口。该方法的实现由非java语言实现,已被编译为特定于处理器的机器码的代码,这些代码可以直接被虚拟机执行,与字节码的区别:虚拟机是一个把通用字节码转换成用于特定处理器的本地代码的程序,比如C。这个特征并非java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern “C”告知C++编译器去调用一个C的函数。
    Java Native Interface(JNI):是一种编程框架(函数模型,反映参数格式等),使得Java虚拟机中的Java程序可以调用本地应用/或库,也可以被其他程序调用。 本地程序一般是用其它语言(C、C++或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序。
    示例:先加载Native库,声明Native函数,*env变量可以在Native代码中用于创建对象、访问域、调用Java中的方法等,支持230个JNI函数。问题是跨语言之后,如何静态分析je.guessMe()这个调用?

    方法:对重要的native code手动建模。例如,对经常调用的arraycopy()函数进行建模,建模后就是一个拷贝循环,但从指针分析角度来讲,看到这个循环,我们就把数组指针进行传递。

    最新工作:Identifying Java Calls in Native Code via Binary Scanning (ISSTA 2020)。通过扫描二进制程序来识别native code中的Java调用。
    扩展:想深入研究Soundiness,可参考网站http://soundiness.org 。
    参考Java 反射由浅入深 | 进阶必备
    java native方法使用
    0  留言 2020-08-10 19:00:31
  • 【课程笔记】南大软件分析课程11——CFL可达性&IFDS


    最近在看“静态分析”技术相关的文章,看到这个系列的笔记和视频教程,感觉介绍得很好,通俗易懂,而且还比较详细,故转载分享,同时也备份保留下,方便自己今后阅读。(PS:建议大家一边看笔记,一边看视频,加深理解)原作者:bsauce原文链接:https://www.jianshu.com/p/2bd21a34eb8b

    首先非常感谢南京大学李樾和谭添老师的无私分享,之前学习程序分析是看的北大熊英飞老师的ppt,但是很多地方没看懂,正如李樾老师所说的那样,熊英飞老师的授课涵盖非常广,不听课只看ppt的话,理解起来还是很有难度的。但李樾老师的视频就讲解的非常易懂,示例都是精心挑选的,所以墙裂推荐。
    推送门:南大课件 南大视频课程 北大课件

    目录
    Infeasible and Realizable Paths——基本概念CFL-Reachablity(IFDS的理论基础,识别Realizable path)Overview of IFDSSupergraph(之前叫iCFG) and Flow FunctionsExploded Supergraph and Tabulation AlgorithmUnderstanding the Distributivity of IFDS
    重点IFDS:Interprocedural,Finite,Distributive,Subset Problem。
    理解CFL-Reachablity,工作机制—括号匹配的过程。
    IFDS的大致原理。
    哪些问题可以利用IFDS,目标问题是否可以利用IFDS,取决于其是否满足可分配性。
    目标:以图可达性分析来进行程序分析,没有了数据流传播的过程。
    1.Infeasible and Realizable Paths——基本概念Infeasible Paths:CFG中实际不会执行到的路径,如不匹配的调用返回边。这种路径可能会影响到程序分析的结果,但静态分析不能完全判定路径是否可达。
    Realizable Paths:跨函数调用产生的返回边和对应的callsite边匹配,这样的path。

    目标:识别Realizable path,避免沿着Unrealizable path来传播数据分析。
    方法:CFL-Reachablity。

    2.CFL-Reachablity(IFDS的理论基础,识别Realizable path)CFL-Reachablity:path连接A和B,或者说A能到达B,当且仅当path所有边的labels连接起来 是context-free language(CFL)中的一个word(或者说经过CFG语法变换之后可以得到该path word)。CFL是编译原理中的概念,遵循CFG语法。
    Context-Free Grammar (CFG):CFG是一个形式化语法,每一个产生的形式都是 S→αS \to \alphaS→α(α\alphaα可以是字符串也可以是空ε\varepsilonε)。EgEgEg,S→aSbS \to aSbS→aSb,S→εS \to \varepsilonS→ε;Context-Free表示不管S出现在哪(不管上下文),S都可以被替换成aSb/εaSb / \varepsilonaSb/ε。
    部分括号匹配问题(Partially Balanced-Parenthesis):利用CFL来解决括号匹配问题,以识别Realizable path。

    部分——有)i一定要有(i,反之有(i不一定要有)i,也即可以调用子函数而不返回。
    标记,调用边——(i;返回边——)i;其他边——e。


    CFL-Reachablity:若path word(所有edge的label连起来组成的单词)可用CFL L(realizable)表示(可进行各种替换),则为Realizable Path。示例如下,(1(2e)2)1(_1(_2e)_2)_1(​1​​(​2​​e)​2​​)​1​​(3就是边的label相连接形成的,绿色是可匹配的部分,realizable可被替换为matched realizable、(i realizable、ε\varepsilonε。语法替换规则如下,这也是一个CFL语言示例:

    利用CFL分析Realizable Path示例:右边显然不是Realizable Path。

    3.Overview of IFDSIFDS含义:Interprocedural,Finite,Distributive,Subset Problem。Interprocedural—全程序分析,Finite—域有限(如live variables、definitions),Distributive—Transfer Function满足f(a∪b)=f(a)∪f(b)f(a \cup b)=f(a) \cup f(b)f(a∪b)=f(a)∪f(b),Subset—子集问题。
    利用图可达性的程序分析框架:采用的操作——Meet-Over-All-Realizable-Paths(MRP),MRPn⊑MOPnMRPn \sqsubseteq MOPnMRPn⊑MOPn。MOP对所有路径进行meet操作,MRP只对realizable path进行meet操作,更准确。
    IFDS步骤:给定程序P,数据流分析问题Q。

    1.构造P的supergraph G∗G^*G​∗​​,根据问题Q定义G∗G^*G​∗​​上每条边的流函数。
    2.构造P的exploded supergraph G♯G ^ \sharpG​♯​​,将流函数转化为Representation relation(分解后变成小子图形式)
    3.问题Q变成图可达性问题(寻找MRP解),对G♯G ^ \sharpG​♯​​采用Tabulation算法。n—程序点,data fact d∈MRPnd \in MRPnd∈MRPn,当且仅当G♯G ^ \sharpG​♯​​中存在一条<Smain,0>→<n,d><Smain, 0> \to <n, d><Smain,0>→<n,d>的 realizable path。


    4.Supergraph(之前叫iCFG) and Flow Functions(1)IFDS步骤一:构造Supergraph说明:之前叫iCFG,给每个node定义transfer function;现在叫做Supergraph,给每个edge定义transfer function。
    Supergraph:G∗=(N∗,E∗)G^*=(N^*, E^*)G​∗​​=(N​∗​​,E​∗​​)。

    G∗G^*G​∗​​包含所有的流图G1G_1G​1​​, G2G_2G​2​​, … (每个函数对应一个流图,本例对应GmainG_{main}G​main​​和GpG_pG​p​​);
    每个流图GpG_pG​p​​都有个开始节点sps_ps​p​​和退出节点epe_pe​p​​;
    每个函数调用包含调用节点CallpCall_pCall​p​​ + 返回节点RetpRet_pRet​p​​。
    函数调用有3类边:

    过程内call-to-return-site边,从Callp→RetpCall_p \to Ret_pCall​p​​→Ret​p​​;
    过程间call-to-start边,从Callp→spCall_p \to s_pCall​p​​→s​p​​(sps_ps​p​​是被调用函数的开头);
    过程间exit-to-return-site边,从ep→Retpe_p \to Ret_pe​p​​→Ret​p​​(epe_pe​p​​是被调用函数的结尾)。



    (2)IFDS步骤一:设计流函数问题Q:假设问题Q是找可能未被初始化的变量,对每个节点n∈N∗n \in N^*n∈N​∗​​,找到执行到n时可能未被初始化的变量集合。
    说明:λ\lambdaλ——lambda 中’.’左侧的S表示输入的集合,右边表示对S的操作。对于未初始化变量问题,遇到var x;指令则 λS.S∪x\lambda S.S \cup {x}λS.S∪x—加入x变量;如遇x:=...;指令则λS.S−x \lambda S.S-{x}λS.S−x—去掉x;遇到无关指令则λS.S \lambda S.SλS.S—不变。
    示例:

    call-to-callee把与callee直接相关信息传递进去,如用形参替换实参;
    exit-to-return边把形参相关信息剔除;
    call-to-return-site只传递局部变量,排除全局变量g,降低误报。全局变量已经传入到被调用函数进行处理了,全局变量是否被初始化取决于被调用函数。


    5.Exploded Supergraph and Tabulation Algorithm(1)IFDS步骤二:构造exploded supergraphExploded Supergraph G♯G ^ \sharpG​♯​​:将trans func转换成边的关系representation relations(graph),每个流函数变成了有2(D+1)个节点,边数最多(D+1)2,D表示dataflow facts元素个数(如待分析的变量个数)。G∗G^*G​∗​​中每个结点n被分解成D+1个结点,每条边n1→n2n_1 \to n_2n​1​​→n​2​​被分解成representation relation。
    representation relation:用Rf表示,流函数-f。Rf⊆(D∪0)×(D∪0)R_f \subseteq (D \cup 0) \times (D \cup 0)R​f​​⊆(D∪0)×(D∪0)。RfR_fR​f​​规则如下:

    0→00 \to 00→0始终有一条边;
    0→d10 \to d_10→d​1​​,y∈f(∅)y \in f( \varnothing)y∈f(∅) 若没有任何输入也能得到y,则加上该边;
    d1→d2d_1 \to d_2d​1​​→d​2​​,y可以从x得到,但不能从0得到,则加上该边;
    还有一条,di→did_i \to d_id​i​​→d​i​​,与did_id​i​​无关时自己连自己,保持可达性。

    示例:
    (1)输入S是什么输出就是什么,1/3;
    (2)无论什么输入,都输出{a},1/2;
    (3)b是无条件产生,所以0→b,a不能往下传了,b已经从0可达了就不用加b→b,c不受影响,也即无论有关a和b的事实之前是什么样,都不再重要;
    (4)b通过a得到所以a→b,不影响a、c的传递。注意,这里的值不是说变量在程序中真正的值是多少,而是说有关此变量的数据流事实的值是什么,如a的值可以为被初始化了和未被初始化两种,对应的集合即不包括和包括a。

    问题:为什么需要0→00 \to 00→0的边?以往数据流分析中,确定程序点(结点)p是否包含data fact a,是看a是否在OUT[p]中;IFDS中,是看<smain,0><s_{main}, 0><s​main​​,0>是否能到达<p, a>。如果没有0→00 \to 00→0的边,则无法完全连通,所以0→00 \to 00→0又称为Glue Edge。
    构建G♯G^ \sharpG​♯​​示例:最后能从<smain,0>→<emain,g><s_{main}, 0> \to <e_{main}, g><s​main​​,0>→<e​main​​,g>(要通过realizable paths),则emaine_{main}e​main​​点的g是可能未初始化的。emaine_{main}e​main​​处的x和nPrint(a,g)n_{Print(a,g)}n​Print(a,g)​​处的g都是初始化过的,因为从smains_{main}s​main​​不可达(不能通过non-realizable paths——绿色线)。



    (2)IFDS步骤三:Tabulation算法——判断是否可达说明:实心圈表示从<smain,0><s_{main}, 0><s​main​​,0>通过realizable paths可达,空心圈表示不可达。
    目标:给定exploded supergraph G♯G^ \sharpG​♯​​,Tabulation算法通过寻找从<smain,0><s_{main}, 0><s​main​​,0>的所有realizable paths来确定MRP解。也即n—程序点,data fact d∈MRPnd \in MRPnd∈MRPn,当且仅当G♯G^ \sharpG​♯​​中存在一条<Smain,0>→<n,d><S_{main}, 0> \to <n, d><S​main​​,0>→<n,d>的realizable path。
    Tabulation算法:复杂度是O(ED3)O(ED^3)O(ED​3​​),E—supergraph的边数,D是域中待分析元素的个数。主要工作:括号匹配+路径探索。主要就是处理调用边、返回边、总结边,将间接可达的两结点直接连起来,每个结点用Xn存储当前所有可达的data fact。

    calledProc: 把函数调用结点(call)和代表被调用函数名关联上
    returnSite: 把call结点和return结点连起来
    callers: 把函数名映射到call结点所形成的集合关联
    procOf: 把函数结点和它的函数主体关联


    Tabulation算法工作原理:假设只关注1个data fact,p’被p和p’’同时调用。

    处理括号匹配:每次处理到返回点ep′e_{p^{\prime}}e​p​′​​​​时,开始括号匹配(call-to-return匹配),找到调用点(Callp,Callp′′)(Callp, Call{p^{\prime \prime}})(Callp,Callp​′′​​)和相应的返回点(Retp,Retp′′)(Retp, Ret{p^{\prime \prime}})(Retp,Retp​′′​​)。
    处理总结边——SummaryEdge:总结边—<Call,dm>→<Ret,dn><Call,d_m> \to <Ret,d_n><Call,d​m​​>→<Ret,d​n​​>,表示dmd_md​m​​通过调用p′p^{\prime}p​′​​能到达pnp_np​n​​,要避免重复处理ppp和p′′p^{\prime \prime}p​′′​​中调用同一函数p′p^{\prime}p​′​​(优化)。


    Tabulation算法优点:传统的worklist算法是利用了queue的特性,每次循环只考虑与被改变值结点的相关结点。论文中用于解决图可达问题的Tabulation 算法是基于worklist的动态规划算法,比传统worklist算法考虑interprocedure问题更精确也更省时。
    6.Understanding the Distributivity of IFDS问题:不能用IFDS进行常量传播分析、指针分析。
    原因:由IFDS的框架决定,一次只能处理1个变量。例如,表示若x和y都存在则…,无法表示这种关系。不满足F(x^y)=F(x)^F(y)。
    总结:给定语句S,如果输出取决于多个输入的data fact,则该分析不具备可分配性,不能用IFDS表达。IFDS中,每个data fact(圆圈)与其传播(边)都可以各自处理,且不影响最终结果。
    指针分析:箭头表示变量是否指向new T,但由于缺乏别名信息alias(x,y) / alias(x.f,y.f),导致分析结果错误。归根结底,要想在IFDS中获取别名信息alias(x,y),需要考虑多个输入data fact(x、y),所以不能用IFDS。

    参考用求解图内节点是否可达的算法来解决IFDS问题
    0  留言 2020-07-22 10:12:37
  • 【课程笔记】南大软件分析课程10——基于Datalog的程序分析


    最近在看“静态分析”技术相关的文章,看到这个系列的笔记和视频教程,感觉介绍得很好,通俗易懂,而且还比较详细,故转载分享,同时也备份保留下,方便自己今后阅读。(PS:建议大家一边看笔记,一边看视频,加深理解)原作者:bsauce原文链接:https://www.jianshu.com/p/a8930401dee9

    首先非常感谢南京大学李樾和谭添老师的无私分享,之前学习程序分析是看的北大熊英飞老师的ppt,但是很多地方没看懂,正如李樾老师所说的那样,熊英飞老师的授课涵盖非常广,不听课只看ppt的话,理解起来还是很有难度的。但李樾老师的视频就讲解的非常易懂,示例都是精心挑选的,所以墙裂推荐。
    推送门:南大课件 南大视频课程 北大课件

    目录
    MotivationDatalog介绍Datalog实现指针分析Datalog实现污点分析
    重点Datalog语法,如何利用Datalog实现指针分析和污点分析。
    本节课内容讲到了很多数据逻辑方面的应用,单上数理逻辑会觉得理论性太强,单上这节课的应用知识又觉得理论上不够严谨,总之算是一种互补。
    1.Motivation内容:了解命令式 vs 声明式语言,对比两种语言实现指针分析算法的优劣。
    // 问题:从一群人中挑出成年人。// 命令式语言(Imperative):详细的命令机器怎么(How)去处理一件事情以达到你想要的结果(What)。如JavaSet<Person> selectAdults(Set<Person> persons) { Set<Person> result = new HashSet<>(); for (Person person : persons) if (person.getAge() >= 18) result.add(person); return result; }// 声明式语言(Declarative):只告诉你想要的结果(What),机器自己摸索过程(How)。如SQL,代码更简洁SELECT * FROM Persons WHERE Age >= 18;
    命令式语言—PTA:若采用命令式实现指针分析算法,实现复杂。需考虑worklist数据结构,是数组list还是链表,是先进先出还是先进后出;如何表示指针集合,hash集还是bit向量;如何关联PFG节点和指针;如何处理相关语句中的变量。

    声明式语言—PTA:如何用声明式语言实现PTA?优点是简洁、可读性强、易于实现,例如Datalog。缺点是不方便表达复杂逻辑(Eg,for all全部满足)、不能控制性能。
    2.Datalog介绍Datalog(Data + Logic):是声明式逻辑编程语言,可读性强,最初用于数据库。现在可用于程序分析、大数据、云计算。特点—没有副作用、没有控制流、没有函数、非图灵完备(精简了许多功能)。
    (1)Data(谓词、原子)谓词Predicate:看作一系列陈述的集合,陈述某事情是不是事实(真假)。如Age,表示一些人的年龄。
    事实fact:特定值的组合。Eg,(“Xiaoming”, 18)。

    原子Atom:P(X1, X2, ... , Xn)。P表示谓词名,Xi表示参数(又叫term,可以是变量或常量)。Eg,Age("Xiaoming", 18) == true ;Age("Alan", 23) == false。
    (2)Logic(Rule)Rule:表示逻辑推导规则,若Body都为true,则Head为true。H <- B1, B2, ... ,Bn。H是Head,Bi是Body。 Eg,Adult(person) <- Age(person, age), age >= 18。
    Rule要求:规则中的值要有限,如A(x) <- B(y), x > y;规则不能有悖论,如A(x) <- B(x), !A(x)。
    Datalog中逻辑或:A或B都可推导出C,可写成C<-A. C<-B或者C<-A;B。
    Datalog中逻辑非:!B(...)。
    (3)Datalog谓词分类
    EDB(extensional database)外延数据库:谓词需预先定义,关系不可变,可被当做输入。
    IDB(intensional database)内涵数据库:谓词是根据规则建立的,关系是根据规则推导的,可被看作是是输出。

    说明:H <- B1, B2, ... ,Bn,H只能是IDB,Bi可以是EDB或IDB。
    递归性:Datalog支持递归,也即能够推导出自身。Eg,Reach(from, to) <- Edge(from, to);Reach(from, to) <- Reach(from, node), Edge(node, to)。
    (4)Datalog程序运行Datalog程序运行:输入EDB+rules到Datalog引擎,输出IDB。常用Datalog引擎——LogicBlox, Soufflé, XSB, Datomic, Flora-2。
    Datalog程序性质:单调性、终止性。
    3.Datalog实现指针分析(1)概念EDB:程序句法上可获得的指针相关信息。如New / Assign / Store / Load语句。V-变量,F-域,O-对象。

    New(x: V,o: O) <- i: x = new T()
    Assign(x : V, y : V) <- x=y
    Store(x : V, f : F, y : V) <- x.f = y
    Load(y : V, x : V, f : F) <- y = x.f

    IDB:指针分析结果。

    VarPointsTo(v: V, o : O) ,如VarPointsTo(x,oi)表示oi ∈ 𝑝𝑡(𝑥)
    FieldPointsTo(oi : O, f: V, oj : O) ,如FieldsPointsTo(𝑜i, 𝑓, 𝑜j)表示𝑜j ∈ 𝑝𝑡(𝑜i.𝑓)

    Rules:指针分析规则(与之前相同)。先分析上下文不敏感。

    (2)上下文不敏感PTA示例
    步骤:其实指令处理顺序不固定。

    首先将EDB(指令)表示成表格数据形式。
    处理New指令
    处理Assign指令
    处理Store指令
    处理Load指令

    (3)上下文敏感—全程序指针分析call指令规则:S—指令,M—方法。共3条rule。
    1.首先找到调用的目标函数m,传递this指针。

    2.传递参数

    3.传返回值

    全程序指针分析:引入程序入口函数m。

    4.Datalog实现污点分析EDB谓词-输入:

    Source(m : M) ——产生污点源的函数
    Sink(m : M) ——sink函数
    Taint(l : S, t : T) ——关联某callsite l和它产生的污点数据t

    IDB谓词-输出:

    TaintFlow(t : T, m : M) ——表示污点数据t会流向sink函数m
    规则:处理source和sink函数。

    课后问题
    有的调用图有多个main入口方法,咋办?将多个入口函数都加入到EntryMethod(m)即可。
    有没有datalog和传统结合的做法如chord(java+Datalog实现)
    0  留言 2020-07-21 15:56:32
  • 【课程笔记】南大软件分析课程9——污点分析


    最近在看“静态分析”技术相关的文章,看到这个系列的笔记和视频教程,感觉介绍得很好,通俗易懂,而且还比较详细,故转载分享,同时也备份保留下,方便自己今后阅读。(PS:建议大家一边看笔记,一边看视频,加深理解)原作者:bsauce原文链接:https://www.jianshu.com/p/f43218636968

    首先非常感谢南京大学李樾和谭添老师的无私分享,之前学习程序分析是看的北大熊英飞老师的ppt,但是很多地方没看懂,正如李樾老师所说的那样,熊英飞老师的授课涵盖非常广,不听课只看ppt的话,理解起来还是很有难度的。但李樾老师的视频就讲解的非常易懂,示例都是精心挑选的,所以墙裂推荐。
    推送门:南大课件 南大视频课程 北大课件

    目录
    信息流安全保密性和完整性显示流和隐藏信道-Explicit Flows and Covert Channels污点分析
    重点显示流和隐藏信道,使用污点分析来检测信息流漏洞。
    1.信息流安全访问控制:关注信息访问。
    信息流安全:关注信息传播。
    信息流:x->y表示x的值流向y。
    信息等级:对不同变量进行分级,即安全等级,H-高密级,L-低密级。
    安全策略:非干涉策略,高密级变量H的信息不能影响(流向)低密级变量L。
    2.保密性和完整性保密性—信息泄露,读保护;完整性—信息篡改,写保护。
    完整性错误类型:命令注入、SQL注入、XSS攻击、… 。都属于注入错误。
    完整性更宽泛的定义:准确性、完整性、一致性。准确性表示关键数据不被不可信数据破坏;完整性表示系统存储了所有的数据;一致性表示发送的数据和接收的数据是一致的。
    3.显示流和隐藏信道-Explicit Flows and Covert Channels显示流:直接的数值传递。由于显示流能泄露更多信息,所以本课程关注显示流的信息泄露。
    隐式信息流—侧信道:程序可能会以一些意想不到的方式泄露数据。
    // Eg1 隐式流if (secret_H < 0) public_L = 1; else Public_L = 0;// Eg2 终止信道while(secret_H < 0) { ... };// Eg3 时间信道if (secret_H < 0) for (int i = 0; i< 1000000; ++i) { ... };// Eg4 异常if (secret_H < 0) throw new Exception("...");// Eg5 如果访问数组越界,则可以推断secret可以为负数int sa_H[] = getSecretArray();sa_H[secret_H] = 0;
    covert channels:信道指的是传递信息的机制,原本目的不是为了传递信息的信道。
    4.污点分析说明:使用最广的信息流分析技术,需将程序数据分为两类,把感兴趣的数据标记为污点数据。
    (1)概念Sources & Sink:Sources是污点数据的源,一般是有些函数的返回值,如read();Sink是特定的程序点,某些敏感函数。
    保密性:Source是秘密数据,sink是泄露点,信息泄露漏洞。
    x = getPassword(); // source y = x;log(y); // sink
    完整性:Source是不可信数据,Sink是关键计算,注入漏洞。
    x = readInput(); // source cmd = "..." + x; execute(cmd); // sink
    (2)污点分析定义:关注的是,污点数据是否能流向sink点。或者说,sink点处的指针指向哪些污点数据。
    TA/PTA对比:污点分析与指针分析,一个是污点数据的流向,一个是抽象对象的流向。可把污点数据看作是对象,source看作allocation-site,借助指针分析来实现污点分析。
    标记:tit_it​i​​表示调用点i返回的污点数据,指针集就包含普通对象 + 污点数据。

    输入:source(返回污点数据的函数),sink(违反安全规则的函数)
    输出:TaintFlows—<tit_it​i​​, m>,表示tit_it​i​​这个污点数据会流向m函数,污点数据和sink函数这个pair的集合就是TaintFlows。
    规则:主要规则不变,关键是Sources和Sink调用的处理。

    Sources:对于产生污点源的函数调用m,将返回值标记为污点值tlt_lt​l​​,并更新接收变量r的指向。
    Sink:对于Sink函数调用m,若所传参数的指向集包含污点tjt_jt​j​​,则将<tj,m><t_j, m><t​j​​,m>加入TaintFlows。



    示例:第3行产生新对象o11o_{11}o​11​​的同时,产生的污点数据t3t_3t​3​​;最终指针分析发现,t3t_3t​3​​会流向sink函数log()。

    问答:实现分析器用到:分析Java用Soot/WALA;分析C++用LLVM;有的用Datalog实现分析器(如DOOP分析框架)。
    隐藏信道的论文:Implicit Flows: Can’t Live With ‘Em, Can’t Live Without ‘Em
    LLVM指针分析工具:SVF
    0  留言 2020-07-21 14:23:35
  • 【课程笔记】南大软件分析课程8——指针分析-上下文敏感


    最近在看“静态分析”技术相关的文章,看到这个系列的笔记和视频教程,感觉介绍得很好,通俗易懂,而且还比较详细,故转载分享,同时也备份保留下,方便自己今后阅读。(PS:建议大家一边看笔记,一边看视频,加深理解)原作者:bsauce原文链接:https://www.jianshu.com/p/5ab79839f686

    首先非常感谢南京大学李樾和谭添老师的无私分享,之前学习程序分析是看的北大熊英飞老师的ppt,但是很多地方没看懂,正如李樾老师所说的那样,熊英飞老师的授课涵盖非常广,不听课只看ppt的话,理解起来还是很有难度的。但李樾老师的视频就讲解的非常易懂,示例都是精心挑选的,所以墙裂推荐。
    推送门:南大课件 南大视频课程 北大课件

    目录
    介绍Context Sensitive Pointer Analysis:RulesContext Sensitive Pointer Analysis:AlgorithmsContext Sensitivity Variants—上下文的选取
    重点
    上下文敏感指针分析的完整算法(一般其他教程中很少涉及到)。上下文敏感概念,堆对象的上下文敏感表示,上下文敏感指针分析的规则。上下文的三种选择,以及效率、准确度的对比。
    1.上下文不敏感的问题说明:上下文敏感分析是对指针分析的准确性提升最有效的技术。
    (1)问题
    问题:上下文不敏感时,分析常量传播这个问题,由于没有明确调用id()的上下文,会把不同的调用混合在一起,对id函数内的变量n只有一种表示(没有对局部变量进行区分),导致n指向的对象集合增大,将i识别为非常量NAC。实际上,x.get()的值只来自于One()对象,i应该是常量1。
    解决:根据调用的上下文(主要有3种:如根据调用点所在的行数——call-site sensitivity)来区分局部变量。
    (2)上下文敏感分析概念:

    call-site sensitivity (call-string):根据调用点位置的不同来区分上下文,3:id(n1) / 4:id(n2)。
    Cloning-Based Context Sensitivity:每种上下文对应一个节点,标记调用者行数。克隆多少数据,后面会讨论。



    Context-Sensitive Heap:面向对象程序(如Java)会频繁修改堆对象,称为heap-insensitive。所以不仅要给变量加上下文,也要给堆抽象加上下文,称为heap context(本课程是基于allocate-site来进行堆抽象的)。
    堆抽象上下文示例:

    堆抽象上下文不敏感:如果不区分8 X x = new X();调用的堆抽象的上下文,导致只有1个o8.f,把两个上下文调用产生的o8.f指向集合都合并了,得出了o8.f的错误指向的结果。
    堆抽象上下文敏感:用不同的调用者来区分堆抽象,如3:o8、4:o8是不同的堆抽象。所以说,既要根据上下文的不同来区分局部变量,也要区分堆抽象,例如:3:p是给变量加上下文,3:o8是给堆抽象加上下文。
    2.Context Sensitive Pointer Analysis:Rules标记:根据调用者的行数来区分不同上下文,只要区分了函数、变量、堆对象,就能够区分实例域、上下文敏感的指针(变量+对象域)。C—上下文(暂时用调用点的行数表示),O—对象,F—对象中的域。

    规则:跟之前区别不大,只是增加了个上下文标记,注意load表示和之前有区别。

    call指令规则:

    上下文对于Dispatch(oi, k)(找目标函数)没有影响,根据oi指向和函数签名k找到目标函数。select(c, l, c’:oi, m)根据调用时的信息来给调用目标函数选择上下文(c是调用者的上下文,l是调用者的行号,c’:oi是x对象的指向集合,m是目标函数),ct表示目标函数的上下文(后面会将如何Select如何选择上下文)。c是可以累积的,一连串的调用,上下文将用一连串的行数来表示。
    传递this变量:ct:mthisc^t:m_{this}c​t​​:m​this​​是目标函数ct:mc^t:mc​t​​:m的this变量
    传递参数:ct:mpjc^t:m_{pj}c​t​​:m​pj​​是目标函数ct:mc^t:mc​t​​:m的第j个形参。
    传递返回值:ct:mretc^t:m_{ret}c​t​​:m​ret​​是目标函数ct:mc^t:mc​t​​:m的返回值


    3.Context Sensitive Pointer Analysis:Algorithms区别:和过程间指针分析相比,仍然分为两个过程,分别是构造PFG和根据PFG传递指向信息。主要区别是添加了上下文。
    PFG构造:边添加规则和之前一样,Assign、Store、Load、Call,Call需要加参数传递、返回值传递的边。

    符号:

    S:可达语句的集合(就是RM中的语句)Sm:函数m中的语句RM:可达函数的集合CG:调用图的边
    算法:被调用函数的上下文暂时用ct表示,之后会解释Select()函数。

    先处理New、Assign指令。AddReachable(c:m)只多了上下文。遍历WL,Propagate()和原来相同。处理Store、Load指令,AddEdge()只多了上下文。处理Call指令,ProcessCall(),多了一行ct=Select(c,l,c’:oi,m),在找到调用目标函数之后,需选择被调用的函数的上下文。
    4.Context Sensitivity Variants—上下文的选取上下文的选取主要采用3类:

    Call-Site SensitivityObject SensitivityType Sensitivity…
    说明:Select(c,l,c’:oi,m),c—调用者上下文,l—调用者,c’:oi—接收对象(含堆的上下文信息)。
    (1)Call-Site Sensitivity原理:又称为k-call-site sensitivity / k-CFA,不断添加调用行号。1991年Olin Shivers提出。

    Select(c,l,c’:oi,m) = (l’,…,l’’, l)

    问题:如果函数调用自身,导致无限递归,如何限制上下文长度?
    解决:k-limiting Context Abstraction。只取最后k个上下文,通常取k<=3。例如,函数的上下文通常取2,堆上下文通常取1。
    示例:采用1-Call-Site。
    interface Number { int get(); }class One implements Number { public int get() { return 1; }}class Two implements Number { public int get() { return 2; }}1 class C {2 static void main() {3 C c = new C();4 c.m();5 }67 Number id(Number n) {8 return n;9 }10 void m() {11 Number n1,n2,x,y;12 n1 = new One();13 n2 = new Two();14 x = this.id(n1);15 y = this.id(n2);16 x.get();17 }18 }



    WL
    正处理
    PFG
    指针集
    RM
    CG
    处理语句
    算法语句




    1




    {[]:C.main()}

    3
    AddReachable(mentry)—加入RM


    2
    [<[]:c, {o3}>]





    3
    AddReachable(mentry)—处理New


    3
    []
    <[]:c, {o3}>

    pt([]:c) ={o3};



    While开头,Propagate()—遍历WL更新指针


    4
    [⟨[4]:C.mthis, {o3}⟩]





    4
    ProcessCall()—this指针加入WL


    5
    [⟨[4]:C.mthis, {o3}⟩]




    {[ ]:4 → [4]:C.m()};

    ProcessCall()——函数加入CG


    6
    [⟨[4]:C.mthis, {o3}⟩,⟨[4]:n1, {o12⟩,⟨[4]:n2, {o13⟩]

    没有参数/返回值

    {[]:C.main(), [4]:C.m()}

    12,13
    ProcessCall():AddReachable(m)处理m函数中的New


    7
    [⟨[4]:n1, {o12⟩,⟨[4]:n2, {o13⟩]
    ⟨[4]:C.mthis, {o3}⟩

    pt([]:c) ={o3};pt([4]:C.mthis)={o3};



    While开头,Propagate()—遍历WL更新指针


    8
    [⟨[4]:n1, {o12⟩,⟨[4]:n2, {o13⟩]






    ProcessCall():处理m中的this调用


    9
    [⟨[4]:n1, {o12⟩,⟨[4]:n2, {o13⟩]





    14
    ProcessCall():Select(c,l,c’:oi)选择上下文ct=[14]


    10
    [⟨[4]:n1, {o12⟩,⟨[4]:n2, {o13⟩]



    {[]:C.main(), [4]:C.m(),[14]:C.id(Number)}
    {[ ]:4 → [4]:C.m();[4]:14 → [14]:C.id(Number)};

    ProcessCall():AddReachable([14]:C.id(Number))


    11
    [⟨[4]:n1, {o12⟩,⟨[4]:n2, {o13⟩]

    [4]:n1→[14]:n→[4]:x;




    ProcessCall():AddEdge()参数边/返回值边


    12
    [⟨[4]:n1, {o12⟩,⟨[4]:n2, {o13⟩]

    [4]:n1→[14]:n→[4]:x;[4]:n2→[15]:n→[4]:y;

    {[]:C.main(), [4]:C.m(),[14]:C.id(Number),[15]:C.id(Number)}
    {[ ]:4 → [4]:C.m();[4]:14 → [14]:C.id(Number),[4]:15 → [15]:C.id(Number)};
    15
    ProcessCall()同理


    13
    []
    [⟨[4]:n1, {o12⟩,⟨[4]:n2, {o13⟩]





    While开头—遍历WL更新指针


    14
    []





    16
    While开头,ProcessCall()—处理x.get()



    上下文不敏感vs上下文敏感(1-Call-Site):

    (2)Object Sensitivity原理:针对面向对象语言,用receiver object来表示上下文。对比1层的调用点敏感和对象敏感,时间和准确性上对象敏感显然更优,这是由面向对象语言的特点所确定的。

    Select(c,l,c’:oi,m) = [oj, … , ok, oi] (c’ = [oj, … , ok])

    示例:选取1-object,最终pt(x)=o3pt(x)=o_3pt(x)=o​3​​。

    对比:对比1-Call-Site和1-object上下文,在这个示例中1-object明显更准确。原因是面向对象语言的特性,多态性产生很多继承链,一层一层调用子对象,其中最关键的是receiver object,receiver object决定了调用者的根源。本例有多层调用,若采用2-Call-Site就不会出错。


    示例2:在本示例中,1-Call-Site明显更准确。因为同一个receiver object用不同参数多次调用了子函数,导致局部变量无法区分。

    结论:所以理论上,对象敏感与callsite敏感的准确度无法比较。但是对于面向对象语言,对象敏感的准确度要优于callsite敏感。
    (3)Type Sensitivity原理:牺牲精度,提高速度。基于创建点所在的类型,是基于对象敏感粗粒度的抽象,精度较低。

    Select(c,l,c’:oi,m) = [𝑡′,…,𝑡′′,InType(𝑜𝑖)] 其中𝑐′ = [𝑡′, … , 𝑡′′]


    (4)总体对比精度:object > type > call-site
    效率:type > object > call-site
    本课老师提出选择上下文的方法,对代码的特点有针对性的选择上下文方法,见A Principled Approach to Selective Context Sensitivity for Pointer Analysis。厉害了!
    0  留言 2020-07-19 16:50:53
  • 【课程笔记】南大软件分析课程7——指针分析基础


    最近在看“静态分析”技术相关的文章,看到这个系列的笔记和视频教程,感觉介绍得很好,通俗易懂,而且还比较详细,故转载分享,同时也备份保留下,方便自己今后阅读。(PS:建议大家一边看笔记,一边看视频,加深理解)原作者:bsauce原文链接:https://www.jianshu.com/p/5cbc5bb5c4da

    首先非常感谢南京大学李樾和谭添老师的无私分享,之前学习程序分析是看的北大熊英飞老师的ppt,但是很多地方没看懂,正如李樾老师所说的那样,熊英飞老师的授课涵盖非常广,不听课只看ppt的话,理解起来还是很有难度的。但李樾老师的视频就讲解的非常易懂,示例都是精心挑选的,所以墙裂推荐。
    推送门:南大课件 南大视频课程 北大课件

    目录
    指针分析规则如何实现指针分析指针分析算法指针分析如何处理函数调用(过程间指针分析)
    重点理解指针分析的规则、指针流图PFG、指针分析算法。
    理解指针分析调用函数的规则、过程间指针分析算法、实时调用图构建。
    1.指针分析规则首先分析前4种语句:New / Assign / Store / Load。
    指针分析的域和相应的记法:变量/函数/对象/实例域/指针,用pt表示程序中的指向关系(映射)。

    规则:采用推导形式,横线上面是条件,横线下面是结论。

    New:创建对象,将new T()对应的对象oi加入到x的指针集。
    Assign:将y的指针集加入到x对应的指针集。
    Store:让oi的field指向oj。
    Load:Store的反操作。


    2.如何实现指针分析算法要求:全程序指针分析,要容易理解和实现。
    本质:在指针(变量/域)之间传递指向信息。Andersen-style分析(很普遍)——很多solving system把指针分析看作是一种包含关系,eg,x = y,x包含y。
    问题:当一个指针的指向集发生变化,必须更新与它相关的其他指针。如何表示这种传递关系?PFG。
    PFG:用指针流图PFG来表示指针之间的关系,PFG是有向图。

    Nodes:Pointer = V U (O x F) 节点n表示一个变量或抽象对象的域。Edges:Pointer X Pointer 边x -> y 表示指针x指向的对象may会流入指针y。
    Edges添加规则:根据程序语句 + 对应的规则。

    示例:

    PTA步骤:

    构造PFG(根据以上示例,PFG也受指向关系影响)
    根据PFG传播指向信息

    3.指针分析算法(1)过程内PTA算法
    符号:

    S:程序语句的集合。WL:Work list,待合并的指针信息,二元组的集合,<指针n,指向的对象集合pts>。pts将被加入到n的指向集pt(n)中。PFG:指针流图。
    步骤:对每种语句都是基于第1小节的规则来实现。

    对S中所有类似New x = new T()的语句,将<x, {oi}>加入到WL。
    对S中所有类似Assign x = y的语句,调用AddEdge()将y -> x加入到PFG,<x, pt(y)>加入到WL(传播指向信息)。
    遍历WL,取一个元素<n, pts>,除去pts中与pt(n)重复的对象得到Δ\DeltaΔ,调用Propagate(n,Δ\DeltaΔ)将Δ\DeltaΔ加入到pt(n),且取出PFG中所有n指向的边n->s,将<s, pts>加入到WL(根据PFG将指向信息传递给同名指针)。
    如果n表示一个变量x(x跟Store/Load指令相关),对Δ\DeltaΔ中的每个对象oi。对S中所有类似 Store x.f = y 的语句,调用AddEdge()将y -> oi.f加入到PFG,<oi.f, pt(y)>加入到WL(传播指向信息);对S中所有类似Load y = x.f的语句,调用AddEdge()将oi.f -> y加入到PFG,<y, pt(oi.f)>加入到WL(传播指向信息)。

    问题:

    为什么要去重?避免冗余,英文叫做Differential propagation差异传播。
    指针集用什么数据结构存储?混合集 Hibra-set,集合元素小于16个用hash set,大于16个用big-rector 位存储。
    开源项目有哪些?Soot、WALA、Chord。

    (2)示例1 b = new C(); 2 a = b;3 c = new C(); 4 c.f = a;5 d = c;6 c.f = d; 7 e = d.f;

    4.指针分析如何处理函数调用构造调用图技术对比:

    CHA:基于声明类型,不精确,引入错误的调用边和指针关系。
    指针分析:基于pt(a),即a指向的类型,更精确,构造更准的CG并对指针分析有正反馈(所以过程间指针分析和CG构造同时进行,很复杂)。

    void foo(A a) { // pt(a) = ??? ... b = a.bar(); // pt(b) = ??? 把a的指向分析清楚了,就能确定a.bar()到底调用哪个对象的bar()函数,那么b的指向也明确了。 ... }
    (1)调用语句规则call语句规则:主要分为4步。


    找目标函数m:Dispatch(oi, k)——找出pt(x),也即oi类型对象中的k函数。
    receiver object:把x指向的对象(pt(x))传到m函数的this变量,即mthism_{this}m​this​​。
    传参数:pt(aj), 1<=j<=n 传给m函数,即p(mpj)p(m{pj})p(mpj), 1<=j<=n。建立PFG边,a1−>ma_1->ma​1​​−>m{p1},…,an->m{pn}。
    传返回值:pt(mret)pt(m{ret})pt(mret)传给pt(r)。建立PFG边,r<−mr<-mr<−m{ret}。

    问题:为什么PFG中不添加x−>mthisx->m{this}x−>mthis边?因为mmm{this}只和自己这个对象相关,而可能有pt(x)={new A, new B, new C},指定对象的x只流向对应的对象,是无法跨对象传递的。
    (2)过程间PTA算法问题:由于指针分析和CG构造互相影响,所以每次迭代只分析可达的函数和语句。然后不断发现和分析新的可达函数。
    可达示例:

    算法:黄色背景的代码是和过程内分析不同的地方。

    符号:

    mentry:入口main函数
    Sm:函数m中的语句
    S:可达语句的集合(就是RM中的语句)
    RM:可达函数的集合
    CG:调用图的边

    步骤:基于调用规则来实现。

    首先调用AddReachable(mentry)AddReachable(m^{entry})AddReachable(m​entry​​),将入口函数mentrym^{entry}m​entry​​的语句加到S中。处理New x = new T()语句,把<x, {oi}>加入到WL;处理Assign x = y语句,调用AddEdge(y, x)加入边到PFG。
    跟过程内指针分析一样,遍历WL,取一个元素<n, pts>,除去pts中与pt(n)重复的对象得到Δ\DeltaΔ,调用Propagate(n,Δ\DeltaΔ)将Δ\DeltaΔ加入到pt(n),且取出PFG中所有n指向的边n->s,将<s, pts>加入到WL(根据PFG将指向信息传递给同名指针)。
    如果n表示一个变量x(x跟Store/Load指令相关),对Δ\DeltaΔ中的每个对象oi。对S中所有类似 Store x.f = y 的语句,调用AddEdge()将y -> oi.f加入到PFG,<oi.f, pt(y)>加入到WL(传播指向信息);对S中所有类似 Load y = x.f 的语句,调用AddEdge()将oi.f -> y加入到PFG,<y, pt(oi.f)>加入到WL(传播指向信息)。
    最后调用ProcessCall(x, oi),处理与x相关的call指令。取出S中类似r = x.k(a1,...,an)的调用语句L,首先调用Dispatch(oi, k)解出调用的目标函数m,把<mthis, {oi}>加入到WL(传递接收对象,上下文敏感分析将用到),将L->m这条调用边加入到CG;调用AddReachable(m)将新的语句加入到S,并处理New/Assign语句;调用AddEdge()将实参->形参、返回值->r边加入到PFG(传递参数、返回值),并将<形参,pt(实参)>、<r,pt(返回值)>加入到WL。

    问题:为什么ProcessCall(x, oi)中,要判断L->m这条边是否已经加入到CG?因为x可能指向多个对象,就会多次处理L这个调用指令,可能x中别的对象oj早就已经将这条边加入进去了。
    (3)示例class A { static void main(){ A a = new A(); A b = new B(); A c = b.foo(a); } A foo(Ax){...}}class B extends A { A foo(A y) { A r=newA(); return r; }}




    WL
    正处理
    PFG
    指针集
    RM
    CG
    语句
    算法语句




    1
    []

    {}

    {}
    {}

    初始化


    2
    []



    {A.main()}

    1,2
    AddReachable(mentry)


    3
    [<a,{o3}>, <b,{o4}>]





    3,4



    4
    [<b,{o4}>]
    <a,{o3}>

    pt(a)={o3};



    while开头


    5
    []
    <b,{o4}>

    pt(b)={o4}



    while开头


    6
    []





    5
    ProcessCall(b, o4)


    7
    [<B.foothis, {o4}>]




    {5->B.foo(A)}

    m=Dispatch(o4, foo())=B.foo();添加到调用图


    8
    [<B.foothis, {o4}>, <r, o11>]



    {A.main(), B.foo()}


    AddReachable(B.foo());添加到可达函数


    9
    [<B.foothis, {o4}>, <r, o11>, <y, {o3}>]

    {a->y, r->c}




    AddEdge();添加参数边、返回值边


    10
    [<r, o11>, <y, {o3}>]
    <B.foothis, {o4}>

    pt(B.foothis)={o4};



    while开头,B.foothis没有调用任何函数


    11
    [<y, {o3}>, <c, {o11}>]
    <r, o11>

    pt(r)={o11};



    while开头


    12

    <y, {o3}>, <c, {o11}>

    pt(y)={o3};pt(c)={o11}



    while开头



    如果是CHA的话,CG={5->B.foo(A), 5->A.foo(A)},错误识别为调用边。
    结果:

    问题:没有入口函数的?如对库函数处理,生成调用库函数的程序。
    0  留言 2020-07-19 11:55:31
  • 【课程笔记】南大软件分析课程6——指针分析介绍


    最近在看“静态分析”技术相关的文章,看到这个系列的笔记和视频教程,感觉介绍得很好,通俗易懂,而且还比较详细,故转载分享,同时也备份保留下,方便自己今后阅读。(PS:建议大家一边看笔记,一边看视频,加深理解)原作者:bsauce原文链接:https://www.jianshu.com/p/9d15edf2604e

    首先非常感谢南京大学李樾和谭添老师的无私分享,之前学习程序分析是看的北大熊英飞老师的ppt,但是很多地方没看懂,正如李樾老师所说的那样,熊英飞老师的授课涵盖非常广,不听课只看ppt的话,理解起来还是很有难度的。但李樾老师的视频就讲解的非常易懂,示例都是精心挑选的,所以墙裂推荐。
    推送门:南大课件 南大视频课程 北大课件

    目录
    Motivation指针分析介绍影响指针分析的关键要素分析哪些语句
    重点什么是指针分析?影响指针分析的关键因素是什么?指针分析要分析哪些指令?
    1.Motivation指针分析必要性

    2.指针分析目标:分析程序指针可以指向哪些内存。对于Java等面向对象语言,主要分析指针指向哪个对象。
    说明:指针分析属于may analysis,分析的结果是某指针所有可能指向哪些对象,是个over-approximation集合。
    示例:面向对象语言中的指针指向问题。对于setB()函数,this指向new A(),因为是调用者是a.setB();setB()中的b是x传过来的,所以b指向new B(),A.b指向 new B()。

    区别:

    指针分析:分析指针所有可能指向的对象。
    别名分析:分析两个指针是否指向相同的对象,可通过指针分析来推导得到。

    应用:基本信息(别名分析/调用图),编译优化(嵌入虚拟调用),漏洞(空指针),安全分析(信息流)。
    3.影响指针分析的关键要素指标:精度(precision)& 效率(efficiency)。
    影响因素:本课程,我们主要分析分配点的堆抽象技术、上下文敏感/不敏感、流不敏感、全程序分析。

    (1)堆抽象(内存建模)问题:程序动态执行时,堆对象个数理论上是无穷无尽的,但静态分析无法处理这个问题。所以为保证指针分析可以终止,我们采用堆抽象技术,将无穷的具体对象抽象成有限的抽象对象。也即,将有共性的对象抽象成1个静态对象,从而限制静态分析对象的个数。
    // 示例for (...) { A a = new A();}
    技术概览

    我们只学习Allocation-Site技术,最常见也最常被使用。
    Allocation-Site原理:将动态对象抽象成它们的创建点(Allocation-Site),来表示在该点创建的所有动态对象。Allocation-Site个数是有限的。
    示例:循环创建了3个对象,我们用O2来抽象表示这3个动态对象。

    (2)上下文敏感 Context Sensitivity问题:考虑是否区分不同call-site对同一函数的调用。

    Context-sensitive:根据某函数调用上下文的不同,多次分析同一函数。
    Context-insensitive:每个函数只分析一次。


    (3)流敏感 Flow Sensitivity问题:考虑语句顺序(控制流)的影响 vs 把程序当做无序语句的集合。
    方法:流敏感会在每个程序点都保存一份指针指向关系映射,而流不敏感则对整个程序保存一份指向关系映射。
    说明:目前流敏感对Java提升不大,不过在C中很有效,本课程分析的是Java,所以重点讨论流不敏感技术。
    指针分析示例:

    (4)分析范围 Analysis Scope问题:分析程序的哪一部分?

    Whole-program 全程序:分析全程序的指向关系。
    Demand-driven 需求驱动:只分析影响特定域的指针的指向关系。

    4.分析哪些语句问题:哪些语句会影响指针指向,那就只分析这些语句。
    Java指针类型:

    Lacal variable: x
    Static field:C.f (有时称为全局变量)——不分析
    Instance field: x.f (对象的field)
    Array element: array[i] ——不分析,因为静态分析无法确定下标,所以将array中所有成员映射到一个field中,等价于Instance field,所以不重复分析。如下图所示:


    影响指针指向的语句:
    1. New: x = new T()2. Assign:x = y3. Store: x.f = y4. Load: y = x.f5. Call: r = x.k(a,...) - Static call: C.foo() - Special call: super.foo() / x.<init>() / this.privateFoo() - **Virtual call**:x.foo()
    复杂的内存访问可以通过引入临时变量,转化为三地址代码:
    x.f.g.h = y;// 转化为t1 = x.f;t2 = t1.g;t2.h = y;
    0  留言 2020-07-17 11:33:24
  • 【课程笔记】南大软件分析课程5——过程间分析


    最近在看“静态分析”技术相关的文章,看到这个系列的笔记和视频教程,感觉介绍得很好,通俗易懂,而且还比较详细,故转载分享,同时也备份保留下,方便自己今后阅读。(PS:建议大家一边看笔记,一边看视频,加深理解)原作者:bsauce原文链接:https://www.jianshu.com/p/2d14c0ae41cd

    首先非常感谢南京大学李樾和谭添老师的无私分享,之前学习程序分析是看的北大熊英飞老师的ppt,但是很多地方没看懂,正如李樾老师所说的那样,熊英飞老师的授课涵盖非常广,不听课只看ppt的话,理解起来还是很有难度的。但李樾老师的视频就讲解的非常易懂,示例都是精心挑选的,所以墙裂推荐。
    推送门:南大课件 南大视频课程 北大课件

    目录
    Motivation调用图构建过程间控制流分析过程间数据流分析
    重点:学习如何利用类层级分析来构建调用图;过程间控制流/数据流分析;过程间的常量传播。
    1.Motivation问题:过程内的分析未考虑函数调用,导致分析不精确。
    过程间分析:Inter-procedural Analysis,考虑函数调用,又称为全程序分析(Whole Program Analysis),需要构建调用图,加入Call edges和Return edges。
    2.调用图构建(1)调用图定义:本质是调用边的集合,从调用点(call-sites)到目标函数(target methods / callees)的边。
    示例:

    应用:是所有过程间分析(跨函数分析)的基础,程序优化,程序理解,程序调试。
    (2)面向对象语言的调用图构造(Java)代表性算法:从上往下精度变高,速度变慢,重点分析第1、4个算法。

    Class hierarchy analysis(CHA)Rapid type analysis(RTA)Variable type analysis(VTA)Pointer analysis(k-CFA)
    Java调用分类:

    Method Dispatch:最难的是Virtual call,其中关键步骤是Method Dispatch,就是找到最终调用的实际函数。
    virtual call在程序运行时才能得到,基于2个要素得到:

    reciever object的具体类型:c
    调用点的函数签名:m。(通过signature可以唯一确定一个函数)

    signature = 函数所在的类 + 函数名 + 描述符描述符 = 返回类型 + 参数类型
    简记为C.foo(P, Q, R)


    (3)Method Dispatch(virtual call)定义:用Dispatch(c, m)来模拟动态Method Dispatch过程,c表示reciever object,m表示函数签名。

    解释:若该类的非抽象方法(实际可执行的函数主体)中包含和m相同名字、传递/返回参数的m‘,则直接返回;否则到c的父类中找。
    示例:

    (4)Class Hirarchy Analysis (CHA) 类层级分析目的:根据每个virtual call 的 receiver varible 的声明类型来求解所有可能调用的目标函数。如 A a = ... ; a.foo(); 这个a就是receiver varible,声明类型就是A。假定a可以指向A以及A所有子类对象,CHA的过程就是从A和子类中去找目标函数。
    算法:Resolve(cs)——利用CHA算法找到调用点所有可能的调用目标。

    算法示例:

    算法应用:

    错误:以上b.foo()的调用目标 C.foo()和D.foo()是错误的,因为已经指定了是B类型,所以b.foo()根本不会调用C、D的foo()。因为CHA只考虑声明类型,也就是B,导致准确度下降。多态性就是说,父类可以引用子类的对象,如B b=new C()。
    优缺点:CHA优点是速度快,只考虑声明类型,忽略数据流和控制流;缺点是准确度低。
    总结:本类中有同名函数就在本类和子类找,没有就从父类找,接着找父类的子类中的同名函数(CHA分析)。
    (5)利用CHA构造调用图算法:遍历每个函数中的每个调用指令,调用CHA的Resolve()找到对应的目标函数和调用边,函数+调用边=调用图。

    示例:

    3.过程间控制流分析定义:过程间控制流图ICFG = CFG + (Call edges + Return edges)。

    Call edges:连接调用点和目标函数入口Return edges:从return语句连到Return site(Call site后面一条语句)
    示例:

    4.过程间数据流分析说明:对ICFG进行数据流分析,没有标准的一套算法。
    对比:

    常量传播数据流分析:

    Node transfer:与过程内分析相同,对每个调用点,将等号左边部分去掉。Call edge transfer:传参Return edge transfer:传返回值
    常量传播示例:

    说明:黄色背景边必须有,从b = addOne(a)到c=b-3,a通过此边传递,b通过addOne()传递。若a也通过addOne()传递,会额外消耗系统资源。
    0  留言 2020-07-15 10:12:26
  • 【课程笔记】南大软件分析课程4——数据流分析基础


    最近在看“静态分析”技术相关的文章,看到这个系列的笔记和视频教程,感觉介绍得很好,通俗易懂,而且还比较详细,故转载分享,同时也备份保留下,方便自己今后阅读。(PS:建议大家一边看笔记,一边看视频,加深理解)原作者:bsauce原文链接:https://www.jianshu.com/p/d314b316b332

    首先非常感谢南京大学李樾和谭添老师的无私分享,之前学习程序分析是看的北大熊英飞老师的ppt,但是很多地方没看懂,正如李樾老师所说的那样,熊英飞老师的授课涵盖非常广,不听课只看ppt的话,理解起来还是很有难度的。但李樾老师的视频就讲解的非常易懂,示例都是精心挑选的,所以墙裂推荐。
    推送门:南大课件 南大视频课程 北大课件

    关于这一节zcc的笔记已经够完美了,我就直接在他基础上记录了。
    目录
    迭代算法-另一个角度偏序(Partial Order)上下界(Upper and Lower Bounds)格(Lattice),半格(Semilattice),全格和格点积(Complete and Product Lattice)数据流分析框架(via Lattice)单调性与不动点定理(Monotonicity and Fixed Point Theorem)迭代算法转化为不动点理论从lattice的角度看may/must分析分配性(Distributivity)和MOP常量传播Worklist算法
    重点上节课是介绍了3种数据流分析迭代算法,本节课将从数学理论的角度来讨论数据流分析,加深对数据流分析算法的理解。
    1.迭代算法-另一个角度本质:常见的数据流迭代算法,目的是通过迭代计算,最终得到一个稳定的不变的解。
    (1)理论定义1:给定有k个节点(基本块)的CFG,迭代算法就是在每次迭代时,更新每个节点n的OUT[n]。
    定义2:设数据流分析的值域是V,可定义一个k-元组:(OUT[n1],OUT[n2],..,OUT[nk])(OUT[n_1],OUT[n_2], .. , OUT[n_k])(OUT[n​1​​],OUT[n​2​​],..,OUT[n​k​​])是集合 (V1×V2..×Vk)(V_1 \times V_2 .. \times V_k)(V​1​​×V​2​​..×V​k​​) (幂集,记为VkV_kV​k​​)的一个元素,表示每次迭代后k个节点整体的值。
    定义3:每一次迭代可看作是Vk映射到新的Vk,通过转换规则和控制流来映射,记作函数F:Vk→VkF:V_k \rightarrow V_kF:V​k​​→V​k​​。
    迭代算法本质:通过不断迭代,直到相邻两次迭代的k-元组值一样,算法结束。
    (2)图示
    不动点:当Xi = F(Xi)时,就是不动点。
    问题:

    迭代算法是否一定会停止(到达不动点)?迭代算法如果会终止,会得到几个解(几个不动点)?迭代几次会得到解(到达不动点)?
    2.偏序(Partial Order)定义:给定偏序集(P,⊑)(P, \sqsubseteq)(P,⊑),⊑\sqsubseteq⊑是集合PPP上的二元关系,若满足以下性质则为偏序集:

    自反性Reflexivity:∀x∈P, x⊑x
    对称性Antisymmetry:∀x,y∈P, x⊑y∧y⊑x ⇒ x=y
    传递性Transitivity:∀x,y∈P, x⊑y ∧ y⊑z ⇒ x⊑z

    例子:

    P是整数集,⊑\sqsubseteq⊑表示≤\leq≤,是偏序集;若⊑\sqsubseteq⊑表示<,则显然不是偏序集
    P是英文单词集合,⊑\sqsubseteq⊑表示子串关系(可以存在两个元素不具有偏序关系,不可比性),是偏序集

    3.上下界(Upper and Lower Bounds)(1)定义定义:给定偏序集(P, \sqsubseteq),且有P的子集S⊆P:

    ∀x∈S, x⊑u, 其中u∈P,则u是子集S的上界 (注意,u并不一定属于S集)
    ∀x∈S, l⊑x, 其中l∈P,则l是S的下界

    最小上界:least upper bound(lub 或者称为join),用⊔S表示。上确界?
    定义:对于子集S的任何一个上界u,均有⊔S⊑u。
    最大下界:greatest lower bound(glb 或者称为meet),用⊓S表示。下确界?
    定义:对于子集S的任何一个下界l,均有l⊑⊓S。
    (2)示例若S只包含两个元素,a、b(S = {a, b})那么上界可以表示为a⊔b,下界可以表示为a⊓b。

    (3)特性
    并非每个偏序集都有上下确界


    如果存在上下确界,则是唯一的
    利用传递性和反证法即可证明。
    4.格(Lattice),(半格)Semilattice,全格,格点积(Complete and Product Lattice)都是基于上下确界来定义的。
    (1)格定义:给定一个偏序集(P,⊑),∀a,b∈P,如果存在a⊔b和a⊓b,那么就称该偏序集为格。偏序集中的任意两个元素构成的集合均存在最小上界和最大下界,那么该偏序集就是格。
    例子:

    (S, ⊑)中S是整数子集,⊑\sqsubseteq⊑是≤\leq≤,是格点
    (S, ⊑)中S是英文单词集,⊑\sqsubseteq⊑表示子串关系,不是格点,因为单词pin和sin就没有上确界
    (S, ⊑)中S是{a, b, c}的幂集,⊑\sqsubseteq⊑表示⊆\subseteq⊆子集,是格点

    (2)半格定义:给定一个偏序集(P,⊑),∀a,b∈P:

    当且仅当a⊔b存在(上确界),该偏序集叫做 join semilatice
    当且仅当a⊓b存在(下确界),该偏序集叫做 meet semilatice

    (3)全格定义:对于格点 (S, ⊑\sqsubseteq⊑) (前提是格点)的任意子集S,⊔S上确界和⊓S下确界都存在,则为全格complete lattice。
    例子:

    P是整数集,⊑\sqsubseteq⊑是≤\leq≤,不是全格,因为P的子集正整数集没有上确界
    (S, ⊑)中S是{a, b, c}的幂集,⊑\sqsubseteq⊑表示⊆\subseteq⊆子集,是全格

    符号:⊤=⊔P\top = \sqcup P⊤=⊔P,叫做top;⊥=⊓P\perp = \sqcap P⊥=⊓P,叫做bottom。
    性质:有穷的格点必然是complete lattice。全格一定有穷吗? 不一定,如实数界[0, 1]。
    (4)格点积定义:给定一组格,L1=(P1,⊑1)L_1=(P_1, \sqsubseteq 1)L​1​​=(P​1​​,⊑1),L2=(P2,⊑2)L_2=(P_2, \sqsubseteq 2)L​2​​=(P​2​​,⊑2),.. ,Ln=(Pn,⊑n)L_n=(P_n, \sqsubseteq n)L​n​​=(P​n​​,⊑n),都有上确界⊔i\sqcup i⊔i和下确界⊓i\sqcap i⊓i,则定义格点积 Ln=(P,⊑)Ln = (P, \sqsubseteq)Ln=(P,⊑):
    P=P1×..×PnP = P_1 \times .. \times P_nP=P​1​​×..×P​n​​(x1,..xn)⊑(y1,..yn)⇔(x1⊑y1)∧..∧(xn⊑yn)(x_1, .. x_n) \sqsubseteq (y_1, .. y_n) \Leftrightarrow (x_1 \sqsubseteq y_1) \wedge .. \wedge (x_n \sqsubseteq y_n)(x​1​​,..x​n​​)⊑(y​1​​,..y​n​​)⇔(x​1​​⊑y​1​​)∧..∧(x​n​​⊑y​n​​)(x1,..xn)⊔(y1,..yn)=(x1⊔y1,..,xn⊔yn)(x_1, .. x_n) \sqcup (y_1, .. y_n) = (x1 \sqcup y_1, .., x_n \sqcup y_n)(x​1​​,..x​n​​)⊔(y​1​​,..y​n​​)=(x1⊔y​1​​,..,x​n​​⊔y​n​​)(x1,..xn)⊓(y1,..yn)=(x1⊓y1,..,xn⊓yn)(x_1, .. x_n) \sqcap (y_1, .. y_n) = (x_1 \sqcap y_1, .., x_n \sqcap y_n)(x​1​​,..x​n​​)⊓(y​1​​,..y​n​​)=(x​1​​⊓y​1​​,..,x​n​​⊓y​n​​)性质:格点积也是格点;格点都是全格,则格点积也是全格。
    5.数据流分析框架(via Lattice)数据流分析框架(D, L, F) :

    D—方向
    L—格点(值域V,meet ⊓\sqcap⊓ 或 join ⊔\sqcup⊔ 操作)
    F—转换规则V→VV \rightarrow VV→V。

    数据流分析可以看做是迭代算法对格点 利用转换规则和 meet/join操作。
    6.单调性与不动点定理(Monotonicity and Fixed Point Theorem)目标问题:迭代算法一定会停止(到达不动点)吗?
    (1)单调性定义:函数f:L→Lf: L \rightarrow Lf:L→L,满足∀x,y∈L,x⊑y⇒f(x)⊑f(y),则为单调的。
    (2)不动点理论定义:给定一个完全lattice(L,⊑),如果f:L→L是单调的,并且L有限
    那么我们能得到最小不动点,通过迭代:f(⊥),f(f(⊥)),…,fk(⊥)直到找到最小的一个不动点。
    同理 我们能得到最大不动点,通过迭代:f(⊤),f(f(⊤)),…,fk(⊤)直到找到最大的一个不动点。
    (3)证明不动点的存在性;
    最小不动点证明。
    7.迭代算法转化为不动点理论问题:我们如何在理论上证明迭代算法有解、有最优解、何时到达不动点?那就是将迭代算法转化为不动点理论。因为不动点理论已经证明了,单调、有限的完全lattice,存在不动点,且从⊤开始能找到最大不动点,从⊥开始能找到最小不动点。
    目标:证明迭代算法是一个完全lattice(L,⊑)lattice(L, \sqsubseteq)lattice(L,⊑),是有限的,单调的。

    (1)完全lattice证明根据第5小节,迭代算法每个节点(基本块)的值域相当于一个lattice,每次迭代的k个基本块的值域就是一个k-元组。k-元组可看作lattice积,根据格点积性质:若Lk中每一个lattice都是完全的,则Lk也是完全的。
    (2)L是有限的迭代算法中,值域是0/1,是有限的,则lattice有限,则Lk也有限。
    (3)F是单调的函数F:BB中转换函数fi:L → L + BB分支之间的控制流影响(汇聚是join ⊔\sqcup⊔ / meet ⊓\sqcap⊓ 操作,分叉是拷贝操作)。

    转换函数:BB的gen、kill是固定的,值域一旦变成1,就不会变回0,显然单调。
    join/meet操作:L × L → L 。证明:∀x,y,z∈L,且有x⊑y需要证明x⊔z⊑y⊔z。

    总结:迭代算法是完全lattice,且是有限、单调的,所以一定有解、有最优解。
    (4)算法何时到达不动点?定义:lattice高度—从lattice的top到bottom之间最长的路径。

    最坏情况迭代次数:设有n个块,每次迭代只有1个BB的OUT/IN值的其中1位发生变化(则从top→bottom这1位都变化),则最多迭 (n × h) 次。
    8.从lattice的角度看may/must分析说明:may 和 must 分析算法都是从不安全到安全(是否安全取决于safe-aprroximate过程),从准确到不准确。

    (1)may分析以 Reaching Definitions分析为例:

    从⊥\perp⊥开始,⊥\perp⊥表示所有定义都不可达,是不安全的结果(因为这个分析的应用目的是为了查错,查看变量是否需要初始化。首先在Entry中给每个变量一个假定义,标记所有变量为都为未初始化状态,⊥\perp⊥表示所有的假定义都无法到达,说明所有变量在中间都进行了赋值,那就不需要对任何变量进行初始化,这是不安全的,可能导致未初始化错误)。
    ⊤\top⊤表示所有Entry中的假定义都可达,从查错角度来说,需要对每个变量都进行初始化,非常安全!但是这句话没有用,我都要初始化的话还做这个分析干嘛?
    Truth:表明最准确的验证结果,假设{a,c}是truth,那么包括其以上的都是safe的,以下的都是unsafe,就是上图的阴影和非阴影。
    从⊥\perp⊥到⊤\top⊤,得到的最小不动点最准确,离Truth最近。上面还有多个不动点,越往上越不准。


    (2)must分析以available expressions分析为例:

    从⊤\top⊤开始,表示所有表达式可用。如果用在表达式计算优化中,那么有很多已经被重定义的表达式也被优化了(实际上不能被优化),那么该优化就是错误的,不安全!
    ⊥\perp⊥表示没有表达式可用,都不需要优化,很安全!但没有用。
    从⊤\top⊤到⊥\perp⊥,就是从不安全到安全,存在一个Truth,代表准确的结果。
    从⊤\top⊤到⊥\perp⊥,达到一个最大不动点,离truth最近的最优解。

    迭代算法转化到lattice上,may/must分析分别初始化为最小值⊥\perp⊥和最大值⊤\top⊤,最后求最小上界/最大下界。
    9.分配性(Distributivity)和MOP目的:MOP(meet-over-all-paths)衡量迭代算法的精度。
    (1)概念定义:最终将所有的路径一起来进行join/meet操作。
    路径P = 在cfg图上从entry到基本块si的一条路径(P = Entry → s1 → s2 → … → s~i )。
    路径P上的转移函数Fp:该路径上所有语句的转移函数的组合fs1,fs2,… ,fsi-1,从而构成FP。
    MOP:从entry到si所有路径的FP的meet操作。本质—求这些值的最小上界/最大下界。

    MOP准确性:有些路径不会被执行,所以不准确;若路径包含循环,或者路径爆炸,所以实操性不高,只能作为理论的一种衡量方式。
    (2)MOP vs 迭代算法
    对于以上的CFG,抽象出itter和MOP公式。
    证明:

    根据最小上界的定义,有x⊑x⊔y和 y⊑x⊔y。
    由于转换函数是单调的,则有F(x)⊑F(x⊔y)和F(y)⊑F(x⊔y),所以F(x⊔y)就是F(x)和F(y)的上界。
    根据定义,F(x)⊔F(y)是F(x)和F(y)的最小上界。
    所以F(x)⊔F(y)⊑F(x⊔y)。

    结论:所以,MOP更准确。若F满足分配律,则迭代算法和MOP精确度一样 F(x⊔y)=F(x)⊔F(y)。一般,对于控制流的join/meet,是进行集合的交或并操作,则满足分配律。
    10.常量传播 (constant propagation)问题描述:在程序点p处的变量x,判断x是否一定指向常量值。
    类别:must分析,因为要考虑经过p点所有路径上,x的值必须都一样,才算作一定指向常量。
    表示:CFG每个节点的OUT是pair(x, v)的集合,表示变量x是否指向常数v。
    数据流分析框架(D, L, F)(1)D:forward更直观
    (2)L:lattice

    变量值域:所有实数。must分析,所以⊤\top⊤是UNDEF未定义(unsafe),⊥\perp⊥是NAC非常量(safe)。
    meet操作:must分析, ⊓\sqcap⊓。在每个路径汇聚点PC,对流入的所有变量进行meet操作,但并非常见的交和并,所以不满足分配律。

    NAC⊓v=NACNAC \sqcap v = NACNAC⊓v=NAC
    UNDEF⊓v=vUNDEF \sqcap v = vUNDEF⊓v=v 未初始化的变量不是我们分析的目标
    c⊓v=?c⊓c=c c1⊓c2=NACc \sqcap v = ? c \sqcap c = c \space c1 \sqcap c2 =NACc⊓v=?c⊓c=c c1⊓c2=NAC

    (3)F转换函数
    OUT[s] = gen U (IN[s] - {(x, _})
    输出 = BB中新被赋值的 U 输入 - BB中相关变量值已经不是f常量的部分。
    对所有的赋值语句进行分析(不是赋值语句则不管,用val(x)表示x指向的值):

    (4)性质:不满足分配律

    可以发现,MOP更准确。F(X⊓Y)⊑F(X)⊓F(Y)F(X \sqcap Y) \sqsubseteq F(X) \sqcap F(Y)F(X⊓Y)⊑F(X)⊓F(Y),但是是单调的。
    11.Worklist算法本质:对迭代算法进行优化,采用队列来存储需要处理的基本块,减少大量的冗余的计算。
    0  留言 2020-07-13 22:12:43
  • 【课程笔记】南大软件分析课程3——数据流分析应用


    最近在看“静态分析”技术相关的文章,看到这个系列的笔记和视频教程,感觉介绍得很好,通俗易懂,而且还比较详细,故转载分享,同时也备份保留下,方便自己今后阅读。(PS:建议大家一边看笔记,一边看视频,加深理解)原作者:bsauce原文链接:https://www.jianshu.com/p/45eb5e5565d5

    首先非常感谢南京大学李樾和谭添老师的无私分享,之前学习程序分析是看的北大熊英飞老师的ppt,但是很多地方没看懂,正如李樾老师所说的那样,熊英飞老师的授课涵盖非常广,不听课只看ppt的话,理解起来还是很有难度的。但李樾老师的视频就讲解的非常易懂,示例都是精心挑选的,所以墙裂推荐。
    推送门:南大课件 南大视频课程 北大课件

    目录
    数据流分析总览预备知识Reaching Definitions Analysis (may analysis)Live Variables Analysis (may analysis)Available Expressions Analysis (must analysis)
    重点
    理解3种数据流分析的含义,如何设计类似的算法,如何优化理解种数据流分析的共性与区别理解迭代算法并弄懂算法为什么能停止

    1.数据流分析总览
    may analysis:输出可能正确的信息(需做over-approximation优化,才能成为Safe-approximation安全的近似,可以有误报-completeness),注意大多数静态分析都是may analysis
    must analysis:输出必须正确的信息(需做under-approximation优化,才能成为Safe-approximation安全的近似,可以有漏报-soundness)

    Nodes (BBs/statements)、Edges (control flows)、CFG (a program)
    例如:

    application-specific Data <- abstraction (+/-/0)
    Nodes <- Transfer function
    Edges <- Control-flow handling

    不同的数据流分析 有 不同的数据抽象表达 和 不同的安全近似策略,如 不同的 转换规则 和 控制流处理。

    2.预备知识输入/输出状态:程序执行前/执行后的状态(本质就是抽象表达的数据的状态,如变量的状态)。
    数据流分析的结果:最终得到,每一个程序点对应一个数据流值(data-flow value),表示该点所有可能程序状态的一个抽象。例如,我只关心x、y的值,我就用抽象来表示x、y所有可能的值的集合(输入/输出的值域/约束),就代表了该程序点的程序状态。

    控制流约束:约束求解做的事情,推断计算输入到输出,或反向分析。

    3.Reaching Definitions Analysis (may analysis)问题定义:给变量v一个定义d(赋值),存在一条路径使得程序点p能够到达q,且在这个过程中不能改变v的赋值。
    应用举例:检测未定义的变量,若v可达p且v没有被定义,则为未定义的变量。
    抽象表示:设程序有n条赋值语句,用n位向量来表示能reach与不能reach。
    (1)公式分析什么是definition? D: v = x op y 类似于赋值。
    Transfer Function:OUT[B]=genBU(IN[B]−killB)OUT[B] = gen_B U (IN[B] - kill_B)OUT[B]=gen​B​​U(IN[B]−kill​B​​) ——怎么理解,就是基于转换规则而得到。
    解释:基本块B的输出 = 块B内的所有变量v的定义(赋值/修改)语句 U (块B的输入 - 程序中其它所有定义了变量v的语句)。本质就是本块与前驱修改变量的语句 作用之和(去掉前驱的重复修改语句)。
    Control Flow:IN[B]=Upa predecesso of BOut[P]IN[B] = U_{p_{a \space predecesso \space of \space B}} Out[P]IN[B]=U​p​a predecesso of B​​​​Out[P] ——怎么理解,就是基于控制流而得到。
    解释:基本块B的输入 = 块B所有前驱块P的输出的并集。注意,所有前驱块意味着只要有一条路径能够到达块B,就是它的前驱,包括条件跳转与无条件跳转。

    (2)算法目的:输入CFG,计算好每个基本块的killB(程序中其它块中定义了变量v的语句)和genB(块B内的所有变量v的定义语句),输出每个基本块的IN[B]和OUT[B]。
    方法:首先所有基本块的OUT[B]初始化为空。遍历每一个基本块B,按以上两个公式计算块B的IN[B]和OUT[B],只要这次遍历时有某个块的OUT[B]发生变化,则重新遍历一次(因为程序中有循环存在,只要某块的OUT[B]变了,就意味着后继块的IN[B]变了)。

    (3)实例抽象表示:设程序有n条赋值语句,用n位向量来表示能reach与不能reach。
    说明:红色-第1次遍历;蓝色-第2次遍历;绿色-第3次遍历。
    结果:3次遍历之后,每个基本块的OUT[B]都不再变化。

    现在,我们可以回想一下,数据流分析的目标是,最后得到了,每个程序点关联一个数据流值(该点所有可能的程序状态的一个抽象表示,也就是这个n位向量)。在这个过程中,我们对个基本块,不断利用基于转换规则的语义(也就是transfer functions,构成基本块的语句集)-OUT[B]、控制流的约束-IN[B],最终得到一个稳定的安全的近似约束集。
    (4)算法会停止吗?OUT[B]=genBU(IN[B]−killB)OUT[B] = gen_B U (IN[B] - kill_B)OUT[B]=gen​B​​U(IN[B]−kill​B​​)大致理解:genB和 killB是不变的,只有IN[B]在变化,所以说OUT[B]只会增加不会减少,n向量长度是有限的,所以最终肯定会停止。具体涉及到不动点证明,后续课程会讲解。
    4.Live Variables Analysis (may analysis)问题定义:某程序点p处的变量v,从p开始到exit块的CFG中是否有某条路径用到了v,如果用到了v,则v在p点为live,否则为dead。其中有一个隐含条件,在点p和引用点之间不能重定义v。

    应用场景:可用于寄存器分配,如果寄存器满了,就需要替换掉不会被用到的变量。
    抽象表示:程序中的n个变量用长度为n bit的向量来表示,对应bit为1,则该变量为live,反之为0则为dead。
    (1)公式分析Control Flow:OUT[B]=USa successor of BIN[S]OUT[B] = U_{S_{a \space successor \space of \space B}} IN[S]OUT[B]=U​S​a successor of B​​​​IN[S]
    理解:我们是前向分析,只要有一条子路是live,父节点就是live。
    Transfer Function:IN[B]=useBU(OUT[B]−defB)IN[B] = use_B U (OUT[B] - def_B)IN[B]=use​B​​U(OUT[B]−def​B​​)
    理解:IN[B] = 本块中use出现在define之前的变量 U (OUT[B]出口的live情况 - 本块中出现了define的变量)。define指的是定义/赋值。
    特例分析:如以下图所示,第4种情况,v=v-1,实际上use出现在define之前,v是使用的。

    (2)算法目的:输入CFG,计算好每个基本块中的defB(重定义)和useB(出现在重定义之前的使用)。输出每个基本块的IN[B]和OUT[B]。
    方法:首先初始化每个基本块的IN[B]为空集。遍历每一个基本块B,按以上两个公式计算块B的OUT[B]和IN[B],只要这次遍历时有某个块的IN[B]发生变化,则重新遍历一次(因为有循环,只要某块的IN[B]变了,就意味前驱块的OUT[B]变了)。
    问题:遍历基本块的顺序有要求吗? 没有要求,但是会影响遍历的次数。

    初始化规律:一般情况下,may analysis 全部初始化为空,must analysis全部初始化为all。
    (3)实例抽象表示:程序中的n个变量用长度为n bit的向量来表示,对应bit为1,则该变量为live,反之为0则为dead。
    说明:从下往上遍历基本块,黑色-初始化;红色-第1次;蓝色-第2次;绿色-第3次。
    结果:3次遍历后,IN[B]不再变化,遍历结束。

    5.Available Expressions Analysis (must analysis)问题定义:程序点p处的表达式x op y可用需满足2个条件,一是从entry到p点必须经过x op y,二是最后一次使用x op y之后,没有重定义操作数x、y。(如果重定义了x 或 y,如x = a op2 b,则原来的表达式x op y中的x或y就会被替代)。
    应用场景:用于优化,检测全局公共子表达式。
    抽象表示:程序中的n个表达式,用长度为n bit的向量来表示,1表示可用,0表示不可用。
    说明:属于forward分析。
    (1)公式分析Transfer Function:OUT[B]=genBU(IN[B]−killB)OUT[B] = gen_B U (IN[B] - kill_B)OUT[B]=gen​B​​U(IN[B]−kill​B​​)
    理解:genB—基本块B中所有新的表达式(并且在这个表达式之后,不能对表达式中出现的变量进行重定义)—>加入到OUT;killB—从IN中删除变量被重新定义的表达式。
    Control Flow:IN[B]=∩Pa predecessor of BOUT[P]IN[B] = \cap_{P_{a \space predecessor \space of \space B}} OUT[P]IN[B]=∩​P​a predecessor of B​​​​OUT[P]
    IN[B]=∩Pa predecessor of BOUT[P]IN[B] = \cap_{P_{a \space predecessor \space of \space B}} OUT[P]IN[B]=∩​P​a predecessor of B​​​​OUT[P]理解:从entry到p点的所有路径都必须经过该表达式。

    问题:该分析为什么属于must analysis呢?因为我们允许有漏报,不能有误报,比如以上示例中,改为x=3,去掉 b=e16∗xb=e^{16}*xb=e​16​​∗x,该公式会把该表达式识别为不可用。但事实是可用的,因为把x=3替换到表达式中并不影响该表达式的形式。这里虽然漏报了,但是不影响程序分析结果的正确性。
    (2)算法目的:输入CFG,提前计算好genB和killB。
    方法:首先将OUT[entry]初始化为空,所有基本块的OUT[B]初始化为1…1。遍历每一个基本块B,按以上两个公式计算块B的IN[B]和OUT[B],只要这次遍历时有某个块的OUT[B]发生变化,则重新遍历一次(因为有循环,只要某块的OUT[B]变了,就意味后继块的IN[B]变了)。

    (3)实例抽象表示:程序中的n个表达式,用长度为n bit的向量来表示,1表示可用,0表示不可用。
    说明:黑色-初始化;红色-第1次;蓝色-第2次。
    结果:2次遍历后,OUT[B]不再变化,遍历结束。

    6.三种分析技术对比
    问题:怎样判断是May还是Must?
    Reaching Definitions表示只要从赋值语句到点p存在1条路径,则为reaching,结果不一定正确;Live Variables表示只要从点p到Exit存在1条路径使用了变量v,则为live,结果不一定正确;Available Expressions表示从Entry到点p的每一条路径都经过了该表达式,则为available,结果肯定正确。
    0  留言 2020-07-09 10:52:38
  • 【课程笔记】南大软件分析课程2——IR


    最近在看“静态分析”技术相关的文章,看到这个系列的笔记和视频教程,感觉介绍得很好,通俗易懂,而且还比较详细,故转载分享,同时也备份保留下,方便自己今后阅读。(PS:建议大家一边看笔记,一边看视频,加深理解)原作者:bsauce原文链接:https://www.jianshu.com/p/acb73f72cf46

    首先非常感谢南京大学李樾和谭添老师的无私分享,之前学习程序分析是看的北大熊英飞老师的ppt,但是很多地方没看懂,正如李樾老师所说的那样,熊英飞老师的授课涵盖非常广,不听课只看ppt的话,理解起来还是很有难度的。但李樾老师的视频就讲解的非常易懂,示例都是精心挑选的,所以墙裂推荐。
    推送门:南大课件 南大视频课程 北大课件

    目录
    编译器和静态分析的关系AST vs IRIR:3-地址代码(3AC)实际静态分析器的3AC—Soot(Java)SSA-静态单赋值基本块(BB)控制流图(CFG)
    1.编译器和静态分析的关系源码->(Scanner - 词法Lexical分析-Regular Expression)->(Parser- 语法Syntax分析-Context-Free Grammar), 生成AST ->(Type Checker - 语义Semantic分析 - Attribute Grammar),生成 Decorated AST -> Translator,生成IR,进行静态分析 -> Code Generator

    2.AST vs IR
    AST :高级,更接近于语法结构,依赖于语言种类,适用于快速类型检查,缺少控制流信息
    IR:低级,更接近于机器码,不依赖语言种类,压缩且简洁,包含控制流信息。是静态分析的基础


    3.IR:3-地址代码(3AC)// 最多1个操作符a+b+3 -> t1 = a+b t2 = t1+3Address: Name:a、b Constant: 3 编译器的临时变量:t1、t2

    4.实际静态分析器的3AC—Soot(Java)Soot-常用的Java静态分析框架
    // java IR(Jimple)基本知识invokespecial:call constructor, call superclass methods, call private methodsinvokevirtual: instance methods call (virtual dispatch)invokeinterface: cannot optimization, checking interface implementationinvokestation:call static methodsJava 7: invokedynamic -> Java static typing, dynamic language runs on JVMmethod signature: class name, return type, method name(parameter1 type, parameter2 type)
    5.SSA-静态单赋值定义:给每一个定义变量一个新的名字,传递到接下来的使用当中,每个变量有1个定义(赋值的目标变量)。

    优点:唯一的变量名可以间接体现程序流信息,简化分析过程;清楚的Define-Use信息。
    缺点:引入很多变量和phi-function;转换为机器码时效率变低(引入很多拷贝操作)。
    6.基本块(BB)定义:只有1个开头入口和1个结尾出口的最长3-地址指令序列。
    识别基本块的算法:首先确定入口指令,第一条指令是入口;任何跳转指令的目标地址是入口;任何跟在跳转指令之后的指令是入口。然后构造基本块,任何基本块包含1个入口指令和其接下来的指令。
    我的想法:对于下1条指令,若该指令不是入口,则可以加入;若该指令有多个出口,则停止加入,否则继续判断下一条指令。

    7.控制流图(CFG)控制流边:基本块A的结尾有跳转指令跳转到基本块B;原始指令序列中,B紧跟着A,且A的结尾不是无条件跳转。

    添加Entry / Exit:没有块跳转到该块 / 没有跳转到其他块。
    0  留言 2020-07-08 13:04:54
  • 【课程笔记】南大软件分析课程1——课程介绍


    最近在看“静态分析”技术相关的文章,看到这个系列的笔记和视频教程,感觉介绍得很好,通俗易懂,而且还比较详细,故转载分享,同时也备份保留下,方便自己今后阅读。(PS:建议大家一边看笔记,一边看视频,加深理解)原作者:bsauce原文链接:https://www.jianshu.com/p/8d06766d232c

    首先非常感谢南京大学李樾和谭添老师的无私分享,之前学习程序分析是看的北大熊英飞老师的ppt,但是很多地方没看懂,正如李樾老师所说的那样,熊英飞老师的授课涵盖非常广,不听课只看ppt的话,理解起来还是很有难度的。但李樾老师的视频就讲解的非常易懂,示例都是精心挑选的,所以墙裂推荐。
    推送门:南大课件 南大视频课程 北大课件
    我觉得上这门课,最大的收获就是,锻炼出一种软件分析的程序化思维。之所以做这个笔记,是为了总结和方便回看,毕竟回看笔记比回看视频快很多。
    讲数据流分析的时候,关键是首先抽象表示数据(一般是用向量),然后设计transfer function转换规则和control flow控制流规则,然后遍历基本块,根据这两个规则去计算,一旦某个基本块的抽象表示改变了,则再次遍历一遍。这对于我们以后设计数据流分析算法是很有帮助的,我们需要思考所要解决的问题到底是forward还是backward,是may还是must analysis,这相当于是一个分析模板。

    本课程主要内容:IR,数据流分析,过程间分析,CFL可达性和IFDS,指针分析,抽象解释。
    1.PL—Programming Languages
    理论:语言设计,类型系统,语义和逻辑
    环境:编译器,运行系统
    应用:程序分析,程序验证,程序合成
    技术:抽象解释(Abstract interpretation),数据流分析(Data-flow analysis, ),Hoare logic,Model checking,Symbolic execution等等

    静态分析作用:程序可靠性、程序安全性、编译优化、程序理解(调用关系、类型识别)。
    2.soundness与completeness
    soundness:对程序进行了over-approximate过拟合,不会漏报(有false positives误报)
    completeness:对程序进行了under-approximate欠拟合,不会误报(有false negatives漏报)

    很多重要领域如军工、航天领域,我们追求的是soundness,但是要平衡精度和速度。那么我们绝大多数软件分析方法都做到了completeness,那么只要能证明满足soundness,那么该分析方法就是正确的。
    那么什么样的SA是完美的呢?定义是既overapproximate又underapproximate的SA是完美的。overapproximate也叫sound,underapproximate也叫complete,他们之间的关系可以用一个图很好的表示。


    sound表示报告包含了所有的真实错误,但可能包含了误报的错误,导致误报
    complete表示报告包含的错误都是真实的错误,但可能并未包含全部的错误,造成了漏报

    3.软件分析步骤
    abstraction:抽象值,定义集合(类别)
    Safe-approximation安全近似

    Transfer Functions:对抽象值的操作,转换规则Control flows
    0  留言 2020-07-08 13:04:40
  • 污点分析技术介绍


    最近在看“污点分析”技术相关的文章,看到这篇文章介绍得不错,通俗易懂,而且还比较详细,故转载分享,同时也备份保留下,方便自己今后阅读。
    原文链接:https://github.com/firmianay/CTF-All-In-One/blob/master/doc/5.5_taint_analysis.md

    一、静态污点分析1.1 基本原理污点分析是一种跟踪并分析污点信息在程序中流动的技术。在漏洞分析中,使用污点分析技术将所感兴趣的数据(通常来自程序的外部输入)标记为污点数据,然后通过跟踪和污点数据相关的信息的流向,可以知道它们是否会影响某些关键的程序操作,进而挖掘程序漏洞。即将程序是否存在某种漏洞的问题转化为污点信息是否会被 Sink 点上的操作所使用的问题。
    污点分析常常包括以下几个部分:

    识别污点信息在程序中的产生点(Source点)并对污点信息进行标记
    利用特定的规则跟踪分析污点信息在程序中的传播过程
    在一些关键的程序点(Sink点)检测关键的操作是否会受到污点信息的影响

    举个例子:
    [...]scanf("%d", &x); // Source 点,输入数据被标记为污点信息,并且认为变量 x 是污染的[...]y = x + k; // 如果二元操作的操作数是污染的,那么操作结果也是污染的,所以变量 y 也是污染的[...]x = 0; // 如果一个被污染的变量被赋值为一个常数,那么认为它是未污染的,所以 x 转变成未污染的[...]while (i < y) // Sink 点,如果规定循环的次数不能受程序输入的影响,那么需要检查 y 是否被污染
    然而污点信息不仅可以通过数据依赖传播,还可以通过控制依赖传播。我们将通过数据依赖传播的信息流称为显式信息流,将通过控制依赖传播的信息流称为隐式信息流。
    举个例子:
    if (x > 0) y = 1;else y = 0;
    变量 y 的取值依赖于变量 x 的取值,如果变量 x 是污染的,那么变量 y 也应该是污染的。
    通常我们将使用污点分析可以检测的程序漏洞称为污点类型的漏洞,例如 SQL 注入漏洞:
    String user = getUser();String pass = getPass();String sqlQuery = "select * from login where user='" + user + "' and pass='" + pass + "'";Statement stam = con.createStatement();ResultSetrs = stam.executeQuery(sqlQuery);if (rs.next()) success = true;
    在进行污点分析时,将变量 user 和 pass 标记为污染的,由于变量 sqlQuery 的值受到 user 和 pass 的影响,所以将 sqlQuery 也标记为污染的。程序将变量 sqlQuery 作为参数构造 SQL 操作语句,于是可以判定程序存在 SQL 注入漏洞。
    使用污点分析检测程序漏洞的工作原理如下图所示:


    基于数据流的污点分析:在不考虑隐式信息流的情况下,可以将污点分析看做针对污点数据的数据流分析。根据污点传播规则跟踪污点信息或者标记路径上的变量污染情况,进而检查污点信息是否影响敏感操作
    基于依赖关系的污点分析:考虑隐式信息流,在分析过程中,根据程序中的语句或者指令之间的依赖关系,检查 Sink 点处敏感操作是否依赖于 Source 点处接收污点信息的操作

    1.2 方法实现静态污点分析系统首先对程序代码进行解析,获得程序代码的中间表示,然后在中间表示的基础上对程序代码进行控制流分析等辅助分析,以获得需要的控制流图、调用图等。在辅助分析的过程中,系统可以利用污点分析规则在中间表示上识别程序中的 Source 点和 Sink 点。最后检测系统根据污点分析规则,利用静态污点分析检查程序是否存在污点类型的漏洞。
    1.2.1 基于数据流的污点分析在基于数据流的污点分析中,常常需要一些辅助分析技术,例如别名分析、取值分析等,来提高分析精度。辅助分析和污点分析交替进行,通常沿着程序路径的方向分析污点信息的流向,检查 Source 点处程序接收的污点信息是否会影响到 Sink 点处的敏感操作。
    过程内的分析中,按照一定的顺序分析过程内的每一条语句或者指令,进而分析污点信息的流向。

    记录污点信息:在静态分析层面,程序变量的污染情况为主要关注对象。为记录污染信息,通常为变量添加一个污染标签。最简单的就是一个布尔型变量,表示变量是否被污染。更复杂的标签还可以记录变量的污染信息来自哪些 Source 点,甚至精确到 Source 点接收数据的哪一部分。当然也可以不使用污染标签,这时我们通过对变量进行跟踪的方式达到分析污点信息流向的目的。例如使用栈或者队列来记录被污染的变量
    程序语句的分析:在确定如何记录污染信息后,将对程序语句进行静态分析。通常我们主要关注赋值语句、控制转移语句以及过程调用语句三类:

    赋值语句

    对于简单的赋值语句,形如 a = b 这样的,记录语句左端的变量和右端的变量具有相同的污染状态。程序中的常量通常认为是未污染的,如果一个变量被赋值为常量,在不考虑隐式信息流的情况下,认为变量的状态在赋值后是未污染的
    对于形如 a = b + c 这样带有二元操作的赋值语句,通常规定如果右端的操作数只要有一个是被污染的,则左端的变量是污染的(除非右端计算结果为常量)
    对于和数组元素相关的赋值,如果可以通过静态分析确定数组下标的取值或者取值范围,那么就可以精确地判断数组中哪个或哪些元素是污染的。但通常静态分析不能确定一个变量是污染的,那么就简单地认为整个数组都是污染的
    对于包含字段或者包含指针操作的赋值语句,常常需要用到指向分析的分析结果

    控制转移语句

    在分析条件控制转移语句时,首先考虑语句中的路径条件可能是包含对污点数据的限制,在实际分析中常常需要识别这种限制污点数据的条件,以判断这些限制条件是否足够包含程序不会受到攻击。如果得出路径条件的限制是足够的,那么可以将相应的变量标记为未污染的
    对于循环语句,通常规定循环变量的取值范围不能受到输入的影响。例如在语句 for (i = 1; i < k; i++){} 中,可以规定循环的上界 k 不能是污染的

    过程调用语句

    可以使用过程间的分析或者直接应用过程摘要进行分析。污点分析所使用的过程摘要主要描述怎样改变与该过程相关的变量的污染状态,以及对哪些变量的污染状态进行检测。这些变量可以是过程使用的参数、参数的字段或者过程的返回值等。例如在语句 flag = obj.method(str); 中,str 是污染的,那么通过过程间的分析,将变量 obj 的字段 str 标记为污染的,而记录方法的返回值的变量 flag 标记为未污染的
    在实际的过程间分析中,可以对已经分析过的过程构建过程摘要。例如前面的语句,其过程摘要描述为:方法 method 的参数污染状态决定其接收对象的实例域 str 的污染状态,并且它的返回值是未受污染的。那么下一次分析需要时,就可以直接应用摘要进行分析


    代码的遍历:一般情况下,常常使用流敏感的方式或者路径敏感的方式进行遍历,并分析过程中的代码。如果使用流敏感的方式,可以通过对不同路径上的分析结果进行汇集,以发现程序中的数据净化规则。如果使用路径敏感的分析方式,则需要关注路径条件,如果路径条件中涉及对污染变量取值的限制,可认为路径条件对污染数据进行了净化,还可以将分析路径条件对污染数据的限制进行记录,如果在一条程序路径上,这些限制足够保证数据不会被攻击者利用,就可以将相应的变量标记为未污染的

    过程间的分析与数据流过程间分析类似,使用自底向上的分析方法,分析调用图中的每一个过程,进而对程序进行整体的分析。
    1.2.2 基于依赖关系的污点分析在基于依赖关系的污点分析中,首先利用程序的中间表示、控制流图和过程调用图构造程序完整的或者局部的程序的依赖关系。在分析程序依赖关系后,根据污点分析规则,检测 Sink 点处敏感操作是否依赖于 Source 点。
    分析程序依赖关系的过程可以看做是构建程序依赖图的过程。程序依赖图是一个有向图。它的节点是程序语句,它的有向边表示程序语句之间的依赖关系。程序依赖图的有向边常常包括数据依赖边和控制依赖边。在构建有一定规模的程序的依赖图时,需要按需地构建程序依赖关系,并且优先考虑和污点信息相关的程序代码。
    1.3 实例分析在使用污点分析方法检测程序漏洞时,污点数据相关的程序漏洞是主要关注对象,如 SQL 注入漏洞、命令注入漏洞和跨站脚本漏洞等。
    下面是一个存在 SQL 注入漏洞 ASP 程序的例子:
    <% Set pwd = "bar" Set sql1 = "SELECT companyname FROM " & Request.Cookies("hello") Set sql2 = Request.QueryString("foo") MySqlStuff pwd, sql1, sql2 Sub MySqlStuff(password, cmd1, cmd2) Set conn = Server.CreateObject("ADODB.Connection") conn.Provider = "Microsoft.Jet.OLEDB.4.0" conn.Open "c:/webdata/foo.mdb", "foo", password Set rs = conn.Execute(cmd2) Set rs = Server.CreateObject("ADODB.recordset") rs.Open cmd1, conn End Sub%>
    首先对这段代码表示为一种三地址码的形式,例如第 3 行可以表示为:
    a = "SELECT companyname FROM "b = "hello"param0 Requestparam1 bcallCookiesreturn csql1 = a & c
    解析完毕后,需要对程序代码进行控制流分析,这里只包含了一个调用关系(第 5 行)。
    接下来,需要识别程序中的 Source 点和 Sink 点以及初始的被污染的数据。
    具体的分析过程如下:

    调用 Request.Cookies(“hello”) 的返回结果是污染的,所以变量 sql1 也是污染的
    调用 Request.QueryString(“foo”) 的返回结果 sql2 是污染的
    函数 MySqlStuff 被调用,它的参数 sql1,sql2 都是污染的。分了分析函数的处理过程,根据第 6 行函数的声明,标记其参数 cmd1,cmd2 是污染的
    第 10 行是程序的 Sink 点,函数 conn.Execute 执行 SQL 操作,其参数 cmd2 是污染的,进而发现污染数据从 Source 点传播到 Sink 点。因此,认为程序存在 SQL 注入漏洞

    二、动态污点分析2.1 动态污点分析的基本原理动态污点分析是在程序运行的基础上,对数据流或控制流进行监控,从而实现对数据在内存中的显式传播、数据误用等进行跟踪和检测。动态污点分析与静态污点分析的唯一区别在于静态污点分析技术在检测时并不真正运行程序,而是通过模拟程序的执行过程来传播污点标记,而动态污点分析技术需要运行程序,同时实时传播并检测污点标记。
    动态污点分析技术可分为三个部分:

    污点数据标记:程序攻击面是程序接受输入数据的接口集,一般由程序入口点和外部函数调用组成。在污点分析中,来自外部的输入数据会被标记为污点数据。根据输入数据来源的不同,可分为三类:网络输入、文件输入和输入设备输入
    污点动态跟踪:在污点数据标记的基础上,对进程进行指令粒度的动态跟踪分析,分析每一条指令的效果,直至覆盖整个程序的运行过程,跟踪数据流的传播

    动态污点跟踪通常基于以下三种机制

    动态代码插桩:可以跟踪单个进程的污点数据流动,通过在被分析程序中插入分析代码,跟踪污点信息流在进程中的流动方向
    全系统模拟:利用全系统模拟技术,分析模拟系统中每条指令的污点信息扩散路径,可以跟踪污点数据在操作系统内的流动
    虚拟机监视器:通过在虚拟机监视器中增加分析污点信息流的功能,跟踪污点数据在整个客户机中各个虚拟机之间的流动

    污点动态跟踪通常需要影子内存(shadow memory)来映射实际内存的污染情况,从而记录内存区域和寄存器是否是被污染的。对每条语句进行分析的过程中,污点跟踪攻击根据影子内存判断是否存在污点信息的传播,从而对污点信息进行传播并将传播结果保存于影子内存中,进而追踪污点数据的流向
    一般情况下,数据移动类和算数类指令都将造成显示的信息流传播。为了跟踪污点数据的显示传播,需要在每个数据移动指令和算数指令执行前做监控,当指令的结果被其中一个操作数污染后,把结果数据对应的影子内存设置为一个指针,指向源污染点操作数指向的数据结构

    污点误用检查:在正确标记污点数据并对污点数据的传播进行实时跟踪后,就需要对攻击做出正确的检测即检测污点数据是否有非法使用的情况

    动态污点分析的优缺点:

    优点

    误报率较低检测结果的可信度较高。
    缺点

    漏报率较高:由于程序动态运行时的代码覆盖率决定的。平台相关性较高:特定的动态污点分析工具只能够解决在特定平台上运行的程序。资源消耗大:包括空间上和时间上。

    2.2 动态污点分析的方法实现2.2.1 污点数据标记污点数据通常主要是指软件系统所接受的外部输入数据,在计算机中,这些数据可能以内存临时数据的形式存储,也可能以文件的形式存储。当程序需要使用这些数据时,一般通过函数或系统调用来进行数据访问和处理,因此只需要对这些关键函数进行监控,即可得到程序读取或输出了什么污点信息。另外对于网络输入,也需要对网络操作函数进行监控。
    识别出污点数据后,需要对污点进行标记。污点生命周期是指在该生命周期的时间范围内,污点被定义为有效。污点生命周期开始于污点创建时刻,生成污点标记,结束于污点删除时刻,清除污点标记。

    污点创建

    将来自于非可靠来源的数据分配给某寄存器或内存操作数时将已经标记为污点的数据通过运算分配给某寄存器或内存操作数时
    污点删除

    将非污点数据指派给存放污点的寄存器或内存操作数时将污点数据指派给存放污点的寄存器或内存地址时,此时会删除原污点,并创建新污点一些会清除污点痕迹的算数运算或逻辑运算操作时

    2.2.2 污点动态跟踪当污点数据从一个位置传递到另一个位置时,则认为产生了污点传播。污点传播规则:



    指令类型
    传播规则
    举例说明




    拷贝或移动指令
    T(a)<-T(b)
    mov a, b


    算数运算指令
    T(a)<-T(b)
    add a, b


    堆栈操作指令
    T(esp)<-T(a)
    push a


    拷贝或移动类函数调用指令
    T(dst)<-T(src)
    call memcpy


    清零指令
    T(a)<-false
    xor a, a



    注:T(x) 的取值分为 true 和 false 两种,取值为 true 时表示 x 为污点,否则 x 不是污点。
    对于污点信息流,通过污点跟踪和函数监控,已经能够进行污点信息流流动方向的分析。但由于缺少对象级的信息,仅靠指令级的信息流动并不能完全给出要分析的软件的确切行为。因此,需要在函数监控的基础上进行视图重建,如获取文件对象和套接字对象的详细信息,以方便进一步的分析工作。
    根据漏洞分析的实际需求,污点分析应包括两方面的信息:

    污点的传播关系,对于任一污点能够获知其传播情况
    对污点数据进行处理的所有指令信息,包括指令地址、操作码、操作数以及在污点处理过程中这些指令执行的先后顺序等

    污点动态跟踪的实现通常使用:

    影子内存:真实内存中污点数据的镜像,用于存放程序执行的当前时刻所有的有效污点
    污点传播树:用于表示污点的传播关系
    污点处理指令链:用于按时间顺序存储与污点数据处理相关的所有指令

    当遇到会引起污点传播的指令时,首先对指令中的每个操作数都通过污点快速映射查找影子内存中是否存在与之对应的影子污点从而确定其是否为污点数据,然后根据污点传播规则得到该指令引起的污点传播结果,并将传播产生的新污点添加到影子内存和污点传播树中,同时将失效污点对应的影子污点删除。同时由于一条指令是否涉及污点数据的处理,需要在污点分析过程中动态确定,因此需要在污点处理指令链中记录污点数据的指令信息。
    2.2.3 污点误用检查污点敏感点,即 Sink 点,是污点数据有可能被误用的指令或系统调用点,主要分为:

    跳转地址:检查污点数据是否用于跳转对象,如返回地址、函数指针、函数指针偏移等。具体操作是在每个跳转类指令(如call、ret、jmp等)执行前进行监控分析,保证跳转对象不是污点数据所在的内存地址
    格式化字符串:检查污点数据是否用作printf系列函数的格式化字符串参数
    系统调用参数:检查特殊系统调用的特殊参数是否为污点数据
    标志位:跟踪标志位是否被感染,及被感染的标志位是否用于改变程序控制流
    地址:检查数据移动类指令的地址是否被感染

    在进行污点误用检查时,通常需要根据一些漏洞模式来进行检查,首先需要明确常见漏洞在二进制代码上的表现形式,然后将其提炼成漏洞模式,以更有效地指导自动化的安全分析。
    2.3 动态污点分析的实例分析下面我们来看一个使用动态污点分析的方法检测缓冲区溢出漏洞的例子。
    void fun(char *str){ char temp[15]; printf("in strncpy, source: %s\n", str); strncpy(temp, str, strlen(str)); // Sink 点}int main(int argc, char *argv[]){ char source[30]; gets(source); // Source 点 if (strlen(source) < 30) fun(source); else printf("too long string, %s\n", source); return 0;}
    漏洞很明显, 调用 strncpy 函数存在缓冲区溢出。
    程序接受外部输入字符串的二进制代码如下:
    0x08048609 <+51>: lea eax,[ebp-0x2a]0x0804860c <+54>: push eax0x0804860d <+55>: call 0x8048400 <gets@plt>...0x0804862c <+86>: lea eax,[ebp-0x2a]0x0804862f <+89>: push eax0x08048630 <+90>: call 0x8048566 <fun>
    程序调用 strncpy 函数的二进制代码如下:
    0x080485a1 <+59>: push DWORD PTR [ebp-0x2c]0x080485a4 <+62>: call 0x8048420 <strlen@plt>0x080485a9 <+67>: add esp,0x100x080485ac <+70>: sub esp,0x40x080485af <+73>: push eax0x080485b0 <+74>: push DWORD PTR [ebp-0x2c]0x080485b3 <+77>: lea eax,[ebp-0x1b]0x080485b6 <+80>: push eax0x080485b7 <+81>: call 0x8048440 <strncpy@plt>
    首先,在扫描该程序的二进制代码时,能够扫描到 call <gets@plt>,该函数会读入外部输入,即程序的攻击面。确定了攻击面后,我们将分析污染源数据并进行标记,即将 [ebp-0x2a] 数组(即源程序中的source)标记为污点数据。程序继续执行,该污染标记会随着该值的传播而一直传递。在进入 fun() 函数时,该污染标记通过形参实参的映射传递到参数 str 上。然后运行到 Sink 点函数 strncpy()。该函数的第二个参数即 str 和 第三个参数 strlen(str) 都是污点数据。最后在执行 strncpy() 函数时,若设定了相应的漏洞规则(目标数组小于源数组),则漏洞规则将被触发,检测出缓冲区溢出漏洞。
    0  留言 2020-07-07 09:30:59
  • 程序验证(十二):完全正确性


    版权声明:本文为CSDN博主「swy_swy_swy」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。原文链接:https://blog.csdn.net/swy_swy_swy/article/details/106844776

    一、完全正确性完全正确性(total correctness),写作:
    [P]c[Q][P]c[Q][P]c[Q]意思是:

    如果我们从一个满足PPP的环境开始执行ccc
    那么ccc一定终止
    且它的最终的环境满足QQQ

    二、良基关系(Well-Founded Relations)集合SSS上的一个二元关系≺\prec≺是一个良基关系,当且仅当:

    在SSS中不存在无限序列s1,s2,s3,..s_1,s_2,s_3,..s​1​​,s​2​​,s​3​​,..,使得对于所有i>0i > 0i>0都有si+1≺sis_{i+1}\prec s_is​i+1​​≺s​i​​
    也就是说,SSS中每个下降序列都是有限的

    2.1 词法意义上的良基关系给定集合S1,..,SnS_1,.. ,S_nS​1​​,..,S​n​​和关系≺1,..,≺n\prec_1 ,.. ,\prec_n≺​1​​,..,≺​n​​ ,令S1×..×S_1\times .. \timesS​1​​×..×
    定义关系≺\prec≺:
    (s1,..,sn)≺(t1,..,tn)⇔⋁i=1n(si≺iti∧⋀j=1i−1sj=tj)(s_1 ,.. ,s_n)\prec (t_1 ,.. ,t_n)\Leftrightarrow \bigvee ^n_{i=1} (s_i\prec_i t_i\wedge\bigwedge ^{i-1}_{j=1} s_j=t_j)(s​1​​,..,s​n​​)≺(t​1​​,..,t​n​​)⇔⋁​i=1​n​​(s​i​​≺​i​​t​i​​∧⋀​j=1​i−1​​s​j​​=t​j​​)解释一下,(s1,..,sn)≺(t1,..,tn)(s_1 ,.. ,s_n)\prec (t_1 ,.. ,t_n)(s​1​​,..,s​n​​)≺(t​1​​,..,t​n​​)当且仅当:

    在某一位置iii,si≺itis_i\prec_i t_is​i​​≺​i​​t​i​​
    而对于所有之前的位置jjj,sj=tjs_j=t_js​j​​=t​j​​

    2.2 证明完全正确性为了证明程序终止,我们需要:

    在程序的状态上构造一个良基关系≺\prec≺
    证明每一个基础路径的的输出状态都“小于”输入状态

    如果满足以上两点,那么程序一定终止,否则就存在一个程序状态的无限序列,这可以被映射到≺\prec≺上的一个无限下降序列。
    与以上两步相对应,我们需要:

    找到一个函数δ\deltaδ将程序状态映射到一个带有已知良基关系≺\prec≺的集合SSS
    证明δ\deltaδ在每一个基础路径上依据≺\prec≺都是下降的

    函数δ\deltaδ被称为秩函数(ranking function)。
    2.3 对于秩函数的注释我们用带有↓\downarrow↓的代码注释秩函数,需要在函数的循环头位置注释。
    2.4 vcvcvcvc即为:
    P→wlp(c1;..;cn,δ(x)≺δ(x′))[x/x′]P\to wlp(c_1; .. ;c_n,\delta (x)\prec \delta (x'))[x/x']P→wlp(c​1​​;..;c​n​​,δ(x)≺δ(x​′​​))[x/x​′​​]计算的时候用x′x^{\prime}x​′​​代表开始时xxx的值,计算完毕后再换回来。
    0  留言 2020-07-03 14:32:32
  • 程序验证(十一):演绎验证(下)


    版权声明:本文为CSDN博主「swy_swy_swy」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。原文链接:https://blog.csdn.net/swy_swy_swy/article/details/106797717

    一、新特征引入向Imp语言引入新的特征:

    断言注释(assertion annotation)
    过程(procedure)

    1.1 断言断言注释具有以下形式:
    {P}\{P\}{P}从语义上说,它们与运行时断言(runtime assertion)一样(比如c里面的assert)。如果当前环境使得断言表达式为假,程序停止执行。
    1.2 程序举个例子:

    这个过程就是一个带有注释的过程:

    前置条件由requires注释
    后置条件由ensures注释
    前置条件与后置条件中的自由变量可以是形式参数
    后置条件也可以包括特殊的变量rvrvrv,代表返回值(return value)

    二、部分正确性的证明一个程序(program)是部分正确的,如果对于它的每个过程(procedure)PPP都有:

    只要PPP的前置条件在过程的入口处被满足
    那么PPP的后置条件一定在过程的出口处被满足

    我们从基本思路:

    使用注释(annotation)把程序分为更简单的几部分
    独立地为每个部分生成vcvcvc,假定每个注释都成立
    根据每个部分的正确性,推出整体的正确性

    2.1 基本路径(Basic Paths)一个基本路径是一系列指令,满足:

    开始于一个过程的前置条件或循环不变量
    终止于一个循环不变量,一个断言或一个过程的后置条件
    不穿过循环:循环不变量只在路径的开头或结尾出现

    2.2 假设(assume)语句assume b的意思:

    只有当b在当前环境下为真,路径的剩余部分才可以执行
    当讨论剩余的路径时,我们可以假定b成立

    2.3 路径分割每一个assume,都引入两个分支,一个假定bbb成立,一个假定¬b\neg b¬b成立,所以在之前的例子里,有更多的基本路径。
    如何计算基本路径有多少呢?当我们计算基础路径的数量时,我们遵循深度优先的习惯。
    当遇到一个assume时:

    先假定它成立,然后生成相应的路径
    再假定它不成立,重复

    2.4 过程调用(Procedure Call)可以把过程调用替换为后置条件断言,需要引入另一个基础路径以确保前置条件成立,将前置条件与后置条件中的形式参数换为在调用中真正出现的参数。
    综上,过程调用的步骤为:
    1.给定一个过程fff具有以下原型:

    2.当fff在上下文w:=f(e1,..,en)w:=f(e_1 ,.. ,e_n)w:=f(e​1​​,..,e​n​​)中被调用时,用断言{P[e1,..,en]}\lbrace P[e_1,.. ,e_n] \rbrace{P[e​1​​,..,e​n​​]}表示调用上下文
    3.在穿过调用的路径中

    创建新的变量vvv来存放返回值
    将调用替换为后置条件的假定:


    三、vc生成当我们列举基础路径时,我们实际上得到了vcvcvc。
    注意,由于基础路径不穿过循环,所以我们只需要为三种命令生成vcvcvc:

    赋值:见上文
    序列:见上文
    assume:


    举例:
    0  留言 2020-07-03 10:12:16
  • 程序验证(十):演绎验证(上)


    版权声明:本文为CSDN博主「swy_swy_swy」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。原文链接:https://blog.csdn.net/swy_swy_swy/article/details/106751208

    一、基础路径(Basic Approach)给定一个程序ccc,由以下specification注解:
    {P}c{Q}\{P\}c\{Q\}{P}c{Q}为了证明这个三元组,我们构造一个验证条件(verification condition, VC)的集合:

    每个VC都是某个理论的一阶公式
    如果所有的VC都是永真的,那么{P}c{Q}\lbrace P \rbrace c \lbrace Q \rbrace{P}c{Q}就是永真的

    二、谓词转换器给定一个断言QQQ和一个程序ccc,一个谓词转换器(predicate transformer)是一个函数,输出另一个断言。
    最弱前置条件(weakest precondition)谓词转换器产生一个wp(c,Q)wp(c,Q)wp(c,Q),使得:

    对于[wp(c,Q)]c[Q][wp(c,Q)]c[Q][wp(c,Q)]c[Q]是永真的,且
    对于任何满足[P]c[Q][P]c[Q][P]c[Q]的PPP,P⇒wp(c,Q)P\Rightarrow wp(c,Q)P⇒wp(c,Q),也就是说,wp(c,Q)wp(c,Q)wp(c,Q)是这种断言中最弱的

    广义最弱前置条件(weakest liberal precondition)谓词转换器产生一个wlp(c,Q)wlp(c,Q)wlp(c,Q),使得:

    对于wlp(c,Q)cQ{wlp(c,Q)}c{Q}wlp(c,Q)cQ是永真的,且
    对于wlp(c,Q)wlp(c,Q)wlp(c,Q)是这种断言中最弱的

    wlpwlpwlp为我们提供了一种逆向的思路,这也符合我们的直觉。
    三、wlp的定义我们用霍尔三元组来定义wlpwlpwlp:
    wlp(y:=x+1,(∀x.x<z→x<y)→x+1≤y)=?wlp(y:=x+1, (\forall x.x < z \to x < y) \to x+1\le y)=?wlp(y:=x+1,(∀x.x<z→x<y)→x+1≤y)=?注意,答案并不是:
    (∀x.x<z→x<x+1)→x+1≤x+1(\forall x.x < z\to x < x+1) \to x+1 \le x+1(∀x.x<z→x<x+1)→x+1≤x+1因为当我们用x+1x+1x+1替换yyy以处理:
    (∀x.x<z→x<y)(\forall x.x < z\to x < y)(∀x.x<z→x<y)时,变量xxx是被捕获的(captured)
    3.1 捕获避免代入(capture-avoiding substitution)当我们扩展P[a/x]P[a/x]P[a/x]时,我们需要:

    只代入xxx的自由形式(free occurence)
    将aaa中不自由的变量重命名以避免捕获

    3.2 数组赋值规则数组赋值的霍尔规则可以表示为:
    AsgnArr {Q[x⟨a1◃a2⟩/x]}x[a1]:=a2{Q}AsgnArr~\frac{}{\{Q[x\langle a_1\triangleleft a_2\rangle /x]\}x[a_1]:=a_2\{Q\}}AsgnArr ​{Q[x⟨a​1​​◃a​2​​⟩/x]}x[a​1​​]:=a​2​​{Q}​​​​相应的转换器即为:
    wlp(x[a1]:=a2,Q)=Q[x⟨a1◃a2⟩/x]wlp (x[a_1]:=a_2,Q)=Q[x\langle a_1\triangleleft a_2\rangle /x]wlp(x[a​1​​]:=a​2​​,Q)=Q[x⟨a​1​​◃a​2​​⟩/x]举例:计算
    wlp(b[i]:=5,b[i]=5)wlp(b[i]:=5,b[i]=5)wlp(b[i]:=5,b[i]=5)wlp(b[i]:=5,b[i]=5)=(b⟨◃5⟩[i]=5)=(5=5)=truewlp(b[i]:=5,b[i]=5)=(b\langle\triangleleft 5\rangle [i]=5)=(5=5)=truewlp(b[i]:=5,b[i]=5)=(b⟨◃5⟩[i]=5)=(5=5)=true计算
    wlp(b[n]:=x,∀i.1≤i<n→b[i]≤b[i+1])wlp(b[n]:=x,\forall i.1\le i < n\to b[i]\le b[i+1])wlp(b[n]:=x,∀i.1≤i<n→b[i]≤b[i+1])进行代入
    wlp(b[n]:=x,∀i.1≤i<n→b[i]≤b[i+1])=∀i.1≤i<n→(b⟨◃x⟩)[i]≤(b⟨n◃x⟩)[i+1]=(b⟨n◃x⟩)[n−1]≤(b⟨n◃x⟩)[n]∧∀i.1≤i<n−1→(b⟨n◃x⟩)[i]≤(b⟨n◃x⟩)[i+1]wlp(b[n]:=x,\forall i.1\le i < n\to b[i]\le b[i+1])=\forall i.1\le i < n\to (b\langle\triangleleft x\rangle)[i]\le (b\langle n\triangleleft x\rangle)[i+1]=(b\langle n\triangleleft x\rangle)[n-1]\le (b\langle n\triangleleft x\rangle)[n]\wedge \forall i.1\le i < n-1\to (b\langle n\triangleleft x\rangle)[i]\le (b\langle n\triangleleft x\rangle)[i+1]wlp(b[n]:=x,∀i.1≤i<n→b[i]≤b[i+1])=∀i.1≤i<n→(b⟨◃x⟩)[i]≤(b⟨n◃x⟩)[i+1]=(b⟨n◃x⟩)[n−1]≤(b⟨n◃x⟩)[n]∧∀i.1≤i<n−1→(b⟨n◃x⟩)[i]≤(b⟨n◃x⟩)[i+1]3.3 序列(sequencing)依据霍尔规则:
    Seq {P}c1{P′}{P′}c2{Q}{P}c1;c2{Q}Seq~\frac{\{P\}c_1\{P'\}\qquad\{P'\}c_2\{Q\}}{\{P\}c_1;c_2\{Q\}}Seq ​{P}c​1​​;c​2​​{Q}​​{P}c​1​​{P​′​​}{P​′​​}c​2​​{Q}​​相应的谓词转换器即为:
    wlp(c1;c2,Q)=wlp(c1,wlp(c2,Q))wlp(c_1;c_2,Q)=wlp(c_1,wlp(c_2,Q))wlp(c​1​​;c​2​​,Q)=wlp(c​1​​,wlp(c​2​​,Q))3.4 条件依据霍尔规则:

    相应的转换器即为:

    3.5 while循环依据等价关系:

    相应的wlpwlpwlp即为,此处略,最后转了个圈又回来了。
    3.6 近似最弱前置条件一般来说,我们无法总是算出循环的wlpwlpwlp,比如上面的情况。但是,我们可以借助于循环不变式来近似它。
    下面,我们使用这种方式表示循环:
    while b do{I}cwhile~b~do\{I\}cwhile b do{I}c这里III是由程序员提供的循环不变量。
    最为直观的想法是令:
    wlp(while b do{I}c,Q)=Iwlp(while~b~do\{I\}c,Q)=Iwlp(while b do{I}c,Q)=I但此时III可能不是最弱的前置条件。
    如果我们草率地认为:
    wlp(while b do{I}c,Q)=Iwlp(while~b~do\{I\}c,Q)=Iwlp(while b do{I}c,Q)=I我们漏了两件事情:

    没有检查I∧¬bI\wedge\neg bI∧¬b得到QQQ
    我们不知道III是否真的是一个循环不变式

    所以我们需要构造一个额外的验证条件(verification condition)的集合:

    为了在执行循环后确保QQQ能够实现,需要满足两个条件:

    一是vc(while b doIc,Q)vc(while~b~do{I}c,Q)vc(while b doIc,Q)中的每一个公式都是永真的
    二是wlp(while b doIc,Q)=Iwlp(while~b~do{I}c,Q)=Iwlp(while b doIc,Q)=I一定是永真的

    3.7 构造vcwhile是唯一一个引入额外条件的命令,但是其他的声明可能也包含循环,所以:
    vc(x:=a,Q)=∅vc(x:=a,Q)=\varnothingvc(x:=a,Q)=∅vc(c1;c2,Q)=vc(c1,wlp(c2,Q))∪vc(c2,Q)vc(c_1;c_2,Q)=vc(c_1,wlp(c_2,Q))\cup vc(c_2,Q)vc(c​1​​;c​2​​,Q)=vc(c​1​​,wlp(c​2​​,Q))∪vc(c​2​​,Q)vc(if b then c1 else c2,Q)=vc(c1,Q)∪vc(c2,Q)vc(if~b~then~c_1~else~c_2,Q)=vc(c_1,Q)\cup vc(c_2,Q)vc(if b then c​1​​ else c​2​​,Q)=vc(c​1​​,Q)∪vc(c​2​​,Q)3.8 综合综上,我们得到验证:
    {P}c{Q}\{P\}c\{Q\}{P}c{Q}的通用方法:

    计算P′=wlp(c,Q)P^{\prime}=wlp(c,Q)P​′​​=wlp(c,Q)
    计算vc(c,Q)vc(c,Q)vc(c,Q)
    检查P→P′P\to P^{\prime}P→P​′​​ 的永真性
    检查每个F∈vc(c,Q)F\in vc(c,Q)F∈vc(c,Q)的永真性

    若3,4检验均通过,那么{P}c{Q}\lbrace P \rbrace c \lbrace Q \rbrace{P}c{Q}是永真的,但反之不一定成立,因为循环不变式可能不是最弱的前置条件。
    0  留言 2020-07-02 19:18:16
  • 程序验证(九):程序正确性规范


    版权声明:本文为CSDN博主「swy_swy_swy」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。 原文链接:https://blog.csdn.net/swy_swy_swy/article/details/106725080

    什么是程序的正确性?应当在指定的前提下,进行预定的行为,达到指定的结果。
    一、部分正确性(Partial Correctness)部分正确性指的是一个程序的停止行为。
    我们将部分正确性用霍尔三元组(Hoare triples)表达:
    {P}c{Q}\{P\}c\{Q\}{P}c{Q}
    其中ccc是一个程序
    其中PPP和QQQ是一阶逻辑的断言(assertion)
    其中P,QP, QP,Q的自由变量可以在程序的变量中随意选择
    其中PPP是先验条件(precondition),QQQ是后验条件(postcondition)

    上述{P}c{Q}\lbrace P \rbrace c \lbrace Q \rbrace{P}c{Q}的含义:

    在一个满足PPP的环境中开始执行ccc
    如果ccc终止,那么它的最终环境将满足QQQ

    注意,部分正确性没有排除以下两点:

    程序执行不终止
    程序没有从PPP开始执行

    二、完全正确性(Total Correctness)部分正确性没有要求终止,完全正确性是一个更强的声明,写作:
    [P]c[Q][P]c[Q][P]c[Q]
    如果我们从一个满足PPP的环境开始执行ccc
    那么ccc一定终止,而且它最终的环境满足QQQ

    三、断言给定三元组:
    {P}c{Q}\{P\}c\{Q\}{P}c{Q}公式PPP与QQQ是一阶断言。
    对于Imp,有用的理论是TZ∪TAT_Z\cup T_AT​Z​​∪T​A​​。PPP或QQQ中的变量包括程序变量、量词变量、其他逻辑变量,即
    vars(Q)=pvars(Q)∪qvars(Q)∪lvars(Q)vars(Q)=pvars(Q)\cup qvars(Q)\cup lvars(Q)vars(Q)=pvars(Q)∪qvars(Q)∪lvars(Q)3.1 断言的语义由于lvars(Q)lvars(Q)lvars(Q)的存在,我们不能仅依据环境σ\sigmaσ来判断QQQ的值,所以,令α\alphaα为lvars(Q)lvars(Q)lvars(Q)的变量的一个赋值,那么:

    断言的可满足性与永真性:

    如果对于所有σ\sigmaσ,σ⊨Q\sigma\models Qσ⊨Q,那么我们写作⊨Q\models Q⊨Q。
    3.2 部分正确性的语义我们说{P}c{Q}\lbrace P \rbrace c \lbrace Q \rbrace{P}c{Q}在σ\sigmaσ中在α\alphaα下永真,写作σ⊨α{P}c{Q}\sigma\models_{\alpha} \lbrace P \rbrace c \lbrace Q \rbraceσ⊨​α​​{P}c{Q},如果:
    ∀σ′.(σ⊨αP∧⟨c,σ⟩⇓σ′)→σ′⊨αQ\forall \sigma^{\prime}.(\sigma\models_{\alpha}P\wedge \langle c,\sigma\rangle\Downarrow\sigma^{\prime})\to\sigma^{\prime}\models_{\alpha}Q∀σ​′​​.(σ⊨​α​​P∧⟨c,σ⟩⇓σ​′​​)→σ​′​​⊨​α​​Q也就是说:

    只要σ\sigmaσ在α\alphaα下满足PPP
    而且ccc在σ\sigmaσ中执行得到σ′\sigma^{\prime}σ​′​​
    那么σ′\sigma^{\prime}σ​′​​ 在α\alphaα下满足QQQ

    我们说{P}c{Q}\lbrace P \rbrace c \lbrace Q \rbrace{P}c{Q}是永真的,写作⊨{P}c{Q}\models \lbrace P \rbrace c \lbrace Q \rbrace⊨{P}c{Q},如果:
    ∀σ,α.σ⊨α{P}c{Q}\forall \sigma,\alpha.\sigma\models_{\alpha} \{P\}c\{Q\}∀σ,α.σ⊨​α​​{P}c{Q}也就是:
    ∀σ,σ′,α.(σ⊨αP∧⟨c,σ⟩⇓σ′)→σ′⊨αQ\forall \sigma,\sigma',\alpha. (\sigma\models_{\alpha}P\wedge\langle c,\sigma\rangle\Downarrow\sigma') \to \sigma'\models_{\alpha} Q∀σ,σ​′​​,α.(σ⊨​α​​P∧⟨c,σ⟩⇓σ​′​​)→σ​′​​⊨​α​​Q四、三元组的证明(verify)我们将引入一种逻辑,该逻辑能从已知的三元组推出新的三元组,这种逻辑叫做霍尔逻辑(Hoare logic)。
    引入规则的形式为:
    ⊢{P}c{Q}\vdash \{P\}c\{Q\}⊢{P}c{Q}4.1 跳过与赋值(skip&assignment)Skip {P}skip{P}Skip~\frac{}{\{P\} skip \{P\}}Skip ​{P}skip{P}​​​​Asgn {Q[a/x]}x:=a{Q}Asgn~ \frac{}{\{Q[a/x]\}x:=a\{Q\}}Asgn ​{Q[a/x]}x:=a{Q}​​​​其中Q[a/x]Q[a/x]Q[a/x]:在QQQ中把所有xxx换成aaa,举个例子:
    (5+x)[(x+1)/x]=5+(x+1)(5+x)[(x+1)/x]=5+(x+1)(5+x)[(x+1)/x]=5+(x+1)4.2 逻辑的加强与削弱注意,我们不能证明一个很显而易见的东西:
    ⊢{y=0}x:=1{x=1}\vdash\{y=0\}x:=1\{x=1\}⊢{y=0}x:=1{x=1}但我们可以证明:
    ⊢{1=1}x:=1{x=1}\vdash\{1=1\}x:=1\{x=1\}⊢{1=1}x:=1{x=1}于是引入前提加强(precondition strengthening):
    Pre ⊢{P′}c{Q}P⇒P′{P}c{Q}Pre~\frac{\vdash\{P'\}c\{Q\}\qquad P\Rightarrow P'}{\{P\}c\{Q\}}Pre ​{P}c{Q}​​⊢{P​′​​}c{Q}P⇒P​′​​​​例如:
    Pre Asgn ⊢{1=1}x:=1{x=1}y=0⇒1=1{y=0}x:=1{x=1}Pre~\frac{Asgn~\frac{}{\vdash\{1=1\}x:=1\{x=1\}}\qquad y=0\Rightarrow 1=1}{\{y=0\}x:=1\{x=1\}}Pre ​{y=0}x:=1{x=1}​​Asgn ​⊢{1=1}x:=1{x=1}​​​​y=0⇒1=1​​与之相似,引入一削弱后验条件的规则:
    Post ⊢{P}c{Q′}Q′⇒Q{P}c{Q}Post~\frac{\vdash\{P\}c\{Q'\}\qquad Q'\Rightarrow Q}{\{P\}c\{Q\}}Post ​{P}c{Q}​​⊢{P}c{Q​′​​}Q​′​​⇒Q​​在二者的基础上,引入序列规则(consequence rule):
    Conseq P⇒P′⊢{P′}c{Q′}Q′⇒Q{P}c{Q}Conseq~\frac{P\Rightarrow P'\qquad\vdash \{P'\}c\{Q'\}\qquad Q'\Rightarrow Q}{\{P\}c\{Q\}}Conseq ​{P}c{Q}​​P⇒P​′​​⊢{P​′​​}c{Q​′​​}Q​′​​⇒Q​​4.3 组合(composition)Seq {P}c1{P′}{P′}c2{Q}{P}c1;c2{Q}Seq~\frac{\{P\}c_1\{P'\}\qquad \{P'\}c_2\{Q\}}{\{P\}c_1;c_2\{Q\}}Seq ​{P}c​1​​;c​2​​{Q}​​{P}c​1​​{P​′​​}{P​′​​}c​2​​{Q}​​举例:使用霍尔逻辑证明swap函数
    {x=x′∧y=y′}t:=x;x:=y;y:=t{y=x′∧x=y′}\{x=x'\wedge y=y'\}t:=x;x:=y;y:=t\{y=x'\wedge x=y'\}{x=x​′​​∧y=y​′​​}t:=x;x:=y;y:=t{y=x​′​​∧x=y​′​​}1.由Asgn:
    {x=x′∧y=y′}t:=x{t=x′∧y=y′}\{x=x'\wedge y=y'\}t:=x\{t=x'\wedge y=y'\}{x=x​′​​∧y=y​′​​}t:=x{t=x​′​​∧y=y​′​​}2.由Asgn:
    {t=x′∧y=y′}x:=y{t=x′∧x=y′}\{t=x'\wedge y=y'\}x:=y\{t=x'\wedge x=y'\}{t=x​′​​∧y=y​′​​}x:=y{t=x​′​​∧x=y​′​​}3.由Asgn:
    {t=x′∧x=y′}y:=t{y=x′∧x=y′}\{t=x'\wedge x=y'\}y:=t\{y=x'\wedge x=y'\}{t=x​′​​∧x=y​′​​}y:=t{y=x​′​​∧x=y​′​​}4.由1,2,Seq:
    {x=x′∧y=y′}t:=x;x:=y{t=x′∧x=y′}\{x=x'\wedge y=y'\}t:=x;x:=y\{t=x'\wedge x=y'\}{x=x​′​​∧y=y​′​​}t:=x;x:=y{t=x​′​​∧x=y​′​​}5.最后,由3,4,Seq:
    Q.E.DQ.E.DQ.E.D4.4 条件(conditional)If {P∧b}c1{Q}{P∧¬b}c2{Q}{P}if b then c1 else c2{Q}If~\frac{\{P\wedge b\}c_1\{Q\}\qquad \{P\wedge \neg b\}c_2\{Q\}}{\{P\} if~b~then~c_1~else~c_2\{Q\}}If ​{P}if b then c​1​​ else c​2​​{Q}​​{P∧b}c​1​​{Q}{P∧¬b}c​2​​{Q}​​
    在true分支:如果bbb成立,我们需要证明{P∧b}c1Q\lbrace P\wedge b \rbrace c_1{Q}{P∧b}c​1​​Q
    在false分支:如果¬b\neg b¬b成立,我们需要证明{P∧¬b}c2{Q}\lbrace P\wedge\neg b \rbrace c_2 \lbrace Q \rbrace{P∧¬b}c​2​​{Q}

    4.5 while循环(loop)While {P∧b}c{P}{P}while b do c{P∧¬b}While~\frac{\{P\wedge b\}c\{P\}}{\{P\} while~b~do~c\{P\wedge\neg b\}}While ​{P}while b do c{P∧¬b}​​{P∧b}c{P}​​其中,PPP是循环不变量(loop invariant):

    在进入循环前成立,并且在每次迭代后保持不变
    这由前提{P∧b}c{P}\lbrace P\wedge b \rbrace c \lbrace P \rbrace{P∧b}c{P}所确定

    为了使用While,需要先证明PPP是不变量。
    五、可靠性与完备性
    之前的规则对于部分正确性都是可靠的
    对于Imp,是不完备的,因为可以归约为TPAT_{PA}T​PA​​
    0  留言 2020-07-02 09:43:51
  • 程序验证(八):形式语义


    版权声明:本文为CSDN博主「swy_swy_swy」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。原文链接:https://blog.csdn.net/swy_swy_swy/article/details/106698807

    一、语义描述方法
    操作语义:用抽象机描述程序执行引起的状态改变,关心状态改变是怎样产生的,与语言的实现关系紧密
    指称语义:使程序执行的效果对应数学对象,只关心程序执行的效果,不关心其是怎样产生的
    公理语义:将程序的语义性质表示为命题,采用数理逻辑的方法研究

    二、引入玩具语言Imp2.1 语法范畴
    数字集NumNumNum,用nnn表示数字
    变元集VarVarVar,用xxx表示变元
    算术表达式集AexpAexpAexp,用aaa表示算术表达式
    布尔表达式集BexpBexpBexp,用bbb表示布尔表达式
    语句集ComComCom,用ccc表示语句

    2.2 语法a∈AExp::=n∈Z∣x∈Var∣a1+a2∣a1∗a2∣a1−a2a\in AExp::=n\in Z |x\in Var|a_1+a_2|a_1*a_2|a_1-a_2a∈AExp::=n∈Z∣x∈Var∣a​1​​+a​2​​∣a​1​​∗a​2​​∣a​1​​−a​2​​b∈BExp::=true∣false∣a1=a2∣a1≤a2∣¬b∣b1∧b2b\in BExp::=true|false|a_1=a_2|a_1\le a_2|\neg b|b_1\wedge b_2b∈BExp::=true∣false∣a​1​​=a​2​​∣a​1​​≤a​2​​∣¬b∣b​1​​∧b​2​​c∈Com::=skip∣x:=a∣c1;c2∣if b then c1 else c2∣while b do cc\in Com::=skip|x:=a|c_1;c_2|if~b~then~c_1~else~c_2|while~b~do~cc∈Com::=skip∣x:=a∣c​1​​;c​2​​∣if b then c​1​​ else c​2​​∣while b do c三、表达式语义3.1 表达式的语义采用二进制
    n::=0∣1∣n0∣n1n::=0|1|n0|n1n::=0∣1∣n0∣n1语义函数N:Num→ZN: Num \to ZN:Num→Z
    N[0]=0N[0]=0N[0]=0
    N[1]=1N[1]=1N[1]=1
    N[n0]=2∗N[0]N[n0]=2*N[0]N[n0]=2∗N[0]
    N[n1]=2∗N[n]+1N[n1]=2*N[n]+1N[n1]=2∗N[n]+1
    3.2 状态环境是从变元集到整数集的函数
    Env=Var→ZEnv=Var \to ZEnv=Var→Z
    如σ=[x↦5,y↦7,z↦0]\sigma = [x \mapsto 5,y \mapsto 7,z \mapsto 0]σ=[x↦5,y↦7,z↦0],即σx=5,σy=7,σz=0\sigma x=5,\sigma y=7,\sigma z=0σx=5,σy=7,σz=0
    设σ′=σ[x↦7]\sigma^{\prime} =\sigma [x\mapsto 7]σ​′​​=σ[x↦7],则σ′x=7\sigma^{\prime} x=7σ​′​​x=7,对于不同于xxx的变元yyy,yσ′y=σyy\sigma^{\prime} y=\sigma yyσ​′​​y=σy

    状态是一个二元组⟨c,σ⟩\langle c,\sigma\rangle⟨c,σ⟩,其中σ\sigmaσ是当前变量的赋值,ccc为下一条被执行的语句。
    3.3 算术表达式的语义语义函数A:Aexp→(Env→Z)A: Aexp\to (Env\to Z)A:Aexp→(Env→Z):

    A[n]σ=N[n]A[n]\sigma = N[n]A[n]σ=N[n]
    A[x]σ=σxA[x]\sigma = \sigma xA[x]σ=σx
    A[a1+a2]σ=A[a1]σ+A[a2]σA[a_1+a_2]\sigma = A[a_1]\sigma +A[a_2]\sigmaA[a​1​​+a​2​​]σ=A[a​1​​]σ+A[a​2​​]σ
    A[a1a2]σ=A[a1]σA[a2]σA[a_1a_2]\sigma = A[a_1]\sigma A[a_2]\sigmaA[a​1​​a​2​​]σ=A[a​1​​]σA[a​2​​]σ
    A[a1−a2]σ=A[a1]σ−A[a2]σA[a_1-a_2]\sigma = A[a_1]\sigma -A[a_2]\sigmaA[a​1​​−a​2​​]σ=A[a​1​​]σ−A[a​2​​]σ

    3.4 布尔表达式的语义语义函数B:Bexp→(Env→T)B:Bexp\to (Env\to T)B:Bexp→(Env→T):
    B[true]σ=trueB[true]\sigma = trueB[true]σ=true
    B[false]σ=falseB[false]\sigma = falseB[false]σ=false
    B[a1=a2]σ=A[a1]σ=A[a2]σB[a_1=a_2]\sigma = A[a_1]\sigma = A[a_2]\sigmaB[a​1​​=a​2​​]σ=A[a​1​​]σ=A[a​2​​]σ
    B[a1≤a2]σ=A[a1]σ≤A[a2]σB[a_1\le a_2]\sigma = A[a_1]\sigma\le A[a_2]\sigmaB[a​1​​≤a​2​​]σ=A[a​1​​]σ≤A[a​2​​]σ
    B[¬b]σ=¬B[b]σB[\neg b]\sigma = \neg B[b]\sigmaB[¬b]σ=¬B[b]σ
    B[b1∧b2]σ=B[b1]σ∧B[b2]σB[b_1\wedge b_2]\sigma = B[b_1]\sigma\wedge B[b_2]\sigmaB[b​1​​∧b​2​​]σ=B[b​1​​]σ∧B[b​2​​]σ
    3.5 举例在环境σ=[x↦1,y↦3]\sigma = [x\mapsto 1,y\mapsto 3]σ=[x↦1,y↦3]下计算表达式(x+2)∗y(x+2)*y(x+2)∗y的值
    A[(x+2)∗y]σ=A[(x+2)]σ∗A[y]σ=(A[x]σ+A[2]σ)∗A[y]σ=(1+2)∗3=9A[(x+2)*y]\sigma =A[(x+2)]\sigma * A[y]\sigma = (A[x]\sigma +A[2]\sigma) * A[y]\sigma =(1+2)* 3 = 9A[(x+2)∗y]σ=A[(x+2)]σ∗A[y]σ=(A[x]σ+A[2]σ)∗A[y]σ=(1+2)∗3=93.6 代入用算术表达式a0a_0a​0​​替换算术表达式aaa中变元yyy的所有出现得到的算术表达式记为a[y↦a0]a[y\mapsto a_0]a[y↦a​0​​]:
    A[a[y↦a0]]σ=A[a](σ[y↦A[a0]σ])A[a[y\mapsto a_0]]\sigma = A[a](\sigma [y\mapsto A[a_0]\sigma ])A[a[y↦a​0​​]]σ=A[a](σ[y↦A[a​0​​]σ])用算术表达式a0a_0a​0​​替换布尔表达式bbb中变元yyy的所有出现得到的布尔表达式记为b[y↦a0]b[y\mapsto a_0]b[y↦a​0​​]:
    B[b[y↦a0]]σ=B[b](σ[y↦A[a0]σ])B[b[y\mapsto a_0]]\sigma = B[b](\sigma [y\mapsto A[a_0]\sigma])B[b[y↦a​0​​]]σ=B[b](σ[y↦A[a​0​​]σ])四、操作语义4.1 概念包括以下两种:

    结构操作语义(小步操作语义):描述执行语句的各步计算如何发生
    自然语义(大步操作语义):描述如何得到语句执行终止的最终状态

    4.2 结构操作语义结构操作语义强调计算的具体步骤,基于状态之间的迁移关系→\to→来定义,即
    ⟨c,σ⟩→⟨c′,σ′⟩\langle c,\sigma\rangle\to\langle c',\sigma'\rangle⟨c,σ⟩→⟨c​′​​,σ​′​​⟩意义:语句ccc从环境σ\sigmaσ执行到中途,这时环境为σ′\sigma^{\prime}σ​′​​,待执行的语句为c′c^{\prime}c​′​​。
    反复应用上面的迁移关系,直至程序终止状态⟨skip,σ⟩\langle skip, \sigma \rangle⟨skip,σ⟩。
    若无状态⟨c′,σ′⟩\langle c^{\prime}, \sigma^{\prime} \rangle⟨c​′​​,σ​′​​⟩使得⟨c,σ⟩→⟨c′,σ′⟩\langle c,\sigma \rangle \to\langle c^{\prime},\sigma^{\prime} \rangle⟨c,σ⟩→⟨c​′​​,σ​′​​⟩,则称⟨c,σ⟩\langle c,\sigma\rangle⟨c,σ⟩是呆滞的(stuck)。
    4.2.1 Imp的结构操作语义Asgn:⟨x:=a,σ⟩→⟨skip,σ[x↦A[a]σ]⟩Asgn:\frac{}{\langle x:=a,\sigma\rangle\to\langle skip,\sigma [x\mapsto A[a]\sigma]\rangle}Asgn:​⟨x:=a,σ⟩→⟨skip,σ[x↦A[a]σ]⟩​​​​Skip:no ruleSkip:no~ruleSkip:no ruleSeq1:⟨c1,σ⟩→⟨c1′,σ1′⟩⟨c1;c2,σ⟩→⟨c1′;c2,σ′⟩Seq1:\frac{\langle c_1,\sigma\rangle\to\langle c'_1,\sigma'_1\rangle}{\langle c_1;c_2,\sigma\rangle\to\langle c'_1;c_2,\sigma'\rangle}Seq1:​⟨c​1​​;c​2​​,σ⟩→⟨c​1​′​​;c​2​​,σ​′​​⟩​​⟨c​1​​,σ⟩→⟨c​1​′​​,σ​1​′​​⟩​​Seq2:⟨skip;c,σ⟩→⟨c,σ⟩Seq2:\frac{}{\langle skip;c,\sigma\rangle\to\langle c,\sigma\rangle}Seq2:​⟨skip;c,σ⟩→⟨c,σ⟩​​​​IfTrue:⟨if b then c1 else c2,σ⟩→⟨c1,σ⟩IfTrue:\frac{}{\langle if~b~then~c_1~else~c_2,\sigma\rangle\to\langle c_1,\sigma\rangle}IfTrue:​⟨if b then c​1​​ else c​2​​,σ⟩→⟨c​1​​,σ⟩​​​​IfFalse:⟨if b then c1 else c2,σ⟩→⟨c2,σ⟩IfFalse:\frac{}{\langle if~b~then~c_1~else~c_2,\sigma\rangle\to \langle c_2,\sigma\rangle}IfFalse:​⟨if b then c​1​​ else c​2​​,σ⟩→⟨c​2​​,σ⟩​​​​While:⟨while b do c,σ⟩→⟨if b then(c;while b do c)else skip,σWhile:\frac{}{\langle while~b~do~c,\sigma\rangle\to\langle if~b~then(c;while~b~do~c)else~skip,\sigma}While:​⟨while b do c,σ⟩→⟨if b then(c;while b do c)else skip,σ​​​​4.2.2 推导序列语句ccc从环境σ\sigmaσ开始的推导序列有以下两种形式(记γ0=⟨c,σ⟩\gamma_0=\langle c,\sigma\rangleγ​0​​=⟨c,σ⟩):

    有限状态序列γ0,γ1,..,γk\gamma0,\gamma_1,..,\gamma_kγ0,γ​1​​,..,γ​k​​,其中γ0→γ1,..,γ\gamma_0\to \gamma_1,..,\gammaγ​0​​→γ​1​​,..,γ{k-1}\to \gamma_k可以推导得出。γk\gamma_kγ​k​​的形式为⟨skip,σ′⟩\langle skip,\sigma^{\prime} \rangle⟨skip,σ​′​​⟩或者γk\gamma_kγ​k​​是呆滞的
    无限状态序列γ0,γ1,..\gamma_0,\gamma_1,..γ​0​​,γ​1​​,..,其中γ0→γ1\gamma_0\to \gamma_1γ​0​​→γ​1​​,γ1→γ2\gamma_1\to\gamma_2γ​1​​→γ​2​​等可以推导得到
    将γ0→γ1→..→γk\gamma_0\to \gamma_1\to .. \to\gamma_kγ​0​​→γ​1​​→..→γ​k​​简记为γ0→∗γk\gamma_0 \to ^{*}\gamma_kγ​0​​→​∗​​γ​k​​

    4.2.3 确定性结构操作语义具有确定性:对于任意语句ccc,从任意环境σ\sigmaσ出发,只要⟨c,σ⟩→⟨c′,σ′⟩\langle c,\sigma\rangle\to\langle c^{\prime},\sigma^{\prime}\rangle⟨c,σ⟩→⟨c​′​​,σ​′​​⟩且⟨c,σ⟩→⟨c′′,σ′′⟩\langle c,\sigma\rangle\to\langle c^{\prime\prime},\sigma^{\prime\prime}\rangle⟨c,σ⟩→⟨c​′′​​,σ​′′​​⟩,就有σ′=σ′′\sigma^{\prime}=\sigma^{\prime\prime}σ​′​​=σ​′′​​
    4.2.4 终止和循环
    若存在从状态⟨c,σ⟩\langle c,\sigma\rangle⟨c,σ⟩开始的有限推导序列,则称语句ccc从环境σ\sigmaσ执行是终止的
    若存在从状态⟨c,σ⟩\langle c,\sigma\rangle⟨c,σ⟩开始的无限推导序列,则称语句ccc从环境σ\sigmaσ执行是循环的
    若语句ccc从每个环境执行都是终止的,则称语句ccc总是终止的
    若语句ccc从每个环境执行都是循环的,则称语句ccc总是循环的

    4.2.5 语义等价如果对于任意状态σ\sigmaσ,满足下列条件:

    对于任意终止或呆滞格局⟨c′,σ′⟩\langle c^{\prime},\sigma^{\prime}\rangle⟨c​′​​,σ​′​​⟩,⟨c1,σ⟩→∗⟨c′,σ′⟩\langle c_1,\sigma\rangle \to^{*} \langle c^{\prime},\sigma^{\prime}\rangle⟨c​1​​,σ⟩→​∗​​⟨c​′​​,σ​′​​⟩,当且仅当⟨c2,σ⟩→∗⟨c′,σ′⟩\langle c_2,\sigma\rangle \to^{*} \langle c^{\prime},\sigma^{\prime}\rangle⟨c​2​​,σ⟩→​∗​​⟨c​′​​,σ​′​​⟩
    存在从⟨c1,σ⟩\langle c_1,\sigma\rangle⟨c​1​​,σ⟩开始的无限推导序列当且仅当存在从⟨c2,σ⟩\langle c_2,\sigma\rangle⟨c​2​​,σ⟩开始的无限推导序列,则称语句c1c_1c​1​​和c2c_2c​2​​是语义等价的,如语句c1;(c2;c3)c_1;(c_2;c_3)c​1​​;(c​2​​;c​3​​)和语句(c1;c2);c3(c_1;c_2);c_3(c​1​​;c​2​​);c​3​​是语义等价的

    4.2.6 语义函数可将语句ccc的意义概括为从EnvEnvEnv到EnvEnvEnv的部分函数:
    SSOS:Cmd→(Env↪Env)S_{SOS} : Cmd\to(Env\hookrightarrow Env)S​SOS​​:Cmd→(Env↪Env)定义

    例如,SSOS[while true do skip]σ=undefS_{SOS}[while \space true \space do \space skip]\sigma = undefS​SOS​​[while true do skip]σ=undef
    4.3 自然语义自然语义关心语句执行对环境的改变。
    从环境σ\sigmaσ执行语句ccc将终止于环境σ′\sigma^{\prime}σ​′​​:
    ⟨c,σ⟩⇓σ′\langle c,\sigma\rangle\Downarrow \sigma^{\prime}⟨c,σ⟩⇓σ​′​​规则的一般形式:

    4.3.1 Imp的自然语义
    每个规则有若干前提和一个结论。称有0个前提的规则为公理,如BigAsgn, BigSkip, BigWhileFalse是公理。
    当使用公理和规则得出⟨c,σ⟩⇓σ′\langle c,\sigma\rangle\Downarrow \sigma^{\prime}⟨c,σ⟩⇓σ​′​​时,就得到一推导树,树根是⟨c,σ⟩⇓σ′\langle c,\sigma\rangle\Downarrow \sigma^{\prime}⟨c,σ⟩⇓σ​′​​,树叶是公理,每个分支点是某规则的结论,而它的儿子是该规则的前提。
    4.3.2 终止和循环
    若存在环境σ′\sigma^{\prime}σ​′​​使得⟨c,σ⟩⇓σ′\langle c,\sigma\rangle\Downarrow\sigma^{\prime}⟨c,σ⟩⇓σ​′​​,则称语句ccc从环境σ\sigmaσ执行是终止的
    若不存在环境σ′\sigma^{\prime}σ​′​​使得⟨c,σ⟩⇓σ′\langle c,\sigma\rangle\Downarrow \sigma^{\prime}⟨c,σ⟩⇓σ​′​​,则称语句ccc从环境σ\sigmaσ执行是循环的
    若语句ccc从每个环境执行都是终止的,则称语句ccc总是终止的
    若语句ccc从每个环境执行都是循环的,则称语句ccc总是循环的

    4.3.3 语义等价如果对于任意环境σ\sigmaσ和σ′\sigma^{\prime}σ​′​​:
    ⟨c0,σ⟩⇓σ′⇔⟨c1,σ⟩⇓σ′\langle c_0,\sigma\rangle\Downarrow\sigma^{\prime} \Leftrightarrow \langle c_1,\sigma\rangle\Downarrow \sigma^{\prime}⟨c​0​​,σ⟩⇓σ​′​​⇔⟨c​1​​,σ⟩⇓σ​′​​则称语句c0c_0c​0​​和c1c_1c​1​​是语义等价的。
    4.3.4 确定性自然语义具有确定性:对于任意语句ccc,和任意环境σ1,σ2,σ\sigma_1,\sigma_2,\sigmaσ​1​​,σ​2​​,σ,只要⟨c,σ⟩⇓σ1\langle c,\sigma \rangle\Downarrow \sigma_1⟨c,σ⟩⇓σ​1​​且⟨c,σ⟩⇓σ2\langle c,\sigma\rangle\Downarrow \sigma_2⟨c,σ⟩⇓σ​2​​,就有σ1=σ2\sigma_1=\sigma_2σ​1​​=σ​2​​,即
    ∀σ,σ1,σ2,c.(⟨c,σ⟩⇓σ1∧⟨c,σ⟩⇓σ2)→(σ1=σ2)\forall \sigma,\sigma_1,\sigma_2,c.(\langle c,\sigma\rangle\Downarrow\sigma_1\wedge\langle c,\sigma\rangle\Downarrow\sigma_2)\to (\sigma_1 =\sigma_2)∀σ,σ​1​​,σ​2​​,c.(⟨c,σ⟩⇓σ​1​​∧⟨c,σ⟩⇓σ​2​​)→(σ​1​​=σ​2​​)4.3.5 语义函数可将语句ccc的意义概括为从FnvFnvFnv到EnvEnvEnv的部分函数:
    Sns:Cmd→(Env↪Env)S_{ns}: Cmd\to (Env\hookrightarrow Env)S​ns​​:Cmd→(Env↪Env)定义

    4.4 两种语义的对比
    对于语言Imp的每个语句ccc,任意环境σ\sigmaσ和σ′\sigma^{\prime}σ​′​​,若⟨c,σ⟩⇓σ′\langle c,\sigma\rangle\Downarrow \sigma^{\prime}⟨c,σ⟩⇓σ​′​​,则⟨c,σ⟩→∗⟨skip,σ′⟩\langle c,\sigma\rangle\to^{*}\langle skip,\sigma^{\prime} \rangle⟨c,σ⟩→​∗​​⟨skip,σ​′​​⟩
    对于语言Imp的每个语句ccc,任意环境σ\sigmaσ和σ′\sigma^{\prime}σ​′​​ ,若⟨c,σ⟩→k⟨skip,σ′⟩\langle c,\sigma\rangle\to^k\langle skip,\sigma^{\prime}\rangle⟨c,σ⟩→​k​​⟨skip,σ​′​​⟩,则⟨c,σ⟩⇓σ′\langle c,\sigma\rangle\Downarrow\sigma^{\prime}⟨c,σ⟩⇓σ​′​​
    对于语言Imp的每个语句ccc,Sns[c]=SSOS[c]S_{ns}[c]=S_{SOS}[c]S​ns​​[c]=S​SOS​​[c]
    0  留言 2020-07-01 11:58:25
  • 程序验证(七):可满足性模理论(Satisfiability Modulo Theories)


    版权声明:本文为CSDN博主「swy_swy_swy」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。原文链接:https://blog.csdn.net/swy_swy_swy/article/details/106675816

    一、SMTSatisfiability Modulo Theories(SMT)是以下情况的公式的判定问题:

    一些一阶理论的复合
    具有任意的布尔结构

    二、DPLL(T): DPLL Modulo Theories这是现代SMT求解器的基础技术。
    将SMT问题分解为可以高效求解的子问题:

    使用SAT求解技术来处理布尔结构(宏观)
    使用专门的理论求解器(theory solver)来判定背景理论的可满足性(微观)

    三、布尔结构通过TTT-公式的语义,我们递归定义公式FFF的布尔结构:

    这里PiP_iP​i​​是布尔变量。
    举例:考虑以下公式
    F:g(a)=c∧(f(g(a))≠f(c)∨g(a)=d)∧c≠dF:g(a)=c\wedge (f(g(a))\ne f(c)\vee g(a)=d)\wedge c\ne dF:g(a)=c∧(f(g(a))≠f(c)∨g(a)=d)∧c≠dFFF的布尔抽象:
    B(F)=B(g(a)=c)∧(B(f(g(a))≠f(c))∨B(g(a)=d))∧B(c≠d)=P1∧(¬P2∨P3)∧¬P4B(F)=B(g(a)=c)\wedge (B(f(g(a))\ne f(c))\vee B(g(a)=d))\wedge B(c\ne d)=P_1 \wedge (\neg P_2\vee P_3)\wedge \neg P_4B(F)=B(g(a)=c)∧(B(f(g(a))≠f(c))∨B(g(a)=d))∧B(c≠d)=P​1​​∧(¬P​2​​∨P​3​​)∧¬P​4​​我们也可以定义B−1B^{-1}B​−1​​,比如B−1(P1∧P3∧P4)B^{-1}(P_1 \wedge P_3 \wedge P_4)B​−1​​(P​1​​∧P​3​​∧P​4​​)就是g(a)=c∧g(a)=d∧c=dg(a)=c\wedge g(a)=d\wedge c=dg(a)=c∧g(a)=d∧c=d
    3.1 布尔抽象为什么称为抽象?因为它实际上是一个过度简化。几个事实:

    如果FFF是satsatsat,那么B(F)B(F)B(F)总是satsatsat
    如果B(F)B(F)B(F)是satsatsat,那么FFF一定是satsatsat吗?不是
    如果FFF是unsatunsatunsat,那么B(F)B(F)B(F)一定是unsatunsatunsat吗?不是
    如果B(F)B(F)B(F)是unsatunsatunsat,那么FFF呢?是

    3.2 T与SAT求解器的结合3.2.1 基本算法
    构造FB:=B(F)F_B:=B(F)F​B​​:=B(F)
    如果FBF_BF​B​​是unsatunsatunsat,那么返回unsatunsatunsat
    否则,获得一个FBF_BF​B​​的赋值α\alphaα
    构造C=⋀i=1nPi↔α(Pi)C=\bigwedge^n_{i=1} P_i\leftrightarrow \alpha (P_i)C=⋀​i=1​n​​P​i​​↔α(P​i​​)
    将B−1(C)B^{-1}(C)B​−1​​(C)发送到TTT-求解器
    如果TTT-求解器判断为satsatsat,那么返回satsatsat
    否则,更新FB:=FB∧¬CF_B :=F_B\wedge \neg CF​B​​:=F​B​​∧¬C,重复以上步骤

    最后一步更新的解释:

    如果不更新,我们的FBF_BF​B​​会得到同样的unsatunsatunsat模型
    其中¬C\neg C¬C叫做理论冲突子句(theory conflict clause)
    更新之后,可以防止求解器未来搜索同样的路径

    3.2.2 举例判断以下公式的可满足性:
    F:g(a)=c∧(f(g(a))≠f(c)∨g(a)=d)∧c≠dF:g(a)=c\wedge (f(g(a))\ne f(c)\vee g(a)=d)\wedge c\ne dF:g(a)=c∧(f(g(a))≠f(c)∨g(a)=d)∧c≠d构造布尔抽象:
    B(F)=P1∧(¬P2∨P3)∧¬P4B(F)=P_1\wedge (\neg P_2\vee P_3)\wedge \neg P_4B(F)=P​1​​∧(¬P​2​​∨P​3​​)∧¬P​4​​找到一个satsatsat赋值(通过SAT求解器):
    α={P1↦1,P2↦0,P3↦1,P4↦0}\alpha =\{P_1\mapsto 1,P_2\mapsto 0,P_3\mapsto 1,P_4\mapsto 0\}α={P​1​​↦1,P​2​​↦0,P​3​​↦1,P​4​​↦0}构造C=P1∧¬P2∧P3∧¬P4C = P_1\wedge \neg P_2\wedge P_3\wedge \neg P_4C=P​1​​∧¬P​2​​∧P​3​​∧¬P​4​​。
    在TTT-求解器中搜索B−1(C)B^{-1}(C)B​−1​​(C):
    g(a)=c∧f(g(a))≠f(c)∧g(a)=d∧c≠dg(a)=c\wedge f(g(a))\ne f(c)\wedge g(a)=d\wedge c\ne dg(a)=c∧f(g(a))≠f(c)∧g(a)=d∧c≠dunsatunsatunsat更新FBF_BF​B​​:
    P1∧(¬P2∨P3)∧¬P4∧(¬P1∨P2∨¬P3∨P4)P_1\wedge (\neg P_2\vee P_3)\wedge \neg P_4\wedge (\neg P_1\vee P_2\vee\neg P_3 \vee P_4)P​1​​∧(¬P​2​​∨P​3​​)∧¬P​4​​∧(¬P​1​​∨P​2​​∨¬P​3​​∨P​4​​)找到一个satsatsat赋值(通过SAT求解器):
    α={P1↦1,P2↦1,P3↦1,P4↦0}\alpha =\{P_1\mapsto 1,P_2\mapsto 1,P_3\mapsto 1,P_4\mapsto 0\}α={P​1​​↦1,P​2​​↦1,P​3​​↦1,P​4​​↦0}构造C=P1∧P2∧P3∧¬P4C=P_1\wedge P_2\wedge P_3\wedge \neg P_4C=P​1​​∧P​2​​∧P​3​​∧¬P​4​​。
    在TTT-求解器中搜索B−1(C)B^{-1}(C)B​−1​​(C):
    g(a)=c∧f(g(a))=f(c)∧g(a)=d∧c≠dg(a)=c\wedge f(g(a))=f(c)\wedge g(a)=d\wedge c\ne dg(a)=c∧f(g(a))=f(c)∧g(a)=d∧c≠dunsatunsatunsat更新FBF_BF​B​​:
    P1∧(¬P2∨P3)∧¬P4∧(¬P1∨P2∨¬P3∨P4)∧(¬P1∨¬P2∨¬P3∨P4)P_1\wedge (\neg P_2\vee P_3)\wedge \neg P_4\wedge (\neg P_1\vee P_2\vee\neg P_3\vee P_4)\wedge (\neg P_1\vee\neg P_2\vee\neg P_3\vee P_4)P​1​​∧(¬P​2​​∨P​3​​)∧¬P​4​​∧(¬P​1​​∨P​2​​∨¬P​3​​∨P​4​​)∧(¬P​1​​∨¬P​2​​∨¬P​3​​∨P​4​​)找到一个赋值:
    α={P1↦1,P2↦0,P3↦0,P4↦0}\alpha = \{P_1\mapsto 1,P_2\mapsto 0,P_3\mapsto 0,P_4\mapsto 0\}α={P​1​​↦1,P​2​​↦0,P​3​​↦0,P​4​​↦0}构造C=P1∧¬P2∧¬P3∧¬P4C=P_1\wedge \neg P_2\wedge \neg P_3\wedge \neg P_4C=P​1​​∧¬P​2​​∧¬P​3​​∧¬P​4​​ 。
    在TTT-求解器中搜索B−1(C)B^{-1}(C)B​−1​​(C):
    g(a)=c∧f(g(a))≠f(c)∧g(a)≠d∧c≠dg(a)=c\wedge f(g(a))\ne f(c)\wedge g(a)\ne d\wedge c\ne dg(a)=c∧f(g(a))≠f(c)∧g(a)≠d∧c≠d更新FBF_BF​B​​:
    P1∧(¬P2∨P3)∧¬P4∧(¬P1∨P2∨¬P3∨P4)∧(¬P1∨¬P2∨¬P3∨P4)∧(¬P1∨P2∨P3∨P4)P_1\wedge (\neg P_2\vee P_3)\wedge \neg P_4\wedge (\neg P_1\vee P_2\vee\neg P_3\vee P_4)\wedge (\neg P_1\vee\neg P_2\vee \neg P_3\vee P_4)\wedge (\neg P_1\vee P_2\vee P_3\vee P_4)P​1​​∧(¬P​2​​∨P​3​​)∧¬P​4​​∧(¬P​1​​∨P​2​​∨¬P​3​​∨P​4​​)∧(¬P​1​​∨¬P​2​​∨¬P​3​​∨P​4​​)∧(¬P​1​​∨P​2​​∨P​3​​∨P​4​​)注意,这个布尔抽象已经是unsatunsatunsat了,所以我们说FFF是unsatunsatunsat了。
    3.2.3 另一个例子考虑这样的TZT_ZT​Z​​-公式FFF:
    F:0<x∧x<1wedge(x<2∨..∨x<99)F:0 < x\wedge x < 1wedge (x < 2\vee .. \vee x < 99)F:0<x∧x<1wedge(x<2∨..∨x<99)布尔抽象:
    P0∧P1∧(P2∨..∨P99)P_0\wedge P_1\wedge (P_2\vee .. \vee P_99)P​0​​∧P​1​​∧(P​2​​∨..∨P​9​​9)一共有298−12^{98}-12​98​​−1个可满足的赋值,但是没有一个满足FFF。然而,我们每次只能添加一个冲突子句!所以我们需要改进。
    四、真正的DPLL(TTT)4.1 思路
    不要把SAT求解器看做一个黑箱
    当构造出赋值的时候,渐进的查询理论求解器
    在之前的例子中,添加{0<x,x<1}\lbrace 0<x,x<1 \rbrace{0<x,x<1}后会立刻停止

    4.2 举例还是之前的例子:

    布尔抽象:B(F)=P1,¬P2,P3,¬P4B(F)={{P_1},{\neg P_2,P_3},{\neg P_4}}B(F)=P​1​​,¬P​2​​,P​3​​,¬P​4​​
    DPLL从P1P_1P​1​​,¬P4\neg P_4¬P​4​​开始
    此时,根据公理,我们有更多的逻辑传递:

    g(a)=c⇒f(g(a))=f(c)g(a)=c\Rightarrow f(g(a))=f(c)g(a)=c⇒f(g(a))=f(c)g(a)=c∧c≠d⇒g(a)≠dg(a)=c\wedge c\ne d\Rightarrow g(a)\ne dg(a)=c∧c≠d⇒g(a)≠d判定¬P2\neg P_2¬P​2​​与P3P_3P​3​​过于冗长,所以我们可以添加一些引理(theory lemmas):
    P1→P2P_1\to P_2P​1​​→P​2​​P1∧¬P4→¬P3P_1\wedge \neg P_4\to \neg P_3P​1​​∧¬P​4​​→¬P​3​​4.3 核(unsat core)我们之前是把¬C\neg C¬C添加到原式,一个不满足核(unsatisfiable core)C∗C^{*}C​∗​​是CCC的一个子集,满足:

    C∗C^{*}C​∗​​依然是不可满足的
    删除C∗C^{*}C​∗​​的任何元素,都使它可满足

    比如
    F:0<x∧x<1∧x<2∧..∧x<99F:0 < x\wedge x < 1\wedge x < 2\wedge .. \wedge x < 99F:0<x∧x<1∧x<2∧..∧x<99不满足核是0<x∧x<10<x \wedge x<10<x∧x<1,所以我们添加¬C∗\neg C^{*}¬C​∗​​而不是¬C\neg C¬C。
    0  留言 2020-06-30 16:30:18
  • 程序验证(六):纳尔逊-欧朋算法(Nelson-Oppen Procedure)


    版权声明:本文为CSDN博主「swy_swy_swy」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。原文链接:https://blog.csdn.net/swy_swy_swy/article/details/106633999

    一、动机截至目前,我们学习了一些一阶理论,每一个都是关于某一种数据类型。然而,现实中的公式并不是由单一的理论组成,如:
    ∀i.0≤i≤n→a[i]≤a[i+1]\forall i.0\le i\le n\to a[i]\le a[i+1]∀i.0≤i≤n→a[i]≤a[i+1]这个公式实际上包含了两个理论:等价与数组。
    我们需要找到一个方法,将复杂的一阶逻辑公式转化为简单的一阶逻辑公式
    二、一些概念2.1 复合理论给定T1T_1T​1​​和T2T_2T​2​​,且Σ1∩Σ2={=}\Sigma_1\cap \Sigma_2 = \lbrace = \rbraceΣ​1​​∩Σ​2​​={=},那么复合理论T1∪T2T_1\cup T_2T​1​​∪T​2​​有:

    符号集:Σ1∪Σ2\Sigma_1 \cup \Sigma_2Σ​1​​∪Σ​2​​
    公理集:A1∪A2A_1 \cup A_2A​1​​∪A​2​​

    纳尔逊-欧朋(Nelson-Oppen)复合方法:T1∪T2T_1\cup T_2T​1​​∪T​2​​是可判定的,如果T1T_1T​1​​与T2T_2T​2​​均满足:

    是量词自由的合取的片段(Quantifier-free, conjunctive fragments)
    可判定的
    稳定无限的(stably-infinite)

    2.2 稳定无限的理论一个建立在符号集Σ\SigmaΣ上的理论TTT是稳定无限的,如果对于每个量词自由的公式FFF,只要FFF是TTT-可满足的,存在一个解释,它的大小(cardinality)是无限的。
    例如这样的一个理论:
    Σ={a,b,=}\Sigma =\{a,b,=\}Σ={a,b,=}公理:∀x.x=a∨x=b\forall x.x=a\vee x=b∀x.x=a∨x=b
    这个理论不是稳定无限的,因为每个解释(D,I)(D,I)(D,I)都有这样的性质:DDD包含至多两个元素,即∣D∣≤2|D|\le 2∣D∣≤2。
    但是大多数我们关心的理论,即TE,TA,TZT_E,T_A,T_ZT​E​​,T​A​​,T​Z​​等等,都是稳定无限的。
    三、纳尔逊-欧朋算法3.1 概况输入:由复合理论T1∪T2T_1\cup T_2T​1​​∪T​2​​得到的公式FFF
    输出:等价的公式F1∧F2F_1\wedge F_2F​1​​∧F​2​​,这里:

    其中F1F_1F​1​​是一个T1T_1T​1​​公式
    其中F2F_2F​2​​是一个T2T_2T​2​​公式

    这个算法的功能:

    将FFF净化为F1F_1F​1​​和F2F_2F​2​​
    将F1F_1F​1​​和F2F_2F​2​​中共享的变量做等价变换

    3.2 步骤1:变量抽象3.2.1 理论目标:FFF中所有的文字或者属于T1T_1T​1​​,或者属于T2T_2T​2​​,但不能是二者共有
    方法:将以下两个转化方法不断使用,直到不能再用为止:

    对于任何一项f(..,t,..)f(.. , t, ..)f(..,t,..),满足f∈Σif\in \Sigma_if∈Σ​i​​且t∉Σit\notin \Sigma_it∉Σ​i​​,将ttt用一个新的(fresh)变量www替换,并在最后合取上t=wt=wt=w
    对于任一谓词p(..,t,..)p(.. ,t, ..)p(..,t,..),满足p∈Σip\in \Sigma_ip∈Σ​i​​且t∉Σit\notin \Sigma_it∉Σ​i​​,将ttt用一个新的(fresh)变量www替换,并在最后合取上t=wt=wt=w
    结束的时候,我们就可以把FFF分为F1F_1F​1​​和F2F_2F​2​​了

    3.2.2 举例考虑TE∪TZT_E\cup T_ZT​E​​∪T​Z​​公式FFF:
    F:1≤x∧x≤2∧f(x)≠f(1)∧f(x)≠f(2)F:1\le x\wedge x\le 2\wedge f(x)\ne f(1)\wedge f(x)\ne f(2)F:1≤x∧x≤2∧f(x)≠f(1)∧f(x)≠f(2)
    在TET_ET​E​​中的非逻辑符是哪些?f,=f,=f,=
    在TZT_ZT​Z​​中的非逻辑符是哪些? 1,2,≤,=1,2,\le,=1,2,≤,=
    净化:用f(w1)f(w_1)f(w​1​​)代替f(1)f(1)f(1),用f(w2)f(w_2)f(w​2​​)代替f(2)f(2)f(2),加入w1=1w_1=1w​1​​=1,w2=2w_2=2w​2​​=2,得到

    1≤x∧x≤2∧f(x)≠f(w1)∧f(x)≠f(w2)∧w1=1∧w2=21\le x\wedge x\le 2\wedge f(x)\ne f(w_1)\wedge f(x)\ne f(w_2)\wedge w_1=1\wedge w_2=21≤x∧x≤2∧f(x)≠f(w​1​​)∧f(x)≠f(w​2​​)∧w​1​​=1∧w​2​​=2
    其中FE:f(x)≠f(w1)∧f(x)≠f(w2)F_E:f(x)\ne f(w_1)\wedge f(x)\ne f(w_2)F​E​​:f(x)≠f(w​1​​)∧f(x)≠f(w​2​​)
    其中FZ:1≤x∧x≤2∧w1=1∧w2=2F_Z:1\le x\wedge x\le 2\wedge w_1 = 1\wedge w_2 = 2F​Z​​:1≤x∧x≤2∧w​1​​=1∧w​2​​=2

    3.3 步骤2:猜测与检查(guess and check)3.3.1 理论给定F1F_1F​1​​与F2F_2F​2​​,定义共享变量集VVV:
    V=free(F1)∩free(F2)V=free(F_1)\cap free(F_2)V=free(F​1​​)∩free(F​2​​)令EEE为VVV上的一个等价关系,由EEE生成的arrangement α(V,E)\alpha (V,E)α(V,E)即为:
    α(V,E):⋀u,v∈V.uEvu=v∧⋀u,v∈V.¬(uEv)u¬v\alpha (V,E): \bigwedge _{u,v\in V.uEv} u=v \wedge\bigwedge _{u,v\in V.\neg (uEv)} u\neg vα(V,E):⋀​u,v∈V.uEv​​u=v∧⋀​u,v∈V.¬(uEv)​​u¬v公式F=F1∧F2F=F_1 \wedge F_2F=F​1​​∧F​2​​是(T1∪T2)(T_1\cup T_2)(T​1​​∪T​2​​)-可满足的当且仅当存在这样的α(V,E)\alpha (V,E)α(V,E)使得:

    其中F1∧α(V,E)F_1\wedge \alpha (V,E)F​1​​∧α(V,E)是T1T_1T​1​​-可满足的
    其中F2∧α(V,E)F_2\wedge \alpha (V,E)F​2​​∧α(V,E)是T2T_2T​2​​-可满足的

    3.3.2 举例考虑之前的净化后的两个公式,共享变量V={w1,w2,x}V= \lbrace w_1,w_2,x \rbraceV={w​1​​,w​2​​,x}。
    猜测并检查VVV上的等价关系:
    {{w1},{w2},{x}}\{\{w_1\},\{w_2\},\{x\}\}{{w​1​​},{w​2​​},{x}}{{w1,w2},{x}}\{\{w_1,w_2\},\{x\}\}{{w​1​​,w​2​​},{x}}{{w1},{w2,x}}\{\{w_1\},\{w_2,x\}\}{{w​1​​},{w​2​​,x}}{{w2},{w1,x}}\{\{w_2\},\{w_1,x\}\}{{w​2​​},{w​1​​,x}}{{w1,w2,x}}\{\{w_1,w_2,x\}\}{{w​1​​,w​2​​,x}}3.3.3 效率问题这个guess and check的时间复杂度是指数级的,所以不太实用,所以我们换个方法。
    3.4 步骤3:等价推导(equality propagation)3.4.1 凸理论(convex theory)一个理论是凸的(convex),如果它对于每个变量自由的公式FFF,都满足:

    F⇒⋁i=1nui=viF\Rightarrow \bigvee ^n_{i=1} u_i =v_iF⇒⋁​i=1​n​​u​i​​=v​i​​则
    F⇒ui=vi for some i∈{1,..,n}F\Rightarrow u_i = v_i~for~some~i\in\{1,.. , n\}F⇒u​i​​=v​i​​ for some i∈{1,..,n}其中TZ,TAT_Z, T_AT​Z​​,T​A​​不是凸的,但是TE,TQT_E,T_QT​E​​,T​Q​​是凸的。
    举例:TZT_ZT​Z​​不是凸的
    例如,考虑这样的量词自由的合取ΣZ\Sigma_ZΣ​Z​​ -公式
    F:1≤z∧z≤2∧u=1∧v=2F: 1\le z\wedge z\le 2\wedge u=1\wedge v=2F:1≤z∧z≤2∧u=1∧v=2那么
    F⇒z=u∨z=vF\Rightarrow z=u\vee z=vF⇒z=u∨z=v但是无法推出
    F⇒z=uF\Rightarrow z=uF⇒z=u或
    F⇒z=vF\Rightarrow z=vF⇒z=v3.4.2 等价推导给定F1F_1F​1​​与F2F_2F​2​​

    让Ti(i=1,2)T_i (i=1,2)T​i​​(i=1,2)报告任何有关共享变量(包括u,vu, vu,v)的新推出的等价关系

    如果TiT_iT​i​​是凸的,令u=vu=vu=v为新推出的等价关系
    如果TiT_iT​i​​不是凸的,令⋁i(ui=vi)\bigvee_i (u_i = v_i)⋁​i​​(u​i​​=v​i​​)为推出的等价关系的析取

    将新推出的等价关系存储到EEE中(EEE是已经发现的等价关系的集合)

    如果TiT_iT​i​​是凸的,将u=vu=vu=v添加到EEE
    如果TiT_iT​i​​不是凸的,将搜索过程依据不同的析取⋁i(ui=vi)\bigvee_i(u_i = v_i)⋁​i​​(u​i​​=v​i​​) 分成不同的分支(通过在EEE中添加相应的等价关系)

    对于每一个分支,将EEE传播到另一个判定程序(也就是递归进行),重复以上步骤

    算法返回:

    satsatsat如果任一分支得到一个完整的arrangement
    unsatunsatunsat如果所有的分支都推出矛盾
    satsatsat如果所有的分支都不能发现新的等价关系

    3.4.3 举例考虑ΣE∪ΣQ\Sigma_E\cup \Sigma_QΣ​E​​∪Σ​Q​​-公式:
    F:f(f(x)−f(y))≠f(z)∧x≤y∧y+z≤x∧0≤zF:f(f(x)-f(y))\ne f(z)\wedge x\le y\wedge y+z\le x\wedge 0\le zF:f(f(x)−f(y))≠f(z)∧x≤y∧y+z≤x∧0≤z在第一步后,FFF被分为两个公式:
    FE:f(w)≠f(z)∧u=f(x)∧v=f(y)F_E:f(w)\ne f(z)\wedge u=f(x)\wedge v=f(y)F​E​​:f(w)≠f(z)∧u=f(x)∧v=f(y)FQ:x≤y∧y+z≤x∧0≤z∧w=u−vF_Q:x\le y\wedge y+z\le x\wedge 0\le z\wedge w=u-vF​Q​​:x≤y∧y+z≤x∧0≤z∧w=u−vV=shared(FE,FQ)={x,y,z,u,v,w}V=shared(F_E,F_Q)=\{x,y,z,u,v,w\}V=shared(F​E​​,F​Q​​)={x,y,z,u,v,w}注意,TET_ET​E​​与TQT_{Q}T​Q​​都是凸理论。
    于是

    考虑TE∪TZT_E\cup T_{Z}T​E​​∪T​Z​​-公式FFF:
    F:1≤x∧x≤2∧f(x)≠f(1)∧f(x)≠f(2)F:1\le x\wedge x\le 2\wedge f(x)\ne f(1)\wedge f(x)\ne f(2)F:1≤x∧x≤2∧f(x)≠f(1)∧f(x)≠f(2)在第一步后,FFF被分为两个公式:
    FE:f(x)≠f(w1)∧f(x)≠f(w2)F_E:f(x)\ne f(w_1)\wedge f(x)\ne f(w_2)F​E​​:f(x)≠f(w​1​​)∧f(x)≠f(w​2​​)FZ:1≤x∧x≤2∧w1=1∧w2=2F_{Z} :1\le x\wedge x\le 2\wedge w_1=1\wedge w_2=2F​Z​​:1≤x∧x≤2∧w​1​​=1∧w​2​​=2V=shared(FE,FZ)={w1,w2,x}V=shared(F_E,F_{Z})=\{w_1,w_2,x\}V=shared(F​E​​,F​Z​​)={w​1​​,w​2​​,x}注意,TET_ET​E​​是凸的,TZT_{Z}T​Z​​不是。
    于是

    考虑TE∪TZT_E\cup T_{Z}T​E​​∪T​Z​​-公式FFF:
    F:1≤x∧x≤3∧f(x)≠f(1)∧f(x)≠f(3)∧f(1)≠f(2)F: 1\le x\wedge x\le 3\wedge f(x)\ne f(1)\wedge f(x)\ne f(3)\wedge f(1)\ne f(2)F:1≤x∧x≤3∧f(x)≠f(1)∧f(x)≠f(3)∧f(1)≠f(2)在第一步后,FFF被分为两个公式:
    FE:f(x)≠f(w1)∧f(x)≠f(w3)∧f(w1)≠f(w2)F_E:f(x)\ne f(w_1)\wedge f(x)\ne f(w_3)\wedge f(w_1)\ne f(w_2)F​E​​:f(x)≠f(w​1​​)∧f(x)≠f(w​3​​)∧f(w​1​​)≠f(w​2​​)FZ:1≤x∧x≤3∧w1=1∧w2=2∧w3=3F_{Z}:1\le x\wedge x\le 3\wedge w_1=1\wedge w_2=2\wedge w_3=3F​Z​​:1≤x∧x≤3∧w​1​​=1∧w​2​​=2∧w​3​​=3V=shared(FE,FZ)={w1,w2,w3,x}V=shared(F_E,F_{Z})=\{w_1,w_2,w_3,x\}V=shared(F​E​​,F​Z​​)={w​1​​,w​2​​,w​3​​,x}注意,TET_ET​E​​是凸的,TZT_{Z}T​Z​​不是。
    于是
    0  留言 2020-06-30 13:27:01
显示 0 到 25 ,共 25 条

发送私信

人生最好的三个词:久别重逢,失而复得,虚惊一场

93
文章数
34
评论数
eject