Android签名验证与反调试机制的对抗技术
Posted Tr0e
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android签名验证与反调试机制的对抗技术相关的知识,希望对你有一定的参考价值。
文章目录
前言
android 的 APK 文件为了防止被篡改和重打包,经常会做签名校验来保证自身完整性,当程序被篡改后将提示用户或者直接退出运行。同时有些 APP 为了防止被攻击者动态调试和分析,还做了反调试机制。本文来学习记录下 Android 签名验证机制与反调试机制的实现原理和其对抗技术。
签名验证
Android 系统使用 JAR 包的签名机制对 APK 进行完整性保护,确保 APK 在不安全的网络传输时的完整性得到保护。但 Android 系统没有对数字签名的颁发者进行管理,任何人都可以生成数字签名,并使用该签名对 APK 包进行重新签名。如果 APP 本身不对自身的签名来源进行有效的完整性检查,攻击者可以篡改应用(插入恶意代码、木马、后门、广告等),重新签名并且二次发布,导致应用程序完整性被破坏。为了说明 APK 签名比对对软件安全的有效性,我们有必要了解一下 Android APK 的签名机制。
1.1 签名机制
对比一个没有签名的 APK 和一个签名好的 APK,我们会发现,签名好的 APK 包中多了一个叫做 META-INF 的文件夹。里面有三个文件,分别名为 MANIFEST.MF、CERT.SF 和 CERT.RSA,这些就是使用 signapk.jar 生成的签名文件。
签名文件 | 作用 |
---|---|
MANIFEST.MF | 保存了 apk 所有文件的摘要信息(SHA-1+Base64) |
CERT.SF | 保存了对 MANIFEST.MF 文件再进行一次 SHA-1 并 Base64 加密的信息,并同时保存了 MANIFEST.MF 文件的摘要信息 |
CERT.RSA | 保存了公钥和所采用的加密算法等信息 |
其中 signapk.jar 是 Android 源码包中的一个签名工具,由于 Android 是个开源项目,所以可以直接找到 signapk.jar 的源码,路径为 /build/tools/signapk/SignApk.java。通过阅读 signapk 源码,我们可以理清签名 APK 包的整个过程。
1、 生成 MANIFEST.MF 文件:
程序遍历 update.apk 包中的所有文件(entry),对非文件夹非签名文件的文件,逐个生成 SHA1 的数字签名信息,再用 Base64 进行编码。具体代码见这个方法:
private static Manifest addDigestsToManifest(JarFile jar)
关键代码如下:
for (JarEntry entry: byName.values()) {
String name = entry.getName();
if (!entry.isDirectory() && !name.equals(JarFile.MANIFEST_NAME) &&
!name.equals(CERT_SF_NAME) && !name.equals(CERT_RSA_NAME) &&
(stripPattern == null ||!stripPattern.matcher(name).matches())){
InputStream data = jar.getInputStream(entry);
while ((num = data.read(buffer)) > 0) {
md.update(buffer, 0, num);
}
Attributes attr = null;
if (input != null) attr = input.getAttributes(name);
attr = attr != null ? new Attributes(attr) : new Attributes();
attr.putValue("SHA1-Digest", base64.encode(md.digest()));
output.getEntries().put(name, attr);
}
}
之后将生成的签名写入 MANIFEST.MF 文件,关键代码如下:
Manifest manifest = addDigestsToManifest(inputJar);
je = new JarEntry(JarFile.MANIFEST_NAME);
je.setTime(timestamp);
outputJar.putNextEntry(je);
manifest.write(outputJar);
这里简单介绍下 SHA1 数字签名。简单地说,它就是一种安全哈希算法,类似于 MD5 算法。它把任意长度的输入,通过散列算法变成固定长度的输出(这里我们称作“摘要信息”)。你不能仅通过这个摘要信息复原原来的信息。另外,它保证不同信息的摘要信息彼此不同。因此如果你改变了 apk 包中的文件,那么在 apk 安装校验时,改变后的文件摘要信息与 MANIFEST.MF 的检验信息不同,于是程序就不能成功安装。
2、 生成 CERT.SF 文件:
对前一步生成的 Manifest,使用 SHA1-RSA 算法,用私钥进行签名。关键代码如下:
Signature signature = Signature.getInstance("SHA1withRSA");
signature.initSign(privateKey);
je = new JarEntry(CERT_SF_NAME);
je.setTime(timestamp);
outputJar.putNextEntry(je);
writeSignatureFile(manifest,
new SignatureOutputStream(outputJar, signature));
RSA 是一种非对称加密算法。用私钥通过 RSA 算法对摘要信息进行加密。在安装时只能使用公钥才能解密它。解密之后,将它与未加密的摘要信息进行对比,如果相符,则表明内容没有被异常修改。
3、 生成 CERT.RSA 文件:
生成 MANIFEST.MF 没有使用密钥信息,生成 CERT.SF 文件使用了私钥文件。那么我们可以很容易猜测到,CERT.RSA 文件的生成肯定和公钥相关。CERT.RSA 文件中保存了公钥、所采用的加密算法等信息。核心代码如下:
je = new JarEntry(CERT_RSA_NAME);
je.setTime(timestamp);
outputJar.putNextEntry(je);
writeSignatureBlock(signature, publicKey, outputJar);
其中 writeSignatureBlock 的代码如下:
private static void writeSignatureBlock(
Signature signature, X509Certificate publicKey, OutputStream out)
throws IOException, GeneralSecurityException {
SignerInfo signerInfo = new SignerInfo(
new X500Name(publicKey.getIssuerX500Principal().getName()),
publicKey.getSerialNumber(),
AlgorithmId.get("SHA1"),
AlgorithmId.get("RSA"),
signature.sign());
PKCS7 pkcs7 = new PKCS7(
new AlgorithmId[] { AlgorithmId.get("SHA1") },
new ContentInfo(ContentInfo.DATA_OID, null),
new X509Certificate[] { publicKey },
new SignerInfo[] { signerInfo });
pkcs7.encodeSignedData(out);
}
好了,分析完APK包的签名流程,我们可以清楚地意识到:
- Android 签名机制其实是对 APK 包完整性和发布机构唯一性的一种校验机制;
- Android 签名机制不能阻止 APK 包被修改,但修改后的再签名无法与原先的签名保持一致(拥有私钥的情况除外);
- APK 包加密的公钥就打包在 APK 包内,且不同的私钥对应不同的公钥,我们可以对比公钥来判断私钥是否一致;
- Android 并不要求所有应用程序的签名证书都由可信任 CA 的根证书签名,通过这点保证了其生态系统的开放性,所有人都可以用自己生成的证书对应用程序签名。
如果想修改一个已经发布的应用程序,哪怕是修改一张图片,都必须对其进行重新签名。但是,签原始应用的私钥一般是拿不到的(肯定在原始应用程序开发者的手上,且不可能公布出去),所以只能用另外一组公私钥对,生成一个新的证书,对重打包的应用进行签名,因此重打包的 apk 中所带证书的公钥肯定和原始应用不一样。同时,在手机上如果想安装一个应用程序,应用程序安装器会先检查相同包名的应用是否已经被安装过,如果已经安装过,会继续判断已经安装的应用和将要安装的应用,其所携带的数字证书中的公钥是否一致。如果相同,则继续安装;而如果不同,则会提示用户先卸载前面已安装的应用。
1.2 签名验签
在程序中获取 APK 的签名时,通过 signature 方法进行获取,如下:
packageInfo = manager.getPackageInfo(pkgname,PackageManager.GET_SIGNATURES);
signatures = packageInfo.signatures;
for (Signature signature : signatures) {
builder.append(signature.toCharsString());
}
signature = builder.toString();
所以一般的程序就是在代码中通过判断 signature 的值,来判断 APK 是否被重新打包过。
APK 签名比对的应用场景大致有三种:
- 程序自检测:在程序运行时,自我进行签名比对,比对样本可以存放在 APK 包内,也可存放于云端。缺点是程序被破解时,自检测功能同样可能遭到破坏,使其失效;
- 可信赖的第三方检测:由可信赖的第三方程序负责 APK 的软件安全问题,对比样本由第三方收集,放在云端,这种方式适用于杀毒安全软件或者APP Market之类的软件下载市场,缺点是需要联网检测,在无网络情况下无法实现功能(不可能把大量的签名数据放在移动设备本地);
- 系统限定安装:这就涉及到改 Android 系统了,限定仅能安装某些证书的 APK,软件发布商需要向系统发布上申请证书,如果发现问题,能追踪到是哪个软件发布商的责任,适用于系统提供商或者终端产品生产商,缺点是过于封闭,不利于系统的开放性。
以上三种场景,虽然各有缺点,但缺点并不是不能克服的。例如,我们可以考虑程序自检测的功能用 native method的 方法实现等等。软件安全是一个复杂的课题,往往需要多种技术联合使用,才能更好的保障软件不被恶意破坏。
附上一个完整 APK 签名校验工具类:
public class SignCheckUtil {
private Context context;
private String cer = null;
private String type = "SHA1";
private String sha1RealCer = "签名SHA1值";
private String md5RealCer = "签名MD5";
private static final String TAG = "sign";
public SignCheckUtil(Context context,String type) {
this.context = context;
this.type = type;
}
/**
* 获取应用的签名
*
* @return
*/
public String getCertificateSHA1Fingerprint() {
String hexString = "";
//获取包管理器
PackageManager pm = context.getPackageManager();
//获取当前要获取 SHA1 值的包名,也可以用其他的包名,但需要注意,
//在用其他包名的前提是,此方法传递的参数 Context 应该是对应包的上下文。
String packageName = context.getPackageName();
//签名信息
Signature[] signatures = null;
try {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
PackageInfo packageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNING_CERTIFICATES);
SigningInfo signingInfo = packageInfo.signingInfo;
signatures = signingInfo.getApkContentsSigners();
} else {
//获得包的所有内容信息类
PackageInfo packageInfo = pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES);
signatures = packageInfo.signatures;
}
byte[] cert = signatures[0].toByteArray();
//将签名转换为字节数组流
InputStream input = new ByteArrayInputStream(cert);
//证书工厂类,这个类实现了出厂合格证算法的功能
CertificateFactory cf = CertificateFactory.getInstance("X509");
//X509 证书,X.509 是一种非常通用的证书格式
X509Certificate c = null;
c = (X509Certificate) cf.generateCertificate(input);
//加密算法的类,这里的参数可以使 MD4,MD5 等加密算法
MessageDigest md = MessageDigest.getInstance(type);
//获得公钥
byte[] publicKey = md.digest(c.getEncoded());
//字节到十六进制的格式转换
hexString = byte2HexFormatted(publicKey);
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
} catch (NoSuchAlgorithmException e1) {
e1.printStackTrace();
} catch (CertificateEncodingException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
}
return hexString.trim();
}
//这里是将获取到得编码进行16 进制转换
private String byte2HexFormatted(byte[] arr) {
StringBuilder str = new StringBuilder(arr.length * 2);
for (int i = 0; i < arr.length; i++) {
String h = Integer.toHexString(arr[i]);
int l = h.length();
if (l == 1)
h = "0" + h;
if (l > 2)
h = h.substring(l - 2, l);
str.append(h.toUpperCase());
if (i < (arr.length - 1))
str.append(':');
}
return str.toString();
}
/**
* 检测签名是否正确
*
* @return true 签名正常 false 签名不正常
*/
public boolean check() {
if (this.sha1RealCer != null || md5RealCer!= null) {
cer = getCertificateSHA1Fingerprint();
if ((TextUtils.equals(type,"SHA1") && this.cer.equals(this.sha1RealCer)) || (TextUtils.equals(type,"MD5") && this.cer.equals(this.md5RealCer))) {
return true;
}
}
return false;
}
}
1.3 签名绕过
在讲签名绕过的方式前,需要先明确 DEX 校验和签名校验:
- 将 APK 以压缩包的形式打开删除原签名后,再签名,安装能够正常打开,但是用 IDE(即 apk 改之理,会自动反编译 dex)工具二次打包,却出现非正常情况的,如:闪退/弹出非正版提示框,则可以确定是dex文件的校验;
- 将 APK 以压缩包的形式打开删除原签名再签名,安装之后打开异常的,则基本可以断定是签名检验。如果在断网的情况下同样是会出现异常,则是本地的签名检验;如果首先出现的是提示网络没有连接,则是服务器端的签名校验。
针对给类签名校验方式的绕过:
签名校验方式 | 介绍 | 绕过方式 |
---|---|---|
Java 层校验 | 获取签名信息和验证的方法都写在 Android 的 Java 层 | 1)Hook Java 层函数的返回值;2)反编译修改校验函数逻辑并二次打包 ;3)动态调试 APK 并篡改内存中校验函数的返回值 |
SO 层校验 | 获取签名信息和验证的方法都写在 Android 的 So 层 | 1) Hook SO 层函数的返回值;2)反汇编程序、修改校验函数逻辑并二次打包 ;3)动态调试 APK 并篡改内存中校验函数的返回值 |
服务器验证 | 在 Android 的 Java 层获取签名信息,上传服务器在服务端进行签名然后返回验证结果 | 1)拦截并篡改服务端的校验返回结果;2)反编译程序并篡改破坏校验过程 |
具体的对抗案例分析可参见:APK签名校验绕过,此处不展开叙述。
反反调试
反调试在代码保护中扮演着很重要的角色,虽然并不能完全阻止逆向行为,但是能在长期的攻防战中给破解人员不断的增加逆向难度。
2.1 tracerPid检测
APK 在被调试的状态下,Linux 会向/proc/<pid>/status
文件中写入一些进程状态信息,其中最大的变化就是文件中的 TracerPid 字段被写入了调试进程的 pid,如下图所示:
所以可以通过检测/proc/<pid>/status
文件中 TracerPid 的值是否为 0 来判断当前进程正在被调试,是的话则杀死进程。具体的 So 层检测示例代码如下:
#include <unistd.h>
...
void check_process_status(){
int buffsize=1024;
char filename[buffsize]; // 文件名
char line[buffsize]; // 文件中的每一行
int pid=getpid(); // 获取进程号
sprintf(filename,"/proc/%d/status",pid);
FILE *fp=fopen(filename,"r");
if (fp != NULL){
while (fgets(line,buffsize,fp)){
if (strncmp(line,"TracerPid",9)==0){
int status=atoi(&line[10]);
if (status!=0){
fclose(fp);
kill(pid,SIGKILL); // 杀死进程
}
break;
}
}
}
fclose(fp);
}
jint JNI_OnLoad(JavaVM* vm, void* reserved){
check_process_status();
...
}
至于破解反调试的方法:Frida Hook 篡改校验调试状态的函数的返回值,或者使用 IDA 反汇编 APK 并篡改程序逻辑后重新打包,具体参见:IDA动态调试破解AliCrackme与反调试对抗。
2.2 进程名称检测
根据上一种反调试方法我们知道可以通过检测 TracerPid 的值判断程序是否被调试,而 TracerPid 的值就是调试器的进程号,调试器的进程名则被存储在/proc/<pid>/cmdline
文件中,这里的 pid 为调试器的 pid。所以可以检测/proc/<pid>/cmdline
文件中的内容是否包含一些调试器的进程名,比如 android_server,来判断程序是否被调试。
校验代码示例如下:
void check_process_name(){
int buffsize=1024;
char filename[buffsize];
char line[buffsize];
char name[buffsize];
char nameline[buffsize];
int pid=getpid();
sprintf(filename,"/proc/%d/status",pid);
FILE *fp=fopen(filename,"r");
if (fp!=NULL){
while (fgets(line,buffsize,fp)){
// 检测/proc/<pid>/status文件的某一行中是否包含TracerPid
if (strstr(line,"TracerPid")!=NULL){
int status=atoi(&line[10]);
if (status!=0){
sprintf(name,"/proc/%d/cmdline",status);
FILE *fpname=fopen(name,"r");
if (fpname!=NULL){
while (fgets(nameline,buffsize,fpname)!=NULL){
// 检测/proc/<pid>/cmdline文件的某一行是否包含android_server
if (strstr(nameline,"android_server")!=NULL){
kill(pid,SIGKILL);
}
}
}
fclose(fpname);
}
}
}
}
fclose(fp);
}
若要绕过反调试,修改 android_server 的文件名即可。
2.3 关键文件检测
在使用 IDA 动态调试之前一般会先将 IDA Pro 目录下的 android_server 放入到 /data/local/tmp 目录下,所以可以检测 /data/local/tmp 目录是否包含一个名为 android_server 的文件。
在 native_lib.cpp 文件中添加一个 check_name() 方法,并在 JNI_OnLoad() 中调用:
void check_name(){
char* root_path="/data/local/tmp";
DIR* dir;
dir=opendir(root_path); // 打开目录
int pid=getpid();
if (dir!=NULL){
dirent* currentDir;
while ((currentDir=readdir(dir))!=NULL){
if (strncmp(currentDir->d_name,"android_server",14)==0){
kill(pid,SIGKILL);
}
}
closedir(dir);
}
}
若要绕过反调试,可以修改 android_server 的文件名,或者将 android_server 放在其他目录。
2.4 调试端口检测
android_server 的默认监听的端口号是 23946,所以可以通过检测这个端口号来起到一定的反调试作用。在 Linux 系统中,/proc/net/tcp
文件会记录一些连接信息,在启动 android_server 以后,该文件中多了一行内容:
可以看到,/proc/net/tcp
文件中多了一个运行在 5D8A 端口上的连接信息,而 5D8A 正好是 23946 的十六进制,因此可以检测该文件中的端口号来达到反调试的效果。
在 native_lib.cpp 中添加一个 check_port() 方法,并在 JNI_OnLoad() 中调用:
void check_port(){
int buffsize=1024;
char filename[buffsize];
char line浅谈android反调试之 签名校验