Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# Changelog

## 4.3 Build 246 Hotfix (2026-04-23)

修复 4.2 Build 225 至 4.3 Build 246 期间引入的三处注入/移除注入崩溃。三处均为 Swift 运行时陷阱(`brk #1`),`try?` 无法捕获。

### 修复

- **zstd 流式解压崩溃(注入路径)**:`InjectorV3+Preprocess.swift` 中 `ZStd.decompress` 以 `Data()` 起始,再 `append(contentsOf: ArraySlice<UInt8>)`,首次 COW 时在 `_NSZeroData` 背书状态下触发 ARC `brk #1`。改为 `UnsafeMutableRawPointer` 裸缓冲配合 `Data.append(_:count:)`,绕开 Sequence/COW 路径;循环条件收紧为 `streamResult == 0` 退出,无进展时直接抛错,避免截断输入下潜在死循环。
- **MachOKit 非 Mach-O 文件崩溃(移除注入路径)**:commit `8a832b4` 引入的 Unity 回退扫描对 `Frameworks/*.framework/` 中所有 level-2 文件(`Info.plist`、`.car`、`.nib`、`.bin` 等)调用 `isMachO`,`MachOKit.loadFromFile` 在 `NSFileHandle.read<A>(offset:swapHandler:)` 内部 `brk #1`。`isMachO` 收紧为仅校验前 4 字节 Mach-O / fat magic(8 种变体),非 Mach-O 文件不再进入 MachOKit。
- **MachOKit DyldCache 路径崩溃(移除注入路径)**:commit `5ea814a` 引入的 `injectedAssetNames` 反查循环对每个带 `.troll-fools.bak` 备份的 Mach-O 调用 `loadedDylibsOfMachO`,MachOKit 的 load commands 迭代进入 `DyldCache.programsTrieEntries` → `Sequence.programOffsets` 触发 `brk #1`。移除注入本不需要该反查。新增独立实现 `collectModifiedMachOs`,仅扫描文件系统中有 `.troll-fools.bak` 兄弟文件的 Mach-O,完全避开 MachOKit load commands 路径。

完整根因与修复见 `hotfix-4.3-246` annotated tag。

------

## 4.3 Build 246 Hotfix (2026-04-23) [EN]

Fixed three injection/ejection crashes introduced between builds 225 and 246. All three are Swift runtime traps (`brk #1`) that `try?` cannot catch.

### Fixed

- **zstd streaming decompression crash (inject)**: `ZStd.decompress` in `InjectorV3+Preprocess.swift` started from an empty `Data()` backed by `_NSZeroData` and grew via `append(contentsOf: ArraySlice<UInt8>)`; the first COW transition triggered an ARC `brk #1`. Rewritten to use a raw `UnsafeMutableRawPointer` buffer with `Data.append(_:count:)` — bypasses the Sequence/COW path. Loop tightened: break on `streamResult == 0` and fail fast on stalled progress instead of potentially spinning on truncated input.
- **MachOKit crash on non-Mach-O files (eject)**: The Unity fallback scan added in `8a832b4` called `isMachO` on every level-2 file inside `Frameworks/*.framework/` (including `Info.plist`, `.car`, `.nib`, `.bin`). `MachOKit.loadFromFile` `brk #1`s inside `NSFileHandle.read<A>(offset:swapHandler:)` on such inputs. `isMachO` is now a 4-byte magic check only (8 Mach-O / fat magic variants) — non-Mach-O files never reach MachOKit.
- **MachOKit DyldCache trap (eject)**: The `injectedAssetNames` diff loop added in `5ea814a` called `loadedDylibsOfMachO` on every Mach-O with a `.troll-fools.bak` sibling; MachOKit's load-command iteration reaches `DyldCache.programsTrieEntries` → `Sequence.programOffsets` and traps. Eject does not need that filter. A dedicated `collectModifiedMachOs` now does a plain filesystem scan for `.bak` siblings, avoiding every MachOKit load-command path.

Full root-cause notes live in the annotated `hotfix-4.3-246` tag.

------

## 4.3 Build 246 (2026-04-16)

修复二次注入时可能误选已注入动态库作为目标 Mach-O 的问题。
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ PRs are always welcome.
- [x] `optool` is buggy so we need to compile a statically linked `install_name_tool` or `llvm-install-name-tool` on iOS to achieve a smaller package size.
- [x] Support for `.deb` or `.zip`.

## Hotfix Notes (2026-04-23)

A hotfix on top of 4.3 Build 246 resolves three injection/ejection crashes introduced between builds 225 and 246. All three surfaced as Swift runtime traps (`brk #1`) that `try?` could not catch:

- Inject path — zstd streaming decompression tripped ARC during the first COW transition from an empty `Data` (`_NSZeroData`-backed).
- Eject path — the fallback scan called `isMachO` on non-Mach-O files (`Info.plist`, `.car`, `.nib`), reaching a trap inside MachOKit's `NSFileHandle.read`.
- Eject path — the `injectedAssetNames` backup-diff loop reached MachOKit's `DyldCache.programsTrieEntries` parser and trapped there.

See [CHANGELOG.md](CHANGELOG.md) and the annotated `hotfix-4.3-246` tag for per-bug root cause and fix details.

## Credits

This project is inspired by [Patched-TS-App](https://github.com/34306/Patched-TS-App) by **[Huy Nguyen](https://x.com/Little_34306) and [Nathan](https://x.com/dedbeddedbed)**.
Expand Down
41 changes: 39 additions & 2 deletions TrollFools/InjectorV3+Eject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,45 @@ extension InjectorV3 {
}

fileprivate func collectModifiedMachOs() throws -> [URL] {
try frameworkMachOsInBundle(bundleURL)
.filter { hasAlternate($0) }.elements
// Eject only needs Mach-Os that have a `.troll-fools.bak` sibling (signifying
// prior modification). Routing through frameworkMachOsInBundle drags in
// loadedDylibsOfMachO → MachOKit load-command iteration, which can hit a
// Swift runtime trap (brk #1) inside MachOKit's DyldCache handling that
// `try?` cannot catch. Do a plain filesystem scan instead.
var modifiedMachOs: [URL] = []

if hasAlternate(executableURL) {
modifiedMachOs.append(executableURL)
}

let frameworksURL = bundleURL.appendingPathComponent("Frameworks")
guard let enumerator = FileManager.default.enumerator(
at: frameworksURL,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
) else {
return modifiedMachOs
}

for case let itemURL as URL in enumerator {
if checkIsInjectedBundle(itemURL) || enumerator.level > 2 {
enumerator.skipDescendants()
continue
}
if itemURL.path.hasSuffix(".\(Self.alternateSuffix)") {
continue
}
let atLevel2 = enumerator.level == 2
let atLevel1Dylib = enumerator.level == 1 && itemURL.pathExtension.lowercased() == "dylib"
guard atLevel2 || atLevel1Dylib else {
continue
}
if hasAlternate(itemURL) && isMachO(itemURL) {
modifiedMachOs.append(itemURL)
}
}

return modifiedMachOs
}

// MARK: - Load Commands
Expand Down
42 changes: 38 additions & 4 deletions TrollFools/InjectorV3+MachO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,46 @@ import MachOKit
import OrderedCollections

extension InjectorV3 {
func isMachO(_ target: URL) -> Bool {
if (try? MachOKit.loadFromFile(url: target)) != nil {
true
// Mach-O magic numbers (both native and byte-swapped).
// MachOKit's loadFromFile can hit a Swift runtime trap (brk #1) on files
// that are not Mach-O — `try?` does not catch those — so callers must
// screen with this cheap pre-check before invoking MachOKit.
private static let machOMagics: Set<UInt32> = [
0xFEEDFACE, 0xCEFAEDFE,
0xFEEDFACF, 0xCFFAEDFE,
0xCAFEBABE, 0xBEBAFECA,
0xCAFEBABF, 0xBFBAFECA,
]

fileprivate func hasMachOMagic(_ target: URL) -> Bool {
guard let size = (try? target.resourceValues(forKeys: [.fileSizeKey]).fileSize),
size >= 32
else {
return false
}
guard let handle = try? FileHandle(forReadingFrom: target) else {
return false
}
defer { try? handle.close() }
let head: Data?
if #available(iOS 13.4, *) {
head = try? handle.read(upToCount: 4)
} else {
false
head = handle.readData(ofLength: 4)
}
guard let data = head, data.count == 4 else {
return false
}
let magic = data.withUnsafeBytes { $0.load(as: UInt32.self) }
return Self.machOMagics.contains(magic)
}

func isMachO(_ target: URL) -> Bool {
// Magic-byte check only: MachOKit.loadFromFile eagerly touches DyldCache
// code in its parser, which can hit a Swift runtime trap (brk #1) inside
// MachOKit that `try?` does not catch. Magic bytes alone are sufficient
// to classify a file as Mach-O for injection/eject scanning.
hasMachOMagic(target)
}

func isProtectedMachO(_ target: URL) throws -> Bool {
Expand Down
43 changes: 26 additions & 17 deletions TrollFools/InjectorV3+Preprocess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,29 +247,38 @@ fileprivate enum ZStd {
}

let chunkSize = max(Int(ZSTD_DStreamOutSize()), 1)
var chunk = [UInt8](repeating: 0, count: chunkSize)
let chunk = UnsafeMutableRawPointer.allocate(byteCount: chunkSize, alignment: 1)
defer {
chunk.deallocate()
}

var output = Data()
try data.withUnsafeBytes { (sourceBuffer: UnsafeRawBufferPointer) throws -> Void in
guard let sourcePtr = sourceBuffer.baseAddress else {
throw SWCompression.DataError.corrupted
}
var input = ZSTD_inBuffer(src: sourcePtr, size: sourceBuffer.count, pos: 0)

try data.withUnsafeBytes { sourceBuffer in
var input = ZSTD_inBuffer(src: sourceBuffer.baseAddress, size: sourceBuffer.count, pos: 0)
var streamResult: size_t = 1
while true {
var outBuffer = ZSTD_outBuffer(dst: chunk, size: chunkSize, pos: 0)
let streamResult = ZSTD_decompressStream(stream, &outBuffer, &input)
guard ZSTD_isError(streamResult) == 0 else {
throw SWCompression.DataError.corrupted
}

while input.pos < input.size || streamResult != 0 {
let produced = try chunk.withUnsafeMutableBytes { destinationBuffer in
var outBuffer = ZSTD_outBuffer(
dst: destinationBuffer.baseAddress,
size: destinationBuffer.count,
pos: 0
if outBuffer.pos > 0 {
output.append(
chunk.assumingMemoryBound(to: UInt8.self),
count: Int(outBuffer.pos)
)
streamResult = ZSTD_decompressStream(stream, &outBuffer, &input)
guard ZSTD_isError(streamResult) == 0 else {
throw SWCompression.DataError.corrupted
}
return outBuffer.pos
}

if produced > 0 {
output.append(contentsOf: chunk[..<Int(produced)])
if streamResult == 0 {
break
}

if outBuffer.pos == 0 && input.pos == input.size {
throw SWCompression.DataError.corrupted
}
}
}
Expand Down
Loading