Files
ipsw/pkg/kernel/iokit/scan.go
blacktop 14457b7d96 feat: address sandbox research feedback
Add dyld softlink/import/disassembly helpers, sandbox reach and diff support, richer WebKit IPC metadata, and expanded IOKit method recovery.

Fix MOVK softlink address reconstruction and avoid cross-case argument-state poisoning while scanning switch-dispatched user-client methods.
2026-05-24 16:28:00 -06:00

597 lines
15 KiB
Go

package iokit
import (
"fmt"
"io"
"maps"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/blacktop/go-macho"
mtypes "github.com/blacktop/go-macho/types"
"github.com/blacktop/ipsw/pkg/kernelcache"
"github.com/blacktop/ipsw/pkg/kernelcache/cpp"
"github.com/blacktop/ipsw/pkg/signature"
"github.com/blacktop/ipsw/pkg/symbols"
)
const (
defaultMaxFunctionInstructions = 320
defaultSwitchMaxFunctionInstructions = 2048
defaultMaxVtableSlots = 240
)
type analyzer struct {
root *macho.File
scanner *cpp.Scanner
classes []cpp.Class
infos []*classInfo
byName map[string][]*classInfo
symbolMap map[uint64]string
symCache map[uint64]string
methods map[methodCacheKey]methodAnalysis
ucRefs map[uint64][]string
stderr io.Writer
maxInst int
maxSwitchInst int
maxSlots int
userClient map[string]struct{}
}
type classInfo struct {
cpp.Class
index int
family string
isService bool
userClient bool
}
func Scan(root *macho.File, conf Config) ([]Record, error) {
if root == nil {
return nil, fmt.Errorf("nil kernelcache")
}
maxInst, maxSwitchInst := normalizeMaxInstructionCaps(conf.MaxFunctionInstructions)
if conf.MaxVtableSlots == 0 {
conf.MaxVtableSlots = defaultMaxVtableSlots
}
classFilter, err := compileClassFilter(conf.ClassFilter)
if err != nil {
return nil, err
}
scanner := cpp.NewScanner(root, cpp.Config{})
classes, err := scanner.Scan()
if err != nil {
return nil, fmt.Errorf("cpp scan: %w", err)
}
a := newAnalyzer(root, scanner, classes, conf, maxInst, maxSwitchInst)
userClients := a.userClientClasses()
if len(userClients) == 0 {
return nil, ErrNoIOUserClients
}
progress(conf.Stderr, "kernel iokit-methods: discovered %d IOUserClient subclasses\n", len(userClients))
records, err := a.methodRecords(userClients)
if err != nil {
return nil, err
}
records = append(records, a.serviceClientRecords()...)
records = dedupeRecords(records)
records = filterRecordsByClass(records, classFilter)
SortRecords(records)
return records, nil
}
func normalizeMaxInstructionCaps(requestedMaxInst int) (int, int) {
if requestedMaxInst <= 0 {
return defaultMaxFunctionInstructions, defaultSwitchMaxFunctionInstructions
}
return requestedMaxInst, requestedMaxInst
}
func newAnalyzer(root *macho.File, scanner *cpp.Scanner, classes []cpp.Class, conf Config, maxInst, maxSwitchInst int) *analyzer {
a := &analyzer{
root: root,
scanner: scanner,
classes: classes,
byName: make(map[string][]*classInfo),
symbolMap: loadKernelSymbolMap(conf.Kernelcache, conf.Stderr),
symCache: make(map[uint64]string),
methods: make(map[methodCacheKey]methodAnalysis),
ucRefs: make(map[uint64][]string),
stderr: conf.Stderr,
maxInst: maxInst,
maxSwitchInst: maxSwitchInst,
maxSlots: conf.MaxVtableSlots,
userClient: make(map[string]struct{}),
}
a.buildClassInfos()
return a
}
func (a *analyzer) buildClassInfos() {
a.infos = make([]*classInfo, 0, len(a.classes))
for idx := range a.classes {
info := &classInfo{
Class: a.classes[idx],
index: idx,
}
if reachesClass(a.classes, idx, "IOUserClient2022") {
info.family = "IOUserClient2022"
} else if reachesClass(a.classes, idx, "IOUserClient") {
info.family = "IOUserClient"
}
info.userClient = info.family != "" &&
info.Name != "IOUserClient" &&
info.Name != "IOUserClient2022"
info.isService = reachesClass(a.classes, idx, "IOService") && info.Name != "IOService"
a.infos = append(a.infos, info)
a.byName[info.Name] = append(a.byName[info.Name], info)
if info.userClient {
a.userClient[info.Name] = struct{}{}
}
}
for _, infos := range a.byName {
sort.SliceStable(infos, func(i, j int) bool {
if infos[i].Bundle != infos[j].Bundle {
return infos[i].Bundle < infos[j].Bundle
}
return infos[i].MetaPtr < infos[j].MetaPtr
})
}
}
func reachesClass(classes []cpp.Class, idx int, target string) bool {
seen := make(map[int]struct{})
for idx >= 0 && idx < len(classes) {
if _, ok := seen[idx]; ok {
return false
}
seen[idx] = struct{}{}
if classes[idx].Name == target {
return true
}
idx = classes[idx].SuperIndex
}
return false
}
func (a *analyzer) userClientClasses() []*classInfo {
out := make([]*classInfo, 0, 128)
for _, info := range a.infos {
if info.userClient {
out = append(out, info)
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Bundle != out[j].Bundle {
return out[i].Bundle < out[j].Bundle
}
return out[i].Name < out[j].Name
})
return out
}
func (a *analyzer) methodRecords(userClients []*classInfo) ([]Record, error) {
slots, err := a.externalMethodSlots(userClients)
if err != nil {
return nil, err
}
legacySlots := a.legacyMethodSlots(userClients)
records := make([]Record, 0, len(userClients))
for _, info := range userClients {
slot, ok := slots[info.family]
if !ok {
records = append(records, a.unknownMethodRecord(info, 0, "vtable_unresolved"))
continue
}
entry, ok := a.scanner.VtableEntry(info.Class, slot)
if !ok || entry.Address == 0 {
records = append(records, a.unknownMethodRecord(info, 0, "vtable_unresolved"))
continue
}
analysis := a.analyzeExternalMethod(entry.Address, dispatchKindHintForFamily(info.family))
switch analysis.kind {
case DispatchExternalMethod, DispatchExternalMethod2022:
recs, err := a.dispatchRecords(info, analysis)
if err != nil {
return nil, err
}
records = append(records, recs...)
case DispatchSwitch:
records = append(records, a.switchRecords(info, analysis)...)
default:
if recs, ok, err := a.legacyMethodRecordsForInfo(info, legacySlots); err != nil {
return nil, err
} else if ok {
records = append(records, recs...)
continue
}
note := analysis.note
if note == "" {
note = "indirect"
}
records = append(records, a.unknownMethodRecord(info, entry.Address, note))
}
}
return records, nil
}
func (a *analyzer) externalMethodSlots(userClients []*classInfo) (map[string]int, error) {
symbolSlots := make(map[string]map[int]int)
for _, info := range userClients {
for _, entry := range a.scanner.VtableEntries(info.Class, a.maxSlots) {
if isNamedMethod(a.symbolName(entry.Address), "externalMethod") {
addSlot(symbolSlots, info.family, entry.Index)
}
}
}
out := make(map[string]int)
for family, slots := range symbolSlots {
slot, err := chooseSlot(family, slots, "symbol")
if err != nil {
return nil, err
}
out[family] = slot
}
patternSlots := make(map[string]map[int]int)
for _, info := range userClients {
if _, ok := out[info.family]; ok {
continue
}
for _, entry := range a.scanner.VtableEntries(info.Class, a.maxSlots) {
analysis := a.analyzeExternalMethod(entry.Address, dispatchKindHintForFamily(info.family))
if analysis.kind == DispatchExternalMethod ||
analysis.kind == DispatchExternalMethod2022 ||
analysis.kind == DispatchSwitch {
addSlot(patternSlots, info.family, entry.Index)
}
}
}
for family, slots := range patternSlots {
if _, ok := out[family]; ok {
continue
}
slot, err := chooseSlot(family, slots, "pattern")
if err != nil {
return nil, err
}
out[family] = slot
}
return out, nil
}
func (a *analyzer) legacyMethodSlots(userClients []*classInfo) map[string]int {
symbolSlots := make(map[string]map[int]int)
for _, info := range userClients {
for _, entry := range a.scanner.VtableEntries(info.Class, a.maxSlots) {
if isNamedMethod(a.symbolName(entry.Address), "getTargetAndMethodForIndex") {
addSlot(symbolSlots, info.family, entry.Index)
}
}
}
out := make(map[string]int)
for family, slots := range symbolSlots {
if slot, err := chooseSlot(family, slots, "legacy"); err == nil {
out[family] = slot
}
}
patternSlots := make(map[string]map[int]int)
for _, info := range userClients {
if _, ok := out[info.family]; ok {
continue
}
for _, entry := range a.scanner.VtableEntries(info.Class, a.maxSlots) {
analysis := a.analyzeLegacyExternalMethod(entry.Address)
if analysis.kind == DispatchExternalMethodLegacy {
addSlot(patternSlots, info.family, entry.Index)
}
}
}
for family, slots := range patternSlots {
if _, ok := out[family]; ok {
continue
}
if slot, err := chooseSlot(family, slots, "legacy pattern"); err == nil {
out[family] = slot
}
}
return out
}
func (a *analyzer) legacyMethodRecordsForInfo(info *classInfo, legacySlots map[string]int) ([]Record, bool, error) {
slot, ok := legacySlots[info.family]
if !ok {
return nil, false, nil
}
entry, ok := a.scanner.VtableEntry(info.Class, slot)
if !ok || entry.Address == 0 {
return nil, false, nil
}
analysis := a.analyzeLegacyExternalMethod(entry.Address)
if analysis.kind != DispatchExternalMethodLegacy {
return nil, false, nil
}
records, err := a.legacyDispatchRecords(info, analysis)
if err != nil {
return nil, false, err
}
return records, true, nil
}
func addSlot(slots map[string]map[int]int, family string, slot int) {
if family == "" || slot < 0 {
return
}
if slots[family] == nil {
slots[family] = make(map[int]int)
}
slots[family][slot]++
}
func chooseSlot(family string, slots map[int]int, source string) (int, error) {
if len(slots) == 0 {
return -1, fmt.Errorf("no %s externalMethod slot candidates for %s", source, family)
}
type candidate struct {
slot int
count int
}
candidates := make([]candidate, 0, len(slots))
for slot, count := range slots {
candidates = append(candidates, candidate{slot: slot, count: count})
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].count != candidates[j].count {
return candidates[i].count > candidates[j].count
}
return candidates[i].slot < candidates[j].slot
})
if len(candidates) > 1 && candidates[0].count == candidates[1].count {
return -1, fmt.Errorf("externalMethod vtable slot differs across %s hierarchy (%s candidates: %v)", family, source, slots)
}
return candidates[0].slot, nil
}
func isNamedMethod(symbolName string, method string) bool {
if symbolName == "" {
return false
}
return strings.Contains(symbolName, "::"+method+"(") ||
strings.Contains(symbolName, "::"+method+" ")
}
func (a *analyzer) unknownMethodRecord(info *classInfo, methodAddr uint64, note string) Record {
extra := map[string]string{"slice_notes": note}
return Record{
Kind: KindMethod,
Class: info.Name,
Bundle: info.Bundle,
Selector: -1,
MethodAddr: hexAddr(methodAddr),
MethodSymbol: a.symbolName(methodAddr),
DispatchKind: DispatchUnknown,
ScalarInputCount: -1,
ScalarOutputCount: -1,
StructInputSize: -1,
StructOutputSize: -1,
Flags: -1,
Resolved: false,
Extra: extra,
}
}
func (a *analyzer) symbolName(addr uint64) string {
if a == nil || addr == 0 {
return ""
}
if a.symCache != nil {
if cached, ok := a.symCache[addr]; ok {
return cached
}
}
if name := a.symbolMap[addr]; name != "" {
return a.cacheSymbolName(addr, symbols.DemangleSymbolName(name))
}
if a.scanner == nil {
return ""
}
if name := a.scanner.SymbolName(addr); name != "" {
return a.cacheSymbolName(addr, name)
}
if body, err := a.scanner.FunctionBodyAt(addr); err == nil && body.Function.StartAddr != addr {
if name := a.symbolMap[body.Function.StartAddr]; name != "" {
return a.cacheSymbolName(addr, symbols.DemangleSymbolName(name))
}
if name := a.scanner.SymbolName(body.Function.StartAddr); name != "" {
return a.cacheSymbolName(addr, name)
}
}
return a.cacheSymbolName(addr, "")
}
func (a *analyzer) cacheSymbolName(addr uint64, name string) string {
if a != nil && a.symCache != nil {
a.symCache[addr] = name
}
return name
}
func loadKernelSymbolMap(path string, stderr io.Writer) map[uint64]string {
out := make(map[uint64]string)
if path == "" {
return out
}
smap := signature.NewSymbolMap()
for _, candidate := range kernelSymbolMapCandidates(path) {
if err := smap.LoadJSON(candidate); err == nil {
maps.Copy(out, smap)
progress(stderr, "kernel iokit-methods: loaded symbol map %s\n", candidate)
return out
}
}
return out
}
func kernelSymbolMapCandidates(path string) []string {
base := filepath.Base(path) + ".symbols.json"
seen := make(map[string]struct{})
var out []string
add := func(candidate string) {
candidate = filepath.Clean(candidate)
if _, ok := seen[candidate]; ok {
return
}
seen[candidate] = struct{}{}
out = append(out, candidate)
}
dir := filepath.Dir(path)
for range 4 {
add(filepath.Join(dir, base))
matches, _ := filepath.Glob(filepath.Join(dir, "*", base))
sort.Strings(matches)
for _, match := range matches {
add(match)
}
next := filepath.Dir(dir)
if next == dir {
break
}
dir = next
}
return out
}
func (a *analyzer) serviceClientRecords() []Record {
records := a.personalityRecords()
records = append(records, a.newUserClientRecords()...)
return records
}
func classBundle(infos []*classInfo) string {
if len(infos) == 0 {
return ""
}
return infos[0].Bundle
}
func classExists(infos []*classInfo) bool {
return len(infos) > 0
}
func hexAddr(addr uint64) string {
if addr == 0 {
return "0x0"
}
return fmt.Sprintf("%#x", addr)
}
func dedupeRecords(records []Record) []Record {
if len(records) < 2 {
return records
}
SortRecords(records)
out := records[:0]
var last string
for _, rec := range records {
key := recordKey(rec)
if key == last {
continue
}
out = append(out, rec)
last = key
}
return out
}
func compileClassFilter(pattern string) (*regexp.Regexp, error) {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
return nil, nil
}
re, err := regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("invalid class filter %q: %w", pattern, err)
}
return re, nil
}
func filterRecordsByClass(records []Record, classFilter *regexp.Regexp) []Record {
if classFilter == nil || len(records) == 0 {
return records
}
out := records[:0]
for _, rec := range records {
if recordMatchesClassFilter(rec, classFilter) {
out = append(out, rec)
}
}
return out
}
func recordMatchesClassFilter(rec Record, classFilter *regexp.Regexp) bool {
if classFilter == nil {
return true
}
return classFilter.MatchString(rec.Class) ||
classFilter.MatchString(rec.ServiceClass) ||
classFilter.MatchString(rec.UserClientClass)
}
func recordKey(r Record) string {
return strings.Join([]string{
r.Kind,
r.Bundle,
r.Class,
fmt.Sprint(r.Selector),
r.MethodAddr,
r.DispatchKind,
r.ServiceBundle,
r.ServiceClass,
r.UserClientBundle,
r.UserClientClass,
r.Source,
extraSortString(r.Extra),
}, "\x00")
}
func progress(w io.Writer, format string, args ...any) {
if w != nil {
fmt.Fprintf(w, format, args...)
}
}
func rootForPrelink(root *macho.File) *macho.File {
if root == nil || root.FileHeader.Type != mtypes.MH_FILESET {
return root
}
if kernelFile, err := root.GetFileSetFileByName("com.apple.kernel"); err == nil {
return kernelFile
}
if kernelFile, err := root.GetFileSetFileByName("kernel"); err == nil {
return kernelFile
}
return root
}
func kextBundles(root *macho.File) []kernelcache.CFBundle {
if root == nil {
return nil
}
if bundles, err := kernelcache.GetKexts(root); err == nil {
return bundles
}
if kernelRoot := rootForPrelink(root); kernelRoot != root {
if bundles, err := kernelcache.GetKexts(kernelRoot); err == nil {
return bundles
}
}
return nil
}