iOS安全开发中的Frida检测

作者: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为例:

  1. frida-server(系统进程):
    • 通过task_for_pid() 拿到目标 App 的 task port
  2. 在目标 App 中:
    • 创建远程线程(thread_create_running
    • 调用dlopen() / Mach-O loader
  3. 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 的工作流程:

  1. 初始化
  • 获取当前进程 task,自地址 0 开始遍历虚拟内存区域。
  • 调用 init_self_image_range 记录自身镜像范围,后续跳过本进程主二进制区域。
  1. 遍历内存区域
  • 使用 vm_region_64 逐段获取区域起始地址、大小和保护属性。
  • 如果区域属于自身镜像则跳过;非 VM_PROT_READ 区域也跳过。
  1. 读取并扫描
  • 为区域大小分配缓冲区,vm_read_overwrite 读取该区域内容。
  • 遍历明文特征数组 kFridaPlainList(包含 Frida 相关字符串,如 frida_gadgetfrida/runtime/core.jsFridaAgentRunner 等),对每个特征调用 memory_contains 在该缓冲区内查找。
  1. 命中处理与日志
  • 如命中,hitCount++,并使用 dladdr 获取当前区域地址对应的模块路径,提取文件名,记录日志:
    • 内存检测到的特征
    • 地址(十六进制)
    • 模块名
    • hitCount
  1. 后续与回调
  • 释放缓冲区,继续下一个区域;若 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 对抗能力提供有价值的参考。

相关推荐
咘噜biu3 小时前
Java后端和前端的接口数据加密方案(椭圆曲线集成加密方案)
java·前端·安全·aes·密钥协商ecdh·椭圆曲线集成加密方案
乾元4 小时前
Service Mesh 与网络抽象:AI 如何做服务层次网络策略生成(微服务 / 云原生)
网络·人工智能·安全·微服务·云原生·运维开发·service_mesh
B2_Proxy5 小时前
如何搭建高速稳定安全的网络环境?住宅代理是关键
服务器·网络·安全
米羊1215 小时前
fastjson (1概述)
网络·安全
94620164zwb55 小时前
隐私安全模块 Cordova 与 OpenHarmony 混合开发实战
安全
缘友一世6 小时前
计算机网络中的安全(8)复习
网络·计算机网络·安全
能年玲奈喝榴莲牛奶6 小时前
安全服务-应急响应测评
安全·web安全·安全服务
菩提小狗7 小时前
小迪安全_第4天:基础入门-30余种加密编码进制&Web&数据库&系统&代码&参数值|小迪安全笔记|网络安全|
前端·网络·数据库·笔记·安全·web安全
Bypass--7 小时前
防护篇 | 云原生安全攻防实战
安全·云原生·容器·kubernetes