mirror of
https://github.com/radareorg/r2sleigh.git
synced 2026-11-07 14:49:22 -10:00
A `pairs` mode compares the posture keys both tools print; a key radare2 prints and r2s does not is counted with the unfilled values (elf 33, mach0 489, pe 822 on CI's TESTBINS_REF, which the baseline now holds). The one disagreement class is `static`: radare2 prints it for any image with an empty DT_NEEDED, a shared object included; r2s says it of an image with neither PT_INTERP nor PT_DYNAMIC (38 images on the local corpus, judged r2s-right). Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01456qmqCGpCWJkrg6JUZNNM
823 lines
36 KiB
Python
Executable File
823 lines
36 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Differential harness: run the same commands through radare2 and r2s.
|
|
|
|
radare2 is the validation target for everything the engine reproduces natively,
|
|
so the cheapest way to grade discovery, mapping and decoding is to ask both
|
|
tools the same question and report where they disagree.
|
|
|
|
Two comparison modes, because the two tools do not yet fill the same columns:
|
|
|
|
exact normalised text must match line for line, with integer literals
|
|
canonicalised because the two spell the same value differently.
|
|
Used where r2s already claims radare2's layout, such as `px`, `pd`.
|
|
registers `aoj`: each sampled instruction's size and bytes, and the registers
|
|
it reads and writes. r2s states them from the lifted IL; radare2's
|
|
`aoj` has no register field, so its side is `aeaj 1` (ESIL's `I`, the
|
|
registers read before written, and `W`) after `aei; aeim`, without
|
|
which ESIL stops at the first stack access and drops the rest. The sample is bounded and deterministic:
|
|
each entry point `ie` states and SAMPLED_FUNCTIONS FUNC symbols `is`
|
|
states, evenly spaced by address, and SAMPLED_INSTRUCTIONS instructions
|
|
from each: where the container says code begins, with no discovery. One disagreement
|
|
per register one side names and the other does not; a size or bytes
|
|
disagreement is one, and its registers are not compared.
|
|
pairs compares the `key value` rows of `i` that r2s states (POSTURE_KEYS): a key both print
|
|
with two values is a disagreement; a key radare2 prints and r2s does not is unfilled,
|
|
counted and ratcheted with the values.
|
|
values compares the values each tool reports, column by column where both
|
|
headers name the column. A value r2s reports and radare2 does not is
|
|
a disagreement; a value radare2 reports and r2s does not is unfilled,
|
|
counted per corpus and command and ratcheted (`diff_r2_baseline.json`):
|
|
the count may fall, never rise. It is a floor, not a to-do list: it
|
|
also counts values judged radare2's defect. Used for the listings.
|
|
|
|
Every disagreement is either judged in `diff_r2_judged.toml` (who is right,
|
|
and why) or fails the run; a judgment nothing matches any more is reported, so
|
|
the table cannot outlive what it judged (ROADMAP P11). The judgments were made
|
|
against one radare2 release, which the table names and the run refuses any other,
|
|
as an x86-64 host runs it: radare2 picks a fat file's slice, and the architecture
|
|
of a machine it does not know, by its host's, so the run fixes both to x86-64
|
|
(CI's runner) and gives the file back its own architecture once it is loaded.
|
|
The corpora are radare2-testbins at CI's TESTBINS_REF; a newer checkout holds
|
|
binaries no judgment has seen.
|
|
|
|
Usage:
|
|
scripts/diff_r2.py # default corpus and commands
|
|
scripts/diff_r2.py --bins DIR --limit 20
|
|
scripts/diff_r2.py --bins ELF MACH0 PE --bless # whole corpora; rewrite the baseline
|
|
scripts/diff_r2.py --only pd --verbose
|
|
"""
|
|
|
|
import argparse
|
|
import concurrent.futures
|
|
import os
|
|
import fnmatch
|
|
import json
|
|
import pathlib
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tomllib
|
|
|
|
VERDICTS = ("r2s-right", "r2-right", "unfilled", "deliberate")
|
|
|
|
# Command, comparison mode. The seek is issued to both so the cursor agrees.
|
|
COMMANDS = [
|
|
("px 64", "exact"),
|
|
("pd 8", "exact"),
|
|
("i", "pairs"),
|
|
("ie", "values"),
|
|
("iS", "values"),
|
|
("is", "values"),
|
|
("aoj", "registers"),
|
|
]
|
|
|
|
# The keys of `i` that pairs mode grades: the container's posture, under radare2's names.
|
|
POSTURE_KEYS = ("intrp", "nx", "pic", "relro", "static")
|
|
|
|
# The `aoj` sample: this many FUNC symbols beside the entry points, and this many instructions from each.
|
|
SAMPLED_FUNCTIONS = 4
|
|
SAMPLED_INSTRUCTIONS = 8
|
|
# Printed before each instruction's pair of radare2 answers.
|
|
FENCE = "aoj-fence"
|
|
|
|
HEX = re.compile(r"0x[0-9a-fA-F]+")
|
|
NOISE = re.compile(r"^\s*(WARN|ERROR|INFO):")
|
|
# radare2 annotates the listing from analysis the engine does not have yet.
|
|
ANNOTATION = re.compile(r"^\s*;")
|
|
# A trailing annotation on a disassembly line. Only applied to the listing,
|
|
# where a semicolon never belongs to the instruction itself.
|
|
TRAILING = re.compile(r"\s+;.*$")
|
|
|
|
|
|
def instruction(line):
|
|
"""One listing line from its address on, or nothing when it has none.
|
|
|
|
radare2 draws the control flow in a gutter left of the address -- `,=<`
|
|
for a jump, `:` for the column it passes through -- and a line that is
|
|
only gutter carries no instruction at all. The drawing is analysis made
|
|
visible, not disassembly, so it is dropped for the same reason the
|
|
trailing annotations are.
|
|
"""
|
|
start = line.find("0x")
|
|
return line[start:] if start >= 0 else ""
|
|
|
|
|
|
def normalise(text, strip_trailing=False):
|
|
lines = []
|
|
for line in text.splitlines():
|
|
if NOISE.match(line) or ANNOTATION.match(line):
|
|
continue
|
|
if strip_trailing:
|
|
line = canonical_numbers(TRAILING.sub("", instruction(line)))
|
|
line = line.rstrip()
|
|
if line:
|
|
lines.append(line)
|
|
return lines
|
|
|
|
|
|
def values(lines):
|
|
found = set()
|
|
for line in lines:
|
|
found.update(int(token, 16) for token in HEX.findall(line))
|
|
return found
|
|
|
|
|
|
def pairs(lines):
|
|
"""Each posture key `i` prints, with its value."""
|
|
found = {}
|
|
for line in lines:
|
|
key, _, value = line.strip().partition(" ")
|
|
if key in POSTURE_KEYS:
|
|
found[key] = value.strip()
|
|
return found
|
|
|
|
|
|
def header_of(lines):
|
|
"""The column names of a listing's header, or None when it prints none."""
|
|
return next((line.split() for line in lines if "vaddr" in line.split()), None)
|
|
|
|
|
|
def columns(lines):
|
|
"""Each value with every (column, row) it appears at; the column is the header's name for it."""
|
|
header = header_of(lines)
|
|
found = {}
|
|
for line in lines:
|
|
for index, token in enumerate(line.split()):
|
|
if not HEX.fullmatch(token):
|
|
continue
|
|
column = header[index] if header and index < len(header) else None
|
|
found.setdefault(int(token, 16), []).append((column, line))
|
|
return found
|
|
|
|
|
|
def by_column(lines):
|
|
"""Each column a header names, with the values printed in it."""
|
|
found = {}
|
|
for value, places in columns(lines).items():
|
|
for column, _ in places:
|
|
if column is not None:
|
|
found.setdefault(column, set()).add(value)
|
|
return found
|
|
|
|
|
|
def absent(value, column, named, everywhere):
|
|
"""Whether the other tool lacks this value: in the same column when it names one, else anywhere."""
|
|
if column in named:
|
|
return value not in named[column]
|
|
return value not in everywhere
|
|
|
|
|
|
def cells(header, row):
|
|
"""A row's values by the column its header names."""
|
|
found = {}
|
|
for index, token in enumerate(row.split()):
|
|
if header and index < len(header) and HEX.fullmatch(token):
|
|
found.setdefault(header[index], int(token, 16))
|
|
return found
|
|
|
|
|
|
class Listing:
|
|
"""radare2's rows of one values listing, found by a value they hold or by a column's value."""
|
|
|
|
def __init__(self, lines):
|
|
self.header, self.lines = header_of(lines), lines
|
|
self.held, self.keys = None, {}
|
|
|
|
def holding(self, value):
|
|
if self.held is None:
|
|
self.held = {}
|
|
for found, places in columns(self.lines).items():
|
|
self.held[found] = sorted({" ".join(row.split()) for _, row in places})
|
|
return self.held.get(value, [])
|
|
|
|
def keyed(self, column, value):
|
|
if column not in self.keys:
|
|
self.keys[column] = {}
|
|
for line in self.lines:
|
|
row = cells(self.header, line)
|
|
if column in row:
|
|
self.keys[column].setdefault(row[column], []).append(row)
|
|
return self.keys[column].get(value, [])
|
|
|
|
|
|
def rounded(value, other):
|
|
"""Whether `other` is `value` rounded up to a power-of-two alignment from 0x200 to 0x10000."""
|
|
return other > value and any(other == -(-value // align) * align for align in (1 << n for n in range(9, 17)))
|
|
|
|
|
|
def radare2_only(left_lines, right_lines):
|
|
"""Each (column, value) radare2 reports and r2s does not: what r2s leaves unfilled."""
|
|
ours, theirs = values(left_lines), columns(right_lines)
|
|
named = by_column(left_lines) if header_of(left_lines) else {}
|
|
return sorted(
|
|
{
|
|
(column or "", value)
|
|
for value, places in theirs.items()
|
|
for column, _ in places
|
|
if absent(value, column, named, ours)
|
|
}
|
|
)
|
|
|
|
|
|
# `\b` never matches before a minus, so a negative decimal used to lose its
|
|
# sign: radare2's `[sp, -4]` normalised to `-0x4` while Sleigh's `[sp, -0x4]`
|
|
# normalised to the unsigned word, and the same displacement read as two.
|
|
NUMBER = re.compile(r"-?0x[0-9a-fA-F]+|(?<![0-9A-Za-z_])-?[0-9]+\b")
|
|
|
|
|
|
def canonical_numbers(line):
|
|
"""Rewrite every integer literal to unsigned 64-bit hex.
|
|
|
|
radare2 prints `0xfffffffffffffff0` and a bare `8` where Sleigh prints
|
|
`-0x10` and `0x8`. Both spell the same value, so a diff that reported them
|
|
as a disagreement would bury the defects worth seeing.
|
|
"""
|
|
|
|
def rewrite(match):
|
|
token = match.group(0)
|
|
try:
|
|
value = int(token, 16) if "0x" in token.lower() else int(token, 10)
|
|
except ValueError:
|
|
return token
|
|
return "0x{:x}".format(value & 0xFFFFFFFFFFFFFFFF)
|
|
|
|
return NUMBER.sub(rewrite, line)
|
|
|
|
|
|
def run(argv, timeout):
|
|
"""Return (stdout, stderr, code); code is None when the tool never ran."""
|
|
try:
|
|
# A listing of a non-text byte is not UTF-8: each invalid byte keeps its value, so two
|
|
# different ones still compare different.
|
|
done = subprocess.run(
|
|
argv,
|
|
capture_output=True,
|
|
text=True,
|
|
errors="surrogateescape",
|
|
timeout=timeout,
|
|
check=False,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
return "", "timeout", None
|
|
except OSError as error:
|
|
return "", str(error), None
|
|
return done.stdout, done.stderr, done.returncode
|
|
|
|
|
|
def differences(command, mode, left, right):
|
|
"""Every disagreement of one command on one binary.
|
|
|
|
A listing pairs its lines by position, each pair one disagreement, with
|
|
`None` for a line one tool did not print; a values listing gives each
|
|
value r2s reports and radare2 does not.
|
|
"""
|
|
if mode == "registers":
|
|
return register_differences(left, right)
|
|
if mode == "pairs":
|
|
ours, theirs = pairs(left.splitlines()), pairs(right.splitlines())
|
|
return [
|
|
("lines", (None if key not in theirs else "{} {}".format(key, theirs[key]), "{} {}".format(key, ours[key])))
|
|
for key in POSTURE_KEYS
|
|
if key in ours and ours[key] != theirs.get(key)
|
|
]
|
|
# Only the listing carries trailing annotations; a hex dump's ASCII column
|
|
# can hold a semicolon that must not be mistaken for one.
|
|
strip = command.startswith("pd")
|
|
left_lines, right_lines = normalise(left, strip), normalise(right, strip)
|
|
if mode == "values":
|
|
theirs, ours = values(right_lines), columns(left_lines)
|
|
named = by_column(right_lines) if header_of(right_lines) else {}
|
|
# One disagreement per occurrence: a judgment of one column must not hide another, and a
|
|
# value in a column both name is compared with that column alone, so a swap is seen.
|
|
# A value inside a word (no column of its own) is compared with every value radare2 prints.
|
|
inside = {value: [(None, "")] for value in values(left_lines) if value not in ours}
|
|
listing, header = Listing(right_lines), header_of(left_lines)
|
|
return [
|
|
("value", (value, column, " ".join(row.split()), {"ours": cells(header, row), "theirs": listing}))
|
|
for value, places in sorted({**ours, **inside}.items())
|
|
for column, row in places
|
|
if absent(value, column, named, theirs)
|
|
]
|
|
found = []
|
|
for index in range(max(len(left_lines), len(right_lines))):
|
|
ours = left_lines[index] if index < len(left_lines) else None
|
|
theirs = right_lines[index] if index < len(right_lines) else None
|
|
if ours != theirs:
|
|
found.append(("lines", (theirs, ours)))
|
|
return found
|
|
|
|
|
|
def register_differences(left, right):
|
|
"""Each instruction both tools read at one address: a disagreement about which instruction it
|
|
is, else each register one side's read or written set names and the other's does not.
|
|
|
|
`left` is r2s's sample (`arch` and its `aoj` rows), `right` radare2's rows (`aoj 1` with
|
|
`aeaj 1`'s `I`, `W` and memory accesses added), both JSON. A detail is (address, field,
|
|
register or None, side lacking it or None, r2s's opcode, radare2's opcode, context).
|
|
"""
|
|
sample = json.loads(left or "{}")
|
|
ours = {row["addr"]: row for row in sample.get("rows", [])}
|
|
theirs = {row["addr"]: row for row in json.loads(right or "[]")}
|
|
found = []
|
|
for address in sorted(ours):
|
|
mine, other = ours[address], theirs.get(address)
|
|
spelled = (mine.get("opcode", ""), "<missing>" if other is None else other.get("opcode", ""))
|
|
context = {
|
|
"arch": sample.get("arch", ""),
|
|
"refused": mine.get("refused", ""),
|
|
"ours": "",
|
|
"theirs": "",
|
|
}
|
|
|
|
def instruction(field):
|
|
found.append(("instruction", (address, field, None, None) + spelled + (context,)))
|
|
|
|
if other is None:
|
|
instruction("missing")
|
|
continue
|
|
if (mine["size"], mine["bytes"]) != (other.get("size"), other.get("bytes")):
|
|
instruction("size")
|
|
continue
|
|
if spelled == ("invalid", "invalid"):
|
|
continue
|
|
# One instruction spelled two ways, or two instructions: either way no sets are compared.
|
|
if mine.get("mnemonic") != other.get("mnemonic"):
|
|
instruction("decode")
|
|
continue
|
|
if "refused" in mine:
|
|
instruction("refused")
|
|
continue
|
|
# ESIL that never reached the memory the IL accesses stopped there and dropped the rest,
|
|
# so what its sets hold or lack says nothing.
|
|
memory = any(op.startswith("STORE") or " LOAD " in op for op in mine.get("il", []))
|
|
if memory and not (other.get("@R") or other.get("@W")):
|
|
instruction("halted")
|
|
continue
|
|
for field, key in (("reads", "I"), ("writes", "W")):
|
|
have, want = set(mine[field]), set(other.get(key, []))
|
|
context = dict(context, ours=" ".join(sorted(have)), theirs=" ".join(sorted(want)))
|
|
for register in sorted(have - want):
|
|
found.append(("register", (address, field, register, "r2") + spelled + (context,)))
|
|
for register in sorted(want - have):
|
|
found.append(("register", (address, field, register, "r2s") + spelled + (context,)))
|
|
return found
|
|
|
|
|
|
def load_table(path):
|
|
"""The radare2 release the table was judged against, and its judged disagreements."""
|
|
if not path.exists():
|
|
return None, []
|
|
table = tomllib.loads(path.read_text())
|
|
return table.get("radare2"), load_judgments(path, table)
|
|
|
|
|
|
def load_judgments(path, table=None):
|
|
"""The judged disagreements: each a class of them, with its verdict and reason."""
|
|
if table is None:
|
|
if not path.exists():
|
|
return []
|
|
table = tomllib.loads(path.read_text())
|
|
judged = table["judged"]
|
|
for judgment in judged:
|
|
if judgment["verdict"] not in VERDICTS:
|
|
raise ValueError("unknown verdict {!r}".format(judgment["verdict"]))
|
|
judgment["values"] = {int(value, 16) for value in judgment.get("values", [])}
|
|
judgment["columns"] = set(judgment.get("columns", []))
|
|
judgment["registers"] = set(judgment.get("registers", []))
|
|
judgment["fields"] = set(judgment.get("fields", []))
|
|
judgment["matched"] = 0
|
|
return judged
|
|
|
|
|
|
def release(banner):
|
|
"""The release `radare2 -v` names on its first line, or None."""
|
|
words = banner.split()
|
|
return words[1] if len(words) > 1 and words[0] == "radare2" else None
|
|
|
|
|
|
def dumped(verb, line):
|
|
"""The address and hex bytes a `px` or `pd` line shows, or None."""
|
|
if line is None or not line.startswith("0x"):
|
|
return None
|
|
address, _, rest = line.partition(" ")
|
|
if verb == "px":
|
|
# Eight groups of four digits after two spaces; the ASCII column after them may look hex.
|
|
return address, rest[1:40].replace(" ", "").lower()
|
|
# radare2 marks a line that starts inside the previous instruction with `~`.
|
|
words = [word for word in rest.split() if word != "~"]
|
|
return (address, words[0].lower()) if words and re.fullmatch("[0-9a-fA-F]+", words[0]) else None
|
|
|
|
|
|
def filled(verb, theirs, ours, byte):
|
|
"""Whether radare2's line is r2s's (or nothing) followed only by `byte`: a fill past the mapping."""
|
|
shown, prefix = dumped(verb, theirs), dumped(verb, ours) if ours is not None else None
|
|
if shown is None or (ours is not None and (prefix is None or prefix[0] != shown[0])):
|
|
return False
|
|
head = prefix[1] if prefix else ""
|
|
tail = shown[1][len(head) :]
|
|
return shown[1].startswith(head) and tail != "" and tail == byte * (len(tail) // len(byte))
|
|
|
|
|
|
def aligned(key, column, value, context):
|
|
"""Whether radare2's row with r2s's `key` holds, in this column, r2s's value rounded up."""
|
|
ours = context["ours"].get(key)
|
|
return any(
|
|
column in row and rounded(value, row[column]) for row in context["theirs"].keyed(key, ours)
|
|
)
|
|
|
|
|
|
def judgment_for(judgments, binary, command, difference, corpus=None):
|
|
"""The first judgment that covers this disagreement, or None."""
|
|
verb = command.split()[0]
|
|
kind, detail = difference
|
|
for judgment in judgments:
|
|
patterns = judgment["binaries"]
|
|
patterns = [patterns] if isinstance(patterns, str) else patterns
|
|
verbs = judgment["command"]
|
|
verbs = [verbs] if isinstance(verbs, str) else verbs
|
|
if verb not in verbs or not any(fnmatch.fnmatchcase(binary, p) for p in patterns):
|
|
continue
|
|
if judgment.get("corpus", corpus) != corpus:
|
|
continue
|
|
if kind in ("register", "instruction"):
|
|
_, field, register, lacking, mine, other, context = detail
|
|
# `{register}` in a pattern is the register judged, `{suffix}` it without its first letter.
|
|
def pattern(key):
|
|
text = judgment.get(key, "")
|
|
if register is not None:
|
|
text = text.replace("{register}", re.escape(register)).replace("{suffix}", re.escape(register[1:]))
|
|
return text
|
|
|
|
covered = (
|
|
(not judgment["fields"] or field in judgment["fields"])
|
|
and (not judgment["registers"] or register in judgment["registers"])
|
|
and ("register" not in judgment or re.fullmatch(judgment["register"], register or "") is not None)
|
|
and judgment.get("lacking", lacking) == lacking
|
|
and re.search(pattern("r2s"), mine) is not None
|
|
and re.search(pattern("r2"), other) is not None
|
|
and all(
|
|
re.search(pattern(key), context[key]) is not None
|
|
for key in ("arch", "refused", "ours", "theirs")
|
|
)
|
|
)
|
|
elif kind == "value":
|
|
# No values listed covers every value of those binaries; columns listed cover only values
|
|
# r2s prints in them; `r2s` matches r2s's row and `r2` a radare2 row holding the value.
|
|
value, column, row, context = (detail + (None,))[:4]
|
|
theirs = context["theirs"].holding(value) if context else []
|
|
covered = (
|
|
(not judgment["values"] or value in judgment["values"])
|
|
and (not judgment["columns"] or column in judgment["columns"])
|
|
and re.search(judgment.get("r2s", ""), row) is not None
|
|
and ("r2" not in judgment or any(re.search(judgment["r2"], line) for line in theirs or ["<missing>"]))
|
|
and ("key" not in judgment or (context is not None and aligned(judgment["key"], column, value, context)))
|
|
)
|
|
else:
|
|
theirs, ours = detail
|
|
covered = not judgment["values"] and all(
|
|
re.search(judgment[side], line if line is not None else "<missing>")
|
|
for side, line in (("r2", theirs), ("r2s", ours))
|
|
if side in judgment
|
|
)
|
|
if "fill" in judgment:
|
|
covered = covered and filled(verb, theirs, ours, judgment["fill"])
|
|
if covered:
|
|
judgment["matched"] += 1
|
|
return judgment
|
|
return None
|
|
|
|
|
|
def spelled(difference):
|
|
"""One disagreement as the report prints it, indented under its command."""
|
|
kind, detail = difference
|
|
if kind in ("register", "instruction"):
|
|
address, field, register, lacking, mine, other, context = detail
|
|
what = field if register is None else "{} {} (not in {}'s)".format(field, register, lacking)
|
|
return " {:#x} {} r2s `{}` r2 `{}` [{}] r2s {{{}}} r2 {{{}}}{}".format(
|
|
address, what, mine, other, context["arch"], context["ours"], context["theirs"],
|
|
" ({})".format(context["refused"]) if context["refused"] else "",
|
|
)
|
|
if kind == "value":
|
|
value, column, row = detail[:3]
|
|
return " {:#x}{} {}".format(value, " ({})".format(column) if column else "", row)
|
|
theirs, ours = detail
|
|
return " r2 {}\n r2s {}".format(
|
|
theirs if theirs is not None else "<missing>", ours if ours is not None else "<missing>"
|
|
)
|
|
|
|
|
|
def radare2_argv(r2, script, binary):
|
|
"""radare2 answering `script`, loading `binary` as an x86-64 host does."""
|
|
# `-e` holds through the load, so the file's own architecture and bits are put back after it.
|
|
restore = "e asm.arch=`i~^arch[1]`;e asm.bits=`i~^bits[1]`;"
|
|
return [str(r2), "-N", "-q", "-e", "asm.arch=x86", "-e", "asm.bits=64", "-e", "scr.color=0",
|
|
"-c", restore + script, str(binary)]
|
|
|
|
|
|
def answers(binary, commands, r2, r2s, timeout):
|
|
"""Both tools' output for each command on one binary, or r2s's refusal to open it."""
|
|
# A refusal to open is a capability gap, not a disagreement about a
|
|
# binary both tools read, so it is counted and reported separately.
|
|
_, opened_error, opened_code = run([str(r2s), "-q", "-c", "i", str(binary)], timeout)
|
|
if opened_code != 0:
|
|
reason = opened_error.strip().splitlines()[-1] if opened_error.strip() else "failed"
|
|
return reason.replace("r2s: ", ""), {}
|
|
said = {}
|
|
for command, mode in commands:
|
|
# Every command seeks first, so a seek that timed out once times out each time.
|
|
if None in said.values():
|
|
said[command] = None
|
|
continue
|
|
if mode == "registers":
|
|
said[command] = sampled_instructions(binary, r2, r2s, timeout)
|
|
continue
|
|
script = "s entry0;{}".format(command)
|
|
right, _, right_code = run(radare2_argv(r2, script, binary), timeout)
|
|
left, _, left_code = run([str(r2s), "-q", "-c", script, str(binary)], timeout)
|
|
said[command] = None if right_code is None or left_code is None else (left, right)
|
|
return None, said
|
|
|
|
|
|
def sampled_instructions(binary, r2, r2s, timeout):
|
|
"""(r2s's sample, radare2's rows for the same addresses) as JSON; (None, None) where r2s lists
|
|
no function to sample; None on a timeout."""
|
|
listed, _, code = run([str(r2s), "-q", "-c", "i~^arch;ie;is", str(binary)], timeout)
|
|
if code is None:
|
|
return None
|
|
entries = sampled_entries(listed.splitlines())
|
|
if not entries:
|
|
return None, None
|
|
arch = next((line.split()[1] for line in listed.splitlines() if line.split()[:1] == ["arch"]), "")
|
|
script = ";".join("aoj {} @ {:#x}".format(SAMPLED_INSTRUCTIONS, entry) for entry in entries)
|
|
left, _, code = run([str(r2s), "-q", "-c", script, str(binary)], timeout)
|
|
if code is None:
|
|
return None
|
|
ours = [row for line in left.splitlines() if line.startswith("[") for row in json.loads(line)]
|
|
addresses = sorted({row["addr"] for row in ours})
|
|
if not addresses:
|
|
return None, None
|
|
# Each address's pair is fenced, so a command that prints nothing cannot shift the rest.
|
|
asked = ";".join("?e {};aoj 1 @ {:#x};aeaj 1 @ {:#x}".format(FENCE, a, a) for a in addresses)
|
|
right, _, code = run(radare2_argv(r2, "aei;aeim;" + asked, binary), timeout)
|
|
if code is None:
|
|
return None
|
|
return json.dumps({"arch": arch, "rows": ours}), json.dumps(radare2_rows(right))
|
|
|
|
|
|
def sampled_entries(lines):
|
|
"""The entry points `ie` states, then SAMPLED_FUNCTIONS of the FUNC symbols `is` states, evenly
|
|
spaced over them by address: where the container says code begins, with no discovery run."""
|
|
entries, functions, lib = set(), set(), None
|
|
for line in lines:
|
|
words = line.split()
|
|
if words[:1] == ["nth"] and "lib" in words:
|
|
lib = (line.index(" lib ") + 1, line.index(" name") + 1)
|
|
if len(words) == 5 and words[-1] == "program" and HEX.fullmatch(words[1]):
|
|
entries.add(int(words[1], 16))
|
|
elif len(words) >= 5 and words[4] == "FUNC" and HEX.fullmatch(words[2]) and int(words[2], 16):
|
|
# A symbol a library defines names the slot the loader fills (a PE import), not code.
|
|
if lib is None or not line[lib[0] : lib[1]].strip():
|
|
functions.add(int(words[2], 16))
|
|
functions = sorted(functions - entries)
|
|
step = max(1, len(functions) // SAMPLED_FUNCTIONS)
|
|
return sorted(entries) + functions[::step][:SAMPLED_FUNCTIONS]
|
|
|
|
|
|
def radare2_rows(text):
|
|
"""radare2's fenced `aoj 1` and `aeaj 1` answers, one row per address with `I`, `W` and the
|
|
memory `@R` and `@W` added: `I` is what ESIL reads before writing, as r2s's reads are."""
|
|
rows, blocks = [], [[]]
|
|
for line in text.splitlines():
|
|
if line == FENCE:
|
|
blocks.append([])
|
|
elif line.startswith(("[", "{")):
|
|
blocks[-1].append(line)
|
|
for lines in blocks[1:]:
|
|
try:
|
|
decoded = json.loads(lines[0])[0] if lines else None
|
|
accessed = json.loads(lines[1]) if len(lines) > 1 else {}
|
|
except (ValueError, IndexError):
|
|
continue
|
|
if decoded is None:
|
|
continue
|
|
for key in ("I", "W", "@R", "@W"):
|
|
decoded[key] = accessed.get(key, [])
|
|
rows.append(decoded)
|
|
return rows
|
|
|
|
|
|
def compare(name, command, mode, left, right, judgments, corpus=None):
|
|
"""Every disagreement, those no judgment covers, and the values only radare2 reports."""
|
|
found = differences(command, mode, left, right)
|
|
unjudged = [item for item in found if judgment_for(judgments, name, command, item, corpus) is None]
|
|
missed = radare2_only(normalise(left), normalise(right)) if mode == "values" else []
|
|
if mode == "pairs":
|
|
ours, theirs = pairs(left.splitlines()), pairs(right.splitlines())
|
|
missed = [(key, None) for key in POSTURE_KEYS if key in theirs and key not in ours]
|
|
return found, unjudged, missed
|
|
|
|
|
|
def grade(corpus, binaries, commands, r2, r2s, judgments, args):
|
|
"""One corpus: per command, binaries agreeing, judged, unjudged, and radare2-only values."""
|
|
tally = {command: {"agree": 0, "judged": 0, "unjudged": 0, "r2-only": 0} for command, _ in commands}
|
|
unsupported, skipped = {}, []
|
|
# The tools run in parallel; judging stays in name order, so the report and the matches do not move.
|
|
with concurrent.futures.ThreadPoolExecutor(args.jobs) as pool:
|
|
ran = pool.map(lambda binary: answers(binary, commands, r2, r2s, args.timeout), binaries)
|
|
for binary, (refused, said) in zip(binaries, ran):
|
|
if refused is not None:
|
|
unsupported[refused] = unsupported.get(refused, 0) + 1
|
|
continue
|
|
reported = False
|
|
for command, mode in commands:
|
|
if said[command] is None:
|
|
skipped.append("{} {}".format(binary.name, command))
|
|
continue
|
|
left, right = said[command]
|
|
# A binary neither tool has anything to say about is not a difference.
|
|
if mode == "registers" and left is None:
|
|
continue
|
|
if mode != "registers" and not normalise(right) and not normalise(left):
|
|
continue
|
|
|
|
found, unjudged, missed = compare(binary.name, command, mode, left, right, judgments, corpus)
|
|
tally[command]["r2-only"] += len(missed)
|
|
if not found:
|
|
tally[command]["agree"] += 1
|
|
elif not unjudged:
|
|
tally[command]["judged"] += 1
|
|
else:
|
|
tally[command]["unjudged"] += 1
|
|
if not unjudged and not (missed and args.verbose):
|
|
continue
|
|
if not reported:
|
|
print(binary.name)
|
|
reported = True
|
|
if unjudged:
|
|
print(" {} differs: {} unjudged of {}".format(command, len(unjudged), len(found)))
|
|
for difference in unjudged if args.verbose else unjudged[:1]:
|
|
print(spelled(difference))
|
|
if missed and args.verbose:
|
|
print(" {} radare2 only: {}".format(command, len(missed)))
|
|
shown = [c if v is None else "{}={:#x}".format(c, v) if c else "{:#x}".format(v) for c, v in missed[:8]]
|
|
print(" " + " ".join(shown + (["..."] if len(missed) > 8 else [])))
|
|
return tally, unsupported, skipped
|
|
|
|
|
|
def ratchet(baseline, measured):
|
|
"""Each (corpus, count, before, now) that moved: radare2-only values per command, refusals."""
|
|
moved = []
|
|
for corpus, counts in sorted(measured.items()):
|
|
for command, count in sorted(counts.items()):
|
|
before = baseline.get(corpus, {}).get(command)
|
|
if before != count:
|
|
moved.append((corpus, command, before, count))
|
|
return moved
|
|
|
|
|
|
def main():
|
|
# A line holding an invalid byte prints as its escape.
|
|
sys.stdout.reconfigure(errors="backslashreplace")
|
|
here = pathlib.Path(__file__).resolve().parent.parent
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--r2", default=str(here.parent / "radare2/binr/radare2/radare2"))
|
|
parser.add_argument("--r2s", default=str(here / "target/debug/r2s"))
|
|
parser.add_argument(
|
|
"--bins",
|
|
nargs="+",
|
|
default=[str(here.parent / "radare2/test/bins/elf")],
|
|
help="corpus directories, each graded and ratcheted by its name",
|
|
)
|
|
parser.add_argument("--limit", type=int, help="the first N binaries of each corpus; no ratchet")
|
|
parser.add_argument("--timeout", type=int, default=30)
|
|
parser.add_argument("--jobs", type=int, default=os.cpu_count(), help="binaries run at once")
|
|
parser.add_argument("--only", help="run just the commands whose verb matches this")
|
|
parser.add_argument("--verbose", action="store_true", help="show each unjudged disagreement")
|
|
parser.add_argument(
|
|
"--judged",
|
|
default=str(here / "scripts/diff_r2_judged.toml"),
|
|
help="the judged disagreements",
|
|
)
|
|
parser.add_argument(
|
|
"--baseline",
|
|
default=str(here / "scripts/diff_r2_baseline.json"),
|
|
help="radare2-only values per corpus and command, which may not rise",
|
|
)
|
|
parser.add_argument("--bless", action="store_true", help="write this run's counts as the baseline")
|
|
parser.add_argument(
|
|
"--no-build",
|
|
action="store_true",
|
|
help="measure whatever binary is already there",
|
|
)
|
|
args = parser.parse_args()
|
|
if args.bless and args.limit is not None:
|
|
print("--bless needs whole corpora: drop --limit", file=sys.stderr)
|
|
return 2
|
|
|
|
# Build before measuring. The default binary is the debug one, which a
|
|
# plain `cargo build --release` does not touch, so a run taken after one
|
|
# reported the tree as it was several changes ago.
|
|
if not args.no_build and args.r2s == str(here / "target/debug/r2s"):
|
|
subprocess.run(
|
|
["cargo", "build", "-p", "r2s", "--features", "sleigh"],
|
|
cwd=here,
|
|
check=True,
|
|
)
|
|
|
|
r2, r2s = pathlib.Path(args.r2), pathlib.Path(args.r2s)
|
|
for tool in (r2, r2s):
|
|
if not tool.exists():
|
|
print("missing {}".format(tool), file=sys.stderr)
|
|
return 2
|
|
|
|
commands = [
|
|
(command, mode)
|
|
for command, mode in COMMANDS
|
|
if not args.only or command.split()[0] == args.only
|
|
]
|
|
if not commands:
|
|
print("no command matches --only {}".format(args.only), file=sys.stderr)
|
|
return 2
|
|
|
|
corpora = {}
|
|
for directory in map(pathlib.Path, args.bins):
|
|
binaries = sorted(p for p in directory.iterdir() if p.is_file())[: args.limit]
|
|
if not binaries:
|
|
print("no binaries under {}".format(directory), file=sys.stderr)
|
|
return 2
|
|
corpora[directory.name] = binaries
|
|
|
|
pinned, judgments = load_table(pathlib.Path(args.judged))
|
|
banner, _, _ = run([str(r2), "-v"], args.timeout)
|
|
if pinned and release(banner) != pinned:
|
|
print(
|
|
"{} is radare2 {}; the judgments are against {} (build its tag, or CI's .deb)".format(
|
|
r2, release(banner), pinned
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
|
|
unjudged, measured = 0, {}
|
|
for corpus, binaries in corpora.items():
|
|
print("== {} ({} binaries)".format(corpus, len(binaries)))
|
|
tally, unsupported, skipped = grade(corpus, binaries, commands, r2, r2s, judgments, args)
|
|
print("\n{:<8} {:>8} {:>8} {:>8} {:>8}".format("command", "agree", "judged", "unjudged", "r2-only"))
|
|
print("-" * 44)
|
|
for command, _ in commands:
|
|
row = tally[command]
|
|
print(
|
|
"{:<8} {:>8} {:>8} {:>8} {:>8}".format(
|
|
command, row["agree"], row["judged"], row["unjudged"], row["r2-only"]
|
|
)
|
|
)
|
|
unjudged += row["unjudged"]
|
|
# A binary r2s stops opening, or a run that times out, hides its values: both are ratcheted.
|
|
measured[corpus] = {
|
|
command.split()[0]: tally[command]["r2-only"] for command, mode in commands if mode in ("values", "pairs")
|
|
}
|
|
measured[corpus]["refused"] = sum(unsupported.values())
|
|
measured[corpus]["skipped"] = len(skipped)
|
|
if skipped:
|
|
print("\n{} runs skipped (timeout or launch failure): {}".format(len(skipped), ", ".join(skipped)))
|
|
if unsupported:
|
|
print("\nnot opened by r2s:")
|
|
for reason, count in sorted(unsupported.items(), key=lambda item: -item[1]):
|
|
print(" {:>4} {}".format(count, reason))
|
|
print()
|
|
|
|
stale = [judgment for judgment in judgments if judgment["matched"] == 0]
|
|
if stale:
|
|
print("judgments nothing matched (stale, or outside these corpora):")
|
|
for judgment in stale:
|
|
print(" {} {}: {}".format(judgment["command"], judgment["binaries"], judgment["reason"]))
|
|
print()
|
|
|
|
# A partial corpus says nothing about the whole one's count.
|
|
risen = []
|
|
if args.limit is None:
|
|
path = pathlib.Path(args.baseline)
|
|
baseline = json.loads(path.read_text()) if path.exists() else {}
|
|
if args.bless:
|
|
for corpus, counts in measured.items():
|
|
baseline.setdefault(corpus, {}).update(counts)
|
|
path.write_text(json.dumps(baseline, indent=2, sort_keys=True) + "\n")
|
|
print("blessed {}".format(path))
|
|
else:
|
|
for corpus, command, before, now in ratchet(baseline, measured):
|
|
if before is None:
|
|
risen.append((corpus, command))
|
|
print("{} {}: {} with no baseline; --bless to start one".format(corpus, command, now))
|
|
elif now > before:
|
|
risen.append((corpus, command))
|
|
# Opening a refused binary adds its misses: that rise is blessed with the fall.
|
|
refused = baseline.get(corpus, {}).get("refused"), measured[corpus]["refused"]
|
|
print("{} {}: {} rose to {} (refused {} to {})".format(corpus, command, before, now, *refused))
|
|
else:
|
|
print("{} {}: {} fell to {}; --bless to keep it".format(corpus, command, before, now))
|
|
return 1 if unjudged or risen else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|