首页
社区
课程
招聘
从端侧风险检测到策略闭环
发表于: 2026-9-20 11:06 8882

从端侧风险检测到策略闭环

2026-9-20 11:06
8882

就在今年的一月我开源 iOS-SDK 检测项目,加上七月开发的风控 Agent,今天我可以说终于能够真正写完,看完后你就能理解,现在的风控到底是怎么做的,以及他们做风控的根据是什么。

PS:个人理解,杠就是你对,你

先从一个问题开始,我们在客户端检测到了越狱,检测到了 Hook,或者发现这台设备像云手机,然后呢?直接拦吗?那正常用户怎么办?

反过来,一台设备什么异常都没检测出来,就能说明它没有问题吗?如果它背后的账号每天都在批量领券,操作时间、行为路径还和其他一批账号高度一致,这些东西单看一个 SDK 又能看到多少?看到后,算法和策略该怎么利用这些数据,agent该怎么做?

先说检测。一个风控 SDK 放到手机里面,它到底能看到什么?文件、进程、动态库、函数地址、设备信息、触摸和传感器,这些都能成为观察入口。但拿到一个字段,和通过这个字段判断设备有问题,中间还有一段距离。

比如检测到了 Hook,它修改了什么?是改了业务函数,还是把检测函数本身改了?设备一直没动,是自动化操作,还是用户把手机放在桌子上?这些问题不说清楚,最后就会变成收集一堆字段,然后给一个看起来很准确的分数。

代码是仓库中的节选,分值和阈值是当前实现里的参数,不代表已经通过真实业务校准。文中的路径以 RiskDetectorApp/Sources/CloudPhoneRiskKit/ 为起点。

现在代码里有两类入口比较重要。

第一类是 JailbreakEngine,按配置执行文件、动态库、环境变量、进程状态、URL Scheme 和 Hook 检测。

第二类是 RiskSignalProvider,把硬件、行为、反篡改等结果整理成统一信号。

Detection/Adapter/DetectorRegistry.swift 还维护了 17 种顶层检测类型,分成越狱、反篡改和完整性三组。这里的 17 不是总检测点数,因为一个顶层检测器还会调用多个子检测器,AntiTamperingSignalProvider 里面也有单独的检测计划。

先看 Jailbreak/JailbreakEngine.swift:

简单的看看调用关系。先有配置,再有执行,再有结果。来看:

按检测对象来分,目前主要是这些:

这只是为了方便讲解做的分类。具体执行还受到编译条件、系统版本、配置开关和接口可用性的影响。

最容易理解的是文件检测。越狱工具和相关组件可能留下路径、目录或者配置痕迹,SDK 去检查这些位置,再把命中情况记录下来。

但问题来了,如果对方 Hook 了文件查询接口,让你查什么都说不存在呢?

所以当前 FileDetector 除了检查文件,还检查不同读取方式是否给出冲突结果。

Jailbreak/Detectors/FileDetector.swift 中有这样一段,下面省略了日志:

这里要看懂两件事。

文件存在是一种证据;不同读取路径的查询结果打架,是另一种证据。后者提示查询链可能被干预,但不能单凭不一致就确定是哪一种工具干的,权限和系统差异也需要排查。

这一层还有几个补充检查:

普通可疑路径采用随机抽样,关键路径另行检查。随机化可以改变每次执行的轨迹,但漏查的那次就是没查到,分数补偿不能凭空补回没有取得的证据。

同一组里的其他检测也各有用途:

DyldDetector 看当前进程加载的镜像,EnvDetector 看环境变量,SchemeDetector 看特定 URL Scheme 是否可以打开,SysctlDetector 看能够获取到的进程状态。

这里也有一个很容易忽略的问题:查不到和不存在不是同一个意思。 如果接口被限制了、调用失败了,或者本来就没有权限看,那就不能按“没返回结果”直接推导“环境干净”。

调用 open 的时候,名字还是 open,但最终执行的代码是不是预期的系统实现?这就是 Hook 检测的一条思路。

Jailbreak/Detectors/HookDetector.swift 会解析符号地址,再查询地址所属镜像:

这段代码先用 dlsym 找到符号,再用 dladdr 查询该地址所属镜像,后面根据镜像路径判断是否落在可疑模块或非预期位置。

当前监测的符号包括:

但只看符号属于哪个模块还不够。对方可以直接修改原地址上的机器码,地址仍然在系统库里面,函数入口却已经跳走了。

所以 HookDetector 还组合了这些检查:

拿 Objective-C 来说,调用一个方法时,最终执行的是它对应的 IMP。如果方法实现被替换,方法名可以不变,但实现地址和来源可能已经变化。这里就可以从实现地址、所属模块等角度观察。

函数入口检测则是另一个角度:不只看地址在哪里,还看这个地址上的入口指令有没有出现值得怀疑的跳转模式。

不过,这些检测是互相补充的,不能六项命中就当成六份完全独立的证据。几个检测可能指向同一次修改,也可能共用被干预的底层接口。合法 SDK 的方法交换、系统实现变化,也需要结合预期基线判断。

Frida 检测里最直观的是端口、文件和名字。但只依赖这些特征,改个名字、换个端口就可能失效,因此当前代码还观察协议响应、运行时修改、线程、堆和模块等线索。

Detection/AntiTampering/FridaDetector.swift 的主流程节选如下:

portBehavior 汇总端口行为检测,hookSurface 来自前面取得的运行时 Hook 表面检查;后面还会按已经发现的异常决定是否扩大端口扫描、执行文件痕迹检查。

也就是说,这里不是每次无条件把所有探针跑一遍。

配套模块包括:

线程名字、一个监听端口或者一段相似的内存特征,都不能单独证明整个进程就是 Frida 控制的。它们的价值在于和其他证据放在一起,缩小需要调查的范围。

调试检测是另外一组。

DebuggerDetector 会组合调试附加状态、父进程、环境变量、端口、代码签名调试标志、硬件与软件断点、信号探针,以及 watchdog 的异常记录。

下面这段来自 Detection/AntiTampering/DebuggerDetector.swift:

这两个调用进入 CRiskCore。Swift 层负责组合检测结果,部分底层观察放在 C 层实现。

反篡改计划里还接入了 task port、DTrace/kdebug、LLDB/JIT 等相关探针。每个探针的作用范围不同,不能因为名字里有某种技术,就说它覆盖了该技术的所有使用方式。

还有,调试环境异常和业务作弊仍然不是一回事。开发测试、诊断和研究环境本来就可能触发这些检测,如何处置要放到具体运行模式和业务场景里面讨论。

对方不一定保留明显的 Hook 框架特征,也可以直接改代码。因此还有一条检测路线:记录预期状态,再观察它有没有变化。

FunctionIntegrityHashDetector 对一组关键函数的前 32 字节建立哈希基线,后续再次读取并比较。

它使用 FNV-1a,这里是在做运行时变化检测,不是建立密码学意义上的真实性证明。

Detection/AntiTampering/FunctionIntegrityHashDetector.swift 中的比较逻辑如下:

这段代码很容易理解,但真正要想的是基线从哪里来。

当前实现第一次执行时建立基线,后续才比较。因此它能发现“相对首次快照发生的变化”。如果首次采样之前代码就被修改了,这条检测本身不能证明首次快照可信。

同样,只取入口的一小段,也不代表覆盖整个函数。

内存完整性方面,MemoryIntegrityChecker 会检查可疑镜像、部分函数入口指令,以及一个目标内存区域的权限。

它的 hasWritableExecutableRegion() 从 malloc 符号地址开始查询区域,不应该把这个函数描述成全进程所有内存区域的完整扫描。

权限判断如下:

可写又可执行是值得观察的状态,但判定还要考虑区域用途和运行环境。

查询失败时,这个底层函数返回 false。这里也能看出一个问题:底层布尔值并不总能区分“没发现”和“没查成”,需要继续检查上层怎么解释它。

这一组还包括代码段完整性、导入指针、dyld interpose、共享缓存、运行时完整性、SDK 二进制以及 VM remap 等相关检查,具体执行受对应开关控制。

签名也要分层讲。

CodeSignatureValidator 的一项检查是主 Mach-O 是否存在 LC_CODE_SIGNATURE,同时观察可疑动态库和环境变量;AppSigningIdentityDetector 是另外的签名身份检查。

看到签名加载命令,只能说明结构里有这个命令,不能据此宣称完整证书链已经通过验证。

越狱和 Hook 主要关注执行环境有没有被修改,云手机、虚拟环境识别还要看设备描述是否自洽。

比如型号声称是某台 iPhone,GPU 名称、硬件能力和系统信息是否相符?单个字段能伪造,多个观察入口相互矛盾时,就多了一条值得追查的线索。

Providers/VPhoneHardwareProvider.swift 里的 GPU 判断如下:

已知模式包括:

这里属于名称模式与一致性判断。代码把某些结果标成 hard,并不意味着字符串本身变成了不可伪造的硬件证明。

当前相关 Provider 还有这些:

这些大多需要作为组合证据使用。

没有录入指纹、没插 SIM 卡、正在投屏、地区和时区不一致,都有正常解释。云端使用的设备也可能是真机,所以“真机”和“业务操作可信”仍要分开。云手机一直以来是风控对抗的重要一环这里只写一下我简单的理解

另外,这里有个地方说一下

比如 SystemHardwareProbe.ioKitModel() 的 IOKit 读取在当前实现里限定于 macOS 特定条件,iOS 分支返回空字符串。不能因为源码里出现了 IOKit,就写成 iPhone 上一定完成了这一条交叉检查。

设备信息再多,也不等于操作行为正常。这一层开始关注触摸和运动:操作次数、触摸压力与接触半径的变化、运动能量,以及这些信号在时间上能不能对应起来。

Providers/LayeredConsistencyProvider.swift 里的触摸检测,先做样本量检查:

取得压力和接触半径方差之后,当前代码采用这个启发式条件:

名字叫 touch_entropy,但这段具体计算用的是方差,文章里就应该按方差解释。

小样本、硬件不支持某个维度、系统返回值比较固定,都可能影响判断。这里的 0.75 是实现赋予的置信参数,不是实测得到的“75% 概率是机器人”。

再往下看,SDK 会比较触摸和运动是否脱节。下面保留判断逻辑,省略原注释:

思路是,多次操作同时伴随极低运动能量和很弱的关联,可能值得继续调查。

Behavior/BehaviorCoupling.swift 会把动作时间和运动样本放入共同时间窗口,按时间桶统计触摸次数与平均运动能量,再计算 Pearson 相关系数。

但手机可以放在支架上操作,正常用户也未必一直拿着手机。所以不能把“真实触摸必然导致明显运动”当成成立的前提。

代码里的缺失默认值也需要单独理解:这里运动能量缺失会落到零,相关性缺失会落到一。这只是当前分支的处理方式,不能说所有缺失观测都已经统一处理好了。

物理传感器方面,PhysicalSensorProbe 使用 CoreMotion 相关观测并包含气压计检查;IMUNoiseSpectrumProvider 则采集加速度模长,做频谱分析。

后者设置的目标采样率为 100 Hz、样本量 256、超时 4 秒,并缓存结果 120 秒。

加速度模长的实际计算如下:

后续做 FFT,生成频谱摘要和噪声底,并用当前阈值产生软信号。

这个实现说明我们确实在观察传感器序列,但频谱摘要能否稳定区分设备或异常环境,还需要不同机型、姿态和负载下的数据验证。目标采样间隔也不等于操作系统保证每个回调都准时到达。

、、

GPURenderFingerprintProvider 用 Metal 在 8×8 纹理上执行计算,再形成摘要。

正常返回的是一个带指纹的信号,状态为 .hard(detected: false)。它提供可比较的观测,不等于已经实现“每台物理设备唯一且稳定的身份识别”。

相同硬件与软件环境可能产生相同或相近的结果,系统和驱动变化也可能影响结果,这些都需要实验验证。

BatteryEntropyProvider.swift 中,UIKit 分支是:

把电量乘以 1000 不会变成真实电压。短时间内电量不变,也不能证明没有物理噪声。

这里还把不可用的负数电量映射到了零,所以这条实现需要进一步校准和修正,不能写成“已通过真实电池电压噪声识别云手机”。

DRMCapabilityProvider.swift 在部分平台返回不可用,在 iOS 对应分支里执行的是:

后面又结合机型模式调整分类。因此它目前是能力探测与启发式判断,感觉有点垃圾,当时就觉得其实作用有限,就当作一个bug。

CloudPhoneEnvironmentProvider.measureRTTSamples() 的目标是:

代码测量 connect 调用耗时,并未以连接成功为采样前提。

它更接近本地连接路径与调度耗时观测,不能写成已经测到了远端机房网络往返时延。后续虽计算变异系数与峰度,也不自动证明能够识别人为添加的抖动。

实现使用 systemUptime、usleep、mach_absolute_time,观察时间间隔、睡眠比例和计时器低位分布。

它没有在这条路径里完成真实 IMU 多通道回放序列的识别。因此可以讲它在探索时序异常,不能直接写成加速度计、陀螺仪、磁力计的回放检测都已经做完。

这些点不会因为换个好听的名字就消失。还是有点问题。

App Attest 相关代码要拆开看:

支持状态不是证明验证结果。

当前基础 Provider 对不支持的情况会给出较强信号,这属于现有策略选择,不能据此论证“不支持的设备都是虚拟机”。

证明还需要服务端验证注册材料、挑战和计数器。即使证明验证成立,也不能证明操作人就是一个正常用户,更不能直接证明这次业务行为没有风险。

历史方面,TimePatternProvider 输出事件量、活跃时间分布、操作频率、系统时间跳变等线索。

Graph/LocalDeviceClusterDetector.swift 虽然保留兼容类名,但当前输出的是 local_identity_churn,观察本进程记录过的安装指纹变化。

一个设备指纹变了几次,不等于同一个 IP 下存在几台设备,更不等于发现团伙。

ExternalServerAggregateProvider 可以接收服务端返回的公网 IP、ASN、聚合与图相关信息。这个模块是服务端结果输入端侧的入口,提醒一点这东西留在短侧其实作用不大,指纹会不定时上传一下。然后看下面

Detection/AntiBypass/DecoyProviders.swift 定义了 8 个诱饵 Provider:

这些类执行一些操作,把结果送入 DecoySink,最后返回空信号数组。用途是增加识别注册表中真实检测项的难度。

检测结果应该尽量保留来源和状态。为什么是尽量,你要知道对于高手来说终究这些结果还是被伪造,能做的还是对于脚本小子来说尽量多拖延一会。

文件命中、函数入口变化、硬件描述冲突、采样失败,它们需要不同的解释。如果最后只留下一个分数,服务端根本不知道这次分数是怎么来的,也不知道是不是某个关键检测没有执行成功。

Risk/RiskReport.swift 中定义了以下状态。下面只摘取枚举声明和成员,省略编码实现:

信号结构还保留了这些字段:

id 用来标识信号,category 表示类别,evidence 保存判断依据,state 表示这次检测结果的性质。score 和 weightHint 则是评分相关信息,不能脱离具体评分逻辑单独解释。

但结构有这些字段,不代表所有检测器都已经正确使用。

记住端侧只能尽可能保证真实。

有了这些,服务端才有条件把设备环境与账号行为、交易历史和关联关系放在一起分析。至于一次领券请求到底该放行、挑战还是拒绝。

前面把检测讲了一遍,拿到一堆检测结果之后,总得有人把它们组织起来吧?哪些结果能一起看,哪些其实说的是同一件事,哪些根本没拿到有效数据,最后又该怎么给出一个判断?

这部分就开始碰到算法和策略了。你写一百个检测点,如果后面只是命中一个加十分,那检测点越多,分数可能越高,但判断不一定越准。

、

看项目不能光看文件名。仓库里有 RiskScorer,也有 RiskDetectionEngine,到底哪个负责这里的最终评估,要从入口往下跟。

在 Core/CPRiskKitEvaluation.swift 的 evaluateImpl 里面,可以看到:

这里已经有三个东西了:当前上下文、业务场景,以及额外采集到的信号。检测结果进入 RiskDetectionEngine,再按照策略继续处理。

引擎里还会派生跨层信号、生成压缩摘要、计算基础分、应用组合规则和强制规则,再生成判决。部分路径可以提前返回,所以不能把它理解成每次都把全部步骤跑一遍。

Provider 注册也有保护。初始化注册之后,入口会封存注册表:

对应注册表会记录 Provider 的类型,以及内部 Provider 的实例标识;封存后再注册时,会检查这些约束。这样做是为了收紧运行中替换检测来源的入口。

但这仍然是进程内的保护。不能因为调用了 seal(),就说整个检测链从此不可修改。

假设三个检测器都给了零分:第一个确实检查过,没有发现异常;第二个没权限;第三个需要服务端信息,端上根本判断不了。

这三个零,意思完全不一样。后面如果只拿一个数字来算,信息在这里就已经丢了。

所以前面说的 RiskSignalState,到了评分阶段才真正体现出作用。Decision/RiskDetectionEngine.swift 中有下面这段分支:

可以看到,硬信号命中、软信号、篡改状态,走的是不同处理方式。需要服务端判断和当前不可用的信号,在这个基础评分分支里不直接加分。

这里有两个地方要讲清楚。

第一,hard 是程序中的状态分类。某个 Provider 把结果放进 hard,不代表它在现实中就绝对正确。检测依据是什么,还得回到那个 Provider 看。

第二,confidence 也不能直接理解成真实概率。这里把它限制在零到一,再参与加权,并没有因此证明“0.8 就有八成概率是黑产”。要这样解释,得有样本和校准结果。

另外,这段代码只说明基础评分怎么处理状态。unavailable 还可能被组合规则使用,不能据此说它在整条链上永远没有影响。各个 Provider 有没有正确上报缺失,也得分别检查。

先想一个问题:动态库检测发现异常,函数指针检测发现异常,代码完整性检测也发现异常。这可能是三个不同的问题,也可能都来自同一次 Hook。

如果每个都全额累加,等于同一件事情反复算。

当前实现对硬信号做了两层处理。上一段代码里,同一个信号 ID 取最大权重。然后把不同 ID 的硬信号权重排序,再折扣累加:

比如输入权重是 50、40、30,这个函数输出的硬信号分量就是:

最强的保留,后面的补充证据逐步打折。这能减缓检测点堆叠造成的分数膨胀。

但它还没有识别每条证据的共同来源。不同 ID 也可能高度相关,所以这是一种启发式聚合,不能把它说成已经解决了证据独立性问题。这个折扣也只对应硬信号分量,不是整个引擎对所有信号统一使用的公式。

篡改状态还会影响后续合成:

这意味着篡改信号既可能增加分量,也会增加乘数。这样的策略比较敏感,尤其需要关注重复信号和误报。不能只展示公式,再说一句“融合后更准确”就结束了,准确不准确得靠验证。

单个检测看一个位置,跨层关联开始看不同位置之间的关系。

deriveCrossLayerSignals 里面,有下面几种组合:

翻译一下,就是把硬件与 GPU 结果、运行时篡改线索、行为相关信号和缺失状态放在一起检查。命中后会生成 cross_layer_inconsistency,把具体原因写进证据。

这里已经从“有没有某个文件”走到了“多个观察结果怎么联合解释”。不过名字叫不一致,不代表每一种组合在逻辑上都不可能出现。

真实手机也可以被 Hook,真实手机也可以跑自动化。硬件看起来正常,同时发现运行时篡改,并不天然矛盾。当前代码把这些组合标成 .tampered 并给出较高权重,这是具体的策略选择,需要拿正常设备和对抗样本验证。
作为策略的问题就在这里:你到底观察到了什么,又从这些观察推到了什么。中间这一步不能靠一个字段名带过去。

到了这里还不能只剩一个总分。前面入口传入的 scenario,就是给场景策略用的。

引擎会取出对应场景的阈值、信号权重、组合规则等配置。组合规则命中后,可以增加分数:

还可以配置强制动作。不过是否启用强制规则,以及命中后采取什么动作,都要看实际策略,不能只看某个检测分值就猜最后结果。

这里先不展开服务端封号、交易拦截那些业务动作。SDK 输出的是端侧判决,真正的业务执行还得看接入方和服务端。返回一个 block,不等于服务端已经拒绝了这笔交易。

完整信号里面有 ID、状态和证据,适合解释,但规则匹配不一定每次都要读完整内容。

Util/SignalCompressor.swift 会把选定信息映射成固定九字节摘要。其中前四字节对应分层摘要,后面保存跨层位和扩展位:

这个版本的 mappingVersion 是 1.2。版本要跟着传,否则同一个位在不同版本里含义变了,两端就可能各说各话。

引擎支持根据这个摘要匹配压缩判决规则;匹配到多个规则时选择最严格的动作,再进入对应的协调逻辑。

但九字节不可能无损装下全部证据。它适合表达预先约定的状态,原始测量值、详细原因还得保留。也别把它和密码学哈希混为一谈:这里主要是在编码规则需要的特征,不是在给完整报告做密码学承诺。

还有一种情况:前一次有风险线索,这一次突然全没了。这可能是环境真的变化了,也可能是采集出了问题,或者检测被压住了。

入口会比较前后两次高风险信号 ID。下面是差集计算和触发条件:

最后一行是原函数中的条件开头,分支里会构造 signal_suppression_detected 信号。

这一步关注的是变化,但“消失了”不能直接证明“被攻击者屏蔽了”。配置变化、采样差异、环境恢复,都需要考虑。它更适合作为进一步核查的线索。

SDK 也会记录本地评估历史:

在这条入口里,最终评分先生成,随后才写入这条历史,再计算 pattern() 附到报告。因此不能把这里报告里的时间模式,说成一定已经参与了本次最终评分。

仓库另外还有 TemporalFeaturesCalculator,用于从设备历史快照计算风险趋势、越狱次数、VPN 使用情况、分数统计等。模块存在和默认入口实际使用,是两回事,要沿调用链分别确认。

本地历史也有自己的边界:它是当前客户端可见的历史,不能代替服务端跨账号、跨设备的真实业务记录。

SDK 还会生成图节点描述信息。这里已经在为后续关联分析准备输入了,但端上生成一个描述符,不代表端上已经跑了群体图算法。

Graph/GraphFeatureCollector.swift 中有这样一段:

这里把安装关联用的键、硬件摘要和硬件属性分开保存。几个网络与账号哈希字段则明确留空,不因为模型里有这个字段就随便填。

为什么要分开?因为“是不是同一个实体”和“两个实体像不像”,是两个问题。

哈希可以作为精确匹配的线索,但不能拿 SHA-256 的距离当设备相似度。比较设备特征,要看具体属性,还要处理缺失、共同机型和采集可信度。

而且客户端给出的安装键、设备 ID 都不能直接当成服务端认证过的身份。账号要结合认证上下文,网络信息要结合服务端实际观察。后面建图的时候,这些来源差异会影响边的可信程度。

检测跑完,还有一个实际问题:结果从函数返回到上报,中间有没有被改?

SDK 里有风险结论认证实现。Risk/RiskConclusionSigner.swift 会把分数、风险标记、时间、随机值、篡改状态以及信号摘要放进认证输入:

这一层是用设备密钥做消息认证。它要解决的是约定内容的完整性,不是证明用户是真人,也不是证明检测输入一定真实。采集阶段如果已经被控制,给结果加认证不会自动修复采集结果。

还得注意覆盖范围:这里只直接绑定了列出的字段和一个信号摘要,不能看到 computeFullDigest 这个名字,就默认所有报告字段、证据、账号和业务请求都被绑定了。

时间和 nonce 也不是写进去就自动防重放。服务端还需要检查时效、记录已使用的 nonce,并核对报告和本次请求之间的绑定。这个 verify 函数本身有时间窗口检查和认证校验,没有完成服务端的 nonce 消费流程。

前面的注册表保护、运行时完整性检查,加上这里的结果认证,是在不同位置增加约束。最终效果仍然取决于密钥保护、验证端实现和真机对抗结果,一定要自己对应一遍,我写的是参照啊兄弟。

现在再回头看,SDK 要做的事情已经比较完整了:采集当前环境,把结果表达成带状态和证据的信号,再通过权重、组合和场景策略形成端侧判决,同时保留变化线索、关联特征和可供验证的输出。

真正值得研究的也在这些连接处。哪些信号其实重复了?什么缺失应该触发补采?一次环境变化该不该提高风险?什么信息客户端可以提供,什么判断必须交给服务端?

算法这里没有搞过的有点抽象,不想读的可以跳过

逆向牛子都知道端侧是可以被改的。

对方如果已经能控制客户端,就可能修改检测函数、替换设备属性、干扰行为采集,甚至直接改掉最后返回的分数。具体能改到哪一步,取决于他的能力和我们设置的防护,但设计系统的时候,不能默认客户端永远按我们写好的代码运行。

那是不是检测就没用了?也不是。检测能找到异常线索,增加对抗成本,还能帮助我们判断当前运行环境是否值得信任。问题是,这些线索传到服务端以后,不能直接被当成不可质疑的事实。

同样一份“没有发现异常”的报告,可能是设备真的正常,可能是这次检测没有覆盖到,也可能是检测结果、

我们要解决的事情是:当一部分数据可能被修改、伪造或者刻意模仿时,还能依据什么作判断?单个字段不够,就看字段之间的关系;单次操作不够,就看一段时间;单个账号不够,就看多个实体之间的业务和资源关系。

客户端说自己是一台正常 iPhone,客户端说刚才发生了十次触摸,客户端说没有发现 Hook,这些都属于客户端提供的信息。

服务端收到十次领券请求,这是服务端的观察。业务最终发出了多少张券、发生了多少订单,又是另一层记录。

这些信息可以放在一起分析,但可信范围不同。

最容易出问题的是第四行。服务端确实收到了一次请求,但请求里的设备 ID 可能还是对方填的。把它存进数据库,不会自动完成身份验证。

所以特征不能只保存一个值,还应该知道这个值从哪里来、是否缺失、在什么时间观察到,以及经过了什么绑定和验证。

多个检测结果也不一定是多份独立证据。假如十个字段最终都依赖同一个被 Hook 的接口,它们一起显示正常,可能只是同一份伪造结果被重复使用。

这一步不讲清楚,后面就很容易出现一种情况:模型有几十个特征,看起来信息很多,实际上真正独立、可信的来源没有几个。

继续拿领券举例。对方把端侧报告处理得很干净,再给操作加一点随机延迟。单看一台设备的一次请求,可能确实看不出什么。

但他还要拿到券。

如果想扩大收益,就可能需要更多请求、更多账号,或者持续更长时间。每增加一种操作,都会在系统可见范围内留下新的业务观测。当然,对方也可以降低频率、分散资源来减少这些线索,但这会改变他的效率和成本。

算法可以做的,是量化这些观测,并检查它们之间的关系。

比如一个账号每次操作都不快,但多个账号在相近时间反复使用同一设备资源;每个账号的单次行为看着正常,合在一起却不断重复同一条业务路径;当前报告没有异常,但与之前的历史相比,设备状态和行为特征同时发生变化。

这里不能直接说“关系复杂,所以一定能识别”。有两个前提:

第一,参与比较的信息要有实际观测价值。如果两条数据都被同一个位置控制,对方可以一起修改,所谓相互验证可能只是假的一致。

第二,要考虑正常解释。活动开场会让正常用户集中领券,家庭成员可能共用设备,埋点丢失会造成两端数据对不上。算法需要判断异常程度,不能跳过这些正常情况直接贴标签。

因此,这一层的输出应该是可复核的偏离、关联和证据,而不是一句没有依据的“识别到黑产”。

如果对方产生的所有可观测数据都与正常用户不可区分,仅凭这些数据就无法可靠区分两者。此时需要新的观测、额外验证、业务约束或后续反馈,换一个模型不会凭空增加信息。

算法从哪里开始?先从一个看起来很简单的特征开始:操作间隔。

假设一个账号的记录是:

全事件最短间隔是 1 秒,领券最短间隔却是 60 秒。

你要判断“领券是不是太快”,却拿登录到下单的间隔去判断,规则再精细也会跑偏。这不是模型能力问题,是特征含义一开始就写错了。

Agent 的 agent/tools/featurelib.py 把领券事件单独取出来:

只有一次领券时,无法形成间隔,输出就应该是缺失。当前这项特征没有可计算的间隔时返回 None,不会填成零。

零秒意味着观测时间相同,缺失意味着无法计算。即使观测时间相同,也还要排查时间精度和重复事件,不能直接断言两次动作真正同时发生。

接下来才是对抗问题:对方知道我们看间隔,每次多等一会儿,这个特征可能就弱了。那还可以看窗口内次数、累计业务量、资源复用和行为路径,但每增加一个特征,都应该解释它带来了什么额外信息。

项目已经有事件数、领券次数、不同设备和 IP 数、缺失事件数,以及按会话切分的行为路径。连续同类事件可以压缩成 coupon_claim×N,方便描述“做了什么、按什么顺序做”。

不过,压缩路径会丢掉部分时间细节。已有路径特征,也不意味着已经实现跨账号同步检测。文章里要把当前计算与后续可研究的方法分开。

特征工程真正要落实的是四件事:实体是谁、事件是什么、窗口多长、缺失怎么表达。先把这四件事做对,后面的算法才有共同的输入含义。

同一个账号,一分钟领十次和一个月领十次,含义不同。时间窗口本身就在定义问题。

短窗口更容易看到瞬时集中,长窗口可以覆盖低频持续行为,但也会混入更多历史变化。对方降低单账号频次后,短窗口可能没有明显异常,长窗口有没有补充作用,需要用样本验证。

除了窗口长度,还有一个更隐蔽的问题:这条数据当时是否已经被系统看见。

例如要还原 10 点钟的判断。一条事件发生在 9 点 59 分,却到 10 点 05 分才进入系统。回测在 10 点使用它,就是让过去的系统提前知道了之后才收到的信息。

featurelib.py 因此同时检查事件时间和记录时间:

结合图构建的窗口过滤,历史范围可以表达为:

当前事件可以单独参与判断,但是否计入频次要约定清楚,不能先写进历史,再在另一处重复加一次。

这个版本对没有 recorded_at 的旧记录回退到 ts,因此不能声称所有旧数据都严格还原了当时的可见信息。如果事件时间由客户端提供,接入层也需要检查它的语义和合理范围,时间窗口函数本身无法证明时间戳真实。

对方可以给点击间隔加随机数,那能不能进一步看:触摸发生时,设备运动在做什么?

SDK 的 Behavior/BehaviorCoupling.swift 实现了一种关系特征。它先取触摸与运动数据的时间交集,再分桶,统计每桶触摸次数和平均运动能量,最后计算 Pearson 相关系数。

先对齐时间,是因为触摸是离散事件,运动是连续采样。不能随便拿第一个触摸去配第一个运动样本。

代码首先检查样本量和重叠时间:

每桶触摸次数记作 x_i,平均运动能量记作 y_i,相关系数为:

它比较的是两组量偏离各自均值时,是否倾向于一起变化。

例如触摸次数为 [0, 1, 2, 3],运动能量为 [0, 2, 4, 6],相关系数是 1。它只能说明这组数据完全正线性相关,不能解释成真人概率为 100%。

这项特征的对抗作用在于:如果对方只修改触摸,另一路观测是否还能发现关系上的变化?但如果两路数据都被共同控制,对方也可以伪造相关性。

正常场景同样会影响结果。手机放桌上或支架上,运动可能很弱;走路时,运动可能主要来自步行。两组信号有时间延迟时,当前没有搜索滞后的计算也可能得到较低相关。

实现还有缺失处理的边界:方差为零时返回 nil,但某个运动桶没有样本时,会保留初始化的零值。这可能把缺失当成静止。整体样本够多,不代表每个桶都可用。

这项计算在 buildRiskContext 中有实际调用。验证它时,应分开测试手持、桌面、支架、丢样和合成数据,检查新增识别能力与误报,而不是先假定“低相关就是机器”。

接着看时间上的变化。以前操作量稳定,今天突然增加,这个偏离可以用历史基线量化。

SDK 的 Analysis/AnomalyDetector.swift 提供了 Z-score 和 IQR 方法。但在本章核对的 SDK Sources 中,没有查到 AnomalyDetector(...) 的实例化调用。因此这里讲的是已有可调用模块,不能写成默认评估已经执行。

Z-score 使用历史均值和样本标准差,衡量当前值偏离了多少:

历史值为 [8, 10, 12],均值是 10,样本标准差是 2;当前值为 18,绝对 Z-score 就是 4,超过函数默认阈值 3。

但这里没有证明作弊。业务活动、用户习惯变化和采集窗口改变,都可能造成偏离。三个样本足够函数运行,不代表基线可靠;不能不检查分布假设,就把三倍标准差换成固定误报概率。

IQR 使用分位点构造范围:

超出范围时标记异常。它不直接依赖均值和标准差,但同样受样本组成影响。

这里有几个实际边界。样本不足时,当前 Z-score 返回不异常,没有明确区分无法判断;历史方差为零而当前发生变化时,返回无穷大的 Z-score。IQR 为零时,当前实现却返回不异常。调用方需要知道这些差别。

分位数定义也必须一致。当前 SDK 插值位置使用 p*n-0.5,Agent 候选规则模块使用 (n-1)*p。对于 [0, 10, 20, 30] 的 25% 分位点,前者得到 5,后者得到 7.5。跨语言实现最好用固定输入输出向量约束口径。

更关键的对抗问题是基线污染。对方如果逐渐改变行为,而系统不断接纳这些样本,单次偏离可能不大,长期却已经改变。可信参考窗口、基线冻结和短长期比较都可以研究,但不能说现有 Z-score 已经解决了这些问题。

如果比较的是历史风险分数,还要考虑策略版本。评分方式变了,分数也会变,这不一定来自用户变化。基线需要明确版本和适用场景。

个人历史不足时,可以与人群比较。Agent 的 featurelib.py 会计算中位数、多个高分位点和 MAD,描述特征分布。

MAD 是样本到中位数的绝对距离,再取中位数。当前输出没有乘校正系数,不应直接叫标准差。

但和谁比较很重要。新用户、长期活跃用户、不同业务场景混在一起,正常差异也可能表现成异常。处于尾部分位点的用户,也可能只是正常的大客户。

当前人群基线使用所选数据集的全量账号特征,没有历史时点参数,不能直接当成严格历史回放的基线。样本很少时,插值算出 P99.9 也不代表可靠估计了人群尾部。

有了确认标签之后,还可以评估特征的区分能力。agent/tools/risk.py 实现了 IV、KS、AUC 和 Lift:

IV 的核心代码对每箱计数加 0.5 做平滑:

缺失值也单独成箱。它可能有区分力,但也可能反映权限、版本和渠道的采集差异,不能发现缺失有信息就直接拿去拦截。

这个特征评估实现的 AUC 使用方向无关的口径:

所以越小越可疑的特征也能得到高值。不能看见 AUC 高,就直接设置“超过某值拦截”。

Lift 也有口径细节:当前实现用非缺失样本异常率作为基准,缺失箱却也可能参与比较,解读时需要说明基准人群。

这些指标最依赖的还是标签。旧规则拦下来的账号不能全部当异常,再用它们证明新算法有效;未标注也不能直接当正常。否则看起来很好的区分度,可能只是重复了旧规则和标注偏差。

特征有区分度,说明在这份样本中存在关系。关系能否跨时间保持、是否容易被对方改变,还需要独立验证。

如果对方降低单账号频次,把操作摊到多个账号,单体特征可能变弱。这时可以检查资源是否复用,以及关联是否持续出现。

Agent 的 agent/tools/graph.py 用账号、设备和 IP 构建带类型的节点与边,记录观察时间、次数和身份可信信息。

但同 IP 不等于同主体。企业、学校和运营商共享出口都可能让正常用户集中在一个 IP 上。当前实现把所有 IP 边作为弱边,强关联子图只保留 strong 边:

设备关联也不是天然可信。设备标识可能被修改,安装变化可能改变身份线索。当前版本记录 identity_trust 和 entity_generation,对部分设备按代际区分,但仍兼容 legacy_unverified 标识进入强边路径。strong 是图算法中的关联等级,不是物理身份验证结论。

这里有两种相反的错误:不同主体被错合并会误伤,同一主体被拆成无关节点会漏掉关联。图越连通,不代表越准确。

对于连接账号过多的设备,当前代码会弱化其边:

这是为了限制共享资源引起的大范围传播,但真实批量设备也可能高度连接。因此弱化只是调整关系用途,不代表这个资源安全,更不应该把高连接度证据丢掉。

当前图工具主要使用有界 BFS,限制扩张深度和节点数,超过预算会返回截断标记。返回的关联范围不一定是整个连通分量,也不是恶意团伙名单。

还需要把三个概念分开:

本章核对的图调用链主要是第一种。不能给 BFS 换个名字,就写成已经实现社区发现或图学习。

未来如果加入更复杂的方法,应与简单关联特征对照:多发现了什么,新增多少误报,在共享出口、伪造标识和缺失边下是否稳定。否则只是把图画得更复杂,没有证明判断变好了。

特征和关联拿到之后,还可以让算法帮助搜索候选条件。

Agent 的 agent/tools/rule_mining.py 会从训练样本的特征分位点生成阈值,尝试不同方向,也会在满足条件时生成缺失规则。候选来自确定性的搜索代码,不能描述成 LLM 随口给出了最佳阈值。

单规则按训练侧 F1、Lift 和 Recall 排序,再附加验证侧结果。模块还支持有限数量的 OR 组合搜索,用误伤和漏放成本衡量组合:

对应的简化目标是:

例如误伤成本设为 1,漏放成本设为 5。候选 A 误伤 10 个、漏放 2 个,成本是 20;候选 B 误伤 3 个、漏放 4 个,成本是 23。按这组假设,A 的目标值更低,但还需要检查误伤率等约束。

真实成本未必固定,订单金额、用户价值和验证动作都可能改变损失。这里首先解决的是“我们到底在优化什么”,不能只用准确率概括业务价值。

训练和验证分开也不是一次性动作。搜索函数即使用训练侧排序,人看完验证结果后反复修改条件,仍然会间接利用验证集调参。最终要有独立的后续时间段,必要时按账号或关联群体隔离,避免同一主体反复出现造成过于乐观的结果。

算法输出候选和评估依据,策略流程再决定是否采用。排名第一,不等于可以直接发布。

当验证时,先把完整系统拆开:只用端侧、只用服务端行为,再加入图关联,比较新增发现、误伤、延迟和资源消耗。这样才知道每一部分到底贡献了什么。

做了一些针对对抗前提设置下面几组实验:

算法最终交给策略的,应当是带依据的结果:偏离多少、关联到谁、命中什么条件,以及使用的时间窗口、有效样本、缺失状态和版本。

这样策略才能继续判断,这份证据够不够,需不需要补充验证,应该观察、挑战、限制,还是拒绝。

端侧检测在提供环境线索,算法在组织和比较证据,策略再决定业务动作。把这几步连起来,才能解释系统为什么作出这次判断,也才能知道对抗发生以后,究竟是哪一层出了问题。

,现在假设我们已经拿到一些结果:设备有环境风险,账号的领券间隔比较短,还与其他账号存在资源关联。

然后呢?直接拒绝吗?

算法可以告诉我们偏离多少、关联到谁、命中哪些条件,但具体怎么处理,还得回到业务:这次操作可能造成什么损失,证据有多可靠,拒绝正常用户要付出什么代价,有没有成本更低的验证办法。

同一条 Hook 线索,放在浏览页面和发放权益的环节,处理方式可能不同。同一个高风险账号,低成本操作与不可逆交易,也不应该自动使用同一套动作。

所以策略不能只写成“分数超过多少就拦截”。懂吗?xd不是随便杀

还是拿领券举例。我们真正要防的,是超出业务规则的批量获利,以及它造成的权益损失、正常用户资源被挤占等后果。

“请求很多”“设备异常”“账号之间有关联”是线索,还不是最终业务目标。

比如一个正常用户在活动开始时连续点击,多几次请求并不代表他拿到了多份权益。如果业务本身已经保证一人一次、重复请求幂等,频繁点击与实际资损之间就隔着一层业务约束。

反过来,一个账号频次不高,也可能通过多个账号持续获利。只盯单账号次数,可能既打扰了正常用户,又没有覆盖真正的滥用路径。

因此设计策略之前,至少要明确下面这些内容:

如果这些还没定义,先把阈值从十次改成八次,通常只是把一个不清楚的问题调得更敏感。

在某个业务环节,针对某个实体,使用明确时间范围内的有效证据;满足哪些条件时,采取什么动作;数据不足或依赖失败时,采用什么替代处理;最终把依据和版本留下来。

这里有一个容易混淆的地方:业务设计里可以讨论挑战、限流、暂缓和人工审核,但当前项目的本地规则动作只有三档。

在 agent/tools/rules.py 里:

它们表达的是判定结果。返回 review,并不意味着人工审核已经接单,也不意味着验证码已经弹出。到底映射成补充验证、等待审核还是其他动作,要由业务接入方实现。

同样,返回 reject 也不等于权益一定没有发出去。如果业务先发券、后异步查看决策,拒绝结果就来晚了。

策略不仅要决定动作,还要明确执行位置。否则判定日志看起来很漂亮,业务损失照样发生。

核心代码是:

次数则把当前请求计入:

这个版本的默认参数包括:最短间隔不超过 30 秒、含当前请求的领券次数至少 10 次,历史不同 IP 数至少 3 个时升级为 reject,否则是 review。

这些是代码默认值,不是已经证明适合所有业务的界线。不能因为注释里把某种组合描述得很确定,就把它写成真实人类不可能出现。

我来用一个明确的例子解释:历史已有九次领券,其中最短间隔为二十秒,出现过两个 IP。第十次领券到达时,次数条件满足,按这条规则会得到 review。其他规则仍然可能继续影响最终结果。

第一,当前请求被加进次数,但 gap 取自历史特征,没有在这一段重新计算“本次距离上一次领券”的间隔。IP 数也来自历史。因此它不是三个指标都包含当前请求的完整在线窗口统计。

第二,这里使用最短间隔。历史中有一对请求很近,就可能满足间隔条件,不代表全部请求都持续高频。

第三,这条调用使用的历史特征没有传入窗口长度,不能把它描述成“十分钟内十次领券”。时间范围是策略语义的一部分,不能由文章替代码补出来。

如果要把它扩展成严格的近期高频策略,就需要明确窗口、当前事件的纳入方式、重复请求处理和历史过期规则,再进行回放验证。

因为证据强度、业务损失和处置代价是三件事。

设备有 Hook,说明存在运行时修改线索,但不能直接证明本次领券违反资格。同一设备出现多个账号,说明存在关联,也可能是正常共享。行为偏离历史,说明发生变化,变化原因还需要解释。

这并不意味着只能放行,而是需要选择与现有依据相匹配的动作。

如果证据不足以支持拒绝,但本次操作的潜在损失较高,可以考虑补充验证或暂缓;如果风险低、动作可逆,可能先观察更合适。具体动作必须是业务真实支持的能力。

可以用一个决策目标理解这个取舍:

x 是当前证据,y 是可能的真实状态,a 是可选动作,C 表示采取这个动作造成的代价。

这只是策略设计的数学表达,不是说项目已经实现了这套概率决策。实际风险分也不能自动当成经过校准的概率,成本更需要用业务数据估计。

上一章的 FP 与 FN 成本是这个问题的简化形式。加上验证、等待和审核后,还要考虑验证成本、正常用户流失、审核容量和延迟。

另外,不同规则不一定遵循相同的取舍。当前本地 R006 在相关开关开启时,会对设备情报里的模拟器、root、Hook 标记生成 reject。它是一项强硬的规则选择,存在正常用户受影响的可能,不能把它描述成算法证明了作弊。

关闭该分支的某个开关,也不等于代码会自动把原来的 reject 改成 review;该分支可能不再生成对应命中,最终动作还要看其他规则。

实际请求很少只碰到一个规则。它可能同时命中名单、行为频次、新账号交易和设备环境。

如果每个模块各自给一个动作,总得有统一合成方式。rules.py 支持 worst、sequential、vote 和 weight 四种方式,默认是 worst。

默认分支会取最严格动作:

这种方式直观,但有明显取舍:只要一条规则误报为 reject,就可能决定最终动作。规则越多,越需要关注新增规则的误伤,而不是认为规则多就一定更安全。

顺序合成依赖优先级,要防止前面的弱结论盖住后面的强证据。当前代码对 pass 命中和灰名单观察态做了特殊处理,不能简单理解成代码里谁先出现就听谁的。

投票和加权也不能自动解决这个问题。三个高度相关的规则可能只是重复同一条信息,票数多不代表证据更多。权重设置得不合理,也可能让多个弱信号压过一个关键事实。

合成方式本身就是策略内容,需要和阈值一起版本化、一起验证。不能只回测规则条件,却漏掉动作合成模式的变化。

还有一点:无论最后选什么动作,都应该保留完整命中记录。否则最终只有 reject,事后却不知道是名单、设备还是频次导致的,排查误伤会非常困难。

模型服务超时,能不能把分数填成零继续跑?

不行。零分是一种有效输出,超时是没有拿到输出。混在一起,会让系统在最缺信息的时候表现得最确定。

agent/engine.py 对模型分数有明确校验:

布尔值、非数字、非有限值和越界值不能冒充合法风险分。引擎也会对远端失败和组件不可用设置显式降级信息,不能把降级结果包装成完整执行的正常结论。

但“显式降级”只是让调用方知道发生了什么。降级之后如何处置,还需要业务约定。

高损失、不可逆的动作可能需要暂停或补充验证;低成本、可逆动作可以考虑继续并加强观察。这些是设计选择,不能从一个 degraded 字段自动推出统一动作。

本地规则也是远端决策不可用时的一种备选能力。它覆盖什么、缺少什么、与远端行为是否一致,都需要核对,不能因为还能返回 pass/review/reject,就把两者当成等价引擎。

正常用户被误伤,或者业务有明确的测试与豁免需求,名单可以帮助处理例外。但例外应该有范围和期限。

否则今天为了测试加进去的白名单,几个月后可能还在生效;原本用于领券的豁免,可能影响到完全不同的交易场景。

当前本地规则读取白名单时,会带上事件类型 scope,并使用有效期语义筛选记录。拿到匹配记录后,它对命中动作做降档:

也就是说,原本 reject 的命中降到 review,review 降到 pass,并留下原动作。黑白名单冲突时,这个白名单降档不会执行。

这是一种具体实现,不是所有业务都必须照搬。它表达的重点是:豁免要保留证据和边界,不能把真实出现的风险记录直接抹掉。

白名单账号也可能被盗用,测试身份也可能被滥用。谁批准、豁免什么、何时失效,都应该成为可以审计的内容。

假设把领券次数门槛调低,命中数量增加了。能不能说策略变强了?

只能说命中更多,还没有说明新增命中的是什么。

至少要把新旧策略结果分成四类:

真实评估还需要成熟标签、样本量、时间边界和特征版本。旧策略拦截后的用户没有继续操作,后续结果可能根本看不到,不能随意补成正常或异常标签。


[招生]科锐逆向工程师培训(2026年11月13日实地,远程教学同时开班, 第57期)

收藏
点赞 ・52
打赏
分享
最新回复 (30)
雪    币: 106
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2 楼
沙发
2026-9-20 11:13
0
雪    币: 4325
活跃值: (4315)
能力值: ( LV6,RANK:90 )
在线值:
发帖
回帖
粉丝
3 楼
@温泉划水鱼:沙发
2026-9-20 11:15
0
雪    币: 211
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
4 楼
学习
2026-9-20 15:05
0
雪    币: 1898
活跃值: (3955)
能力值: ( LV7,RANK:110 )
在线值:
发帖
回帖
粉丝
5 楼
1
2026-9-20 15:15
0
雪    币: 20
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
6 楼
1
2026-9-20 15:33
0
雪    币: 6
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
7 楼
感谢分享
2026-9-20 18:20
0
雪    币: 1439
活跃值: (20)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
8 楼
支持支持支持
2026-9-20 21:12
0
雪    币: 160
活跃值: (350)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
9 楼
牛逼
2026-9-20 21:20
0
雪    币: 548
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
10 楼
无敌
2026-9-20 21:30
0
雪    币: 2543
活跃值: (3992)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
11 楼
学习
2026-9-20 22:38
0
雪    币: 864
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
12 楼
学习学习
2026-9-20 23:07
0
雪    币: 1422
活跃值: (1040)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
13 楼
感谢分享
2026-9-21 10:57
0
雪    币: 0
活跃值: (271)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
14 楼
感谢分享111111
2026-9-21 13:44
0
雪    币: 3303
活跃值: (6446)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
15 楼
666
2026-9-21 20:42
0
雪    币: 635
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
16 楼
123123
2026-9-22 09:32
0
雪    币: 1901
活跃值: (6063)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
17 楼
666
2026-9-22 09:45
0
雪    币: 217
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
18 楼
99
2026-9-22 15:37
0
雪    币: 1057
活跃值: (973)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
19 楼
好文
2026-9-22 15:43
0
雪    币:
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
20 楼
ga'sga'g'g
2026-9-27 11:14
0
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
21 楼
1
2026-9-27 19:52
0
雪    币: 52
活跃值: (6266)
能力值: ( LV8,RANK:146 )
在线值:
发帖
回帖
粉丝
22 楼
1
2026-9-29 14:47
0
雪    币: 1474
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
23 楼
66
2026-9-29 17:33
0
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
24 楼
虽然看不懂,学习一下
2026-9-29 21:59
0
雪    币: 7790
活跃值: (25325)
能力值: ( LV12,RANK:550 )
在线值:
发帖
回帖
粉丝
25 楼
1
2026-10-6 14:09
0
游客
登录 | 注册 方可回帖
返回