一次 Flutter 应用任务墙协议的逆向分析与边界复盘

从 APK 拆包、Dart AOT 还原、真机动态取证,到协议验证与安全边界的完整复盘

写在前面(脱敏与边界声明)

  • 本文是一次个人向的协议分析与安全研究过程记录。目标 App、后端域名、Firebase 项目 ID、账号、Token、设备型号等一切可识别信息均已脱敏,文中出现的 *.example<...> 均为占位符,请勿对号入座。
  • 所有操作均使用本人账号,遵循「先静态、再只读、后单点」的验证节奏;未批量注册、未伪造设备指纹、未尝试绕过 Google Play 完整性校验。技术上可调用接口,并不意味着平台允许自动化完成有报酬的任务。
  • 文中保留了大量失败与悬案记录。一篇只有成功的技术复盘没有价值,真正的信息量在那些”这里不对、那里存疑”的推理里。

0. TL;DR:先给结论

问题 结论
任务系统(任务墙)能否由普通 HTTP 客户端直接访问? 能。 纯 HTTPS + JSON + Bearer Token,无设备绑定、无每请求完整性校验
需要 root 吗? 不需要。 存在三条凭据获取路线,免 root 路线端到端验证通过
需要真机长期参与吗? 协议验证阶段无需持续连接真机;会话续期可在 PC 上完成
任务时限由谁校验? min_seconds 由服务端下发,提前提交会收到 too_fast
唯一无法复现的环节是什么? 权益兑换接口(受 Play 完整性栈保护,token 必须由真实设备上的真实 App 实例产生)
最终产出 一套协议验证工具和本地控制台,并形成客户端行为与服务端校验边界的证据链

1. 起点:一个待验证的问题

一切的起因,是在某个技术论坛里看到一张截图:有人部署了一个所谓「一键脚本」(跑在某 Linux 运维面板上),后台在自动完成某款海外工具类 App 里的任务墙小任务——看图/看视频做 A/B 选择,每隔几十秒提交一题,余额以每题 $0.012 的固定增量线性增长,附带任务 ID 和一句话日志。

这款 App 本身是一个典型的「积分回馈」型产品:Google 账号登录,App 内嵌任务墙模块,用户完成数据标注类小任务累积余额,余额可在 App 内兑换权益。任务内容本身是给 AI 生成模型做评测(两个候选视频/图片,哪个更符合一段中文提示词)。

我的问题很具体,也有明确的验收标准:

这个任务系统,到底能不能用一个普通 HTTP 客户端(Python 脚本)完成?还是依赖设备侧计算、完整性校验或动态签名?

注意这不是一个可以拍脑袋回答的问题。Flutter 应用的业务逻辑编译在 libapp.so 里,认证体系套着 Firebase,还有各种疑似安全机制。我给自己定了三层证据标准:

  1. 静态层:从 APK 里读出技术栈、端点清单、调用链,结论必须有字符串/代码依据;
  2. 动态层:真机 + root + logcat + 抓包 + 内存扫描,拿到运行时才可见的事实;
  3. 实测层:在完全脱离手机的环境(PC)里,用真实接口逐字段验证,能复跑的证据才算数。

任何一层单独都不够。比如只靠静态很容易把「服务端持有设备指纹」过度脑补成「任务接口强绑定设备」——实际上第一轮我就下了这么一个证据不足的错误判断,后面靠实测推翻了自己(见 §3.6、§4.5、§7.5)。这种自我修正在复盘里会原样保留。


2. 工具链与环境

2.1 硬件与系统环境

配置
分析主机 Windows 11,PowerShell 5.1
目标设备 一台已 root 的 Android 测试机(约 2021 年旗舰机型,adb 可用,su -c id -u = 0
网络环境 本机代理为 TUN + fakeip 模式(这一点后面酿成了不少麻烦,见 §6.3)
运行期目标环境 ① Windows PC;② OpenWrt 软路由(python3-light,免 root)

2.2 工具清单(全部便携式,零系统安装)

工具 版本 用途
Temurin JDK 17.0.20.1+1 jadx / apktool / keytool 的运行时
apktool 2.9.3 资源与 Manifest 反编译
jadx CLI 1.5.6 Java/Kotlin 层反编译
blutter 对应 Dart 3.8.1 arm64 Dart AOT 方法体还原(Flutter 逆向的关键武器)
Android platform-tools 最新 adb
Python 3.13.15 最终客户端,只用标准库
Edge(headless) 系统自带 Web SDK 行为验证

环境组织成一个自包含的工作区,APK、反编译产物、笔记、证据分目录存放,后期清理只删一个文件夹。这个习惯很重要:整个项目持续约三天,产出物横跨十几种格式,没有目录纪律早就乱了。

2.3 方法论:三层证据法则

我给自己定的工作流,后面所有章节都按这个顺序推进:

静态侦察(apktool / jadx / strings / blutter)
      ↓  形成假设
动态取证(adb / logcat / tcpdump / /proc/pid/mem)
      ↓  证实或证伪
协议实测(PC 直连真实接口,逐字段比对)
      ↓  只有走到这一层的结论才敢写进报告

关键原则:区分「服务端语义」和「客户端实现」。 客户端 JS 里没有 30 秒等待常量,不等于服务端不校验——前者是 grep 结论,后者只能实测。这两件事混在一起是新手最常见的翻车方式。


3. 第一层:静态侦察

3.1 拿到样本:SAI 导出 split bundle

样本是 SAI(Split APKs Installer)导出的拆分安装包,一个 .apk+ 目录(本质是 zip):

<TargetApp> <version>.apk+
├── base.apk                  # 44 MB,主包
├── split_config.arm64_v8a.apk  # 20 MB,原生库(libapp.so 在这里)
├── split_config.zh / en / ko / xxhdpi.apk   # 语言与密度资源
└── apk+.json                 # 拆分清单

主包含 DEX、Flutter 资产与资源;arm64 split 才是业务逻辑的本体。样本的 SHA-256 已记录在笔记里(脱敏起见不贴全值),复跑时先对样本再开工。

3.2 apktool:Manifest 里的第一份情报

反编译 Manifest 后的关键发现:

解读
包名 com.<...>.<...>(已脱敏)
versionName / versionCode 〔已脱敏〕 用于样本归档,不作为公开识别线索
minSdk / targetSdk 28 / 36 高 targetSdk,意味着各类”历史漏洞式”备份方案基本失效
分发渠道 Google Play(com.android.stamp.source=play.google.com,STAMP_TYPE_DISTRIBUTION_APK) Play 分发 + Play 应用签名
Application com.pairip.application.Application Google Play 完整性保护(pairip)包装了真实 Application——这是一个强信号
Firebase 组件 FirebaseAppCheckRegistrar 等在册 App Check 已接入
allowBackup 未声明 默认行为被 targetSdk 抬高后收紧,adb backup 路线(后验)预期收益低
MainActivity exported(常规,含 VIEW intent-filter) 无可疑导出组件

pairip 的初次判定com.pairip.application.Application + integrity.properties 说明这个 App 用了 Google Play 的完整性/许可校验。它通常做两件事:反篡改、以及把 Play Integrity 能力以某种形式暴露给应用。我在这里标记了一个待验证命题:App 的敏感接口是否会经过 pairip 产生一次性证明? 这个悬念一直留到 §5 才解开。

3.3 签名证书:不依赖 apksigner 的解析

需要知道签名证书指纹(后续任务平台 SDK 的初始化请求里会上报 sha256_cert)。不想装 Android SDK,于是直接手写 APK Signing Block 解析——這件事本身就是个不错的小练习:

#!/usr/bin/env python3
"""Extract signer certificate SHA-1/SHA-256 from APK Signing Block (v2/v3), no apksigner needed."""
import hashlib, struct, sys

def lp(data, p):
    """length-prefixed blob: uint32 len + bytes"""
    n = struct.unpack("<I", data[p:p + 4])[0]
    return data[p + 4:p + 4 + n], p + 4 + n

def certs_from_block(blob):
    certs = []
    pos, end = 8, len(blob) - 24 - 8
    while pos + 12 <= end:
        pair_len = struct.unpack("<Q", blob[pos:pos + 8])[0]
        pair_id  = struct.unpack("<I", blob[pos + 8:pos + 12])[0]
        if pair_id in (0x7109871A, 0xF05368C0):          # v2 / v3
            seq = blob[pos + 12:pos + 8 + pair_len]
            signers, _ = lp(seq, 0)
            p = 0
            while p < len(signers):
                signer, p = lp(signers, p)
                signed_data, _ = lp(signer, 0)
                q = 0
                _, q = lp(signed_data, q)               # digests
                cs, q = lp(signed_data, q)              # certificates seq
                r = 0
                while r < len(cs):
                    cert, r = lp(cs, r)
                    certs.append(cert)
        pos += 8 + pair_len
    return certs

def main():
    data = open(sys.argv[1], "rb").read()
    idx = data.rfind(b"PKx05x06")                      # EOCD
    cd_off = struct.unpack("<I", data[idx + 16:idx + 20])[0]
    if data[cd_off - 16:cd_off] != b"APK Sig Block 42":
        raise RuntimeError("no signing block")
    blk_size = struct.unpack("<Q", data[cd_off - 24:cd_off - 16])[0]
    blob = data[cd_off - blk_size - 8:cd_off]
    for i, c in enumerate(certs_from_block(blob)):
        print(f"--- cert #{i}, {len(c)} bytes DER")
        print("SHA-256:", hashlib.sha256(c).hexdigest().upper())
        print("SHA-1:  ", hashlib.sha1(c).hexdigest().upper())

跑出来一对摘要值(脱敏略去),再用 keytool -printcert 核对证书主体和算法;证书有效期与指纹不公开。

判定:Play App Signing 的部署证书。含义有两个:① 设备上从 Play 装的原版应用同签名;② 重打包版签名不同——后来这个差别被用来解释「为什么重打包的机器上某条原生通道直接坏掉」(§10.2)。

3.4 jadx:Java 层没什么料,但排除法有价值

jadx 全量反编译 base.apk(六个 DEX)。结论很「负」但很重要:

  • Java 层几乎只有壳:MainActivity(Flutter embedding)、一个第三方任务平台 SDK(下称 DP,包名 com.<vendor>.<sdk>.*,带一层混淆包)、Firebase SDK、以及若干广告/支付/订阅 SDK;
  • 没有任何业务逻辑:登录、任务、余额全在 Dart 侧;
  • 但 DP SDK 的网络层是原生 HttpURLConnection,代码没混淆,可以完整读出它的请求格式——包括 initialize 时上报的全量设备指纹(签名证书 SHA-256 + 广告 ID + install_id + android_id + 机型/系统/网络/电量/locale)。这补上了「第三方任务平台如何绑定设备」的证据,哪怕最后证明本账号走的是另一条路(§4.5)。

3.5 libapp.so:Dart AOT 的字符串金矿

Flutter 的业务逻辑在 libapp.so(约 8.3 MB AOT 快照,arm64 split 内),libflutter.so 约 11.3 MB。Dart AOT 的一个便利点是:符号/字符串没有被有效混淆strings 一遍,货很多:

  • 完整 Dart 源码路径树:package:<App>/screens/task_wall_screen.dartservices/<dp>_service.dartservices/integrity_service.dartservices/purchase_api.dartservices/wallet_service.dart……相当于免费拿到了模块划分图;
  • HTTP 客户端是 Dio(约 47 处引用)+ package:http(约 20 处),没看到证书固定(pinning)的迹象;
  • 11 个自有 REST 端点字符串,包括 GET /api/integrity/nonce/POST /api/integrity/attest//api/rewards/claim-with-tasks//api/tasks/wall/... 等;
  • MethodChannel 名清单:<app>/<dp><app>/<dp>_events、以及一个语义敏感的 <app>/integrity
  • JS 桥相关字符串:window.on<App>Token(...)window.on<App>Volume(...)(App 注入给 WebView 页面用)、DP SDK 的 <DP>Task / <DP>App 桥;
  • 字段名:wallet_balance_centstasks_completedtasks_revenue_usdtasks_configtasks_wall_urlsdk_key_len / sdk_key_prefix / token_expiry 等。

此时我已经能画出两条并存的”任务提供方”假设:DP 平台(第三方数据标注)自家后端的任务墙网页tasks_config 文档里的 tasks_provider 字段暗示这是可配置的——这个伏笔在 §4.5 引爆。

3.6 第一轮的结论,以及一个被推翻的判断

基于以上静态证据,我第一轮给出的结论是:

“任务协议强绑定设备指纹与第三方 SDK,普通 HTTP 客户端难以直接执行任务。”

这个结论后来被证明证据不足,我撤回了它。 问题出在:我把「DP SDK 初始化时上报设备指纹」这个事实,错误地外推成了「任务接口每次调用都要设备证明」。静态分析只能告诉我”客户端做了什么”,服务端”实际校验什么”是灰的。正确的姿势是:先假设协议本体是朴素 HTTP,再设计实验去测服务端的真实边界。

这次翻车的方法论价值:写进报告的每一句”不可行”,都要配一个”实验编号”。没有实验支撑的判断,连自己都别信。


4. 第二层:动态取证(真机 + root)

静态给出地图后,上真机。root 已具备,adb 直连。

4.1 完整数据目录快照:三个独立来源的反证

导出整包数据目录后第一件事:验证「DP SDK 到底有没有被初始化过」。方法不是找”有”,而是找”没有”——三个独立来源交叉:

  1. shared_prefs/ 共 31 个 XML 文件,逐一列出,没有 <dp>_sdk_prefs.xml
  2. WebView Cookies 数据库 0 条记录;Local Storage / Session Storage 无 DP 域名痕迹;
  3. adb root 实时列 /data/data/<pkg>/shared_prefs/,同样没有。

阶段结论:在这次设备快照与观察窗口内,没有看到 DP SDK 初始化或其页面访问的证据。也就是说,静态分析里最完整的那部分(DP 的 WebView、JS 桥、指纹上报)未能证明与本账号的任务墙路径有关。这不是对所有账号和所有时间的断言,却足以促使我优先检查另一条任务源。

复盘要点:反证(证明某事没发生)经常比证明某事发生了更有信息量,因为它直接砍掉整条研究路线。

4.2 凭据存储调查:三种东西,三种安全等级

<data>/shared_prefs/
├── com.google.android.gms.signin.xml        ← Google 登录 idToken(明文 XML,1 小时有效)
├── Flutter.*.xml / profile_done_<uid>       ← Flutter 侧状态(明文)
└── (EncryptedSharedPreferences 目录)        ← Firebase 持久化登录(设备外不可解密)
  • Google idToken:明文躺在 com.google.android.gms.signin.xmltokenId 字段。这是 Google Sign-In 的缓存,有效期约 1 小时(iat/exp 相差 3600s),且它不是 Firebase token。
  • Firebase ID Token / Refresh Token:存在 EncryptedSharedPreferencescom.google.firebase.auth.api.Store.*),密钥在 Android Keystore 里,设备外不可解密。这把「备份数据文件迁移凭据」的路线直接判死(也再次证明 §3.2 里 allowBackup 未声明 + 高 targetSdk 的判断)。
  • 其它顺带取证:Firebase UID(Flutter prefs 与订阅 SDK 的订阅者 ID 双印证)、FCM Token、订阅 SDK 匿名 ID、机型与 adb 序列号——全部记录后打码。

4.3 logcat 与 tcpdump:以及一个真实的低级错误

原计划双轨取证:adb logcat 全量落盘(多轮,每轮 4~12 MB),同时对设备抓 tcpdump 存 pcap(一晚上攒了 20+ MB)。踩的第一个坑非常朴实:

adb shell "/system/bin/tcpdump -i any -s 0 -w /sdcard/task.pcap"
# /system/bin/sh: no closing quote

引号嵌套被 adb shell 的又一层 shell 解析吞了。修正方式:脚本推到设备上再执行,或者用单引号包住整条命令。这种坑值得写进复盘——它提醒你动态取证本身是有噪声的,每条”异常”先怀疑自己的工具链,再怀疑目标。

logcat 里确实捞到了不少东西:Firebase Auth 的状态变化、[integrity] attest ok=... 自研完整性模块的日志行(App 自己 debugPrint 出来的,说明该模块会把结果打到日志)。但任务墙相关请求在 logcat 里几乎不可见——因为请求发生在 WebView 内的页面 JS 里,不经过任何会打日志的框架。这构成了下一个动作的动机:不抓包,直接从进程内存里看。

4.4 运行时内存取证:验证身份与页面来源

在本人测试设备上,进程内存取证补上了静态分析无法回答的两个问题:运行时究竟使用哪种身份令牌,以及任务墙页面来自哪个后端。分析时只读取目标进程,按内存映射分段检查,避免全量转储影响设备运行。

区分 Google 登录令牌与 Firebase 身份令牌时,我核对了令牌载荷的签发方和过期时间。原始令牌没有进入公开报告;笔记中仅保留长度、摘要和必要的时间关系。这一步提供的是运行时证据,并不能单独证明服务端如何校验任务提交。

4.5 重大更正:任务墙页面根本不来自第三方 SDK

内存扫描捞出来的完整 URL,彻底改变了研究路线:

https://<app-backend>.example/api/tasks/wall/#token=<Firebase ID Token>&bridge=1&caps=volume&platform=android&lang=zh

也就是说——这个账号的任务墙,是 App 自家后端提供的内联网页(URL 本身来自 Firestore 的 tasks_config.tasks_wall_url),页面内联 JS 与后端 API 直接对话;DP 平台只是这个产品的另一条备选任务源tasks_provider 配置切换),本账号从未走过。

研究方向随之从「逆向第三方 SDK 的 task_bundle 协议」切换到「逆向任务墙网页的内联 JS」——后者反而简单得多,因为 JS 是明文的。如果没有 §4.1 的反证和 §4.4 的内存扫描,我很可能一直在错误的协议上使劲。


5. 第三层:Dart AOT 方法体还原(blutter)

字符串层看不到方法体。要回答两个悬而未决的问题:① 自研完整性 attest 到底算了什么;② 任务/兑换接口的完整请求体。工具选 blutter(针对 Dart AOT 的反编译器,按 Dart 版本和架构选择构建)——不选 Frida 是因为这里需要的是离线、可复跑、可引用到报告的方法体还原,而不是动态 hook。

blutter 输出对象池(objs.txt)、伪汇编(asm/)等信息。按字符串索引直接定位到 services/integrity_service.dartIntegrityService,方法体逐步还原如下:

// ensureAttested()
if (DateTime.now().difference(this._lastAttest) < kCacheDuration) return Future.value(true);
return _attest().whenComplete(() => this._pending = null);

// _attest() —— 完整流程
1. user  = FirebaseAuth.instanceFor(app: Firebase.app()).currentUser;  if null -> false
2. idToken = await user.getIdToken();                                  if null -> false
3. POST /api/integrity/nonce/     (Bearer idToken)  -> rawNonce
4. token = await MethodChannel("<app>/integrity").invokeMethod(
              "requestToken", {"nonce": rawNonce});      // ★ 原生侧生成,见下
5. POST /api/integrity/attest/    (Bearer idToken, body {"integrity_token": token})
   -> {"ok": true}  -> 仅在内存缓存时间戳,不落盘

★ 关键发现:在检查过的 DEX 与原生库中,未找到 MethodChannel("<app>/integrity") 的接收方。结合 §3.2 的 pairip 包装和完整性配置,我推测该通道由运行时组件提供,可能封装 Play Integrity API(nonce → verdict token)。仅凭当前材料无法证明具体实现位于哪个组件,因此这里保留为推断。

这个判定的分量很重,它直接切断了后面所有”用脚本伪造 attest”的幻想:

该 token 只能由真实设备上的真实 App 实例产生。 普通 HTTP 客户端、模拟器、重打包包都无法复现。

顺带还原的还有兑换接口 RewardsApi.claimWithTasks 的完整链路:先 ensureAttested()(内部还会再调一次),再 POST /api/rewards/claim-with-tasks/headers 只有 Authorization: Bearer <Firebase ID Token>,body 为 {country_code, data_mb, recipient_email, customer_name}——注意它不携带 integrity_token,说明 attestation 是服务端会话状态(attest ok 之后端按 uid 打了时间窗标记),不是每请求签名。这个细节决定了风险模型的形状:门禁是一次性的、有缓存窗口的,但普通客户端依然过不了。


6. 认证体系:把身份从设备里”迁出来”

任务协议本身(§7)只认 Authorization: Bearer <Firebase ID Token>。于是核心工程问题变成:如何在手机不参与的情况下,长期持有有效的 Firebase 身份?

先静态还原登录链(Dart 源码路径 + blutter 产物交叉):

auth_wrapper.dart : _AuthWrapperState::_signInWithGoogle
  -> GoogleSignIn.signIn (google_sign_in_android)
  -> GoogleSignInAuthentication { idToken, serverAuthCode }
  -> FirebaseAuthHostApi.signInWithCredential
  -> FirebaseAuth.instanceFor().currentUser.getIdToken()   // 全 App 到处都用它

6.1 后端账号体系探测(全部只读)

在动手搬凭据之前,先把 Firebase 项目的认证配置面摸清楚(用的是公开客户端配置 + 未认证接口的错误行为推断):

探测 结果 解读
getProjectConfig?key=<apiKey> 200,authorizedDomains = localhost<proj>.firebaseapp.com<proj>.web.app、某管理后台、官方站 localhost 在授权域里——这是免 root 路线的支点
GET <proj>.firebaseapp.com/__/auth/handler 200,真实 Firebase Auth Handler 页面(含 fireauth.oauthhelper.widget.initialize() 官方 handler 在线可用
signInWithIdp 带假 token INVALID_IDP_RESPONSE: Unable to parse Google id_token Google 登录已启用(若被禁用是 OPERATION_NOT_ALLOWED
accounts:signUp(空 body) ADMIN_ONLY_OPERATION 邮箱密码注册关闭——Google 登录是唯一交互路径,反而简化了攻击面
RTDB /.json?shallow=true 401 规则锁定,anonymous 读不到 tasks_config
Firestore 七种猜测配置路径 全部 403 同上。apiKey 只能来自 App 端认证读取

一个重要的负面结论:直连数据库的路不存在。所有数据面都必须走认证态。这让”认证移植”从可选动作变成了必答题。

6.2 路线一(root):核对设备上的认证状态

在本人已 root 的测试设备上,我核对了 Google 登录缓存与 Firebase 会话的区别,再通过 Firebase 的标准身份交换流程验证:Google 登录令牌可换得 Firebase 会话,后者包含短期 ID Token 和可续期的 Refresh Token。实测返回 HTTP 200。

这里最容易犯的错误是把两种令牌混为一谈:它们的签发方、用途和生命周期不同。公开版只保留这个协议结论与响应状态,不提供从设备存储提取凭据的命令或真实令牌。

6.3 路线二(免 root):把浏览器变成一个”合法 origin”

没有 root 设备的情况下,思路是:利用 localhostauthorizedDomains 里这一事实,在本地跑一个小页面,让用户用自己的浏览器完成一次真实的 Google 授权(Firebase 官方推荐的 localhost 开发流)。

这个方案的每一块都得实测,我把它拆成可独立验证的小实验——这是整个项目里最有”考古感”的部分,因为大部分结论是从错误码的反行为里推出来的:

实验 A:redirect_uri 枚举。 Google OAuth 侧只登记了一个 redirect_uri。把各种候选喂给 accounts.google.com/o/oauth2/auth(关自动重定向看响应):

redirect_uri 候选 Google 的响应
https://<proj>.firebaseapp.com/__/auth/handler 302 → 账号选择页(放行)
https://<proj>.web.app/__/auth/handler redirect_uri_mismatch
http://localhost:8765/__/auth/handler redirect_uri_mismatch
http://127.0.0.1:8765/callback redirect_uri_mismatch
urn:ietf:wg:oauth:2.0:oob redirect_uri_mismatch(提示”只能用于原生应用客户端”)

结论:自建回调地址全部走不通;但官方 Firebase Web SDK 的 redirect_uri 本来就是 handler 自身,天然放行。所以不用去”加回调”,顺着官方流走就行。

实验 B:origin 授权域。 Firefox Auth JS SDK 在调用 signInWithPopup 时校验页面 origin:

origin 结果
http://127.0.0.1:8899 auth/unauthorized-domain
http://localhost:8899 成功构造 handler URL,随后 auth/popup-closed-by-user(因为测试桩关了假弹窗)

注意 auth/popup-closed-by-user 在这里是正向信号:它说明 origin 校验已通过、handler URL 已构造、链路只差一次真实的人工点击。把错误码当探针用,是灰盒测试的基本功。

实验 C:provider 状态(见 §6.1 表)+ 实验 D:handler 的 state 依赖。我尝试过 headless 直 GET handler 带假 code,得到 Unable to process request due to missing initial state——handler 的 state 存在同 origin 的 sessionStorage 里,假 code 驱动不了真 handler。判定:这一环不可脱离真实浏览器,但也不影响正式用户流(SDK 会自动完成)。

实验 E(真实端到端)localhost:8899 起本地服务 + 页面内 Firebase JS SDK,真人点一次 Google 账号授权 → 页面拿到 Google idToken → 立即 signInWithIdp 兑换 → 长期 Refresh Token 落盘。一次通过。

过程中的两个真实故障(都记了档):

  1. 后台进程被工具会话杀掉:早期用 Start-Job 起的本地服务随分析会话一起退出,浏览器满脸问号”localhost 拒绝了连接”。修正:改用独立进程启动(Start-Process -WindowStyle Hidden),并写了个轮询脚本盯端口和凭据状态。教训:托管方式本身就是实验变量,别让工具的副作用污染结论。
  2. TUN/fakeip 代理下整页跳转丢状态signInWithRedirect 的完整回跳链路(localhost → handler → Google → localhost)回来后 getRedirectResult 返回 null。判定为代理改写/延迟导致 Firebase 的 pending-redirect state 丢失,而非授权域问题。修正:页面改用 signInWithPopup(postMessage 回传,不经过 Location 跳转),一次成功。

凭据落盘的安全设计(这部分是写给将来的自己看的,日志里也绝不能露):

def _atomic_write(path, text):
    """tempfile + os.replace 原子替换;权限收窄到仅当前用户"""
    fd, tmp = tempfile.mkstemp(prefix=".tok-", dir=os.path.dirname(path) or ".")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            f.write(text)
        os.replace(tmp, path)              # 同目录原子替换,无半截文件窗口
    finally:
        if os.path.exists(tmp):
            os.unlink(tmp)
    try:
        os.chmod(path, 0o600)              # POSIX 600
    except OSError:
        pass

Windows 上再加一层:DPAPI(CurrentUser)加密落盘、icacls /inheritance:r /grant:r <user> 收窄 ACL、明文字令即删。自测过 protect → check → emit 全流程:check 只输出 uid/token 的 SHA-256 摘要与长度,CLI 红线:任何子命令都不打印 token 明文。凭据文件与目录全部进 .gitignore

6.4 三条路线的统一:两种会话模式

凭据来源归一后,客户端支持两种会话,接口完全一致:

模式 凭据 续期方式 适用
Session(长期) refresh_token.txt securetoken 每小时自动刷新,提前 2 分钟 会话持续性验证
DirectSession(直连) direct_token.txt(1h Firebase ID Token) 过期需重新粘贴/抓包 临时应急
def _refresh(self):
    body = f"grant_type=refresh_token&refresh_token={self.refresh_token}".encode()
    req = _mkreq(config.refresh_url(), data=body,
                 headers={"Content-Type": "application/x-www-form-urlencoded"})
    with socks.open_url(req, timeout=config.TOKEN_TIMEOUT) as r:
        d = json.loads(r.read())
    # 刷新令牌可能轮换,持久化最新的
    if d.get("refresh_token") and d["refresh_token"] != self.refresh_token:
        self.refresh_token = d["refresh_token"]
        _restrict_write(REFRESH_TOKEN_FILE, self.refresh_token)
    self.id_token = d["id_token"]
    self.exp = time.time() + int(d.get("expires_in", 3600))
    return self.id_token

def token(self):
    """始终返回有效 idToken(提前 2 分钟续期)"""
    if not self.id_token or time.time() > self.exp - 120:
        self._refresh()
    return self.id_token

两个细节:提前 2 分钟续期避免临界提交时 401;轮换后的 refresh token 重新原子落盘——否则一次轮换就可能把长期凭据弄丢。


7. 任务协议:从 WebView 内联 JS 到三个端点

认证通道打通后,剩下的就是纯协议工作了。任务墙是自家后端的内联 JS 页面(约 40 KB,明文),URL 形如:

https://<app-backend>.example/api/tasks/wall/#token=<Firebase ID Token>&bridge=1&caps=volume&platform=android&lang=zh

页面从 URL fragment 读取 token。fragment 通常不会作为 HTTP 请求路径发送到服务器,但仍可能留在浏览器历史或客户端日志中,不能视为安全存储。之后 API 调用带 Authorization: Bearer <idToken>Content-Type: application/json;401 时页面尝试通过桥接通道刷新会话。

7.1 三个端点,一张表读完全部协议

端点 方法 请求体 响应(关键字段)
/api/tasks/wall/status/ GET {enabled, balance_usd, tasks_completed, window:{used,max,resets_at}, today:{used,max}}
/api/tasks/wall/assign/ POST {lang, caps} {success, assignment_id, task:{task_id, type, min_seconds, value_usd, payload:{question, media_a, media_b, poster_a, poster_b, gen_prompt, attention:{...}}}},或限制响应 {reason: side_capped/window_capped/daily_capped, blocked_side, retry_at}
/api/tasks/wall/submit/ POST {assignment_id, answer, attn_answer, watched} {success, outcome: credited/too_fast/too_soon/attention_failed, credited, value_usd, balance_usd, retry_in}

任务类型三种:image_choice / video_choice / audio_choice(audio 题会把 gen_prompt 藏起来当作 attention 检查句——原来它还有个”你有没有在听”的功能)。

一次 assign 的真实响应(脱敏后):

{"success": true,
 "assignment_id": "<assignment-id>",
 "task": {"task_id": "<task-id>", "type": "video_choice", "lang": "any",
   "payload": {
     "question": "Which video matches the prompt better?",
     "media_a": "https://<oss>.example/<app>/evals/vid1/.../video-037.mp4",
     "media_b": "https://<oss>.example/<app>/evals/vid1/.../video-037.mp4",
     "poster_a": "...jpg", "poster_b": "...jpg",
     "duration_a": 8.042, "duration_b": 8.042,
     "gen_prompt": "一名女子在行走,一头牛正在吃草",
     "prompt_lang": "zh",
     "attention": {"x": 10, "y": 9, "op": "-", "options": [5,2,9,1], "kind": "math"}},
   "min_seconds": 32, "value_usd": 0.012}}

几个可直接读出的事实:

  • task_id 具有「类型、题号、模型版本」的结构,和论坛截图中的格式一致。这支持“两者使用相似任务协议”的判断,但单靠 ID 结构还不能证明账号或服务端完全相同;
  • 素材是对象存储直链,视频题带海报帧和时长;
  • attention 是 100 以内加减法,答案必在 options 里;
  • min_seconds(本例 32s)由服务端下发value_usd 每题固定 $0.012。

7.2 attention:服务端下发的算术检查

任务响应包含一道简短的算术检查,提交时需带 attn_answer。有限样本中,这项检查没有拒绝正确答案。它能验证请求者是否完成了这一步计算,但不能据此推断服务端拥有可靠的人类行为验证。

7.3 「等待 30 秒」的真相与反作弊语义

  • 时限min_seconds 服务端下发(样本中约 30~40 秒);提前提交会得到 too_fastretry_in。这证明时限至少部分由服务端校验,不能仅凭客户端倒计时作判断。
  • 媒体消费:视频题媒体 1x 倍速锁定、禁拖进度、watched 字段上报覆盖率;audio 题检测音量。但实测发现:不携带 watched 字段也照常 credited——覆盖率上报非强制。这是典型的”客户端有采集、服务端未强校验”。
  • 频率限制(服务端响应中的口径):小时窗口 window.max = 60,日上限 today.max = 200。这些数字说明服务端有配额控制;不能把配额误读成允许自动化提交的许可。

7.4 限流语义:把”服务端在说话”和”客户端出错了”分开

assign 拿不到任务时,响应是有限流语义的:

{"success": true, "reason": "side_capped", "blocked_side": "a", "retry_at": "2026-09-22T18:40:00Z"}

side_capped 的发现过程很有意思:长时间跑之后 A 侧被锁——服务端在强制 A/B 两个选项的分布均衡(防止全选一边刷)。这直接催生了客户端的配平算法(§9.5)。处理限流响应的核心原则:

{success:true, reason:...}服务端决策,按 reason 退避即可;只有真异常(非 dict、缺字段、空响应)才值得报警和重试。

7.5 与第三方 DP 平台的关系:一次排除误判

静态阶段我花了些力气在 DP 协议上(task_bundle / record_task_bundle 也是纯 HTTP + Bearer session_token,同样无每请求设备证明)。动态阶段(§4.1/§4.5)证明本账号根本不走 DP。最终定位:DP 是产品的备选任务提供方(Firestore tasks_provider 切换),且其 session 来自 SDK 初始化、短期有效,与 Firebase 凭据不等价(曾有人误以为导出 DP session 就能替代登录——排除之)。

复盘要点:两条相似的技术路线,先证明”这条没走”,再走另一条。 把排除过程也写进证据链(EVIDENCE §8),后来人不用重跑。


8. 验证路径:只读、单题与有限观察

协议结构明确后,我按风险递增的顺序验证:先读状态,再做一次受控提交,最后记录有限时段的服务端反馈。每一步都单独记账,避免把一次成功外推为长期可靠或平台许可。

8.1 V0:只读客户端

PC 直连,无手机参与,凭据是一次性 Google 登录换出的 Refresh Token:

GET  /api/tasks/wall/status/        (Bearer: Firebase ID Token)
-> HTTP 200
{"enabled": true, "balance_usd": 0.1586, "tasks_completed": 13,
 "window": {"used": 13, "max": 60, "resets_at": "..."},
 "today":  {"used": 13, "max": 200}}

同一时刻另外两个可移植性观测:Refresh Token → securetoken 换新 ID Token HTTP 200expires_in=3600,且 refresh token 稳定不轮换;Google idToken → signInWithIdp 同样 200。三条基础事实全部在纯 PC 环境成立,”脱离手机”从假设变成事实。

8.2 第一次单题闭环

assign   : <task-id> (video_choice, min_seconds=32, value_usd=$0.012)
素材     : 两个 S3 视频直链(2.39MB / 7.83MB)+ 海报帧
attention: 10 - 9 = 1(本地求解)
判断     : 人工 + VLM 海报帧比对 -> B
submit   : {"assignment_id": "...", "answer": "b", "attn_answer": 1}
响应     : {"success": true, "outcome": "credited", "credited": true,
            "value_usd": 0.012, "balance_usd": 0.1706}
结果     : balance $0.1586 -> $0.1706 (+$0.0120),tasks_completed 13 -> 14

脱离手机的完整任务闭环(assign → submit → credited)成立。 同时验证了两个边界:min_seconds 到期后提交未被拒;不携带 watched 也正常 credited。

8.3 有限时段观察与一个关键发现

在一个有限时段内,测试客户端记录到以下结果。发现异常后停止了这项验证:

提交 17 题,17/17 credited(A/B 随机选择)
余额 $0.1706 -> $0.4106(+$0.24,与任务数严格线性)
节奏 ~40s/题(assign + min_seconds 等待 + submit),window 34/60
无 too_fast、无 401、无网络错误
attention 本地求解 100% 正确

关键观察:随机答案也获得了 credited。这是一个需要报告的校验缺口信号,不代表这些答案有质量,也不能据此继续累积有报酬的提交。两种解释仍可能并存:(a) 当前接口只验证流程条件;(b) 质量评分在后续阶段体现,未出现在即时响应中。

当时无法区分,因此我把质量评分是否存在标为 UNKNOWN。这也是做逆向的基本态度:把未证实的猜测留在假设栏,不把即时记账当成质量验证的证据


9. 工程化:从一个能跑的脚本到一个可维护的系统

协议验证很快跑通,但可重复的研究工具仍需要错误分类、状态保存和可控的测试环境。下面是逐项整理过程。

9.1 架构总览

main.py ──┬── status / once / loop        CLI 三件套
          ├── verify                      受控验证流程(状态记录 / 中断恢复)
          ├── console                     终端交互菜单
          └── web                         Web 控制台(默认 127.0.0.1:8787)

core/
├── session.py    Session(长期续期) / DirectSession(1h 直连)
├── taskwall.py   协议客户端(90s 超时 + 重试阶梯 + 401 强制刷新)
├── engine.py     引擎:assign 解析 / 限流响应 / 单题调度
├── scheduler.py  测试任务调度
├── socks.py      SOCKS5/5h/4/4a 客户端,所有出口总闸
├── accounts.py   token 导入/识别/兑换/多账号
├── config.py     环境变量 > agent-config.json > 默认值
└── errors_log.py 结构化错误日志(token 一律掩码,8MB 轮转)

routes/{root,noroot,server}/   三条凭据路线入口,共享同一份 core/
tests/                         mock 后端 + 5 套测试

9.2 为什么只用标准库

约束来自部署目标:OpenWrt 软路由上的 python3-light——没有 pip,装不了 requests。于是:

  • HTTP 走 urllib
  • SOCKS 代理自己实现(SOCKS4/4a/5/5h,约 400 行,含用户名密码认证、域名交给代理解析);
  • Web 控制台用 http.server 单文件实现,页面内嵌;
  • 好处是 scp 整个目录到路由器 python3 main.py web 就能跑;代价是所有边角(TLS 超时分类、连接池复用)都要自己管。

顺带一个 OpenWrt 专属坑:ssl 在 OpenWrt 是独立包,必须 opkg install python3-light python3-ssl;缺包时报的是 ImportError,和代码 bug 长得一样,排查时先跑 python3 -c "import ssl" 再怀疑人生。常驻 RSS 约 10~20 MB,128 MB 路由无压力。

9.3 传输层健壮性:一张重试阶梯

目标后端跑在某免费 PaaS 上,有个著名脾气:冷启动首个请求 30~60 秒才响应。直接调 45s 超时必挂。于是客户端把”传输错误”和”业务错误”彻底分开:

def _call(self, path, body=None, retry_401=True, tries=3):
    url = config.base_url() + config.TASKWALL + path + "/"
    req = urllib.request.Request(
        url, method="POST" if body is not None else "GET",
        data=json.dumps(body).encode() if body is not None else None,
        headers={"Content-Type": "application/json",
                 "Authorization": "Bearer " + self.s.token()})
    for attempt in range(1, tries + 1):
        try:
            with socks.open_url(req, timeout=config.HTTP_TIMEOUT) as r:
                status, raw = r.status, r.read()
            if not raw:
                raise TaskWallError(f"{path} empty body (truncated?)", code=0)
            return status, json.loads(raw)
        except TaskWallError as e:                       # 空 body
            last = e
        except json.JSONDecodeError as e:                # 响应截断
            last = TaskWallError(f"{path} bad json (truncated?): {e} len={len(raw)}", code=0)
        except urllib.error.HTTPError as e:
            if e.code == 401 and retry_401:              # token 临界失效:强制刷新重放一次
                self.s.force_refresh()
                return self._call(path, body, retry_401=False, tries=tries)
            if 500 <= e.code < 600 and attempt < tries:  # 服务端忙:重试
                last = TaskWallError(f"{path} HTTP {e.code} (server busy)", code=e.code)
            else:                                        # 业务 4xx:不重试,直接抛
                raise TaskWallError(f"{path} HTTP {e.code}", code=e.code, payload=...)
        except OSError as e:                             # URLError / 超时 / TLS 统一归类
            last = TaskWallError(f"{path} network: {e}", code=0)
        if attempt < tries:
            time.sleep(2 * attempt)                      # 指数退避
    raise last                                           # 重试耗尽

设计决策备注:

  • 超时 90s(不是常见的 30s):为冷启动让路;
  • 401 强制刷新重放恰好一次:防止 refresh 失败导致的死循环;
  • 业务 4xx 绝不重试:重试一个”今日额度已满”毫无意义,还会放大风控信号;
  • submit 的重试只在传输层:业务成功/失败只看响应体;服务端按 assignment_id 幂等,重发安全。

9.4 单实例锁,与一次 KeyError 悬案

线上跑着 auto 循环,某晚开始每分钟一条 KeyError: 'assignment_id',期间 assign 调用照常发出。完整的破案过程(这段是我在整个项目里最喜欢的推理):

现象:HTTP 层面完全正常——网络中断会抛 TaskWallError: network:,响应截断会抛 JSONDecodeError,只有 HTTP 200 且 JSON 完整 才会走到 KeyError。也就是说服务端成功响应了,只是 body 里没有 assignment_id

排除法

  1. 不是限额:当时 today=125~130/200,window 未满;
  2. 不是响应结构变更:success=true 但缺 task 的变体会带 reason 字段,而报错时没有;
  3. 多条 KeyError 间隔仅 2~5 秒,不符合单实例 60s 退避 → 强烈怀疑同时开了两个 auto 实例:两个客户端并发 assign,撞上服务端的在途任务重发(reissued)逻辑,返回了不带 assignment_id 的变体结构。

修复(三件套 + 回归)

def acquire_lock():
    """防止同一个 agent 同时跑多个实例(并发 assign 会触发服务端非标准响应)"""
    if os.path.exists(config.LOCK_FILE):
        try:
            old = int(open(config.LOCK_FILE).read().strip() or "0")
        except Exception:
            old = 0
        if old > 0 and old != os.getpid() and _pid_alive(old):
            print(f"[x] another instance is running (pid {old}), stop it first. exiting.")
            raise SystemExit(1)
    with open(config.LOCK_FILE, "w") as f:
        f.write(str(os.getpid()))
  1. 新增 parse_assign() 统一解析 assign 响应,三元组分流("task", ...) / ("cap", ...) / None(真异常)。服务端限流语义与客户端异常彻底分开——这是从根上杜绝把 KeyError 当异常抛的架构改动;
  2. 单实例锁 .agent.lock(PID 存活检测,CLI 与 Web 共用一个锁);
  3. 传输层重试阶梯(§9.3);
  4. 回归测试 test_robust.py:7 种畸形 assign 响应 + 重试成功/耗尽/截断三类场景全 PASS,锁实测 exit 1,main.py status 无回归。

透明记录:诊断过程中为确认服务端响应结构,人工手动调用过一次 assign(消耗 1 个窗口名额)——也写进了报告。调试行为本身是被测系统的输入,记账要诚实。

9.5 A/B 分布:把限流当作研究信号

§7.4 的 side_cappedblocked_sideretry_at 说明服务端会关注答案分布。对协议分析来说,这些字段的意义是确认服务端存在额外的行为约束,而不是提示客户端怎样绕开约束。

公开版不提供答案配平或规避检测算法。测试工具对这类响应应停止提交并保留证据,后续研究也需要在平台允许的范围内进行。

9.6 网络出口治理:全流量走一个总闸

代理环境下,出口差异会干扰网络错误的判断。客户端把 HTTP 出口收敛到一个 socks.open_url()

with socks.open_url(req, timeout=config.HTTP_TIMEOUT) as r:
    status, raw = r.status, r.read()

SOCKS 客户端(纯标准库)的几个设计决策:

  • 域名一律交给代理解析(等效全局 socks5h):不在本地做 DNS,防泄露,也避开路由器本地 DNS 异常时卡死;
  • 屏蔽环境里的 http_proxy:防止和其它工具的代理串味;
  • 连代理 IPv4 优先、IPv6 兜底:双栈半连通(拿到 AAAA、连上但不通)会假死 30s+;
  • 素材请求与 API 使用一致的出口:避免测试结果受不同网络路径干扰;
  • 支持 socks5:// / socks5h:// / socks4:// / socks4a:// / 直连,配置优先级:环境变量 > agent-config.json > proxy.txt

网络出口记录用于解释请求失败和代理环境差异;本次结论不依赖多账号或出口切换。

9.7 Web 控制台与部署形态

Web 控制台单文件内嵌页面,默认只绑 127.0.0.1:8787,用于凭据状态检查、协议响应查看、代理配置与错误日志检索(token 掩码)。要远程访问必须显式设访问令牌(web_token.txt 或环境变量),绑 0.0.0.0 时启动日志打印警告。

部署形态做过一轮取舍:SOCKS 出口与凭据轮换回写需要稳定的本地状态,因而常驻进程比无状态函数更容易观察和调试。云端定时方案只做可行性研究,未部署。

9.8 测试:不打真实后端的完整回归

自动化跑在真实服务上,测试不能。于是搭了 mock 后端 + 测试代理服务器:

测试 覆盖
test_socks.py SOCKS 握手/认证/隧道/出口探测(含真实 HTTPS 链路,连测试代理服务器)
test_e2e.py mock 后端完整跑 assign → attention → side_capped → submit,直连与走代理双路
test_assign.py parse_assign 三元组分流(含 side_capped 真实响应)
test_balance.py A/B 配平算法效果
test_robust.py 畸形响应 / 重试逻辑(§9.4 的回归)

10. 安全边界与最终结论

10.1 能做什么 / 不能做什么

能力 判定 依据
读取任务墙状态(余额/计数/限额) 可以(纯 HTTP + Bearer) §8.1 实测 200
assign → 本地求解 → 等时长 → submit → credited 可以(无设备绑定、无每请求证明) §8.2/§8.3 实测
Refresh Token 可在 PC 上续期 可以 §8.1 三条 200
匿名读取 Firestore/RTDB 配置(拿 apiKey 之类) 不可以(401/403,规则正确锁定) §6.1
脚本伪造完整性 attest 不可以(Play 完整性栈,真实设备真实实例才能产生) §5
调用权益兑换接口(attest 门禁后的那一步) 不可以 §5 / §10.2
直写 Firestore 余额字段 不可以(认证态 + 规则限制) §6.1

10.2 唯一的真边界:pairip 完整性门禁

任务链路一路绿灯,但权益兑换前有一步 ensureAttested():nonce 来自服务端,token 由原生完整性客户端生成,验证在服务端。客户端侧看不到算法、拿不到密钥、伪造不了 Play Integrity verdict。这条边界的诚实定位:它不是”我没攻破”,而是”按设计不可从设备外复现”——研究结论是 negative result,同样是交付物。

顺便:重打包机上这条通道直接损坏(MissingPluginException),而任务功能照常——反过来说明任务链路从未依赖原生完整性,与全篇实测互相印证。

10.3 风险侧记录(不要只写成功)

  • 遥测:任务平台前端接了行为遥测。时间模式或提交分布异常都可能被检测;研究工具不应把规避检测作为目标。
  • 配额不等于许可60/小时、200/天 是服务端返回的配额口径,不能推断平台允许脚本持续提交。
  • 质量分 UNKNOWN:随机答案 100% credited 有两种解释只能并存,已立下观测信号(§8.3)。
  • 凭据即账号refresh_token.txt 等同密码。gitignore、600 权限、DPAPI、日志掩码、多账号隔离,一个都不能少。

10.4 那张「一键脚本」截图的最终判定

回到起点的问题。有了全部证据,可以下结论了:

这张截图与“普通 HTTP 客户端调用任务接口”的架构相符:任务状态和提交走网页接口,而权益兑换另有设备完整性门禁。截图本身无法证明提交质量、长期可靠性或平台授权。

静态阶段的”强设备绑定”判断被彻底推翻——推倒自己的错误结论,和建立正确结论是同一件工作的两半。


11. 方法论沉淀:可迁移的东西

技术会过时,流程不会。这次复盘里真正可复用的是这几条:

  1. 三层证据法则:静态(代码/字符串)→ 动态(进程/网络/日志)→ 实测(PC 直连真实接口)。每层结论标注证据编号,可复跑。跨不过第三层的结论只能算假设。
  2. 区分服务端语义与客户端实现:grep 不到 30s 常量 ≠ 服务端不校验;客户端采集 watched ≠ 服务端强校验。永远用实测划定边界。
  3. 反证优先:证明”这条路没走过”(三个独立来源的 negative evidence)比证明”走过”更能砍掉整条错误路线。
  4. 错误码考古学redirect_uri_mismatch 的候选枚举、unauthorized-domain 的 origin 对照、popup-closed-by-user 作为正向信号——灰盒测试的信息量全在错误码的反行为里。
  5. 把限流当 API 用:服务端返回 {success:true, reason:...} 是它在”说话”,客户端该做的是理解语义(按 retry_at 睡),而不是当异常报警。parse_assign 的三元组分流就是这条原则的代码形态。
  6. 先只读、再单点、后循环:风险递增的验证节奏,配合”人工调用也记账”的透明纪律。
  7. 工程化清单:单实例锁、传输/业务错误分层、401 恰好刷新一次、速率双档、token 轮换持久化、凭据原子落盘 + DPAPI + ACL、日志掩码、mock 后端回归。这些东西和逆向无关,是任何长期运行脚本的及格线。
  8. 如实记录 UNKNOWN:随机答案也 credited——解释不了就立个观测信号挂着,比写满”全面攻破”体面得多,也安全得多。

这次实践最值得保留的,是三段可复核的推理:三级证据链怎样推翻最初的设备绑定判断;一次 KeyError 怎样从现象分类走到并发假设与回归测试;以及标准库工具怎样在受限环境中保持清晰的错误边界。对随机答案获得记账的观察,也应连同未知项与研究边界一起记录。


附录 A:错误码 / 异常速查表(脱敏)

编号 现象 根因 处置
E1 Google OAuth redirect_uri_mismatch 自建回调 URI 均未在该 OAuth client 登记;仅官方 handler 放行 走 Firebase 官方 handler,不自建回调
E2 auth/unauthorized-domain 页面 origin 不在 authorizedDomainslocalhost 在、127.0.0.1 不在 一律用 http://localhost:PORT
E3 auth/popup-closed-by-user(测试环境) origin 已放行、handler URL 已构造,只差人工点击 正向信号:说明链路已通
E4 INVALID_IDP_RESPONSE 假 Google id_token 解析失败 证明 Google provider 已启用
E5 handler missing initial state state 在同 origin sessionStorage,假 code 驱动不了 该环不可脱离真实浏览器,不影响正式流
E6 ADMIN_ONLY_OPERATION 邮箱密码注册被关 Google 登录是唯一交互路径
E7 任务接口 401 ID Token 过期/被踢 强制刷新后重放恰好一次
E8 浏览器 “localhost 拒绝连接” 后台服务被分析工具会话杀掉 独立进程启动 + 轮询监控 + 双击入口
E9 signInWithRedirect 回跳后结果 null TUN/fakeip 代理改写整页跳转链,pending state 丢失 signInWithPopup(postMessage 回传)
E10 循环报 KeyError: 'assignment_id' 并发实例撞服务端在途任务重发逻辑 parse_assign 分流 + 单实例锁 + 重试阶梯 + 回归测试
E11 adb shell 引号嵌套错误 双层 shell 解析吞引号 脚本推设备或单引号包裹整条命令

附录 B:成功证据摘录(脱敏)

B1. 免 root 首次认证端到端(PC,真实 Google 账号人工授权一次)

浏览器 localhost 页面 -> Google 授权(人工 1 次) -> Google idToken
  -> signInWithIdp                          : HTTP 200, 返回长期 refreshToken(email 掩码)
  -> securetoken grant_type=refresh_token   : HTTP 200, expires_in=3600
  -> GET /api/tasks/wall/status/            : HTTP 200, {"enabled":true,...}
关浏览器/全新进程再跑                      : PASS(无 401)
凭据文件复制到临时目录再跑                  : PASS(无绝对路径依赖)
ID Token 过期 2h+ 后再刷新 + status         : PASS(21:10 复跑)
导出给 agent + main.py status               : PASS

B2. 任务闭环(PC,无手机参与)

assign : <task-id>  (video_choice, min_seconds=32, value_usd=$0.012)
attention: 10 - 9 = 1 (本地求解)
submit : {"assignment_id":"<assignment-id>","answer":"b","attn_answer":1}
response: {"success":true,"outcome":"credited","credited":true,
           "value_usd":0.012,"balance_usd":0.1706}
balance: $0.1586 -> $0.1706   tasks_completed: 13 -> 14

B3. 无人值守 30 分钟

提交 17 题, 17/17 credited
余额 $0.1706 -> $0.4106 (+$0.24, 严格线性)
节奏 ~40s/题, window 34/60, 无 too_fast / 无 401 / 无网络错误

B4. 速率与限额的服务端口径

window.max = 60 / 小时, today.max = 200 / 天
value_usd = 0.012 / 题(固定)
min_seconds = 30~40(服务端下发)

结语

三天的活儿,从拆一个 44 MB 的 APK 开始,到纯 PC 环境里脚本化完成全流程收尾。中间真正花时间的不是”找到接口”——协议本身朴素得可怜——而是三件更贵的事:证明自己没有在错误的协议上使劲(反证)、把服务端语义和客户端实现分开(分层)、以及把一个能跑的 PoC 养成一个敢放在路由器上跑一个月的系统(工程)。

最后照例声明:本文所有内容均为个人学习与研究用途的技术复盘,对象为本人拥有账号的测试环境;所有敏感信息已脱敏。如果你也想拆点什么,请同样守住三条线:只动自己的资产、先只读后写入、把”做不到”如实写进报告——最后这条,恰恰是区分业余和职业的分水岭。

上一篇
} });