防止第三方反编译篡改应用,防止数据隐私泄露,防止二次打包欺骗用户。
我们在加密的时候会用到一些加密或者编码方法。常见的有,非对称加密算法 RSA 等;对称加密算法 DES、3DES 和 AES 等;不可逆的加密 MD5、SHA256 等。
另外,我们会把重要的加密逻辑放到 Native 层来实现,所以一些 JNI 编程的方法也是需要的。不过,如果仅仅是用来作加密的话,对 C/C++ 的要求是没那么高的。对在 Android 中使用 JNI,可以参考我之前的文章《在 Android 中使用 JNI 的总结》。
在应用和 so 中作签名校验可以说是最基本的安全策略。在应用中作签名校验可以防止应用被二次打包。因为如果别人修改你的代码,肯定要重新打包,此时签名必然会改变。对 so 作签名校验是很有必要的,除了防止应用被打包,也可以防止你的 so 被别人盗用。
可以使用如下的代码在 java 中进行签名校验,
private static String getAppSignatureHash(final String packageName, final String algorithm) {
if (StringUtils.isSpace(packageName)) return "";
Signature[] signature = getAppSignature(packageName);
if (signature == null || signature.length <= 0) return "";
return StringUtils.bytes2HexString(EncryptUtils.hashTemplate(signature[0].toByteArray(), algorithm))
.replaceAll("(?<=[0-9A-F]{2})[0-9A-F]{2}", ":$0");
}
对于在 Native 层作签名校验,将上述方法翻译成对应的 JNI 调用即可,这里就不赘述了。
上面是签名校验的逻辑,看似美好,实际上稍微碰到有点破解的经验的就顶不住了。我之前遇到的一种破解上述签名校验的方法是,在自定义 Application 的 onCreate()
方法中读取 APK 的签名并存储到全局变量中,然后 Hook 获取应用签名的方法,并把上述读取到的真实的签名信息返回,以此绕过签名校验逻辑。
针对上述这种破解方式,我想到的第一个方法是对当前应用的 Application 类型作校验。因为他们加载 Hook 的逻辑是在自定义的 Application 中完成的,如果他们的 Application 和我们自己的 Application 类路径不一致,那么可以认定应用为破解版。
不过,这种方式作用也有限。我当时采用这种策略是考虑到有的破解者可能就是用一个脚本破解所有应用,所以改动一下可以防止这类破解者。但是,后来我也遇到一些“狠人”。因为我的软件用了 360 加固,所以如果加固壳工程的 Application 也认为是合法的。于是,我就看到了有的破解者在我的加固包之上又做了一层加固…
上述签名校验容易被 Hook 绕过,我们还可以采用另一种签名校验方法。
记得之前在《使用 APT 开发组件化框架的若干细节问题》 这篇文章中提到过,ARouter 在加载 APT 生成的路由信息的时候,一种方式是获取软件的 APK,然后从 APK 的 dex 中获取指定包名下的类文件。那么,我们是不是也可以借鉴这种方式来直接对 APK 进行签名校验呢?
首先,你可以采用下面的方法获取软件的 APK,
ApplicationInfo applicationInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), 0);
File sourceApk = new File(applicationInfo.sourceDir);
获取 APK 签名信息的方法比较多,这里我提供的是 Android 源码中的打包文件的签名代码,代码位置是:
https://android.googlesource.com/platform/tools/apksig/+/master
这样,当我们拿到 APK 之后,使用上述方法直接对 APK 的签名信息进行校验即可。
2.4 Janus签名机制漏洞
打包时选择v1和v2签名
增加签名证书的校验代码,降低App被二次打包的几率。
/**
* 检测签名
*/
private boolean checkSignature() {
Context context = WXApplication.getInstance();
try {
PackageInfo packageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
Signature[] signatures = packageInfo.signatures;
if (signatures != null) {
for (Signature signature : packageInfo.signatures) {
//获取MD5或者SHA1
MessageDigest md = MessageDigest.getInstance("SHA1");
md.update(signature.toByteArray());
String currentSignature = bytesToHexString(md.digest()).toUpperCase();
if ("YOUR SIGENATURE".equals(currentSignature)) {
return true;
}
}
} else {
LogUtil.i("signatures ==null");
}
} catch (NameNotFoundException e) {
e.printStackTrace();
LogUtil.e(e);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
LogUtil.e(e);
}
return false;
}
/**
* byte转16进制String
*
* @param src 数据源
* @return string
*/
public String bytesToHexString(byte[] src) {
StringBuilder stringBuilder = new StringBuilder();
if (src == null || src.length <= 0) {
return "";
}
for (byte by : src) {
int v = by & 0xFF;
String hv = Integer.toHexString(v);
if (hv.length() < 2) {
stringBuilder.append(0);
}
stringBuilder.append(hv);
}
return stringBuilder.toString();
}
上述我们提到了一些常用的加密方法,这里介绍下我在设计软件和系统的时候是如何对用户的重要信息作加密处理的。
首先,我的应用在做用户鉴权的时候是通过服务器下发的字段来验证的。为了防止服务器返回的信息被篡改以及在本地被用户篡改,我为返回的鉴权信息增加了签名字段。逻辑是这样的,
服务器查询用户信息之后根据预定义的规则拼接一个字符串,然后使用 SHA256 算法对拼接后的字符串做不可逆向的加密
从服务器拿到用户信息之后会直接丢到 SharedPreference 中(最好加密之后再存储)
当需要做用户鉴权的时候,首先根据之前预定义的规则,对签名字段做校验以判断鉴权信息是否给篡改
如果鉴权信息被篡改,则默认为普通用户权限
除了上述方法之外,为服务器配置 SSL 证书也是必不可少的。现在很多云平台都会提供一年免费的 Trust Asia 的证书(到期可再续费),免费使用即可。
为了防止应用的逻辑被破解,当某些重要的信息(比如上面的鉴权信息)写入到本地的时候,除了做上述处理,我对存储到 SharedPreference 中的键也做了一层处理。主要是使用设备 ID 和键名称拼接,做 SHA256 加密之后作为键值对的键。这里的设备 ID 就是 ANDROID_ID. 虽然 ANDROID_ID 用作设备 ID 并不可靠,但是在这个场景中它可以保证大部分用户存储到本地的键值对中的键是不同的,也就增加了破解者针对某个键值对进行破解的难度。
在代码中直接使用字符串很容易被别人搜索到,一般对于重要的字符串信息,我们可以将其先转换为整数数组。然后再在代码中通过数组得到最终的字符串。比如下面的代码用来将字符串转换为 short 类型的数组,
static short[] getShortsFromBytes(String from) {
byte[] bytesFrom = from.getBytes();
int size = bytes.length%2==0 ? bytes.length/2 : bytes.length/2+1;
short[] shorts = new short[size];
int i = 0;
short s = 0;
for (byte b : bytes) {
if (i % 2 == 0) {
s = (short) (b << 8);
} else {
s = (short) (s | b);
}
shorts[i/2] = s;
i++;
}
return shorts;
}
除了上面的一些方法之外,Android 的 Jetpack 对数据安全开发了 Security 库,适用于运行 Android 6.0 和更高版本的设备。Security 库针对的是 Android 应用中读写文件的安全性。详情可以阅读官方文档相关的内容:
更安全地处理数据:https://developer.android.com/topic/security/data
混淆之后可以让别人反编译我们的代码之后阅读起来更加困难。这在一定程度上可以增强应用的安全性。默认的混淆字典是 abc
等英文字母组成,还是具有一定的可读性的。我们可以通过配置混淆字典进一步增加阅读的难度:使用特殊符号、0oO
这种相近的字符甚至 java 的关键字来增加阅读的难度。配置的方式是,
# 方法名等混淆指定配置
-obfuscationdictionary dict.txt
# 类名混淆指定配置
-classobfuscationdictionary dict.txt
# 包名混淆指定配置
-packageobfuscationdictionary dict.txt
一般来说,当我们自定义混淆字典的时候需要从下面两个方面考虑,
混淆字典增加反编译识别难度使代码可读性变差
减小方法和字段名长度从而减小包体积
对于 o0O
这种虽然可读性变差了,但是代码长度相比于默认混淆字典要长一些,这会增加我们应用的包体积。我在选择混淆字典的时候使用的是比较难以记忆的字符。我把混淆字典放到了 Github 上面,需要的可以自取,
混淆字典:https://github.com/Shouheng88/LeafNote-Community/blob/main/dict.txt
这既可以保证包体积不会增大,又增加了阅读的难度。不过当我们反混淆的时候可能会遇到反混淆乱码的问题,比如 SDK 默认的反混淆工具就有这个问题(工具本身的问题)。
解决方案
WebView.getSettings().setSavePassword(false)
AndroidManifest.xml内关闭数据允许备份
application android:allowBackup=false
审核包含敏感行为的函数调用,确保其使用是必要且限制于授权用户的。
restartPackage - 关闭进程
在移动应用的程序代码内部,可能存在大量开发人员或其他工作人员无意识留下的信息内容。URL信息检测就是通过检测移动应用程序代码内部所存在的URL地址信息,尽可能呈现出应用中所有的URL信息,便于应用开发者查看并评估其安全性。移动应用发布包中的URL地址信息,可能会被盗取并恶意利用在正式服务器上进行攻击,攻击安全薄弱的测试服务器以获取服务器安全漏洞或者逻辑漏洞。
解决方案
1、核查并评估所有的URL信息,判断是否存在涉及内部业务等敏感信息的URL地址,进行删除;
2、尽量不要将与客户端业务相关的URL信息以硬编码的方式写在应用客户端中,建议以动态的方式生成所需要请求的URL
核查所有残留的账户和密码信息,删除与业务无关的账户和密码。
尽量不要将与客户端业务相关的账户密码信息以硬编码的方式写在应用客户端中。
开发者审查应用中显示或者输入关键信息的界面,在此类Activity创建时设置WindowManager.LayoutParams.FLAG_SECURE属性,该属性能防止屏幕被截图和录制
public class DemoActivity extends Activity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE);
setContentView(R.layout.main);
}
}
开发者显式移除有风险的Webview系统隐藏接口。
以下为修复代码示例:
在使用Webview加载页面之前,执行
webView.removeJavascriptInterface("searchBoxJavaBridge_");
webView.removeJavascriptInterface("accessibility");
webView.removeJavascriptInterface("accessibilityTraversal");
开发者应在应用启动时增加对应用运行环境的检测,当发现运行设备为Root设备时,应禁止应用启动。
@Override
protected void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_splash);
if(checkRootPathSU() || checkRootWhichSU()){
ToastUtils.showLong(getResources().getString(R.string.illegal_root));
finish();
return;
}
if(!checkSignature()){
ToastUtils.showLong(getResources().getString(R.string.illegal_apk_signatures));
finish();
return;
}
}
//通过检测指定目录下是否存在su程序来检测运行环境是否为Root设备
//当CheckRootPathSU返回值为true时,禁止应用启动
public static boolean checkRootPathSU()
{
File f=null;
final String kSuSearchPaths[] = {"/system/bin/", "/system/xbin/", "/system/sbin/", "/sbin/","/vendor/bin/"};
try{
for(int i=0;i execResult = executeCommand(strCmd);
if (execResult != null){
return true;
}else{
return false;
}
}
public static ArrayList executeCommand(String[] shellCmd){
String line = null;
ArrayList fullResponse = new ArrayList();
Process localProcess = null;
try {
localProcess = Runtime.getRuntime().exec(shellCmd);
} catch (Exception e) {
return null;
}
BufferedWriter out = new BufferedWriter(new OutputStreamWriter(localProcess.getOutputStream()));
BufferedReader in = new BufferedReader(new InputStreamReader(localProcess.getInputStream()));
try {
while ((line = in.readLine()) != null) {
fullResponse.add(line);
}
} catch (Exception e) {
e.printStackTrace();
}
return fullResponse;
}
开发者需要在应用调用外部浏览器时对引擎版本进行检测,当发现调用Chrome V8引擎并且版本低于4.2时停止调用外部浏览器并且提示用户对调用的系统浏览器进行升级或者修改系统默认的浏览器。
关注我获取更多知识或者投稿