aboutsummaryrefslogtreecommitdiffstats
path: root/psp_emu/harness/fuzz_sysdrv.py
blob: e21932d827b994db52c6a434ea94a809b6635727 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
"""Fuzz SYS_DRV's service-1 RPC surface -- the most privileged code reachable
from a host-driven path.

Chain: host -> HDCP (cmd 0x12/0x13/0x0F) -> ASD -> SVC 0xF2 service 1 ->
       SYS_DRV sub_E087E4 -> opcode >= 0x1000 -> sub_E105A8 (4372 bytes,
       223 handlers)  |  opcode < 0x1000 -> sub_E1DC30 (crypto)

ASD demonstrably emits 11 high opcodes (0x1021 0x103B 0x103C 0x1043 0x1044
0x1057 0x1079 0x1087 0x1095 0x109A 0x109B), so this dispatcher is on a
host-reachable path. It has never been audited handler-by-handler.

Request layout (from sub_E087E4 / sub_E105A8): a dword array, 0x7C bytes copied.
    [0]      opcode
    [2..9]   four {ptr,len} argument pairs
    [14..18] additional scalar fields
sub_E105A8 region-validates each pair via sub_E0DEF4 before use.

Artifact discipline carried over from the ASD campaign (all four bit us there):
  * never write PC from a UC_HOOK_CODE   (fabricated 194 fake "PC control" hits)
  * MMIO aperture is mapped              (benign register reads looked like OOB)
  * classify by INPUT, not fault address (scalars in pointer slots clobber stack)
  * signature tail is zeroed at load     (sig bytes masqueraded as initialised data)
"""
import os
import random
import sys
from collections import Counter

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from pspemu import Component, consts, sysdrv_env   # noqa: E402

ENTRY   = 0xE087E4          # SYS_DRV RPC entry (opcode split lives here)
HIGH    = 0xE105A8          # high dispatcher
CRYPTO  = 0xE1DC30          # crypto dispatcher
GUARD   = 0xE01F20
GATE_ACL = 0xE0AB44        # sub_E0AB44(opcode, arg idx) -> may this arg be a pointer
RED     = b"\x5A\xFE\x5A\xFE" * 4

ASD_EMITTED = [0x1021, 0x103B, 0x103C, 0x1043, 0x1044, 0x1057,
               0x1079, 0x1087, 0x1095, 0x109A, 0x109B]
# ACL ranges from the table at 0xE23398
ACL_RANGES = [(0x1100, 0x11FF), (0x2000, 0x20FF), (0x3000, 0x30FF),
              (0x3100, 0x31FF), (0x4000, 0x400F), (0x6000, 0x60FF),
              (0x7000, 0x70FF), (0x8000, 0x80FF), (0x103D, 0x103D), (0x103F, 0x103F)]


class SysDrvFuzzer:
    def __init__(self, seed=0, only_asd_opcodes=False):
        self.rng = random.Random(seed)
        self.only_asd = only_asd_opcodes
        # Build on sysdrv_env, which implements the SVC semantics SYS_DRV's init
        # actually depends on. A bare Component("sys_drv") plus zero-returning
        # SVC stubs does NOT get init through: stubs return success while
        # installing none of the pointers later initialisers dereference.
        #
        # Do NOT re-register those SVCs as `lambda: 0` afterwards -- that is what
        # the previous version did, and it clobbered the faithful handlers.
        c = self.c = sysdrv_env.make()
        # Map the fabric/HW-counter aperture at 0x3200000. The component map only
        # covers 0x3010000..0x3020000, so benign register touches here surfaced
        # as READ/WRITE_UNMAPPED "findings" -- exactly the artifact this file's
        # own header warns about ("MMIO aperture is mapped"). Measured: it turned
        # 2 of 17 reported faults into noise (0x32001E0 = HW_COUNTER,
        # 0x320016C), both outside any mapped region.
        try:
            c.uc.mem_map(0x03200000, 0x10000)
        except Exception:
            pass
        # sub_E105A8 gates every {ptr,len} pair on sub_E0AB44(opcode,idx) != 0
        # and sub_E0DEF4(ptr,len,type,ctx) != 0.
        #
        # sub_E0DEF4 is not restrictive -- it accepted every address tried.
        # sub_E0AB44 is an (opcode, arg-index) ALLOWLIST: may this argument be a
        # pointer? It is sparse. Only 2 of the 11 ASD-emitted opcodes permit any
        # pointer slot, and 18 of 132 sampled ACL-range opcodes, always index 0.
        #
        # There are three ways to run against it, and only one is both faithful
        # and useful. Measured over 800 iterations, distinct SYS_DRV blocks:
        #
        #   allowlist patched to 1 (the old default)      447 blocks, 2 findings
        #   allowlist real, pointers placed anywhere      107 blocks, 0 findings
        #   allowlist real, pointers only where permitted 604 blocks, 4 findings
        #
        # The middle row is the trap: restoring the real gate while still
        # offering pointers in forbidden slots makes sub_E105A8 reject requests
        # wholesale, so almost nothing runs and the campaign looks clean for the
        # wrong reason. The last row is the default now -- it is faithful AND
        # covers more code than the bypass ever did.
        # SYSDRV_ACL=0 restores the historical behaviour: patch the allowlist to
        # 1 and pass pointers wherever we like. That is what every result from
        # this harness before now was produced under, and it explores states the
        # host cannot reach. Default is now to RESPECT the allowlist.
        self.respect_acl = os.environ.get("SYSDRV_ACL", "1") != "0"
        RET1 = bytes([0x01, 0x20, 0x70, 0x47])   # MOVS R0,#1 ; BX LR
        if not self.respect_acl:
            c.write(0xE0AB44, RET1)
            c.write(0xE0DEF4, RET1)
        self._acl = {}
        # SYS_DRV genuinely needs init (unlike ASD): sub_E087E4 gates on
        # *(off_E08850 + 12) == 1 and returns immediately otherwise.
        #
        # This used to call sub_E0CA2C directly and never check the result. That
        # DID NOT open the gate -- measured, init_flag stayed 0 -- so every
        # iteration bounced off the gate after 4 basic blocks and the campaign
        # reported "5000/5000 ok, no faults" while reaching nothing. Only
        # opcode 0x1000 through the real entry sets the flag.
        #
        # Measured for one dispatch of opcode 0x1021:
        #     gate closed (flag 0):  4 blocks, high dispatcher NOT reached
        #     gate open   (flag 1): 42 blocks, high dispatcher reached
        sysdrv_env.run_init(c)
        self.init_flag = sysdrv_env.init_flag(c)
        if self.init_flag != 1:
            raise RuntimeError(
                "SYS_DRV init did not open the gate (init_flag=%r); every"
                " iteration would bounce off sub_E087E4 and the run would"
                " report a meaningless clean result" % (self.init_flag,))
        self.req = c.alloc(0x100)
        self.bufs = [c.alloc(0x1000 + 64) for _ in range(4)]
        self.reds = [b + 0x1000 for b in self.bufs]
        self.stats = Counter()
        self.findings = []

    def ptr_ok(self, op, idx):
        """May argument `idx` of `op` be a pointer? (sub_E0AB44, cached.)

        Measured density: only 2 of the 11 ASD-emitted opcodes permit ANY
        pointer slot, and 18 of 132 sampled ACL-range opcodes -- always index 0.
        So the genuinely pointer-taking RPC surface is small, and a fuzzer that
        ignores this is mostly exercising argument shapes the firmware refuses.
        """
        if not self.respect_acl:
            return True
        key = (op, idx)
        if key not in self._acl:
            try:
                self._acl[key] = self.c.run_function(GATE_ACL, (op, idx)) != 0
            except Exception:
                self._acl[key] = False
        return self._acl[key]

    def pick_opcode(self):
        r = self.rng.random()
        if self.only_asd or r < 0.35:
            return self.rng.choice(ASD_EMITTED)
        if r < 0.80:
            lo, hi = self.rng.choice(ACL_RANGES)
            return self.rng.randint(lo, hi)
        return self.rng.choice([0x1000, 0x1001, 0x1009, 0x1019, 0x103D, 0x108F,
                                0x2005, 0x3008, 0x3102, 0x3103, 0x3104, 0x4000])

    def run_one(self):
        c, rng = self.c, self.rng
        for b, r in zip(self.bufs, self.reds):
            c.write(b, b"\x00" * 0x1000)
            c.write(r, RED)
        c.write(self.req, b"\x00" * 0x100)
        op = self.pick_opcode()
        c.wr32(self.req, op)
        used_scalar = False
        for i in range(31):
            if i == 0:
                continue
            if 2 <= i <= 9:
                if i % 2 == 0:                                  # pointer slot
                    idx = (i - 2) // 2
                    if not self.ptr_ok(op, idx):
                        v = 0            # firmware refuses a pointer here
                    else:
                        roll = rng.random()
                        if roll < 0.15:
                            v = 0
                        elif roll < 0.30:
                            v = rng.getrandbits(16); used_scalar = True
                        else:
                            v = self.bufs[rng.randrange(4)]
                else:                                           # length slot
                    v = rng.choice([0, 1, 4, 0x10, 0x40, 0x100, 0x1000,
                                    0x2000, 0xFFFF, rng.getrandbits(16)])
            else:
                v = rng.choice([0, 1, 2, 4, rng.getrandbits(8), rng.getrandbits(32)])
            c.wr32(self.req + 4 * i, v)
        for b in self.bufs:
            c.write(b, bytes(rng.getrandbits(8) for _ in range(rng.choice([16, 64, 256, 0x400]))))
        try:
            c.run_function(ENTRY, (self.req, 2, 0, 0), count=2_000_000)
            fault = None
        except Exception as e:
            fault = e
        if fault is not None:
            f = c.faults[-1] if c.faults else {}
            addr = f.get("addr", 0)
            kind = f.get("kind", "FAULT")
            if addr < 0x100000 or used_scalar:
                kind = "scalar_noise"
            self.stats[kind] += 1
            if kind != "scalar_noise":
                self.findings.append((kind, hex(op), hex(f.get("pc", 0)), hex(addr)))
        else:
            self.stats["ok"] += 1
        for i, r in enumerate(self.reds):
            if c.read(r, 16) != RED:
                self.stats["REDZONE_argbuf"] += 1
                self.findings.append(("REDZONE_argbuf%d" % i, hex(op), "-", "-"))


def main(iters=5000, seed=1, only_asd=False):
    f = SysDrvFuzzer(seed, only_asd)
    for _ in range(iters):
        f.run_one()
    print("SYS_DRV fuzz: iters=%d seed=%d asd_opcodes_only=%s acl=%s"
          % (iters, seed, only_asd, "RESPECTED" if f.respect_acl else "BYPASSED (results not host-reachable)"))
    for k, v in f.stats.most_common():
        print("   %-22s %d" % (k, v))
    if f.findings:
        print("\ndistinct findings:")
        seen = set()
        for t in f.findings:
            key = (t[0], t[2], t[3])
            if key in seen:
                continue
            seen.add(key)
            print("   kind=%-18s op=%-10s pc=%-12s addr=%s" % t)
            if len(seen) >= 25:
                break
    else:
        print("\nno faults or redzone violations")


if __name__ == "__main__":
    main(int(sys.argv[1]) if len(sys.argv) > 1 else 5000,
         int(sys.argv[2]) if len(sys.argv) > 2 else 1,
         len(sys.argv) > 3 and sys.argv[3] == "asd")