Tag Analysis

September 18, 2021 · View on GitHub

Overview

Tag analysis is a compile-time static analysis that keeps track of information flows in programs. Information flows from variable X to variable Y, whenever information stored in X is transferred to Y. Tag analysis attaches different kinds of tags to values in a program to keep track of data flows for these tags. For example, taint analysis is a special form of tag analysis that reasons about sensitive information, such as private user data in social network service, or private keys in crypto code. Using tag analysis, a static analyzer can verify that tainted values (i.e., values attached with special taint tags) will never flow to specific program locations that may leak the information, e.g., a method writing the tainted values to a public channel. Because the analysis is carried out at compile time, the analyzer can verify non-trivial information-flow properties without any runtime overheads.

MIRAI supports a context-, field-, flow-, and path-sensitive tag analysis. Context-sensitivity and field-sensitivity come from MIRAI's summary-based inter-procedural analysis and precise abstract heap model, respectively. We implement the tag analysis as a data-flow analysis with a tag domain that over-approximates present and absent tags on values simultaneously. When the data-flow analysis is not precise enough to answer queries, MIRAI generates a propositional formula that encodes both the query and the path condition that guards the query, and uses an off-the-shelf constraint solver such as Z3 to answer the query.

Tag analysis is in general an undecidable problem, so MIRAI might not get sufficiently precise information to verify information-flow properties. MIRAI's tag analysis is developed to be consistent with MIRAI's design, which avoids false negatives (not flagging real errors), and reduces the number of false positives (flagging false errors). When the analysis result is not precise enough, or MIRAI timeouts because the analyzed program has very complex control or data flow, MIRAI will produce a conservative approximation of the program states and report possible errors, some of which may be false positives. As in other static analysis, developer-provided annotations, such as loop invariants, or simplification of the control flow in the analyzed program, could help MIRAI improve analysis precision.

Declaring tags

MIRAI supports developer-defined tags and customizable tag propagation behavior. Tag-related types and macros are provided by the MIRAI Annotations crate.

In MIRAI, tags are just Rust types, except that they are assumed to have at least one generic argument, the first of which should be a const parameter of type TagPropagationSet. Currently, Rust's support for const generics is incomplete. To enable such an incomplete and unstable feature only in MIRAI builds, one can use Rust's mechanism for conditional compilation:

#![cfg_attr(mirai, allow(incomplete_features), feature(generic_const_exprs))]

The code below declares a tag kind named SecretTaintKind.

#[macro_use]
extern crate mirai_annotations;

#[cfg(mirai)]
use mirai_annotations::{TagPropagation, TagPropagationSet};

#[cfg(mirai)]
struct SecretTaintKind<const MASK: TagPropagationSet> {}

MASK is used to specify propagation behavior of different program operations for the tag. For example, if bitwise-xor can be used to “sanitize” tainted data, it will then block the taint tag. MIRAI provides a macro tag_propagation_set! to create a tag-propagation set by specifying all the operations that can propagate the tag. The code below defines a tag named SecretTaint that can only be propagated by equality checks.

#[cfg(mirai)]
const SECRET_TAINT_MASK = tag_propagation_set!(TagPropagation::Equals, TagPropagation::Ne);

#[cfg(mirai)]
type SecretTaint = SecretTaintKind<SECRET_TAINT_MASK>;
#[cfg(not(mirai))]
type SecretTaint = (); // Ensures code compiles in non-MIRAI builds

Attaching and checking tags on values

In MIRAI, tags are attached to values via the add_tag! macro, whose first parameter is a reference to the value that is being tagged, and second parameter is a well-defined tag type. The code below attaches the SecretTaint tag we just defined to a value.

add_tag!(&value_to_be_tagged, SecretTaint);

To check if a value has (respectively, does not have) a tag attached to it, we can use the has_tag! macro (respectively, the does_not_have_tag! macro). Both macros take a reference to the checked value and a tag type as arguments, and return a Boolean value indicating the check result. A common usage is to combine them with MIRAI's specification mechanisms such as verification conditions, pre-conditions, and post-conditions. The code below illustrates the three mechanisms.

// as a verification condition
verify!(does_not_have_tag!(&value_without_tag, SecretTaint));

// as a pre-condition
fn argument_must_be_tainted(msg: Message) {
    precondition!(has_tag!(&msg, SecretTaint));
    ...
}

// as a post-condition
fn result_must_be_tainted() -> Message {
    ...
    postcondition!(has_tag!(&result, SecretTaint));
    result
}

These annotations are equivalent to no-ops when the code is compiled by an unmodified Rust compiler. When compiled with MIRAI, these annotations cause MIRAI to check the conditions and emit diagnostic messages if MIRAI cannot prove the conditions to be true.

Annotating trait methods

Traits provide powerful abstraction mechanisms in Rust, and it is natural to think about adding pre-/post-conditions to trait methods, in a way that all the implementors satisfy the contract. For example, we can define a TaintRemovable trait as follows, where remove_taint is the interface, but every implementor should instead implement the _impl_remove_taint method.

trait TaintRemovable {
    fn remove_taint(&mut self) {
        self._impl_remove_taint();
        postcondition!(does_not_have_tag(self, SecretTaint));
    }

    fn _impl_remove_taint(&mut self);
}

With the Rust design by contracts crate, one can use the contract_trait attribute to get rid of the boilerplate.

use contracts::*;

#[contract_trait]
trait TaintRemovable {
    #[post(does_not_have_tag(self, SecretTaint))]
    fn remove_taint(&mut self);
}

See the trait-methods crate for a complete example.

Scenario I: Detect timing side-channels

Constant-time programming is a well-known discipline to protect programs---especially crypto code---against timing attacks. A common practice is to avoid branchings to be controlled by sensitive information, because attackers could use side-channel attacks that measure different execution times of a program to infer the sensitive information.

For example, the code below implements a compare function that is not constant-time, because the running time of it depends on the length of the longest common prefix of secret and public. An attacker could construct different values as public and measure the running times to infer the content of secret.

// The compare function is **not** constant-time.
fn compare(secret: &[i32], public: &[i32], len: usize) -> bool {
    for i in 0..len {
        if secret[i] != public[i] {
            return false;
        }
    }
    true
}

The code below implements a constant-time compare function, by iterating all the integers in secret and public. The implementation is constant-time because it avoids the early-return behavior that leaks information about secret.

// The compare function is constant-time.
fn compare(secret: &[i32], public: &[i32], len: usize) -> bool {
    let mut result = true;
    for i in 0..len {
        result = result & (secret[i] == public[i]);
    }
    result
}

MIRAI's constant-time verification is enabled by a command-line option --constant_time Name, where Name is the tag kind type identifying sensitive information that should not influence running time, e.g., the type SecretTaintKind defined above. MIRAI reports every branch condition that has the tag used for constant-time verification. If MIRAI timeouts or the analysis result is not precise enough, MIRAI will report all possible errors, which may contain false positives, i.e., some branch conditions are reported to possibly have the constant-time tag, but during any real execution of the program, these branch conditions would not have the constant-time verification tag. Providing annotations (such as loop invariants) or simplifying the program's control flow can help MIRAI reduce the number of false positives.

See the timing-channels crate for a complete example.

Scenario II: Track untrustworthy inputs

In public-key cryptography, the source of a public key might be untrustworthy. Also, public keys obtained from the the outside environment might suffer from non-trivial vulnerabilities, e.g., public keys for Ed25519 might not be safe against small subgroup attacks.

The code below illustrates a scenario, where we want to make sure that every public key used in the system is either a key that we already know is valid, or a user-input key that goes through the try_from method, which checks for key validity. We can verify this property using tag analysis: when a public key is used, we check if it does not have a Tainted tag, or it has a special Sanitized tag that can only be attached to values via try_from. In practice, the Sanitized type can usually be defined as a private type in the module containing try_from, so that developers using this module are not able to attach the Sanitized tag to values, except by calling the try_from method.

// A public key that we already know is valid.
// This key is defined in a way that it does not have the Tainted tag.
const A_VALID_PUBLIC_KEY: PublicKey = ...;

// Deserialize a public key without any validity checks.
fn from_bytes_unchecked(bytes: &[u8]) -> PublicKey {
    let public_key = ...;
    add_tag!(&public_key, Tainted);
    public_key
}

// Deserialize a public key. This method checks for key validity.
fn try_from(bytes: &[u8]) -> Result<PublicKey> {
    let public_key = from_bytes_unchecked(bytes);

    // Perform validity checks. Return Err if the key is invalid.
    ...

    add_tag!(&public_key, Sanitized);
    Ok(public_key)
}

// Check that `sig` is valid for `message` using `public_key`.
fn verify_msg(sig: &Signature, message: &Message, public_key: &PublicKey) -> Result<()> {
    precondition!(does_not_have_tag!(public_key, Tainted) || has_tag!(public_key, Sanitized));
    ...
}

MIRAI reports every verification condition that is provably false. If MIRAI timeouts or the analysis result is not precise enough, MIRAI will report all possible errors, which may contain false positives, i.e., some verification conditions are reported to be possibly false, but during any real execution of the program, these verification conditions would be actually true. Providing annotations (such as loop invariants) or simplifying the program's control flow can help MIRAI reduce the number of false positives.

See the untrustworthy-inputs crate for a complete example.

Scenario III: Record verification status

In a blockchain codebase, there are many verification routines that check the validity of different values, including blocks, messages, keys, etc. As pointed out by this issue, it would add clarity to record verification status in the values.

There are two common ways to record such information. One is to introduce a new type, e.g., a verified VoteMsg will be typed VerifiedVoteMsg. The benefit is that the types would not introduce runtime overheads, but one downside is that developers would need to refactor a large part of the code. The other is to add a Boolean field to data structures to record the verification status, and check these fields at runtime. This approach is more flexible than static methods, but one downside it that it introduces runtime overheads.

Using MIRAI's tag analysis, we have a third way that does not have runtime overheads, or rely on Rust's type system, in a way that the verification recording is orthogonal to the existing code. MIRAI is is more expressive than the type system, though it may still emit false positives in the diagnostics. We can implement this mechanism as illustrated below: after a value has gone through a verification routine, we attach a Verified tag to it. For other code, where a verified value is required, we add a condition to enforce that the value has been attached with the Verified tag.

// Make sure a VoteMsg makes sense.
fn verify(msg: &VoteMsg, validator: &ValidatorVerifier) -> Result<()> {
    ...
    add_tag!(msg, Verified);
    Ok(())
}

// Send the vote to the chosen recipients.
fn send_vote(msg: &VoteMsg, recipients: Vec<Author>) {
    precondition!(has_tag!(msg, Verified));
    ...
}

MIRAI reports every verification condition that is provably false. If MIRAI timeouts or the analysis result is not precise enough, MIRAI will report all possible errors, which may contain false positives. Providing annotations (such as loop invariants) or simplifying the program's control flow can help MIRAI reduce the number of false positives.

See the verification-status crate for a complete example.