文章

App安全

App安全

禁止 APP 录屏和截屏

Android 有些 APP 会为了安全,禁止录屏和截屏,例如:金融、银行相关的。禁止录屏和截屏并不难,只需要在 Activity 的 onCreate() 方法中添加一行代码即可:

1
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);

添加这行代码后,当截屏的时候,系统会弹出一个 Toast 提示 “ 禁止屏幕抓取 “;当录屏的时候,看似能够正常录制,但是保存后的视频,都是一片黑色,并没有 APP 的相关界面。
https://www.cnblogs.com/qixingchao/p/11652392.html

Android KeyStore 非对称 + 对称加密

KeyStore

Android KeyStore 系统允许你存储加密密钥。如果是 AndroidKeyStore 这种类型的话,keystore 难以从设备中导出,并且可以指明 key 的使用规则,例如只有用户验证后,才可以使用 key 等;但如果是 bks 这种的话,就比较容易导出。

  1. Key material never enters the application process.
  2. Key material may be bound to the secure hardware(e.g., Trusted Execution Environment(TEE), Secure Element(SE)) of the Android device.

可以通过以下代码获取你的 KeyStore:

1
2
3
4
5
try {
    KeyStore mKeyStore = KeyStore.getInstance(类型");
} catch (KeyStoreException e) {
    return;
}

KeyStore.getInstance(参数) 中的参数可以传以下内容:

  1. AndroidKeyStore:这里要先区分下 AndroidKeyStore 和 Android KeyStore,虽然这两个一样,但是后者中间多了个空格,意义是不一样的,前者是子集,后者是父集,后者包含前者。而 AndroidKeyStore 主要是用来存储一些密钥 key 的,存进该处的 key 可以为其设置 KeyProtection,例如只能通过用户验证才能取出 key 使用等。这些 key 是存在系统里的,不是在 app 的目录下,并且每个 app 不能访问其他 app 的 key,如果 app1 创建了 key1,并且存储的时候命名为 temp,app2 去通过 temp 去访问 key,是获取不到的!!
  2. KeyStore.getDefaultType():该函数返回的是一个字符串,在 java 下,返回的是 JKS,在 Android 下,返回的是 BKS。(注:android 系统中使用的证书要求以 BKS 的库文件结构保存,通常情况下,我们使用 java 的 keytool 只能生成 jks 的证书库。读取 key 可以通过 psw 来读取)。当你使用这个 keystore 的时候,其文件存放在 data(沙盒中)

签名和 Keystore 关系

签名的话其实是对一个 app 加上开发者的签名,证明这个 app 和开发者的关系。通过签名可以证明这个是正版的 app,如果是假冒伪劣的 app,那么是不允许安装的。这怎么说呢?其实就是用了签名 keystore 文件,在该文件中有一对 非对称密钥,签名的时候使用 私钥 对 apk 中所有的文件内容进行加密,并且签名后的 apk 携带 keystore 文件中的公钥,当安装在手机系统上的时候系统会取出公钥,对 apk 进行验证,查看是不是使用正确私钥加密的 apk,或者 apk 是否被人篡改过。如果篡改过,那么公钥验证不通过。

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
/**
 * AndroidKeyStore加密存储
 *
 * 1. 使用KeyStore随机生成RSA Key
 *
 * 2. 产生AES Key,并用RSA Public Key加密后保存到sp
 *
 * 3. 从SP取出AES Key,并用RSA Private Key解密,用这把AES Key来加解密数据
 */
public final class AndroidKeyStoreUtils {

    private static final String KEYSTORE_PROVIDER = "AndroidKeyStore";
    private static final String KEYSTORE_ALIAS = "KEYSTORE_DEMO";

    private static final String AES_MODE = "AES/GCM/NoPadding";
    private static final String RSA_MODE = "RSA/ECB/PKCS1Padding";

    private static KeyStore sMKeyStore;

    public static void init() {
        try {
            initKeyStore();
            generateKey();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static KeyStore initKeyStore() throws Exception {
        if (sMKeyStore == null) {
            log("1. 初始化AndroidKeyStore");
            sMKeyStore = KeyStore.getInstance(KEYSTORE_PROVIDER);
            sMKeyStore.load(null);
        }
        return sMKeyStore;
    }

    private static void generateKey() {
        try {
            if (getAESKey() == null || getAESIV() == null) {
                genKeyStoreKey(GlobalContext.getAppContext());
                genAESKey();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 1、KeyStore生成RSA Key
     */
    private static void genKeyStoreKey(Context context) throws Exception {
        log("2. AndroidKeyStore生成RSA Key");
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            generateRSAKey_AboveApi23();
        } else {
            generateRSAKey_BelowApi23(context);
        }
    }

    @RequiresApi(api = Build.VERSION_CODES.M)
    private static void generateRSAKey_AboveApi23() throws Exception {
        KeyPairGenerator keyPairGenerator = KeyPairGenerator
                .getInstance(KeyProperties.KEY_ALGORITHM_RSA, KEYSTORE_PROVIDER);

        KeyGenParameterSpec keyGenParameterSpec = new KeyGenParameterSpec
                .Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
                .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
                .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1)
                .build();

        keyPairGenerator.initialize(keyGenParameterSpec);
        keyPairGenerator.generateKeyPair();
    }

    private static void generateRSAKey_BelowApi23(Context context) throws NoSuchAlgorithmException,
            NoSuchProviderException, InvalidAlgorithmParameterException {
        Calendar start = Calendar.getInstance();
        Calendar end = Calendar.getInstance();
        end.add(Calendar.YEAR, 30);

        KeyPairGeneratorSpec spec = new KeyPairGeneratorSpec.Builder(context)
                .setAlias(KEYSTORE_ALIAS)
                .setSubject(new X500Principal("CN=" + KEYSTORE_ALIAS))
                .setSerialNumber(BigInteger.TEN)
                .setStartDate(start.getTime())
                .setEndDate(end.getTime())
                .build();

        KeyPairGenerator keyPairGenerator = KeyPairGenerator
                .getInstance(KeyProperties.KEY_ALGORITHM_RSA, KEYSTORE_PROVIDER);

        keyPairGenerator.initialize(spec);
        keyPairGenerator.generateKeyPair();
    }

    /**
     * 生成AES Key和IV,用RSA Public Key加密AES Key保存到SP
     */
    private static void genAESKey() throws Exception {
        // Generate AES-Key
        byte[] aesKey = new byte[16];
        SecureRandom secureRandom = new SecureRandom();
        secureRandom.nextBytes(aesKey);

        // Generate 12 bytes iv then save to SharedPrefs
        byte[] generated = secureRandom.generateSeed(12);
        String iv = Base64.encodeToString(generated, Base64.DEFAULT);
        log("【genAESKey】初始化一个iv并进行Base64编码保存:" + iv);
        SPUtils.put("aes_iv", iv);

        // Encrypt AES-Key with RSA Public Key then save to SharedPrefs
        String encryptAESKey = encryptRSA(aesKey);
        SPUtils.put("aes_key", encryptAESKey);
        log("【genAESKey】初始化一个AES Key并通过RSA公钥进行加密保存:" + encryptAESKey);

        log("3. 生成AES Key和IV,用RSA Public Key加密AES Key保存到SP,IV base64:" + iv + ",encryptAESKeyByRSAPublicKey:" + encryptAESKey);
    }

    /**
     * RSA公钥加密
     *
     * 使用RSA Public Key 加密 AES Key,存入缓存中
     */
    private static String encryptRSA(byte[] plainText) throws Exception {
        PublicKey publicKey = initKeyStore().getCertificate(KEYSTORE_ALIAS).getPublicKey();

        Cipher cipher = Cipher.getInstance(RSA_MODE);
        cipher.init(Cipher.ENCRYPT_MODE, publicKey);

        byte[] encryptedByte = cipher.doFinal(plainText);
        return Base64.encodeToString(encryptedByte, Base64.DEFAULT);
    }

    /**
     * RSA私钥解密
     *
     * 使用RSA Private Key 解密 得到 AES Key
     */
    private static byte[] decryptRSA(String encryptedText) throws Exception {
        PrivateKey privateKey = (PrivateKey) initKeyStore().getKey(KEYSTORE_ALIAS, null);

        Cipher cipher = Cipher.getInstance(RSA_MODE);
        cipher.init(Cipher.DECRYPT_MODE, privateKey);

        byte[] encryptedBytes = Base64.decode(encryptedText, Base64.DEFAULT);
        byte[] decryptedBytes = cipher.doFinal(encryptedBytes);

        return decryptedBytes;
    }

    private static SecretKeySpec getAESKey() throws Exception {
        String aesKey = (String) SPUtils.get("aes_key", "");
        if (TextUtils.isEmpty(aesKey)) {
            return null;
        }
        byte[] aesKeyBytes = decryptRSA(aesKey);
        return new SecretKeySpec(aesKeyBytes, AES_MODE);
    }

    private static byte[] getAESIV() {
        String aesIV = (String) SPUtils.get("aes_iv", "");
        if (TextUtils.isEmpty(aesIV)) {
            return null;
        }
        byte[] iv = Base64.decode(aesIV, Base64.DEFAULT);
        return iv;
    }

    /**
     * AES Encryption
     *
     * @param plainText: A string which needs to be encrypted.
     * @return A base64's string after encrypting.
     */
    public static String encryptAES(String plainText) throws Exception {
        Cipher cipher = Cipher.getInstance(AES_MODE);
        cipher.init(Cipher.ENCRYPT_MODE, getAESKey(), new IvParameterSpec(getAESIV()));

        // 加密過後的byte
        byte[] encryptedBytes = cipher.doFinal(plainText.getBytes());

        // 將byte轉為base64的string編碼
        String cipherData = Base64.encodeToString(encryptedBytes, Base64.DEFAULT);
        log("AES加密+base64:" + cipherData);
        return cipherData;
    }

    public static String decryptAES(String encryptedText) throws Exception {
        // 將加密過後的Base64編碼格式 解碼成 byte
        byte[] decodedBytes = Base64.decode(encryptedText.getBytes(), Base64.DEFAULT);

        // 將解碼過後的byte 使用AES解密
        Cipher cipher = Cipher.getInstance(AES_MODE);
        cipher.init(Cipher.DECRYPT_MODE, getAESKey(), new IvParameterSpec(getAESIV()));

        String plainTextData = new String(cipher.doFinal(decodedBytes));
        log("Base64+AES解密:" + plainTextData);
        return plainTextData;
    }

    /**
     * 字节数组转16进制
     *
     * @param bytes 需要转换的byte数组
     * @return 转换后的Hex字符串
     */
    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < bytes.length; i++) {
            String hex = Integer.toHexString(bytes[i] & 0xFF);
            if (hex.length() < 2) {
                sb.append(0);
            }
            sb.append(hex);
        }
        return sb.toString();
    }

    private static void log(String msg) {
        LogUtil.i(msg);
    }

}

Reference

App 安全

接口安全

请求合法性校验

  • 身份校验,一般用 token
  • token 一般要有有效期

参数签名校验

  1. header 或 params 参与计算,进行 hash
  2. 加点盐啥的

流程

在 Http 的请求中生成唯一的签名 (signature), 当 Server 接收到请求之后首先对请求中参数进行校验,采用同样签名方式生成签名及其校验,如果服务端生成的签名 (autograph) 和 Http 请求的 Signature 一致,则处理清理,否则,则请求丢弃。其交互流程如下:

wqf3o

以上所示中,网关和前端针对同一个 appKey 采取同样的秘钥,用请求签名的版本号老兼容、过度、演进签名算法,其两侧的签名算法保持一致,此处,由于前端逆向工程及其秘钥储存安全性考虑,前端侧采取无线保镖 (灰度安全图片) 进行秘钥储存,其无线保镖生成规则如下:

2napt
在前端运行时从无线保镖 (安全图片) 获取秘钥,该秘钥用于计算生成请求签名,对于请求签名的生成的代码安全。web 侧用严格的 js 混淆、压缩,并将其核心算法注册到 service worker,app 将签名算法用其 C++ 实现,Android、IOS 通过 FFI 方式进行底层调用,并将其接入到 Http 的 SDK 中。

njm6p
其请求签名的核心算法如下:

网关会根据请求参数,对签名进行验证,签名不合法的请求将会被拒绝。目前暂定支持的签名算法有三种:MD5(sign_method=md5),HMAC_MD5(sign_method=hmac),HMAC_SHA256(sign_method=hmac-sha256),签名大体过程如下:

  • 对所有 API 请求参数(本地仅仅是请求头),但除去 sign 参数和 byte[] 类型的参数),根据参数名称的 ASCII 码表的顺序排序。如:foo:1, bar:2, foo_bar:3, foobar:4 排序后的顺序是 bar:2, foo:1, foo_bar:3, foobar:4。
  • 将排序好的参数名和参数值拼装在一起,根据上面的示例得到的结果为:bar2foo1foo_bar3foobar4。
  • 把拼装好的字符串采用 utf-8 编码,使用签名算法对编码后的字节流进行摘要。如果使用 MD5 算法,则需要在拼装的字符串前后加上 app 的 secret 后,再进行摘要,如:md5(secret+bar2foo1foo_bar3foobar4+secret);如果使用 HMAC_MD5 算法,则需要用 app 的 secret 初始化摘要算法后,再进行摘要,如:hmac_md5(bar2foo1foo_bar3foobar4)。
  • 将摘要得到的字节流结果使用十六进制表示,如:hex(“helloworld”.getBytes(“utf-8”)) = “68656C6C6F776F726C64”

说明:MD5 和 HMAC_MD5 都是 128 位长度的摘要算法,用 16 进制表示,一个十六进制的字符能表示 4 个位,所以签名后的字符串长度固定为 32 个十六进制字符。

JAVA 代码签名示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public static String signTopRequest(Map<String, String> params, String secret, String signMethod) throws IOException {
    // 第一步:检查参数是否已经排序
    String[] keys = params.keySet().toArray(new String[0]);
    Arrays.sort(keys);
 
    // 第二步:把所有参数名和参数值串在一起
    StringBuilder query = new StringBuilder();
    if (Constants.SIGN_METHOD_MD5.equals(signMethod)) { //签名的摘要算法,可选值为:hmac,md5,hmac-sha256
        query.append(secret);
    }
    for (String key : keys) {
        String value = params.get(key);
        if (StringUtils.areNotEmpty(key, value)) {
            query.append(key).append(value);
        }
    }
 
    // 第三步:使用MD5/HMAC加密
    byte[] bytes;
    if (Constants.SIGN_METHOD_HMAC.equals(signMethod)) {
        bytes = encryptHMAC(query.toString(), secret);
    } else {
        query.append(secret);
        bytes = encryptMD5(query.toString());
    }
 
    // 第四步:把二进制转化为大写的十六进制(正确签名应该为32大写字符串,此方法需要时使用)
    //return byte2hex(bytes);
}
 
public static byte[] encryptHMAC(String data, String secret) throws IOException {
    byte[] bytes = null;
    try {
        SecretKey secretKey = new SecretKeySpec(secret.getBytes(Constants.CHARSET_UTF8), "HmacMD5");
        Mac mac = Mac.getInstance(secretKey.getAlgorithm());
        mac.init(secretKey);
        bytes = mac.doFinal(data.getBytes(Constants.CHARSET_UTF8));
    } catch (GeneralSecurityException gse) {
        throw new IOException(gse.toString());
    }
    return bytes;
}
 
public static byte[] encryptMD5(String data) throws IOException {
    return encryptMD5(data.getBytes(Constants.CHARSET_UTF8));
}
 
public static String byte2hex(byte[] bytes) {
    StringBuilder sign = new StringBuilder();
    for (int i = 0; i < bytes.length; i++) {
        String hex = Integer.toHexString(bytes[i] & 0xFF);
        if (hex.length() == 1) {
            sign.append("0");
        }
        sign.append(hex.toUpperCase());
    }
    return sign.toString();
}

post 参数多层时的 sign 时候

设计 sign 校验时,如果要校验 body 的参数和值,那么需要考虑是否要校验多层的结构,很多只设计了单层的校验。

第 1 种单层 {key1:value1, key2:value2},第 2 种多层 {key1:value1,key2:{key3:value3,key4:value4} } 及第 3 种多层数组 {key1:value1,  key2:[{key3:value3,key4:value4],} } 传递参数方式,只支持单层;第 2 种和第 3 种 sign 校验要多考虑

案例:

1
2
3
4
5
6
7
8
9
{
  "wish": [
        {"gid": 2, // 礼物id
         "cnt": 10, // 目标礼物数量
         "rwd": "奖励xx",
        },
        ...
      ]
}

body 加密

body 体进行对称加密

接口重放攻击

请求重放就是指把请求被原封不动地重复发送,一次,两次…n 次,
在针对 http 的请求方式中,有 post、put 等请求方式是不冪等的。这种情况下,就需要更加注意请求重放啦,从而造成不必要的大量的脏数据。在 pc 上的处理通常处理方式是隐藏域或 token 来解决。而针对 restful api 我们需要用 timestamp+nonce 来解决。

client 端 (HTTP SDK)

  1. timestamp
    • 用来表示请求的当前时间戳,这个时间要事先和服务器时间戳校正过。我们预期正常请求带的时间戳会是不同的,如:假设正常人每秒至多会做一个操作。
    • 每个请求携带的时间戳不能和当前时间距离很近,即不能超过规定时间,如 60s。这样请求即使被截取了,也只能在有限时间(如:60s)内进行重放,过期就会失效
  2. nonce
    • 仅仅提供 timestamp 还是不够的,我们还是提供给攻击者 60s 的可攻击时间了。要避免 60 秒内发生攻击,我们还需要使用一个 nonce 随机数。
    • nonce 是由客户端根据随机生成的,比如 md5(timestamp+rand(0, 1000),正常情况下,在短时间内(比如 60s)连续生成两个相同 nonce 的情况几乎为 0。

server 端 (Gateway)

  • 在分布式缓存 (如: REDIS) 中查找是否有 key 为 nonce:{nonce}的 string
  • 如果没有,则创建这个 key,把这个 key 失效的时间和验证 timestamp 失效的时间设置一致,比如是 60s。
  • 如果有,说明这个 key 在 60s 内已被使用过了,这个请求就可以判断为重放请求。

so 安全

1、三方 app key 存 so

通过简单的算法将 key 生成出来

2、so 防止被盗用

so 校验 app 的签名

App 加固

App 加壳

App 反调试与代码保护的一些基本方案 Proguard

isDebuggerConnected

isDebuggerConnected 函数用于检测此刻是否有调试器挂载到程序上,如果返回值为 true 则表示此刻被调试中。用法很简单,如下:

o2vrv

android:debuggable 属性

在 Android 的 AndroidManifest.xml 清单文件的 application 节点下加入 android:debuggable=”false” 属性,使程序不能被调试。在 Java 程序代码里也可检测该属性的值,如下:

u2mbl

Android 密钥、AppSecret 保存到 native 层

保存密钥常用方式

  1. 硬编码到 Java 层 or Java 层变相/计算保存
  2. Gradle,通过 BuildConfig 读取
  3. 写在 gradle properties 中,再在 build gradle 中读取,同第二种方法

实质都是 Java 代码,Android 代码中很容易让别人通过反编译进行读取;这样就存在很大的安全隐患;

保存到 C/C++ 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <jni.h>
#include <string>

#define LOGINFO(...) ((void)__android_log_print(ANDROID_LOG_INFO, "security", __VA_ARGS__))
#define LOGERROR(...) ((void)__android_log_print(ANDROID_LOG_ERROR, "security", __VA_ARGS__))

extern "C"
JNIEXPORT jstring JNICALL
Java_qsbk_app_voice_common_net_SecurityUtils_getSecretKey(JNIEnv *env, jclass type) {
    std::string hello = "obMImdpSP0mqGKlzIYyJej09HqzOj08G";
    return env->NewStringUTF(hello.c_str());
}

extern "C"
JNIEXPORT jstring JNICALL
Java_qsbk_app_voice_common_net_SecurityUtils_getSault(JNIEnv *env, jclass type) {
    return env->NewStringUTF("sault");
}

extern "C"
JNIEXPORT jstring JNICALL
Java_qsbk_app_voice_common_net_SecurityUtils_getMMKVKey(JNIEnv *env, jclass type) {
    return env->NewStringUTF("hbMImdpSP0mqGKlzIYyJej09HqzOj17G");
}

声明 Java 代码

1
2
3
4
5
6
7
8
9
10
11
12
public final class SecurityUtils {
    static {
        System.loadLibrary("qbvoicechatsecurity");
    }

    public static native String getMMKVKey();

    public static native String getSecretKey();

    public static native String getSault();
}

Jni 是通过反射的方式来相互调用,也就是说,我们的 native 方法是不能混淆的,那么就可以反编译拿到.so 库和同名的 native 方法,然后通过二次打包 debug 出这个密钥串。所以我们需要一种预防 debug 的手段,这里我们采取验证 apk 签名的方式来达到目的,当发现 apk 签名和我们自己的签名不一致的时候,调用 so 库直接崩溃即可。

本文由作者按照 CC BY 4.0 进行授权