|
|
本帖最后由 岁安辞 于 2026-9-15 01:14 编辑
可能是新的思路基于 Android Key Attestation 的签名检验
本文AI生成大佬轻喷
最近在研究其他检测点的时候,偶然发现了一个挺有意思的东西:
Android Key Attestation 生成的证书里面居然有应用签名相关的信息。
当时我就在想:
«这玩意是不是也能拿来做 APK 签名检测?»
然后实际测了一下……
还真可以。
所以就记录一下这个思路,纯属个人瞎折腾,欢迎大佬指点。
一、传统的签名检测
我们平时检测 APK 签名,基本就是:
当前 APP
↓
PackageManager
↓
SigningInfo
↓
获取签名证书
↓
计算 SHA-256
↓
和官方签名比较
这种方法大家应该都比较熟了。
而我这次想到的玩法不太一样。
我不直接去问:
«“PackageManager,你告诉我这个 APP 签名是什么。”»
而是:
«“Android Keystore,你给我生成一个带 Attestation 的密钥,然后我看看你证书里面记录的 APP 身份信息。”»
大概就是:
当前 APP
↓
AndroidKeyStore
↓
生成 Attestation Key
↓
拿到证书链
↓
解析 Key Attestation
↓
找到 AttestationApplicationId
↓
拿 signatureDigests
↓
和官方 SHA-256 比较
二、关键东西:AttestationApplicationId
这里最关键的就是这个 OID:
1.3.6.1.4.1.11129.2.1.17
这是 Android Key Attestation 的扩展。
里面有一堆 KeyDescription、AuthorizationList 之类的东西。
其中又能找到:
AttestationApplicationId
里面有:
packageName
signatureDigests
而 "signatureDigests" 就是我们真正想要的东西。
简单理解就是:
AttestationApplicationId
↓
signatureDigests
↓
APP 签名摘要
所以我就想着:
那直接拿这个东西和官方签名 SHA-256 比一下不就完了?
于是就有了下面这个东西。
- package com.test;
- import android.security.keystore.KeyGenParameterSpec;
- import android.security.keystore.KeyProperties;
- import java.security.KeyPairGenerator;
- import java.security.KeyStore;
- import java.security.ProviderException;
- import java.security.cert.Certificate;
- import java.security.cert.X509Certificate;
- import java.security.spec.ECGenParameterSpec;
- import java.util.ArrayList;
- import java.util.List;
- import java.util.UUID;
- public class Detection_Tampering {
- private static final String KEY_ALIAS = "AndroidKeyStore_check";
- private static final String KS = "AndroidKeyStore";
- private static final String ATTEST_OID =
- "1.3.6.1.4.1.11129.2.1.17";
- /**
- * @param expectedSha256 预期签名 SHA-256 (Hex)
- * @return Normal | Tampering with signatures | Unsupported devices
- */
- public static String check(String expectedSha256) {
- try {
- // 1. 生成带 Challenge 的硬件密钥
- KeyPairGenerator kpg =
- KeyPairGenerator.getInstance(
- KeyProperties.KEY_ALGORITHM_EC,
- KS);
- KeyGenParameterSpec spec =
- new KeyGenParameterSpec.Builder(
- KEY_ALIAS,
- KeyProperties.PURPOSE_SIGN
- | KeyProperties.PURPOSE_VERIFY)
- .setAlgorithmParameterSpec(
- new ECGenParameterSpec("secp256r1"))
- .setDigests(
- KeyProperties.DIGEST_SHA256)
- .setAttestationChallenge(
- UUID.randomUUID()
- .toString()
- .getBytes())
- .build();
- kpg.initialize(spec);
- kpg.generateKeyPair();
- // 2. 获取证书链
- KeyStore ks =
- KeyStore.getInstance(KS);
- ks.load(null);
- Certificate[] chain =
- ks.getCertificateChain(KEY_ALIAS);
- if (chain == null || chain.length == 0) {
- clean();
- return "Unsupported devices";
- }
- X509Certificate leaf =
- (X509Certificate) chain[0];
- byte[] ext =
- leaf.getExtensionValue(
- ATTEST_OID);
- if (ext == null) {
- clean();
- return "Unsupported devices";
- }
- // 3. OCTET STRING -> KeyDescription
- byte[] kd =
- octetContent(ext, 0);
- Element kdSeq =
- read(kd, 0);
- if (kdSeq.tagNo != 16) {
- clean();
- return "Unsupported devices";
- }
- // 4. 获取 KeyDescription 顶层字段
- List<Element> top =
- children(
- kdSeq.value(),
- 0,
- kdSeq.value().length);
- int last1 = -1;
- int last2 = -1;
- for (int i = 0; i < top.size(); i++) {
- if (top.get(i).tagNo == 16) {
- last2 = last1;
- last1 = i;
- }
- }
- if (last1 < 0) {
- clean();
- return "Unsupported devices";
- }
- Element sw =
- top.get(
- last2 >= 0
- ? last2
- : last1);
- Element hw =
- top.get(last1);
- // 5. 查找 Tag 709
- byte[] appId =
- findTag709(sw.value());
- if (appId == null) {
- appId =
- findTag709(hw.value());
- }
- if (appId == null) {
- clean();
- return "Unsupported devices";
- }
- // 6. 解析 signatureDigests
- List<byte[]> sigs =
- parseAppIdSigs(appId);
- if (sigs.isEmpty()) {
- clean();
- return "Unsupported devices";
- }
- if (expectedSha256 == null) {
- clean();
- return "Unsupported devices";
- }
- String exp =
- expectedSha256
- .trim()
- .toUpperCase();
- // 7. 比较签名
- for (byte[] s : sigs) {
- if (exp.equals(toHex(s))) {
- clean();
- return "Normal";
- }
- }
- clean();
- return "Tampering with signatures";
- } catch (ProviderException e) {
- clean();
- return "Unsupported devices";
- } catch (Exception e) {
- clean();
- return "Unsupported devices";
- }
- }
- /* ==================== DER 解析 ==================== */
- static final class Element {
- int tagNo;
- int tagClass;
- boolean constructed;
- int content;
- int len;
- int end;
- byte[] raw;
- Element(
- int tagNo,
- int tagClass,
- boolean constructed,
- int content,
- int len,
- int start,
- byte[] raw) {
- this.tagNo = tagNo;
- this.tagClass = tagClass;
- this.constructed = constructed;
- this.content = content;
- this.len = len;
- this.end = content + len;
- this.raw = raw;
- }
- byte[] value() {
- if (len <= 0) {
- return new byte[0];
- }
- byte[] v =
- new byte[len];
- System.arraycopy(
- raw,
- content,
- v,
- 0,
- len);
- return v;
- }
- }
- private static Element read(
- byte[] d,
- int off) throws Exception {
- if (off >= d.length) {
- throw new Exception("DER EOF");
- }
- int first =
- d[off] & 0xFF;
- int tagClass =
- first & 0xC0;
- boolean constructed =
- (first & 0x20) != 0;
- int tagNo =
- first & 0x1F;
- int p =
- off + 1;
- // 多字节 Tag
- if (tagNo == 0x1F) {
- tagNo = 0;
- while (p < d.length) {
- int b =
- d[p++] & 0xFF;
- tagNo =
- (tagNo << 7)
- | (b & 0x7F);
- if ((b & 0x80) == 0) {
- break;
- }
- }
- }
- // 长度
- if (p >= d.length) {
- throw new Exception(
- "DER len EOF");
- }
- int lenByte =
- d[p++] & 0xFF;
- int len;
- if (lenByte < 0x80) {
- len = lenByte;
- } else if (lenByte == 0x80) {
- throw new Exception(
- "Indefinite length not supported");
- } else {
- int n =
- lenByte & 0x7F;
- if (n > 4) {
- throw new Exception(
- "Length too large");
- }
- len = 0;
- for (int i = 0; i < n; i++) {
- if (p >= d.length) {
- throw new Exception(
- "DER len overflow");
- }
- len =
- (len << 8)
- | (d[p++] & 0xFF);
- }
- }
- if (p + len > d.length) {
- throw new Exception(
- "DER content overflow");
- }
- return new Element(
- tagNo,
- tagClass,
- constructed,
- p,
- len,
- off,
- d);
- }
- private static byte[] octetContent(
- byte[] d,
- int off) throws Exception {
- Element e =
- read(d, off);
- if (e.tagNo != 4) {
- throw new Exception(
- "Expected OCTET STRING");
- }
- return e.value();
- }
- private static List<Element> children(
- byte[] d,
- int from,
- int to) throws Exception {
- List<Element> list =
- new ArrayList<Element>();
- int off = from;
- while (off < to) {
- Element e =
- read(d, off);
- list.add(e);
- off = e.end;
- }
- return list;
- }
- private static byte[] findTag709(
- byte[] auth) {
- try {
- int off = 0;
- while (off < auth.length) {
- Element e =
- read(auth, off);
- if (e.tagClass == 0x80
- && e.tagNo == 709) {
- byte[] v =
- e.value();
- try {
- Element inner =
- read(v, 0);
- if (inner.tagNo == 4) {
- return inner.value();
- }
- } catch (Exception ignore) {
- }
- return v;
- }
- off = e.end;
- }
- } catch (Exception ignore) {
- }
- return null;
- }
- private static List<byte[]> parseAppIdSigs(
- byte[] appId) throws Exception {
- List<byte[]> out =
- new ArrayList<byte[]>();
- Element seq =
- read(appId, 0);
- if (seq.tagNo != 16) {
- return out;
- }
- List<Element> kids =
- children(
- seq.value(),
- 0,
- seq.value().length);
- for (Element k : kids) {
- if (k.tagNo == 17) {
- List<Element> sigs =
- children(
- k.value(),
- 0,
- k.value().length);
- for (Element s : sigs) {
- if (s.tagNo == 4) {
- out.add(s.value());
- }
- }
- }
- }
- return out;
- }
- private static String toHex(byte[] b) {
- if (b == null) {
- return "";
- }
- StringBuilder sb =
- new StringBuilder();
- for (byte x : b) {
- sb.append(
- String.format(
- "%02X",
- x));
- }
- return sb.toString();
- }
- private static void clean() {
- try {
- KeyStore ks =
- KeyStore.getInstance(KS);
- ks.load(null);
- if (ks.containsAlias(KEY_ALIAS)) {
- ks.deleteEntry(
- KEY_ALIAS);
- }
- } catch (Exception ignored) {
- }
- }
- }
复制代码
三、目前的测试代码
代码我这里没有使用 BouncyCastle,而是自己简单写了个 DER 解析。
主要就是为了把 Attestation 里面的东西扒出来。
四、实际测试下来确实能用
简单说就是:
我拿正常 APK 测。
然后从 Attestation 里面拿到:
signatureDigests
再和我提前准备好的官方签名 SHA-256 比。
能匹配上:
Normal
换成其他签名之后:
Tampering with signatures
所以至少从实际测试结果来看:
这个思路是成立的。
五、但是马上就发现了一个问题
这东西最大的问题就是:
不是所有手机都能正常搞。
我实际测试发现,部分国产系统,还有一些欧加系设备,可能会因为各种奇奇怪怪的原因导致 TEE 出问题。
比如:
Key Attestation 生成失败
或者:
证书链为空
或者:
拿不到 Attestation 扩展
甚至有些情况看起来就像:
TEE 损坏 / 假死
这种情况下,这个检测点就直接没了。
所以不能看到:
Attestation 失败
就直接:
签名被篡改
因为人家可能只是 TEE 有问题。
六、所以我最后把结果分成了三个
目前代码里面是:
Normal
正常,Attestation 获取到了,而且签名摘要匹配。
Tampering with signatures
Attestation 正常获取到了,但是里面的签名摘要和预期的不一样。
还有:
Unsupported devices
Attestation 根本没法正常搞,或者解析不到需要的信息。
这个状态不能直接算“签名异常”。
七、这个方法和传统签名检测有什么区别?
我觉得这也是这个思路比较好玩的地方。
传统方法:
APP
↓
PackageManager
↓
SigningInfo
↓
签名
而这个:
APP
↓
AndroidKeyStore
↓
KeyMint / TEE
↓
Key Attestation
↓
X509 Certificate
↓
KeyDescription
↓
AttestationApplicationId
↓
signatureDigests
所以它至少算是:
从另一条路拿到了签名相关信息。
不是简单地再写一遍 "PackageManager"。
八、个人实测了一下过签
然后我也顺手拿一些目前见到的过签工具测试了一下。
目前我的测试结果是:
暂时都没成功过掉这个检测。
当然,这肯定不能说明:
«“这个检测无敌了。”»
毕竟我自己测试的东西也有限,测试环境也有限。
只能说:
至少我目前手里的几个东西没过去。
如果有大佬已经研究过这个方向,或者手里有什么好玩的东西,也欢迎自己试试。
说不定哪天就有人把它过了,然后我又得回来学习了 |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
x
|