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签名验证解析相关推荐

  1. 面试:Android 签名校验机制 v1、v2、v3

    探究 Android 签名机制和原理 - 腾讯云开发者社区-腾讯云 一.APK签名可以带来以下好处 应用程序升级 如果想无缝升级一个应用,Android系统要求应用程序的新版本与老版本具有相同的签名与 ...

  2. android摄像头拍照代码,Android调用摄像头拍照开发教程

    现在很多应用中都会要求用户上传一张图片来作为头像,首先我在这接收使用相机拍照和在相册中选择图片.接下来先上效果图: 接下来看代码: 1.布局文件: xmlns:tools="http://s ...

  3. Android签名背景颜色,Android UI设计系列之自定义DrawView组件实现数字签名效果(5)...

    最近项目中有个新的需求,用户在完交易需要进行输入支付密码付款的时候,要让用户签下自己的签名,提起到数字签名这个东西,感觉有点高大上,后来想想数字签名的原理也不是太复杂,主要实现原理就是利用了View的 ...

  4. android apk安装代码,Android安装APK

    7.0以上安装APK,请自行配置FileProvider,具体不多说 android:name="androidx.core.content.FileProvider" andro ...

  5. android增删功能代码,Android SQLite增删查改实例代码部分

    在 Android与SQLite数据库 这个专题里我们谈到了 SQLite 的基本应用,但在实际开发中,为了能够更好的管理和维护数据库,我们会封装一个继承自 SQLiteOpenHelper 类的数据 ...

  6. android小球移动代码,Android自定义圆形View实现小球跟随手指移动效果

    本文实例为大家分享了Android实现小球跟随手指移动效果的具体代码,供大家参考,具体内容如下 一. 需求功能 手指在屏幕上滑动,红色的小球始终跟随手指移动. 实现的思路: 1)自定义View,在on ...

  7. android调频收音机代码,android 收音机 FM 驱动 hal层 框架层以及应用层代码

    [实例简介] android 收音机 FM 驱动 hal层 框架层以及应用层代码 方法一 不需要framework部分 1.fm放到 \hardware\rk2x 2.FmRadio 放到 packa ...

  8. Android钢琴滑动代码,android 钢琴界面实现

    近在做一个钢琴的东西,关于这个界面如何设计画了很长时间,主要是考虑到针对不同的分辨率,如果只针对一种分辨率的话用绝对布局可以实现,实现的基本思想是每个白色的键的位置是可以计算出来的,屏幕的宽度可以获得 ...

  9. android确认密码代码,Android自定义View实现验证码or密码输入框

    前言 最近项目中有支付功能,用户输入密码时要类似微信支付密码输入框的样式,本想直接copy网上的,但设计姐姐总是对样式挑三拣四,抽空自己自定义了一个,无奈之下抽空自定义了个,并把它贴到GitHub上供 ...

最新文章

  1. 避免到服务器的不必要的往返过程
  2. IDC:2018年中国制造业十大预测
  3. Redis 宝典 | 基础、高级特性与性能调优
  4. 【GVA】gorm多对多many2many删除数据的同时级联删除关联中间表中的关联数据
  5. 转载的孩子们注意节操哈!!!
  6. 牛客 -- leetcode -- max-points-on-a-line
  7. java protobuf 例子_java使用protobuf例子
  8. 为什么AI工程师成为当前薪资最高的技术岗位
  9. linux -小记(3) 问题:linux 安装epel扩展源报错
  10. 新sniffer pro 4.75 sp5下载
  11. DocLocker - 文档外发控制系统
  12. Maya2011下载 (破解正式版)
  13. 怎么用一个计算机控制两个屏幕,一台电脑控制多个led显示屏
  14. 深入理解MySQL(2):详谈索引结构
  15. 手机射频测试总结(二)——接收灵敏度
  16. MYSQL_精讲数据库数据类型
  17. HTML第六章上机练习1-5题
  18. Python学习 day03
  19. 我收集的几个威客网站
  20. 简析“无人便利店” 物联网技术的应用

热门文章

  1. outlook添加新账户服务器信息怎么填,outlook如何再添加一个新账户?
  2. 甲骨文发布第三季度财报 云业务拉动营收增长
  3. patten时延差编码
  4. AD7921 AUJ
  5. 兮克SKS7300-12GPY2XGT2XGS交换机搭配爱速特NAS的链路聚合设置教程
  6. linux shell转换成时间,如何在Bash中将时间戳转换为日期?
  7. win7没intel信息服务器,win7功能没有ftp服务器
  8. 各行业防雷工程和防雷接地的应用方案
  9. 设计模式 6 - 原型模式及spring源码案例分析
  10. linux initrd usb热插拔,linux技术之制作USB启动盘