android签名校验代码,Android签名验证解析
1、本文主要内容
知识回顾
签名验证解析
总结
本文介绍下Android在安装apk时,对签名的验证过程
2、知识回顾
在Android签名过程详解一文中,我已经详细说明签名的过程以及为什么要这么做,一起回顾下,当签名完成后,生成的3个文件分别有什么作用:
对Apk中的每个文件做一次算法(数据摘要+Base64编码),保存到MANIFEST.MF文件中
对MANIFEST.MF整个文件做一次算法(数据摘要+Base64编码),存放到CERT.SF文件的头属性中,在对MANIFEST.MF文件中各个属性块做一次算法(数据摘要+Base64编码),存到到一个属性块中。
对CERT.SF文件做签名,内容存档到CERT.RSA中
让我们带着这些基础知识来看看签名的验证过程
3、签名验证解析
Android Apk安装过程解析一文中阐述了apk安装过程,其中在 installPackageLI 方法中将验证签名。
//收集签名并验证
try {
pp.collectCertificates(pkg, parseFlags);
} catch (PackageParserException e) {
res.setError("Failed collect during installPackageLI", e);
return;
}
跟踪collectCertificates方法,它在 PackageParser 类当中:
private static void collectCertificates(Package pkg, File apkFile, int flags)
throws PackageParserException {
final String apkPath = apkFile.getAbsolutePath();
StrictJarFile jarFile = null;
try {
jarFile = new StrictJarFile(apkPath);
// Always verify manifest, regardless of source
//验证apk有没有androidmenifest.xml文件,如果没有则抛出异常
final ZipEntry manifestEntry = jarFile.findEntry(ANDROID_MANIFEST_FILENAME);
if (manifestEntry == null) {
throw new PackageParserException(INSTALL_PARSE_FAILED_BAD_MANIFEST,
"Package " + apkPath + " has no manifest");
}
final List toVerify = new ArrayList<>();
toVerify.add(manifestEntry);
// If we're parsing an untrusted package, verify all contents
//如果是不被信任的应用,那么将apk中除文件夹、META-INF文件夹内的文件、androidmenifest.xml的其它文件entry都添加到要验证的列表中
if ((flags & PARSE_IS_SYSTEM) == 0) {
final Iterator i = jarFile.iterator();
while (i.hasNext()) {
final ZipEntry entry = i.next();
if (entry.isDirectory()) continue;
if (entry.getName().startsWith("META-INF/")) continue;
if (entry.getName().equals(ANDROID_MANIFEST_FILENAME)) continue;
toVerify.add(entry);
}
}
// Verify that entries are signed consistently with the first entry
// we encountered. Note that for splits, certificates may have
// already been populated during an earlier parse of a base APK.
//验证每一个entry
for (ZipEntry entry : toVerify) {
//loadCertificates方法中将验证MANIFEST.MF文件以及CERT.SF
final Certificate[][] entryCerts = loadCertificates(jarFile, entry);
if (ArrayUtils.isEmpty(entryCerts)) {
throw new PackageParserException(INSTALL_PARSE_FAILED_NO_CERTIFICATES,
"Package " + apkPath + " has no certificates at entry "
+ entry.getName());
}
final Signature[] entrySignatures = convertToSignatures(entryCerts);
if (pkg.mCertificates == null) {
pkg.mCertificates = entryCerts;
pkg.mSignatures = entrySignatures;
pkg.mSigningKeys = new ArraySet();
for (int i=0; i < entryCerts.length; i++) {
pkg.mSigningKeys.add(entryCerts[i][0].getPublicKey());
}
} else {
//签名对比,相当于验证CERT.RSA中文件的内容
if (!Signature.areExactMatch(pkg.mSignatures, entrySignatures)) {
throw new PackageParserException(
INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES, "Package " + apkPath
+ " has mismatched certificates at entry "
+ entry.getName());
}
}
}
} catch (GeneralSecurityException e) {
throw new PackageParserException(INSTALL_PARSE_FAILED_CERTIFICATE_ENCODING,
"Failed to collect certificates from " + apkPath, e);
} catch (IOException | RuntimeException e) {
throw new PackageParserException(INSTALL_PARSE_FAILED_NO_CERTIFICATES,
"Failed to collect certificates from " + apkPath, e);
} finally {
closeQuietly(jarFile);
}
}
方法中存在一个列表,toVerify ,待验证的列表,然后通过循环将apk中除文件夹、META-INF文件夹内的文件以及androidmenifest.xml以外的文件都添加到此列表中来,以上代码是否感觉非常熟悉?
// If we're parsing an untrusted package, verify all contents
//如果是不被信任的应用,那么将apk中除文件夹、META-INF文件夹内的文件、androidmenifest.xml的其它文件entry都添加到要验证的列表中
if ((flags & PARSE_IS_SYSTEM) == 0) {
final Iterator i = jarFile.iterator();
while (i.hasNext()) {
final ZipEntry entry = i.next();
if (entry.isDirectory()) continue;
if (entry.getName().startsWith("META-INF/")) continue;
if (entry.getName().equals(ANDROID_MANIFEST_FILENAME)) continue;
toVerify.add(entry);
}
}
是的,MANIFEST.MF文件中正是保存着Apk中的每个文件做一次算法(数据摘要+Base64编码),当然文件夹不做数据摘要,二者的对象非常类似,实际上 toVerify 列表正是为了验证 MANIFEST.MF文件,继续跟踪 loadCertificates 方法
private static Certificate[][] loadCertificates(StrictJarFile jarFile, ZipEntry entry)
throws PackageParserException {
InputStream is = null;
try {
// We must read the stream for the JarEntry to retrieve
// its certificates.
is = jarFile.getInputStream(entry);
readFullyIgnoringContents(is);
return jarFile.getCertificateChains(entry);
} catch (IOException | RuntimeException e) {
throw new PackageParserException(INSTALL_PARSE_FAILED_UNEXPECTED_EXCEPTION,
"Failed reading " + entry.getName() + " in " + jarFile, e);
} finally {
IoUtils.closeQuietly(is);
}
}
loadCertificates 方法中有两个参数,一个是代表apk的文件,另一个是代表apk中某一文件的entry,继续跟踪 getInputStream 方法
public InputStream getInputStream(ZipEntry ze) {
final InputStream is = getZipInputStream(ze);
if (isSigned) {
StrictJarVerifier.VerifierEntry entry = verifier.initEntry(ze.getName());
if (entry == null) {
return is;
}
return new JarFileInputStream(is, ze.getSize(), entry);
}
return is;
}
方法内初始化了一个VerifierEntry 对象,并且构造了一个JarFileInputStream的输入流。StrictJarVerifier对象在StrictJarFile的构造方法中初始化的
//metaEntries 中包含了META-INF文件夹下三个文件读入的byte数组
HashMap metaEntries = getMetaEntries();
//manifest 代表着apk中MANIFEST.MF这个文件的读入结果
this.manifest = new StrictJarManifest(metaEntries.get(JarFile.MANIFEST_NAME), true);
this.verifier = new StrictJarVerifier(
name,
manifest,
metaEntries,
signatureSchemeRollbackProtectionsEnforced);
//读取META-INF下的三个文件,并且把读取结果保存在metaEntries对象中
private HashMap getMetaEntries() throws IOException {
HashMap metaEntries = new HashMap();
Iterator entryIterator = new EntryIterator(nativeHandle, "META-INF/");
while (entryIterator.hasNext()) {
final ZipEntry entry = entryIterator.next();
metaEntries.put(entry.getName(), Streams.readFully(getInputStream(entry)));
}
return metaEntries;
}
StrictJarVerifier的构造函数中有4个变量,第1个是apk的路径,第2个是包含MANIFEST.MF内容的数据结构,第3个是META-INF下的三个文件的读取结果,记住这3个参数。
回到verifier.initEntry函数
//name是要验证的其中一个entry的名字
VerifierEntry initEntry(String name) {
// If no manifest is present by the time an entry is found,
// verification cannot occur. If no signature files have
// been found, do not verify.
//如果没有manifest则返回
if (manifest == null || signatures.isEmpty()) {
return null;
}
//获取MANIFEST.MF中代表这个文件的数据摘要值
Attributes attributes = manifest.getAttributes(name);
// entry has no digest
if (attributes == null) {
return null;
}
//这段代码在收集签名之类的
ArrayList certChains = new ArrayList();
Iterator>> it = signatures.entrySet().iterator();
while (it.hasNext()) {
Map.Entry> entry = it.next();
HashMap hm = entry.getValue();
if (hm.get(name) != null) {
// Found an entry for entry name in .SF file
String signatureFile = entry.getKey();
Certificate[] certChain = certificates.get(signatureFile);
if (certChain != null) {
certChains.add(certChain);
}
}
}
// entry is not signed
if (certChains.isEmpty()) {
return null;
}
Certificate[][] certChainsArray = certChains.toArray(new Certificate[certChains.size()][]);
//真正开始验证MANIFEST.MF文件了
for (int i = 0; i < DIGEST_ALGORITHMS.length; i++) {
final String algorithm = DIGEST_ALGORITHMS[i];
//从MANIFEST.MF文件中获取这个文件的数据摘要值
final String hash = attributes.getValue(algorithm + "-Digest");
if (hash == null) {
continue;
}
byte[] hashBytes = hash.getBytes(StandardCharsets.ISO_8859_1);
try {
//VerifierEntry的name参数为要验证文件的名字,计算数据摘要的对象,MANIFEST.MF文件中获取这个文件的数据摘要值
//以及两个与签名相关的内容
return new VerifierEntry(name, MessageDigest.getInstance(algorithm), hashBytes,
certChainsArray, verifiedEntries);
} catch (NoSuchAlgorithmException ignored) {
}
}
return null;
}
注意,manifest 对象前文已经说过了,是一个包含着MANIFEST.MF文件内容的数据结构,所以在initEntry中,要验证的文件的数据摘要值将被manifest 查找出来:
final String hash = attributes.getValue(algorithm + "-Digest");
algorithm 这是数据摘要采用的算法名,它是这么定义的:
private static final String[] DIGEST_ALGORITHMS = new String[] {
"SHA-512",
"SHA-384",
"SHA-256",
"SHA1",
};
我们解压一个示例apk,发现MANIFEST.MF文件组织的形式如下,数据摘要的算法名是SHA1-Digest。和代码相比对一下发现,二者是吻合的,数据摘要将被读取出来
最后构建了一个VerifierEntry,传入的参数有,name参数为要验证文件的名字,计算数据摘要的对象,MANIFEST.MF文件中获取这个文件的数据摘要值以及两个与签名相关的内容。
回到 getInputStream 方法,此处以VerifierEntry为参数,返回了一个 JarFileInputStream 对象
return new JarFileInputStream(is, ze.getSize(), entry);
再返回到 PackageParser 的 loadCertificates 方法
private static Certificate[][] loadCertificates(StrictJarFile jarFile, ZipEntry entry)
throws PackageParserException {
InputStream is = null;
try {
// We must read the stream for the JarEntry to retrieve
// its certificates.
is = jarFile.getInputStream(entry);
readFullyIgnoringContents(is);
return jarFile.getCertificateChains(entry);
} catch (IOException | RuntimeException e) {
throw new PackageParserException(INSTALL_PARSE_FAILED_UNEXPECTED_EXCEPTION,
"Failed reading " + entry.getName() + " in " + jarFile, e);
} finally {
IoUtils.closeQuietly(is);
}
}
查看 readFullyIgnoringContents 方法
public static long readFullyIgnoringContents(InputStream in) throws IOException {
byte[] buffer = sBuffer.getAndSet(null);
if (buffer == null) {
buffer = new byte[4096];
}
int n = 0;
int count = 0;
while ((n = in.read(buffer, 0, buffer.length)) != -1) {
count += n;
}
sBuffer.set(buffer);
return count;
}
这个方法也没有什么特殊的,就是读文件而已,但不要忘了,传入的输入流对象是 JarFileInputStream ,查看它的read方法:
public int read(byte[] buffer, int byteOffset, int byteCount) throws IOException {
if (done) {
return -1;
}
if (count > 0) {
int r = super.read(buffer, byteOffset, byteCount);
if (r != -1) {
int size = r;
if (count < size) {
size = (int) count;
}
//将文件 写入 VerifierEntry 中,以便为这个文件计算数据摘要值
entry.write(buffer, byteOffset, size);
count -= size;
} else {
count = 0;
}
if (count == 0) {
done = true;
//验证计算得到的数据摘要值
entry.verify();
}
return r;
} else {
done = true;
entry.verify();
return -1;
}
}
在读入数据的同时,会调用 entry.write,将数据写入到 VerifierEntry 当中,并且最后调用 entry.verify 方法。
private final MessageDigest digest;
public void write(byte[] buf, int off, int nbytes) {
digest.update(buf, off, nbytes);
}
void verify() {
byte[] d = digest.digest();
if (!verifyMessageDigest(d, hash)) {
throw invalidDigest(JarFile.MANIFEST_NAME, name, name);
}
verifiedEntries.put(name, certChains);
}
private static boolean verifyMessageDigest(byte[] expected, byte[] encodedActual) {
byte[] actual;
try {
actual = java.util.Base64.getDecoder().decode(encodedActual);
} catch (IllegalArgumentException e) {
return false;
}
return MessageDigest.isEqual(expected, actual);
}
从上述代码可以看出,VerifierEntry 写入文件数据后,会使用 MessageDigest 对象重新计算文件的数据摘要值,并且和之前已经传入进来的数据摘要值进行比对,如果一致就是正确的,至此,MANIFEST.MF文件验证完成。
接下来看对CERT.SF文件的校验。
jarFile.getCertificateChains(entry);
public Certificate[][] getCertificateChains(ZipEntry ze) {
if (isSigned) {
return verifier.getCertificateChains(ze.getName());
}
return null;
}
看起来只是从列表中返回一个证书对象即可,但这些证书对象在哪里读取的呢?它是在StrictJarVerifier初始化时读取的
synchronized boolean readCertificates() {
if (metaEntries.isEmpty()) {
return false;
}
Iterator it = metaEntries.keySet().iterator();
while (it.hasNext()) {
String key = it.next();
if (key.endsWith(".DSA") || key.endsWith(".RSA") || key.endsWith(".EC")) {
verifyCertificate(key);
it.remove();
}
}
return true;
}
值得注意的是,metaEntries在前文中已经说过,它是META-INF文件夹下3个文件使用输入流读取后生成的byte数组,在循环中,key明显就是这3个文件的文件名。显然只有文件名等于CERT.RSA方法,才能进入verifyCertificate方法
查看verifyCertificate方法
private void verifyCertificate(String certFile) {
// Found Digital Sig, .SF should already have been read
// 此前分析过,certFile只可能为CERT.RSA,所以signatureFile通过字符串拼接后是CERT.SF
String signatureFile = certFile.substring(0, certFile.lastIndexOf('.')) + ".SF";
// sfBytes,即是CERT.SF文件的内容
byte[] sfBytes = metaEntries.get(signatureFile);
if (sfBytes == null) {
return;
}
// manifestBytes显然是MANIFEST.MF文件的内容
byte[] manifestBytes = metaEntries.get(JarFile.MANIFEST_NAME);
// Manifest entry is required for any verifications.
if (manifestBytes == null) {
return;
}
// sBlockBytes显然是CERT.RSA文件的内容
byte[] sBlockBytes = metaEntries.get(certFile);
try {
// 收集签名之类的,具体逻辑不细说了
Certificate[] signerCertChain = JarUtils.verifySignature(
new ByteArrayInputStream(sfBytes),
new ByteArrayInputStream(sBlockBytes));
if (signerCertChain != null) {
certificates.put(signatureFile, signerCertChain);
}
} catch (IOException e) {
return;
} catch (GeneralSecurityException e) {
throw failedVerification(jarName, signatureFile);
}
// Verify manifest hash in .sf file
Attributes attributes = new Attributes();
HashMap entries = new HashMap();
try {
// 使用StrictJarManifestReader读取CERT.SF文件的内容,并且把数据保存到attributes当中来
ManifestReader im = new ManifestReader(sfBytes, attributes);
im.readEntries(entries, null);
} catch (IOException e) {
return;
}
// Do we actually have any signatures to look at?
if (attributes.get(Attributes.Name.SIGNATURE_VERSION) == null) {
return;
}
boolean createdBySigntool = false;
//查看CERT.SF文件,查看Created-By属性,是否包含signtool关键字
String createdBy = attributes.getValue("Created-By");
if (createdBy != null) {
createdBySigntool = createdBy.indexOf("signtool") != -1;
}
// Use .SF to verify the mainAttributes of the manifest
// If there is no -Digest-Manifest-Main-Attributes entry in .SF
// file, such as those created before java 1.5, then we ignore
// such verification.
//这是针对以前的java版本在1.5之前的操作,现在忽略它
//而且verify方法的最后一个参数为false,verfy方法返回的就是最后一个参数的值,
//所以如果CERT.SF文件中没有-Digest-Manifest-Main-Attributes这个属性也不用担心
if (mainAttributesEnd > 0 && !createdBySigntool) {
String digestAttribute = "-Digest-Manifest-Main-Attributes";
if (!verify(attributes, digestAttribute, manifestBytes, 0,
mainAttributesEnd, false, true)) {
throw failedVerification(jarName, signatureFile);
}
}
// Use .SF to verify the whole manifest.
//验证MANIFEST.MF整体文件的数据摘要
String digestAttribute = createdBySigntool ? "-Digest"
: "-Digest-Manifest";
if (!verify(attributes, digestAttribute, manifestBytes, 0,
manifestBytes.length, false, false)) {
Iterator> it = entries.entrySet()
.iterator();
while (it.hasNext()) {
Map.Entry entry = it.next();
Manifest.Chunk chunk = manifest.getChunk(entry.getKey());
if (chunk == null) {
return;
}
//验证MANIFEST.MF其它条目的数据摘要值和自己计算的相比,是不是一样的
if (!verify(entry.getValue(), "-Digest", manifestBytes,
chunk.start, chunk.end, createdBySigntool, false)) {
throw invalidDigest(signatureFile, entry.getKey(), jarName);
}
}
}
metaEntries.put(signatureFile, null);
signatures.put(signatureFile, entries);
}
verifyCertificate方法中,分别读取了代表CERT.SF、MANIFEST.MF和CERT.RSA这三个文件的内容,第1步,是从CERT.SF和CERT.RSA读取出签名来。接下来就是验证MANIFEST.MF整体的数据摘要内容以及MANIFEST.MF内部条目的数据摘要内容。
值得注意的是
if (mainAttributesEnd > 0 && !createdBySigntool) {
String digestAttribute = "-Digest-Manifest-Main-Attributes";
if (!verify(attributes, digestAttribute, manifestBytes, 0,
mainAttributesEnd, false, true)) {
throw failedVerification(jarName, signatureFile);
}
}
这段代码并没有啥用,现在的CERT.SF中,开头都不会包含-Digest-Manifest-Main-Attributes这个了,从英文注释上来讲只有java1.5版本以前会有这样的信息,所以这段代码可忽略。真正验证 MANIFEST.MF 整体文件数据摘要的是这段代码:
String digestAttribute = createdBySigntool ? "-Digest"
: "-Digest-Manifest";
if (!verify(attributes, digestAttribute, manifestBytes, 0,
manifestBytes.length, false, false)) {
我们知道,createdBySigntool 值为false,所以 digestAttribute 的值一定是 -Digest-Manifest。我们可以看看示例apk的CERT.SF文件内容
整体文件的算法为SHA1,后缀为 -Digest-Manifest ,和代码一致。
而后续则是验证 MANIFEST.MF 其它条目的数据摘要了,它使用的算法后缀是-Digest,我们从上图依然可以得到验证。
接下来我们看看verify方法,它的主要功能是对比两个数据摘要的内容:
CERT.SF文件中记录着的数据摘要值
从MANIFEST.MF文件中读取文件并重新计算数据摘要值
//Attributes是包含CERT.SF文件内容的数据结构,entry则是数据摘要算法的后缀,data传入的是MANIFEST.MF文件的二进制读入流
//start和和end表示对data读取多个长度
private boolean verify(Attributes attributes, String entry, byte[] data,
int start, int end, boolean ignoreSecondEndline, boolean ignorable) {
for (int i = 0; i < DIGEST_ALGORITHMS.length; i++) {
//获取数据摘要的算法
String algorithm = DIGEST_ALGORITHMS[i];
//遍历数据摘要算法,再拼接上后缀,看看能否从CERT.SF读取到这个条目的值,结果不为空则算法匹配上了
String hash = attributes.getValue(algorithm + entry);
if (hash == null) {
continue;
}
MessageDigest md;
try {
//获取计算数据摘要的工具
md = MessageDigest.getInstance(algorithm);
} catch (NoSuchAlgorithmException e) {
continue;
}
//使用计算工具重新计算数据摘要的值
if (ignoreSecondEndline && data[end - 1] == '\n'
&& data[end - 2] == '\n') {
md.update(data, start, end - 1 - start);
} else {
md.update(data, start, end - start);
}
byte[] b = md.digest();
byte[] hashBytes = hash.getBytes(StandardCharsets.ISO_8859_1);
//看看重新计算的数据摘要值和从CERT.SF读取出来的,是否一致
return MessageDigest.isEqual(b, Base64.decode(hashBytes));
}
return ignorable;
}
要特别留意verify方法的参数,把参数弄明白了基本上整个方法也都明白了。verify方法还是比较简单的,把两种方式得到的数据摘要值比较一下,如果相等就返回true了。
到目前为止,CERT.SF文件也已经验证完成了,那么CERT.RSA是在哪里验证的呢?
PackageParser类的collectCertificates方法会比对一次公钥信息,这其实就是在验证签名是不是原作者的签名,如果不对也会抛出异常:
if (!Signature.areExactMatch(pkg.mSignatures, entrySignatures)) {
throw new PackageParserException(
INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES, "Package " + apkPath
+ " has mismatched certificates at entry "
+ entry.getName());
}
总结
所以,apk的签名校验,通过以上3个步骤,就确保了apk自己的安全性,无法被篡改。
android签名校验代码,Android签名验证解析相关推荐
- 面试:Android 签名校验机制 v1、v2、v3
探究 Android 签名机制和原理 - 腾讯云开发者社区-腾讯云 一.APK签名可以带来以下好处 应用程序升级 如果想无缝升级一个应用,Android系统要求应用程序的新版本与老版本具有相同的签名与 ...
- android摄像头拍照代码,Android调用摄像头拍照开发教程
现在很多应用中都会要求用户上传一张图片来作为头像,首先我在这接收使用相机拍照和在相册中选择图片.接下来先上效果图: 接下来看代码: 1.布局文件: xmlns:tools="http://s ...
- Android签名背景颜色,Android UI设计系列之自定义DrawView组件实现数字签名效果(5)...
最近项目中有个新的需求,用户在完交易需要进行输入支付密码付款的时候,要让用户签下自己的签名,提起到数字签名这个东西,感觉有点高大上,后来想想数字签名的原理也不是太复杂,主要实现原理就是利用了View的 ...
- android apk安装代码,Android安装APK
7.0以上安装APK,请自行配置FileProvider,具体不多说 android:name="androidx.core.content.FileProvider" andro ...
- android增删功能代码,Android SQLite增删查改实例代码部分
在 Android与SQLite数据库 这个专题里我们谈到了 SQLite 的基本应用,但在实际开发中,为了能够更好的管理和维护数据库,我们会封装一个继承自 SQLiteOpenHelper 类的数据 ...
- android小球移动代码,Android自定义圆形View实现小球跟随手指移动效果
本文实例为大家分享了Android实现小球跟随手指移动效果的具体代码,供大家参考,具体内容如下 一. 需求功能 手指在屏幕上滑动,红色的小球始终跟随手指移动. 实现的思路: 1)自定义View,在on ...
- android调频收音机代码,android 收音机 FM 驱动 hal层 框架层以及应用层代码
[实例简介] android 收音机 FM 驱动 hal层 框架层以及应用层代码 方法一 不需要framework部分 1.fm放到 \hardware\rk2x 2.FmRadio 放到 packa ...
- Android钢琴滑动代码,android 钢琴界面实现
近在做一个钢琴的东西,关于这个界面如何设计画了很长时间,主要是考虑到针对不同的分辨率,如果只针对一种分辨率的话用绝对布局可以实现,实现的基本思想是每个白色的键的位置是可以计算出来的,屏幕的宽度可以获得 ...
- android确认密码代码,Android自定义View实现验证码or密码输入框
前言 最近项目中有支付功能,用户输入密码时要类似微信支付密码输入框的样式,本想直接copy网上的,但设计姐姐总是对样式挑三拣四,抽空自己自定义了一个,无奈之下抽空自定义了个,并把它贴到GitHub上供 ...
最新文章
- 避免到服务器的不必要的往返过程
- IDC:2018年中国制造业十大预测
- Redis 宝典 | 基础、高级特性与性能调优
- 【GVA】gorm多对多many2many删除数据的同时级联删除关联中间表中的关联数据
- 转载的孩子们注意节操哈!!!
- 牛客 -- leetcode -- max-points-on-a-line
- java protobuf 例子_java使用protobuf例子
- 为什么AI工程师成为当前薪资最高的技术岗位
- linux -小记(3) 问题:linux 安装epel扩展源报错
- 新sniffer pro 4.75 sp5下载
- DocLocker - 文档外发控制系统
- Maya2011下载 (破解正式版)
- 怎么用一个计算机控制两个屏幕,一台电脑控制多个led显示屏
- 深入理解MySQL(2):详谈索引结构
- 手机射频测试总结(二)——接收灵敏度
- MYSQL_精讲数据库数据类型
- HTML第六章上机练习1-5题
- Python学习 day03
- 我收集的几个威客网站
- 简析“无人便利店” 物联网技术的应用
热门文章
- outlook添加新账户服务器信息怎么填,outlook如何再添加一个新账户?
- 甲骨文发布第三季度财报 云业务拉动营收增长
- patten时延差编码
- AD7921 AUJ
- 兮克SKS7300-12GPY2XGT2XGS交换机搭配爱速特NAS的链路聚合设置教程
- linux shell转换成时间,如何在Bash中将时间戳转换为日期?
- win7没intel信息服务器,win7功能没有ftp服务器
- 各行业防雷工程和防雷接地的应用方案
- 设计模式 6 - 原型模式及spring源码案例分析
- linux initrd usb热插拔,linux技术之制作USB启动盘