作者:luoyanbei@360src
随着移动应用安全机制的不断加强,以 Frida 为代表的动态插桩工具,已成为移动端逆向分析的主流手段。相比反汇编、class-dump 等静态分析方式,Frida 几乎可以在不修改 App 本体的情况下,实时注入代码、Hook 任意函数、篡改参数与返回值,甚至直接操控业务逻辑执行流程。
在越狱的iOS设备上,Frida 可以通过 frida-server 以最高权限运行;在非越狱设备上,也可以通过 Frida Gadget、调试注入、重签名等方式完成动态分析。
在实际攻击场景中,Frida 已被广泛用于: • 绕过登录、风控、反作弊等安全校验 • Hook 网络层以抓取或篡改敏感接口数据 • 直接调用内部私有方法,伪造正常业务流程 • 分析加密算法、关键参数生成逻辑 • 对安全检测逻辑本身进行反制与绕过
在 iOS App 中系统性地识别 Frida 的运行痕迹、注入特征和行为特征,并构建多层次的防御与对抗策略,已经成为移动端安全中不可回避的一环。本文将结合实际逆向与对抗经验,从安全开发的角度讲解frida检测相关特征。
1、检测frida默认端口号
如果设备上运行了 frida-server,并且使用默认端口号27042,App 尝试连接 127.0.0.1:27042,连接成功 说明当前环境下存在Frida使用27042端口。 具体检测端口代码:
ini
#import <sys/socket.h>
#import <arpa/inet.h>
#import <fcntl.h>
#import <unistd.h>
#import <errno.h>
BOOL isFridaPortReallyOpen(void) {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) return NO;
// 非阻塞
fcntl(sockfd, F_SETFL, O_NONBLOCK);
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(27042);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
int ret = connect(sockfd, (struct sockaddr *)&addr, sizeof(addr));
if (ret < 0 && errno != EINPROGRESS) {
close(sockfd);
return NO;
}
fd_set wfds;
FD_ZERO(&wfds);
FD_SET(sockfd, &wfds);
struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 300 * 1000; // 300ms
ret = select(sockfd + 1, NULL, &wfds, NULL, &tv);
if (ret <= 0) {
close(sockfd);
return NO;
}
// 关键:检查 SO_ERROR
int so_error = 0;
socklen_t len = sizeof(so_error);
getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &so_error, &len);
close(sockfd);
return so_error == 0;
}
调用方式
scss
if (isFridaPortReallyOpen()) {
NSLog(@"Frida 默认端口可访问");
// 触发风控 / 上报
}
基于 Frida 默认端口号(27042)的检测并非绝对可靠。一方面,理论上该端口可能被其他进程占用,从而在极少数情况下产生误报;另一方面,Frida 本身支持通过参数或二次编译的方式修改默认监听端口,一旦攻击者对 Frida Server 进行了"魔改"或端口重定向,该检测方式就可能被直接绕过。
因此,端口号检测更适合作为低成本、快速命中的辅助判断条件,而不应作为唯一的判定依据。将其与虚拟内存特征检测等更底层、更难完全抹除的手段进行组合使用,才能在实际对抗中获得更稳定、可信的检测效果。
2、检测app虚拟内存特征
当 Frida 附加到 iOS App 时,Frida 的代码、数据、JS runtime、字符串全部被加载到"目标 App 进程的虚拟内存空间"中。 Frida 的 JavaScript Runtime 会把"脚本字符串"放入内存,例如: require("frida-objc-bridge") frida/runtime/core.js Frida._loadObjC();
这些字符串来源于: • 内置 JS 脚本(core.js) • bridge 初始化代码 • RPC 协议字符串(frida:rpc)
JS 引擎必须把字符串展开为明文才能执行,所以这些字符串一定存在于堆或只读区。
(1) Frida attach 的真实技术路径
以最典型的 frida-server attach为例:
- frida-server(系统进程):
- 通过task_for_pid() 拿到目标 App 的 task port
- 在目标 App 中:
- 创建远程线程(thread_create_running)
- 调用dlopen() / Mach-O loader
- Frida Agent 被加载:
- 成为目标 App 的一个 dylib
- 由 dyld 映射进App 的 VM
(2)app被frida附加后的特征关键词
| 序号 | 关键词 | 用途 |
|---|---|---|
| 1 | frida_gadget | 这是 Frida 在 native 层的"模块身份标识" |
| 2 | /frida-core/lib/gadget/gadget.vala | 错误定位,日志,断言信息 |
| 3 | frida/runtime/core.js | 定义:Module / Memory / Interceptor、RPC、调度器 |
| 4 | frida_agent_main | Agent 的 native 入口,相当于 main() / init |
| 5 | frida_dylib_range= | 这是 Frida 内部"自我感知内存边界"的关键字符串 |
| 6 | /frida-core/lib/agent/agent.vala | 错误定位,日志,断言信息 |
| 7 | frida:rpc | Agent <->Client 的通信协议前缀,JS / Native 双向调用标识 |
| 8 | Frida._loadObjC(); | 注册 ObjC / Java runtime,安装 hook handler |
| 9 | require("frida-objc-bridge"); | Java / ObjC 方法解析,class 枚举,selector hook |
| 10 | /frida-core/lib/payload/spawn-monitor.vala | 错误定位,日志,断言信息 |
| 11 | FridaAgentRunner | 负责启动 JS runtime,管理 lifecycle |
| 12 | FridaSpawnHandler | 处理 spawn / attach 事件,与 server 协同 |
| 13 | FridaPortalClient | Agent RPC / IPC 客户端,与 frida-server 通信 |
| 14 | FridaAgentController | Agent 总控,管理模块、脚本、session |
| 15 | frida.Error. | JS Error 类型,Native → JS 异常封装 |
(3)内存检测frida特征
检测函数 checkAppMemoryForFrida 的工作流程:
- 初始化
- 获取当前进程 task,自地址 0 开始遍历虚拟内存区域。
- 调用
init_self_image_range记录自身镜像范围,后续跳过本进程主二进制区域。
- 遍历内存区域
- 使用
vm_region_64逐段获取区域起始地址、大小和保护属性。 - 如果区域属于自身镜像则跳过;非
VM_PROT_READ区域也跳过。
- 读取并扫描
- 为区域大小分配缓冲区,
vm_read_overwrite读取该区域内容。 - 遍历明文特征数组
kFridaPlainList(包含 Frida 相关字符串,如frida_gadget、frida/runtime/core.js、FridaAgentRunner等),对每个特征调用memory_contains在该缓冲区内查找。
- 命中处理与日志
- 如命中,
hitCount++,并使用dladdr获取当前区域地址对应的模块路径,提取文件名,记录日志:- 内存检测到的特征
- 地址(十六进制)
- 模块名
- hitCount
- 后续与回调
- 释放缓冲区,继续下一个区域;若
vm_region_64失败则结束循环。 - 扫描结束:若
hitCount > 0且传入了onDetected,则执行回调;最后返回hitCount。
#include
#include <string.h>
#include <stdio.h>
#include <mach/mach.h>
#include <mach-o/dyld.h>
#include <dlfcn.h>
#include <mach-o/dyld.h>
#include <mach-o/loader.h>
#include <sys/sysctl.h>
#include <unistd.h>
#include <stdbool.h>
static void *self_image_start = NULL;
static void *self_image_end = NULL;
// 根据内存地址查找所属的模块名称(使用 dladdr)
static const char *find_module_for_address(vm_address_t addr) {
Dl_info info;
if (dladdr((void *)addr, &info)) {
if (info.dli_fname) {
return info.dli_fname;
}
}
return "unknown";
}
// 日志文件相关
static NSString *ProtecToolLogFilePath(void) {
static NSString *logPath = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSArray<NSString *> *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSString *docDir = paths.firstObject ?: NSTemporaryDirectory();
logPath = [docDir stringByAppendingPathComponent:@"protec_log.txt"];
});
return logPath;
}
static void ProtecToolWriteLog(NSString *message) {
if (message.length == 0) return;
NSString *line = [message stringByAppendingString:@"\n"];
NSData *data = [line dataUsingEncoding:NSUTF8StringEncoding];
if (!data) return;
NSString *path = ProtecToolLogFilePath();
NSFileManager *fm = [NSFileManager defaultManager];
if (![fm fileExistsAtPath:path]) {
[data writeToFile:path atomically:YES];
} else {
NSFileHandle *fh = [NSFileHandle fileHandleForWritingAtPath:path];
if (!fh) return;
@try {
[fh seekToEndOfFile];
[fh writeData:data];
} @catch (__unused NSException *e) {
} @finally {
[fh closeFile];
}
}
}
void ProtecToolClearLogFile(void) {
NSString *path = ProtecToolLogFilePath();
if (!path) return;
NSFileManager *fm = [NSFileManager defaultManager];
if ([fm fileExistsAtPath:path]) {
[fm removeItemAtPath:path error:nil];
}
}
NSString *ProtecToolReadLog(void) {
NSString *path = ProtecToolLogFilePath();
if (!path) return @"";
NSError *error = nil;
NSString *content = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:&error];
if (!content || error) {
return @"";
}
return content;
}
#define PTLog(fmt, ...) do { \
NSString *msg__ = [NSString stringWithFormat:(fmt), ##__VA_ARGS__]; \
NSLog(@"%@", msg__); \
ProtecToolWriteLog(msg__); \
} while(0)
static void init_self_image_range(void) {
if (self_image_start) return;
Dl_info info;
if (!dladdr((void *)&init_self_image_range, &info))
return;
self_image_start = info.dli_fbase;
for (uint32_t i = 0; i < _dyld_image_count(); i++) {
if (_dyld_get_image_header(i) == info.dli_fbase) {
const struct mach_header_64 *mh =
(const struct mach_header_64 *)_dyld_get_image_header(i);
intptr_t slide = _dyld_get_image_vmaddr_slide(i);
uintptr_t maxEnd = 0;
const struct load_command *cmd =
(const struct load_command *)((uintptr_t)mh + sizeof(*mh));
for (uint32_t j = 0; j < mh->ncmds; j++) {
if (cmd->cmd == LC_SEGMENT_64) {
const struct segment_command_64 *seg =
(const struct segment_command_64 *)cmd;
uintptr_t end = seg->vmaddr + seg->vmsize;
if (end > maxEnd)
maxEnd = end;
}
cmd = (const struct load_command *)((uintptr_t)cmd + cmd->cmdsize);
}
self_image_end = (void *)(maxEnd + slide);
break;
}
}
}
static const char *kFridaPlainList[] = {
"frida_gadget",
"/frida-core/lib/gadget/gadget.vala",
"frida/runtime/core.js",
"frida_agent_main",
"frida_dylib_range=",
"/frida-core/lib/agent/agent.vala",
"frida:rpc",
"Frida._loadObjC();",
"require(\"frida-objc-bridge\");",
"/frida-core/lib/payload/spawn-monitor.vala",
"FridaAgentRunner",
"FridaSpawnHandler",
"FridaPortalClient",
"FridaAgentController",
"frida.Error."
};
// 内存扫描工具函数
static int memory_contains(const void *haystack, size_t haystack_len,
const void *needle, size_t needle_len) {
if (needle_len == 0 || haystack_len < needle_len) return 0;
const unsigned char *h = haystack;
const unsigned char *n = needle;
for (size_t i = 0; i <= haystack_len - needle_len; i++) {
if (memcmp(h + i, n, needle_len) == 0)
return 1;
}
return 0;
}
/*
获取当前进程 task,初始化从地址 0 开始。
用 vm_region_64 逐段枚举虚拟内存区域;失败即结束循环。
只处理具有 VM_PROT_READ 权限的区域。
为区域大小分配缓冲区,用 vm_read_overwrite 把该区域数据读入。
逐个匹配 kFridaPlainList 中的明文关键字;命中则累加 hitCount。
完成后继续下一个区域;循环结束时,如果命中且传入了 onDetected 回调,就执行它。
返回总命中次数。
*/
int checkAppMemoryForFrida(void (*onDetected)(void)) {
task_t task = mach_task_self();
vm_address_t addr = 0;
vm_size_t size = 0;
int hitCount = 0;
init_self_image_range();
PTLog(@"内存检测--SELF IMAGE RANGE: %p - %p", self_image_start, self_image_end);
while (1) {
vm_region_basic_info_data_64_t info;
mach_msg_type_number_t count = VM_REGION_BASIC_INFO_COUNT_64;
mach_port_t object;
kern_return_t kr = vm_region_64(
task,
&addr,
&size,
VM_REGION_BASIC_INFO_64,
(vm_region_info_t)&info,
&count,
&object
);
if (kr != KERN_SUCCESS)
break;
if ((void *)addr >= self_image_start &&
(void *)addr < self_image_end) {
addr += size;
PTLog(@"内存检测--跳过app自身");
continue;
}
// 只扫描可读内存
if (info.protection & VM_PROT_READ) {
void *buffer = malloc(size);
if (buffer) {
vm_size_t outSize = 0;
if (vm_read_overwrite(task, addr, size,
(vm_address_t)buffer,
&outSize) == KERN_SUCCESS) {
for (size_t i = 0;
i < sizeof(kFridaPlainList)/sizeof(kFridaPlainList[0]);
i++) {
const char *needle = kFridaPlainList[i];
size_t needleLen = strlen(needle);
if (needleLen == 0) continue;
if (memory_contains(buffer, outSize,
needle, needleLen)) {
hitCount++;
// 查找该内存地址所属的模块
const char *moduleName = find_module_for_address(addr);
// 提取模块名称(只显示文件名,不显示完整路径)
const char *moduleFileName = strrchr(moduleName, '/');
if (moduleFileName) {
moduleFileName++; // 跳过 '/'
} else {
moduleFileName = moduleName;
}
PTLog(@"内存检测--发现(%s), 地址:0x%llx, 模块:%s, hitCount=%d",
needle, (unsigned long long)addr, moduleFileName, hitCount);
}
}
}
free(buffer);
}
}
addr += size;
}
if (hitCount > 0 && onDetected) {
PTLog(@"内存检测--执行--onDetected");
onDetected();
}
PTLog(@"内存检测--返回--%d", hitCount);
return hitCount;
}
当app被frida附加,检测frida特征输出信息:
ini
内存检测--发现(FridaAgentRunner), 地址:0x100c1c000, 模块:unknown, hitCount=1
内存检测--发现(FridaSpawnHandler), 地址:0x100c1c000, 模块:unknown, hitCount=2
内存检测--发现(FridaPortalClient), 地址:0x100c1c000, 模块:unknown, hitCount=3
内存检测--发现(FridaAgentController), 地址:0x100c1c000, 模块:unknown, hitCount=4
内存检测--发现(frida.Error.), 地址:0x100c1c000, 模块:unknown, hitCount=5
内存检测--发现(frida.Error.), 地址:0x100c3c000, 模块:unknown, hitCount=6
内存检测--发现(frida_agent_main), 地址:0x10156c000, 模块:unknown, hitCount=7
内存检测--发现(frida_dylib_range=), 地址:0x10157c000, 模块:unknown, hitCount=8
内存检测--发现(require("frida-java-bridge")), 地址:0x101580000, 模块:unknown, hitCount=9
内存检测--发现(frida/runtime/core.js), 地址:0x101580000, 模块:unknown, hitCount=10
内存检测--发现(frida_dylib_range=), 地址:0x101580000, 模块:unknown, hitCount=11
内存检测--发现(/frida-core/lib/agent/agent.vala), 地址:0x101580000, 模块:unknown, hitCount=12
内存检测--发现(require("frida-objc-bridge");), 地址:0x101580000, 模块:unknown, hitCount=13内存检测--发现(FridaAgentRunner), 地址:0x101580000, 模块:unknown, hitCount=14
内存检测--发现(FridaSpawnHandler), 地址:0x101580000, 模块:unknown, hitCount=15
内存检测--发现(FridaPortalClient), 地址:0x101580000, 模块:unknown, hitCount=16
内存检测--发现(FridaAgentController), 地址:0x101580000, 模块:unknown, hitCount=17
内存检测--发现(frida.Error.), 地址:0x101580000, 模块:unknown, hitCount=18
内存检测--发现(frida_agent_main), 地址:0x102804000, 模块:unknown, hitCount=19
3、安全退出App并防止追踪
当检测到frida特征后,执行退出逻辑,并防止检测代码位置暴露,更好的保护检测逻辑,防止绕过。我们的退出代码需要切断「检测 → 退出」之间的可还原因果关系,让逆向者无法通过崩溃、堆栈、日志反推出检测代码。下面的代码可以很好的实现我们的需求。
scss
void trigger_crash_async(void) {
void *p = malloc(16);
free(p);
dispatch_async(dispatch_get_main_queue(), ^{
CFRunLoopPerformBlock(CFRunLoopGetMain(),
kCFRunLoopCommonModes, ^{
volatile char *q = (char *)p;
q[0] = 0x42;
});
});
}
通过 Use-after-free + 异步调度 + RunLoop 执行,在时间、线程、调用栈和语义四个层面切断了"决策 → 崩溃"的因果关系,使崩溃在日志和运行期分析中高度拟态为真实工程 Bug,从而显著提高反追踪与反定位成本。 调用堆栈无法直接找到检测代码和崩溃位置:
objectivec
Termination Signal: Bus error: 10
Termination Reason: Namespace SIGNAL, Code 0xa
Terminating Process: exc handler [9303]
Triggered by Thread: 0
Thread 0 name: Dispatch queue: com.apple.main-thread
Thread 0 Crashed:
0 libobjc.A.dylib 0x00000001a82c4c98 objc_retain + 8
1 CoreFoundation 0x0000000194abbf14 __NSSingleObjectArrayI_new + 84
2 CoreFoundation 0x000000019496b334 -[NSArray initWithArray:range:copyItems:] + 412
3 UIKitCore 0x00000001972cf714 _runAfterCACommitDeferredBlocks + 160
4 UIKitCore 0x00000001972beb4c _cleanUpAfterCAFlushAndRunDeferredBlocks + 200
5 UIKitCore 0x00000001972f0260 _afterCACommitHandler + 76
6 CoreFoundation 0x00000001949ffecc __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 32
7 CoreFoundation 0x00000001949fa5b0 __CFRunLoopDoObservers + 604
8 CoreFoundation 0x00000001949faaf8 __CFRunLoopRun + 960
9 CoreFoundation 0x00000001949fa200 CFRunLoopRunSpecific + 572
10 GraphicsServices 0x00000001aaaf5598 GSEventRunModal + 160
11 UIKitCore 0x00000001972c0004 -[UIApplication _run] + 1052
12 UIKitCore 0x00000001972c55d8 UIApplicationMain + 164
13 TestSpace 0x00000001046deb6c main + 184
14 libdyld.dylib 0x00000001946d9598 start + 4
总结
任何单一检测手段都不可能对 Frida 实现"绝对防御"。在真实对抗场景中,Frida 的行为和特征本身也可以被刻意隐藏或篡改。因此,更合理的策略是将端口检测与内存特征检测进行组合使用,并配合多时机、多路径的触发机制,从而显著提高攻击成本和逆向复杂度。
Frida 防御的核心目标,是在可控的性能与稳定性成本下,尽早发现异常环境、干扰分析过程、并保护关键业务逻辑不被轻易理解和复现。希望本文的思路与实现方式,能为 iOS 开发与安全人员在实际项目中构建 Frida 对抗能力提供有价值的参考。