跳转到内容
123xiao | 无名键客

《安卓逆向实战:从 Frida 动态 Hook 到定位并绕过常见 App 签名校验逻辑》

字数: 0 阅读时长: 1 分钟

安卓逆向实战:从 Frida 动态 Hook 到定位并绕过常见 App 签名校验逻辑

很多同学第一次做 Android 动态分析时,都会卡在一个很现实的问题上:App 一启动就闪退,或者进入关键页面时直接提示“签名异常”“环境风险”“请从官方渠道安装”
这类问题的根子,往往就是签名校验、完整性校验,或者两者的组合。

这篇文章我不打算只讲“怎么写几行 Frida Hook 代码”,而是想带你走一条更接近真实项目的路径:

  1. 先理解 App 常见签名校验到底在校验什么;
  2. 再用 Frida 动态 Hook 去定位“谁在校验”;
  3. 最后再按不同场景选择合适的绕过方式。

说明:本文内容用于授权测试、安全研究与教学。不要把这些方法用于未授权目标。


背景与问题

Android App 的“签名校验”并不只有一种写法。实战里常见的有这几类:

  • Java 层取签名比对
    • PackageManager.getPackageInfo()
    • 新版 API 里的 getSigningInfo()
  • 读取 APK 自身证书摘要
    • 计算 MD5/SHA1/SHA256
  • Native 层校验
    • JNI 调 Java 拿签名
    • 直接在 so 里做摘要比对
  • 多点校验
    • Application 启动时一次
    • 登录前一次
    • 核心接口请求前再来一次
  • 和环境检测绑定
    • Root 检测
    • 调试检测
    • Frida 检测
    • 模拟器检测

很多人一上来就写一个“万能 Hook”,结果发现:

  • Hook 了 getPackageInfo,App 还是闪退;
  • 改了返回值,结果后面 Native 校验又把你拦住;
  • 某个页面能进,但接口层又报签名不一致;
  • 甚至还没执行到你写的 Hook,App 就在 attachBaseContext 里先做检测了。

所以这类问题的核心不是“会不会 Hook”,而是:

能不能快速确定:校验点在哪里、在什么层、依赖什么数据、应该改输入还是改结果。


前置知识与环境准备

你需要准备什么

  • 一台测试 Android 设备或模拟器
  • 已安装 frida-server
  • PC 端安装:
    • adb
    • frida-tools
    • 可选:jadxapktool
  • 目标 App(仅限授权测试)

版本建议

  • Frida 建议客户端与服务端版本一致
  • Android 9 及以上,很多签名相关 API 行为和低版本不同
  • 如果目标 App 有加固,优先准备:
    • spawn 模式注入
    • 延迟 Hook
    • ClassLoader 切换方案

基本连通性验证

adb devices
frida-ps -U
frida -U -f com.target.app -l test.js

一个最小测试脚本:

Java.perform(function () {
  console.log("Frida attached.");
});

如果这一步都不稳定,后面所有分析都会很痛苦。这个坑我踩过,尤其是设备端 frida-server 版本不匹配时,表现特别像“Hook 写错了”。


核心原理

1. App 到底在校验什么

Android 应用签名校验,本质上是把运行时拿到的签名信息,与预置的合法签名做比较

常见数据来源包括:

  • 当前包名对应的安装包签名
  • APK 文件里的证书信息
  • 服务器下发的签名白名单
  • Native 层硬编码的摘要值

一个典型 Java 校验流程如下:

flowchart TD
    A[App 启动/进入关键功能] --> B[获取当前包签名]
    B --> C[计算摘要 MD5/SHA1/SHA256]
    C --> D[与预置值比对]
    D -->|一致| E[正常继续]
    D -->|不一致| F[闪退/弹框/禁用功能]

2. 为什么 Frida 有效

Frida 的价值不只是“改返回值”,更重要的是它能让我们:

  • 观察谁调用了签名 API
  • 打印调用栈
  • 看参数和返回值
  • 在校验前改输入,在校验后改结果
  • Java 层不够时,再进 Native 层

3. 绕过思路的三种层次

我通常把绕过方式分成三层:

第一层:Hook 系统 API,伪造签名来源

优点:

  • 覆盖面广
  • 适合 App 直接调用系统接口取签名

缺点:

  • 容易误伤其他逻辑
  • 新旧 API 差异要处理

第二层:Hook 业务校验函数,直接改结果

优点:

  • 精准、稳定
  • 对多层封装的 App 更有效

缺点:

  • 需要先定位到业务方法

第三层:Hook Native 校验逻辑

优点:

  • 能处理 so 层比对
  • 遇到加固/混淆时常常绕不过去必须上

缺点:

  • 难度更高
  • ABI、符号、时机都更敏感

一张图看完整定位路径

sequenceDiagram
    participant U as 分析者
    participant F as Frida
    participant J as Java层
    participant N as Native层
    participant A as App逻辑

    U->>F: 注入 Hook 脚本
    F->>J: 监听 getPackageInfo / getSigningInfo / MessageDigest
    J->>A: 返回签名相关数据
    A->>A: 做摘要/比对
    alt Java 层直接校验
        F->>A: Hook 业务方法并改返回值
    else Native 层继续校验
        F->>N: Hook JNI/导出函数/关键 libc 调用
        N->>A: 返回伪造结果
    end
    A-->>U: 成功进入功能/不再报签名异常

逐步实战:从“看见调用”到“精准绕过”

下面用一个更接近实战的流程来做。


第一步:全局观察签名相关调用

先别急着改值。第一步一定是打点观察

目标

监控这些关键点:

  • PackageManager.getPackageInfo
  • PackageInfo.signatures
  • PackageInfo.signingInfo
  • Signature.toByteArray
  • MessageDigest.digest
  • 可能的业务校验函数调用栈

Frida 观察脚本

Java.perform(function () {
  var Exception = Java.use("java.lang.Exception");
  var Log = Java.use("android.util.Log");
  var PackageManager = Java.use("android.app.ApplicationPackageManager");
  var Signature = Java.use("android.content.pm.Signature");
  var MessageDigest = Java.use("java.security.MessageDigest");

  function printStack(tag) {
    console.log("======== " + tag + " Stack ========");
    console.log(Log.getStackTraceString(Exception.$new()));
    console.log("===================================");
  }

  // 兼容旧 API:getPackageInfo(String, int)
  PackageManager.getPackageInfo.overload("java.lang.String", "int").implementation = function (pkg, flags) {
    var ret = this.getPackageInfo(pkg, flags);
    console.log("[getPackageInfo] pkg=" + pkg + ", flags=" + flags);
    printStack("getPackageInfo");
    return ret;
  };

  // 监控 Signature.toByteArray
  Signature.toByteArray.implementation = function () {
    var ret = this.toByteArray();
    console.log("[Signature.toByteArray] len=" + ret.length);
    printStack("Signature.toByteArray");
    return ret;
  };

  // 监控 MessageDigest.digest(byte[])
  MessageDigest.digest.overload("[B").implementation = function (input) {
    var algo = this.getAlgorithm();
    console.log("[MessageDigest.digest] algo=" + algo + ", inputLen=" + input.length);
    var ret = this.digest(input);
    console.log("[MessageDigest.digest] retLen=" + ret.length);
    return ret;
  };

  console.log("signature observe hooks loaded");
});

这一步你要看什么

重点不是输出一堆日志,而是要回答这几个问题:

  1. 谁在调用 getPackageInfo
  2. flags 是多少?
    • 老版本常见 64
    • 新版签名信息常见 134217728
  3. 后续有没有立刻进入 MessageDigest.digest
  4. 调用栈里有没有明显业务类名?
    • com.xxx.security.SignCheck
    • 或混淆类如 a.b.c.a

如果你已经看到调用栈落在某个业务类里,那恭喜,后面大概率可以直接精确打击。


第二步:Hook 系统签名接口,验证是否为 Java 层校验

很多 App 会直接通过 PackageManager 获取当前包签名。
这时候最简单的验证方式是:先改系统 API 的输出,观察行为是否变化。

方案 A:直接拦截旧版签名获取逻辑

不少 App 还在用:

  • GET_SIGNATURES = 64

下面脚本演示如何打印并识别目标调用。这里先做“观察+可控干预”,而不是一开始就硬改所有包。

Java.perform(function () {
  var PackageManager = Java.use("android.app.ApplicationPackageManager");

  PackageManager.getPackageInfo.overload("java.lang.String", "int").implementation = function (pkg, flags) {
    console.log("[*] getPackageInfo called: pkg=" + pkg + ", flags=" + flags);

    var info = this.getPackageInfo(pkg, flags);

    if (pkg.indexOf("com.target.app") !== -1) {
      console.log("[+] target package matched");
      if ((flags & 64) !== 0) {
        console.log("[+] GET_SIGNATURES requested");
      }
      if ((flags & 134217728) !== 0) {
        console.log("[+] GET_SIGNING_CERTIFICATES requested");
      }
    }

    return info;
  };
});

方案 B:直接 Hook 业务比对函数

很多时候,比起伪造 PackageInfo直接让业务校验永远返回 true 更稳。

假设你通过 Jadx 或栈日志定位到了:

public boolean checkSign(Context context)

那么 Frida 脚本可以这么写:

Java.perform(function () {
  var SignCheck = Java.use("com.target.app.security.SignCheck");

  SignCheck.checkSign.overload("android.content.Context").implementation = function (ctx) {
    console.log("[+] checkSign bypassed");
    return true;
  };
});

如何判断这招是否有效

看这几个现象:

  • 原来启动闪退,现在能进入首页
  • 原来弹“签名异常”,现在不弹了
  • 日志中校验函数确实被调用了
  • 后续接口没再因为完整性失败而拒绝

如果只解决了启动问题,但核心功能仍失败,说明 App 可能还有第二个校验点,甚至可能在 Native 层。


第三步:处理新版签名 API

Android 9 之后,越来越多 App 开始用 SigningInfo

典型路径是:

  • getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES)
  • 读取 signingInfo
  • 再取:
    • getApkContentsSigners()
    • getSigningCertificateHistory()

Hook 新版签名 API 的观察脚本

Java.perform(function () {
  var SigningInfo = Java.use("android.content.pm.SigningInfo");

  if (SigningInfo.getApkContentsSigners) {
    SigningInfo.getApkContentsSigners.implementation = function () {
      console.log("[*] SigningInfo.getApkContentsSigners called");
      var ret = this.getApkContentsSigners();
      console.log("[*] signer count=" + ret.length);
      return ret;
    };
  }

  if (SigningInfo.getSigningCertificateHistory) {
    SigningInfo.getSigningCertificateHistory.implementation = function () {
      console.log("[*] SigningInfo.getSigningCertificateHistory called");
      var ret = this.getSigningCertificateHistory();
      console.log("[*] history count=" + ret.length);
      return ret;
    };
  }
});

一个很实用的判断点

如果你发现:

  • getPackageInfo 被调用了
  • signingInfo 也被读了
  • 但最终决定逻辑的其实是某个 equals() 比较

那就别死磕系统 API 伪造,直接去找业务层的“摘要比较函数”更省时间。


第四步:定位摘要比对逻辑

很多 App 不直接比较签名原始字节,而是计算摘要后比对字符串,比如:

  • SHA1
  • SHA256
  • MD5(虽然不推荐,但仍然常见)

典型伪代码

Signature[] signs = packageInfo.signatures;
byte[] cert = signs[0].toByteArray();
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(cert);
String hex = bytesToHex(digest);
return "目标摘要".equalsIgnoreCase(hex);

Frida 监控摘要值

Java.perform(function () {
  var MessageDigest = Java.use("java.security.MessageDigest");
  var StringCls = Java.use("java.lang.String");

  function bytesToHex(bytes) {
    var result = [];
    for (var i = 0; i < bytes.length; i++) {
      var b = bytes[i];
      if (b < 0) b += 256;
      var s = b.toString(16);
      if (s.length < 2) s = "0" + s;
      result.push(s);
    }
    return result.join("");
  }

  MessageDigest.digest.overload("[B").implementation = function (input) {
    var algo = this.getAlgorithm();
    var ret = this.digest(input);
    console.log("[digest] algo=" + algo + ", hex=" + bytesToHex(ret));
    return ret;
  };
});

这一步的意义

你可以借这个输出判断:

  • App 用的是哪种摘要算法;
  • 同一份签名是否在多个地方反复比对;
  • 有没有把摘要结果交给 Native 层继续处理。

第五步:当 Java 层绕不过去,进入 Native 层

有些 App 会在 so 里做校验。常见迹象:

  • Java 层看起来只是“拿签名”
  • 但实际结果由 JNI 返回
  • 关键方法名可能像:
    • nativeCheckSign
    • nativeVerify
    • 混淆后的 native 方法

Java 声明层先定位 native 方法

Java.perform(function () {
  var Target = Java.use("com.target.app.security.NativeGuard");

  Target.nativeCheckSign.implementation = function () {
    console.log("[+] nativeCheckSign called");
    var ret = this.nativeCheckSign();
    console.log("[+] nativeCheckSign ret=" + ret);
    return ret;
  };
});

如果这是 native 方法,Frida 仍然能在 Java 声明层拦它。
很多时候,这已经足够了:直接改 Java 声明层返回值

Java.perform(function () {
  var Target = Java.use("com.target.app.security.NativeGuard");

  Target.nativeCheckSign.implementation = function () {
    console.log("[+] bypass nativeCheckSign");
    return true;
  };
});

如果必须进 so 层

你可以先枚举导出符号:

setImmediate(function () {
  var moduleName = "libtarget.so";
  var exports = Module.enumerateExportsSync(moduleName);
  exports.forEach(function (e) {
    if (e.name.indexOf("sign") >= 0 || e.name.indexOf("verify") >= 0) {
      console.log(e.type + " " + e.name + " @ " + e.address);
    }
  });
});

再对目标导出函数下断点:

setImmediate(function () {
  var addr = Module.findExportByName("libtarget.so", "Java_com_target_app_security_NativeGuard_nativeCheckSign");
  if (!addr) {
    console.log("export not found");
    return;
  }

  Interceptor.attach(addr, {
    onEnter: function (args) {
      console.log("[*] nativeCheckSign entered");
    },
    onLeave: function (retval) {
      console.log("[*] nativeCheckSign leave, original=" + retval);
      retval.replace(0x1);
      console.log("[+] nativeCheckSign forced to true");
    }
  });
});

多点校验时,建议这样分层处理

真实场景下,一个 App 可能不止一个校验点。
我更推荐“由浅入深”的分层法,而不是上来写一个到处乱改的脚本。

stateDiagram-v2
    [*] --> 启动校验
    启动校验 --> 页面校验
    页面校验 --> 接口前校验
    接口前校验 --> Native补充校验
    Native补充校验 --> [*]

分层策略

  1. 先拦启动阶段
    • 避免 App 直接退出
  2. 再拦关键页面
    • 确认是哪个功能触发校验
  3. 最后处理接口前校验
    • 这类往往最隐蔽,也最影响实际测试

实战代码:一个更完整的可运行脚本

下面给一份适合实战起手的脚本模板。
它做了几件事:

  • Hook getPackageInfo
  • Hook Signature.toByteArray
  • Hook MessageDigest.digest
  • 支持按类名直接 bypass 某个业务函数
  • 支持打印调用栈

你可以保存为 sign_trace.js 直接跑。

Java.perform(function () {
  var Exception = Java.use("java.lang.Exception");
  var Log = Java.use("android.util.Log");
  var PackageManager = Java.use("android.app.ApplicationPackageManager");
  var Signature = Java.use("android.content.pm.Signature");
  var MessageDigest = Java.use("java.security.MessageDigest");

  function stack(tag) {
    console.log("\n===== " + tag + " =====");
    console.log(Log.getStackTraceString(Exception.$new()));
    console.log("========================\n");
  }

  function bytesToHex(bytes) {
    var out = [];
    for (var i = 0; i < bytes.length; i++) {
      var v = bytes[i];
      if (v < 0) v += 256;
      var h = v.toString(16);
      if (h.length === 1) h = "0" + h;
      out.push(h);
    }
    return out.join("");
  }

  PackageManager.getPackageInfo.overload("java.lang.String", "int").implementation = function (pkg, flags) {
    var ret = this.getPackageInfo(pkg, flags);
    console.log("[getPackageInfo] pkg=" + pkg + ", flags=" + flags);
    if ((flags & 64) !== 0 || (flags & 134217728) !== 0) {
      stack("getPackageInfo(sign-related)");
    }
    return ret;
  };

  Signature.toByteArray.implementation = function () {
    var ret = this.toByteArray();
    console.log("[Signature.toByteArray] len=" + ret.length);
    stack("Signature.toByteArray");
    return ret;
  };

  MessageDigest.digest.overload("[B").implementation = function (input) {
    var algo = this.getAlgorithm();
    var ret = this.digest(input);
    console.log("[MessageDigest.digest] algo=" + algo + ", out=" + bytesToHex(ret));
    return ret;
  };

  // 示例:已知业务函数时,直接 bypass
  try {
    var SignCheck = Java.use("com.target.app.security.SignCheck");
    SignCheck.checkSign.overload("android.content.Context").implementation = function (ctx) {
      console.log("[bypass] com.target.app.security.SignCheck.checkSign");
      return true;
    };
    console.log("[+] business bypass hook loaded");
  } catch (e) {
    console.log("[-] business hook not loaded: " + e);
  }

  console.log("[*] sign trace hooks ready");
});

运行方式:

frida -U -f com.target.app -l sign_trace.js

如果 App 启动太快,可以加 --no-pause 视情况调整:

frida -U -f com.target.app -l sign_trace.js --no-pause

定位路径:从静态分析到动态验证

只靠动态 Hook 也能做,但效率通常不如“静态 + 动态”结合。

我一般的做法

1. 先用 Jadx 搜索关键词

搜索这些关键字很有帮助:

  • getPackageInfo
  • signatures
  • signingInfo
  • MessageDigest
  • SHA1
  • SHA-256
  • MD5
  • Signature
  • X509Certificate
  • CertificateFactory

2. 看谁在 Application 或启动页调用

很多校验会出现在:

  • Application.onCreate
  • attachBaseContext
  • SplashActivity
  • 登录前拦截器

3. 再用 Frida 验证

静态分析猜到位置后,用 Hook 做两件事:

  • 证实它真的被执行
  • 证实改结果后行为真的改变

这个闭环很重要。
不然你会很容易陷入“看起来像校验点,但其实只是辅助函数”的误判。


常见坑与排查

这一部分非常关键。很多逆向失败,不是原理没懂,而是卡在细节。

1. Hook 太晚,关键逻辑已经执行完了

现象:

  • 明明脚本没报错,但 App 还是启动即退
  • 日志里看不到目标方法被触发

排查:

  • -f 方式 spawn 启动目标 App
  • 优先 Hook Application.attach 或更早阶段
  • 遇到加固壳时,注意真实 ClassLoader 可能后加载

一个常见的早期 Hook 模板:

Java.perform(function () {
  var Application = Java.use("android.app.Application");
  Application.attach.overload("android.content.Context").implementation = function (ctx) {
    console.log("[*] Application.attach");
    this.attach(ctx);
  };
});

2. 类找不到,其实是 ClassLoader 问题

现象:

  • Java.use("com.xxx.SignCheck") 报错
  • Jadx 明明看得到类,Frida 却抓不到

原因:

  • App 使用了自定义 ClassLoader
  • 加固后真实 dex 延迟加载

思路:

  • 先枚举已加载类
  • 监听 ClassLoader.loadClass
  • 在合适时机再 Java.use

示例:

Java.perform(function () {
  var ClassLoader = Java.use("java.lang.ClassLoader");

  ClassLoader.loadClass.overload("java.lang.String").implementation = function (name) {
    var ret = this.loadClass(name);
    if (name.indexOf("Sign") >= 0 || name.indexOf("security") >= 0) {
      console.log("[loadClass] " + name);
    }
    return ret;
  };
});

3. 改了 Java 层返回值,结果还是失败

常见原因:

  • Native 层还有二次校验
  • 服务端也做了签名关联校验
  • 校验值被缓存了
  • 你 Hook 的不是最终判断点

建议:

  • 跟踪最终“失败分支”
  • 看弹框/闪退前最后一个业务方法
  • 必要时 Hook System.exitfinish()、异常抛出点辅助定位

4. Hook getPackageInfo 后 App 行为异常

这是我很常见的一类踩坑。

原因:

  • 你把所有包都影响了
  • 某些逻辑依赖真实 PackageInfo
  • 返回对象结构没处理完整

建议:

  • 只对目标包名生效
  • 能 Hook 业务函数就不要全局改系统 API
  • 改值前先观察,确认最小影响范围

5. 混淆严重,找不到签名校验类

思路:

即使类名混淆了,系统 API 和标准库调用通常还在。
所以你可以从这些固定点反推:

  • PackageManager.getPackageInfo
  • Signature.toByteArray
  • MessageDigest.digest

再通过调用栈把业务类捞出来。


安全/性能最佳实践

逆向分析里,脚本“能跑”只是第一步,跑得稳、影响小、便于复用 才更接近真实项目要求。

1. 优先做“观察型 Hook”,再做“修改型 Hook”

先确认:

  • 校验点是否真的存在
  • 是否只在某个页面触发
  • 摘要值是否固定

再决定改输入还是改结果。
上来就大面积强改,往往会把问题搞复杂。

2. 只对目标路径生效

例如:

  • 只处理目标包名
  • 只在调用栈命中特定类时改值
  • 只对特定方法返回值做替换

这样更不容易破坏 App 其他功能。

3. 谨慎打印大对象和高频日志

MessageDigest.digest、字符串比较、集合遍历这类方法可能调用非常频繁。
日志打太多会带来两个问题:

  • App 变卡
  • 你自己看日志看晕

建议:

  • 先按算法名过滤
  • 只对长度、摘要前几位做输出
  • 调试完成后关掉详细日志

4. 业务函数优先于系统 API

如果你已经定位到最终判断函数,例如:

  • isSignatureValid()
  • checkAppIntegrity()
  • nativeVerify()

那优先 Hook 这些函数。
因为它们通常比底层系统 API 更稳定,也更不容易误伤。

5. 做好边界判断

例如:

  • Android 版本差异
  • 方法重载差异
  • 新旧签名 API 共存
  • App 多进程

一个稳一点的脚本,通常都会有 try/catch 和存在性判断。


一个简单的决策表

场景推荐做法备注
App 直接用 getPackageInfo 取签名先观察系统 API,再反推业务类适合初步定位
已定位到 checkSign() 一类函数直接 Hook 返回值精准、稳定
Java 层只做过渡,so 才是真正校验Hook Java native 声明层或 so 导出函数常见于加固或高敏感 App
多点校验分阶段逐个击破不要试图一次性全局硬改
启动即闪退spawn 注入,尽量提前 Hook注意 attachBaseContext

逐步验证清单

实战时你可以按这个清单走,基本不容易乱:

阶段一:确认注入稳定

  • frida-ps -U 能看到设备
  • 脚本能正常加载
  • App 不因注入立刻崩溃

阶段二:确认签名相关调用存在

  • getPackageInfo 有调用日志
  • Signature.toByteArray 有调用日志
  • MessageDigest.digest 有摘要日志
  • 至少拿到一条有效调用栈

阶段三:确认业务校验点

  • 能定位到业务类/方法
  • 改该方法返回值后行为发生变化
  • 关键页面能正常进入

阶段四:确认是否存在二次校验

  • 启动后不报错
  • 进入核心功能不报错
  • 发起关键请求不再因完整性失败

阶段五:收敛脚本

  • 去掉无用日志
  • 缩小 Hook 范围
  • 保留必要注释,方便复现

总结

签名校验绕过这件事,表面上看像是在“改一个返回值”,但真正决定效率的,是你能不能快速回答这几个问题:

  1. 校验发生在哪一层?Java 还是 Native?
  2. 它取的是原始签名、摘要值,还是业务封装后的结果?
  3. 最终决策点在哪个函数?
  4. 是单点校验,还是多点串联?

如果你是中级读者,我给你的可执行建议是:

  • 先观察,后修改
  • 优先找业务判断函数,而不是全局乱改系统 API
  • Java 层无果时,及时转向 Native 层
  • 每次只解决一个校验点,并验证行为变化

最后再强调一次边界:
本文方法适用于授权测试、教学和研究。面对带服务端强绑定校验、设备指纹联动、远程完整性验证的目标时,单纯本地 Hook 往往只能解决一部分问题,不能无限泛化。

如果你按本文流程做下来,至少能把“签名异常到底卡在哪”这个问题,从黑盒变成白盒。
而一旦能定位,绕过通常只是时间问题。


分享到:

上一篇
《Spring Boot 3 中基于 JWT 与 Spring Security 6 的前后端分离登录鉴权实战》
下一篇
《从零到一参与开源项目:中级开发者的选型、提 Issue 与首次贡献实战指南》