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<ZipEntry> 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<ZipEntry> 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<PublicKey>();
                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<ZipEntry> 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<String, byte[]> 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<String, byte[]> getMetaEntries() throws IOException {
    HashMap<String, byte[]> metaEntries = new HashMap<String, byte[]>();

    Iterator<ZipEntry> 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<Certificate[]> certChains = new ArrayList<Certificate[]>();
    Iterator<Map.Entry<String, HashMap<String, Attributes>>> it = signatures.entrySet().iterator();
    while (it.hasNext()) {
        Map.Entry<String, HashMap<String, Attributes>> entry = it.next();
        HashMap<String, Attributes> 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<String> 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<String, Attributes> entries = new HashMap<String, Attributes>();
    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<Map.Entry<String, Attributes>> it = entries.entrySet()
                .iterator();
        while (it.hasNext()) {
            Map.Entry<String, Attributes> 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自己的安全性,无法被篡改。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,319评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,801评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,567评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,156评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,019评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,090评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,500评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,192评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,474评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,566评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,338评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,212评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,572评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,890评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,169评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,478评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,661评论 2 335

推荐阅读更多精彩内容