Android签名过程详解

1、本文主要内容

  • 基础知识
  • 手动签名apk
  • 签名工具源码解析
  • 总结

为了保证apk的安全性,必须对apk进行签名。pms通过签名校验,确保apk没有被破坏,甚至有些权限也与签名有关。本文主要阐述签名原理以及签名过程。

2、基础知识

2.1、数据摘要

对数据源进行算法之后得到一个摘要,也叫作数据指纹,不同的数据源,数据指纹肯定不一样,就和人一样

消息摘要算法(Message Digest Algorithm)是一种能产生特殊输出格式的算法,其原理是根据一定的运算规则对原始数据进行某种形式的信息提取,被提取出的信息就被称作原始数据的消息摘要。

著名的摘要算法有RSA公司的MD5算法和SHA-1算法及其大量的变体

消息摘要的主要特点有:

  • 无论输入的消息有多长,计算出来的消息摘要的长度总是固定的。例如应用MD5算法摘要的消息有128个比特位,用SHA-1算法摘要的消息最终有160比特位的输出。
  • 一般来说(不考虑碰撞的情况下),只要输入的原始数据不同,对其进行摘要以后产生的消息摘要也必不相同,即使原始数据稍有改变,输出的消息摘要便完全不同。但是,相同的输入必会产生相同的输出。
  • 具有不可逆性,即只能进行正向的信息摘要,而无法从摘要中恢复出任何的原始消息。

2.2 签名文件和证书

签名文件和证书是成对出现了,二者不可分离

要确保可靠通信,必须要解决两个问题:

  • 要确定消息的来源确实是其申明的那个人
  • 要保证信息在传递的过程中不被第三方篡改,即使被篡改了,也可以发觉出来。

所谓数字签名,就是为了解决这两个问题而产生的,它是对前面提到的非对称加密技术与数字摘要技术的一个具体的应用。

对于消息的发送者来说,先要生成一对公私钥对,将公钥给消息的接收者。
如果消息的发送者有一天想给消息接收者发消息,在发送的信息中,除了要包含原始的消息外,还要加上另外一段消息。这段消息通过如下两步生成:

  • 对要发送的原始消息提取消息摘要;
  • 对提取的信息摘要用自己的私钥加密。

通过这两步得出的消息,就是所谓的原始信息的数字签名。
而对于信息的接收者来说,他所收到的信息,将包含两个部分,一是原始的消息内容,二是附加的那段数字签名。他将通过以下三步来验证消息的真伪:

  • 对原始消息部分提取消息摘要,注意这里使用的消息摘要算法要和发送方使用的一致;
  • 对附加上的那段数字签名,使用预先得到的公钥解密;
  • 比较前两步所得到的两段消息是否一致。如果一致,则表明消息确实是期望的发送者发的,且内容没有被篡改过;相反,如果不一致,则表明传送的过程中一定出了问题,消息不可信。

通过这种所谓的数字签名技术,确实可以有效解决可靠通信的问题。如果原始消息在传送的过程中被篡改了,那么在消息接收者那里,对被篡改的消息提取的摘要肯定和原始的不一样。并且,由于篡改者没有消息发送方的私钥,即使他可以重新算出被篡改消息的摘要,也不能伪造出数字签名。

所以,综上所述,数字签名其实就是只有信息的发送者才能产生的别人无法伪造的一段数字串,这段数字串同时也是对信息的发送者发送信息真实性的一个有效证明。

不知道大家有没有注意,前面讲的这种数字签名方法,有一个前提,就是消息的接收者必须要事先得到正确的公钥。如果一开始公钥就被别人篡改了,那坏人就会被你当成好人,而真正的消息发送者给你发的消息会被你视作无效的。而且,很多时候根本就不具备事先沟通公钥的信息通道。那么如何保证公钥的安全可信呢?这就要靠数字证书来解决了。

数字证书,一般包含以下一些内容:
证书的发布机构(Issuer)
证书的有效期(Validity)
消息发送方的公钥
证书所有者(Subject)
数字签名所使用的算法
数字签名

可以看出,数字证书其实也用到了数字签名技术。只不过要签名的内容是消息发送方的公钥,以及一些其它信息。但与普通数字签名不同的是,数字证书中签名者不是随随便便一个普通的机构,而是要有一定公信力的机构。一般来说,这些有公信力机构的根证书已经在设备出厂前预先安装到了你的设备上了。所以,数字证书可以保证数字证书里的公钥确实是这个证书的所有者的,或者证书可以用来确认对方的身份。数字证书主要是用来解决公钥的安全发放问题。

综上所述,总结一下,数字签名和签名验证的大体流程如下图所示:

3、手动签名apk

签名apk有两种方法,今天我们介绍signapk工具,signapk工具源码在 build/tools/signapk 中

它的用法非常简单:java -jar signapk.jar platform.x509.pem platform.pk8 Origin.apk signed.apk,其中platform.x509.pem是公钥,platform.pk8为私钥。

apk签名完成以后,解压apk,会多出一个文件夹:META-INF,这里边存在3个文件,它们与本次签名有关。

4、签名工具源码解析

源码文件夹中只有1个java文件,SignApk.java,查看它的main方法,看看apk签名过程中到底做了些什么事情。

public static void main(String[] args) {
    if (args.length < 4) usage();
    for (int i = 0; i < args.length; i++) {
        System.out.println("main parms[" + i +"] = " + args[i]);
    }
    sBouncyCastleProvider = new BouncyCastleProvider();
    Security.addProvider(sBouncyCastleProvider);

    boolean signWholeFile = false;
    String providerClass = null;
    String providerArg = null;
    int alignment = 4;

    int argstart = 0;
    while (argstart < args.length && args[argstart].startsWith("-")) {
        if ("-w".equals(args[argstart])) {
            signWholeFile = true;
            ++argstart;
        } else if ("-providerClass".equals(args[argstart])) {
            if (argstart + 1 >= args.length) {
                usage();
            }
            providerClass = args[++argstart];
            ++argstart;
        } else if ("-a".equals(args[argstart])) {
            alignment = Integer.parseInt(args[++argstart]);
            ++argstart;
        } else {
            usage();
        }
    }

    if ((args.length - argstart) % 2 == 1) usage();
    int numKeys = ((args.length - argstart) / 2) - 1;
    System.out.println("argstart =" + argstart + "  numKeys = " + numKeys + " signWholeFile = " + signWholeFile);
    if (signWholeFile && numKeys > 1) {
        System.err.println("Only one key may be used with -w.");
        System.exit(2);
    }

    loadProviderIfNecessary(providerClass);

    String inputFilename = args[args.length-2];
    String outputFilename = args[args.length-1];

    JarFile inputJar = null;
    FileOutputStream outputFile = null;
    int hashes = 0;

    try {
        File firstPublicKeyFile = new File(args[argstart+0]);

        X509Certificate[] publicKey = new X509Certificate[numKeys];
        try {
            for (int i = 0; i < numKeys; ++i) {
                int argNum = argstart + i*2;
                publicKey[i] = readPublicKey(new File(args[argNum]));
                hashes |= getDigestAlgorithm(publicKey[i]);
            }
        } catch (IllegalArgumentException e) {
            System.err.println(e);
            System.exit(1);
        }

        // Set the ZIP file timestamp to the starting valid time
        // of the 0th certificate plus one hour (to match what
        // we've historically done).
        long timestamp = publicKey[0].getNotBefore().getTime() + 3600L * 1000;

        PrivateKey[] privateKey = new PrivateKey[numKeys];
        for (int i = 0; i < numKeys; ++i) {
            int argNum = argstart + i*2 + 1;
            privateKey[i] = readPrivateKey(new File(args[argNum]));
        }
        inputJar = new JarFile(new File(inputFilename), false);  // Don't verify.

        outputFile = new FileOutputStream(outputFilename);


        if (signWholeFile) {
            SignApk.signWholeFile(inputJar, firstPublicKeyFile,
                                  publicKey[0], privateKey[0], outputFile);
        } else {
            JarOutputStream outputJar = new JarOutputStream(outputFile);

            // For signing .apks, use the maximum compression to make
            // them as small as possible (since they live forever on
            // the system partition).  For OTA packages, use the
            // default compression level, which is much much faster
            // and produces output that is only a tiny bit larger
            // (~0.1% on full OTA packages I tested).
            outputJar.setLevel(9);

            Manifest manifest = addDigestsToManifest(inputJar, hashes);
            copyFiles(manifest, inputJar, outputJar, timestamp, alignment);
            signFile(manifest, inputJar, publicKey, privateKey, outputJar);
            outputJar.close();
        }
    } catch (Exception e) {
        e.printStackTrace();
        System.exit(1);
    } finally {
        try {
            if (inputJar != null) inputJar.close();
            if (outputFile != null) outputFile.close();
        } catch (IOException e) {
            e.printStackTrace();
            System.exit(1);
        }
    }
}

如果可以自己编译源码的同学,可以在其中加log,重新编译signapk工具,以便于自己理解签名过程。

在第3节中可以看到,我们向signapk工具传了4个参数,所以main方法的args数组长度为4,参数的值大家都清楚,所以第1个while循环是不会执行的。所以,argstart 值为0,numKeys 值为1,signWholeFile 值为false。

接下来,读取公钥

for (int i = 0; i < numKeys; ++i) {
     int argNum = argstart + i*2;
     publicKey[i] = readPublicKey(new File(args[argNum]));
     hashes |= getDigestAlgorithm(publicKey[i]);
}

读取私钥

        PrivateKey[] privateKey = new PrivateKey[numKeys];
        for (int i = 0; i < numKeys; ++i) {
            int argNum = argstart + i*2 + 1;
            privateKey[i] = readPrivateKey(new File(args[argNum]));
        }

因为signWholeFile为false,所以下边的方法会被执行

private static Manifest addDigestsToManifest(JarFile jar, int hashes) 
        throws IOException, GeneralSecurityException {
    //jar.getManifest,得到的对象与MANIFEST.MF文件相关
    Manifest input = jar.getManifest();
    Manifest output = new Manifest();
    Attributes main = output.getMainAttributes();
    if (input != null) {
        //如果签名前已经被签名过一次,这是第二次签名,那么向新的MANIFEST.MF添加以前的文文件信息
        main.putAll(input.getMainAttributes());
    } else {
        //未被签名过的apk,MANIFEST.MF的头文件信息中将添加上这样的一段话
        main.putValue("Manifest-Version", "1.0");
        main.putValue("Created-By", "1.0 (Android SignApk)");
    }
    //确定选用哪种算法来计算数据摘要
    MessageDigest md_sha1 = null;
    MessageDigest md_sha256 = null;
    if ((hashes & USE_SHA1) != 0) {
        md_sha1 = MessageDigest.getInstance("SHA1");
    }
    if ((hashes & USE_SHA256) != 0) {
        md_sha256 = MessageDigest.getInstance("SHA256");
    }
    byte[] buffer = new byte[4096];
    int num;

    // We sort the input entries by name, and add them to the
    // output manifest in sorted order. We expect that the output
    // map will be deterministic.

    TreeMap<String, JarEntry> byName = new TreeMap<String, JarEntry>();
    //jar是原apk文件,它是个压缩文件,此处遍历压缩文件中的所有文件,并且加入到byName中
    for (Enumeration<JarEntry> e = jar.entries(); e.hasMoreElements();) {
        JarEntry entry = e.nextElement();
        byName.put(entry.getName(), entry);
    }

    for (JarEntry entry : byName.values()) {
        String name = entry.getName();
        if (!entry.isDirectory() && (stripPattern == null || !stripPattern.matcher(name).matches())) {
            //遍历原apk文件中的所有文件,排队文件夹以及命名与正则表达式不匹配的文件
            InputStream data = jar.getInputStream(entry);
            while ((num = data.read(buffer)) > 0) {
                //文件输入流读入文件,为后续计算数据摘要做准备
                if (md_sha1 != null)
                    md_sha1.update(buffer, 0, num);
                if (md_sha256 != null)
                    md_sha256.update(buffer, 0, num);
            }

            Attributes attr = null;
            if (input != null)
                attr = input.getAttributes(name);
            attr = attr != null ? new Attributes(attr) : new Attributes();
            //计算文件的数据摘要,保存在attr中
            if (md_sha1 != null) {
                attr.putValue("SHA1-Digest", new String(Base64.encode(md_sha1.digest()), "ASCII"));
            }
            if (md_sha256 != null) {
                attr.putValue("SHA-256-Digest", new String(Base64.encode(md_sha256.digest()), "ASCII"));
            }
            System.out.println("addDigestsToManifest  name = " + name + " value = " + attr.getValue("SHA-256-Digest"));
            //以文件名为key,数据摘要为value,保存在output中,也就是签名后apk的MANIFEST.MF中
            output.getEntries().put(name, attr);
        }
    }
    return output;
}

addDigestsToManifest方法遍历apk中的所有文件(解压apk),除文件夹和META-INF文件夹除外,计算这些文件的数据摘要值并且保存在 Manifest 对象中。

执行完addDigestsToManifest方法后,继续看看copyFiles方法:

for (String name : names) {
        JarEntry inEntry = in.getJarEntry(name);
        JarEntry outEntry = null;
        if (inEntry.getMethod() == JarEntry.STORED) continue;
        // Create a new entry so that the compressed len is recomputed.
        outEntry = new JarEntry(name);
        outEntry.setTime(timestamp);
        out.putNextEntry(outEntry);
        InputStream data = in.getInputStream(inEntry);
        while ((num = data.read(buffer)) > 0) {
            out.write(buffer, 0, num);
        }
        out.flush();
    }

copyFiles方法比较简单,它有两段类似上面的代码,复制原apk文件到新的签名后的apk文件当中,就是写文件。

继续看signFile方法:

private static void signFile(Manifest manifest, JarFile inputJar,
        X509Certificate[] publicKey, PrivateKey[] privateKey,
        JarOutputStream outputJar) throws Exception {
    // Assume the certificate is valid for at least an hour.
    long timestamp = publicKey[0].getNotBefore().getTime() + 3600L * 1000;

    // MANIFEST.MF
    //将addDigestsToManifes方法得到的manifest数组写入到MANIFEST.MF文件当中
    JarEntry je = new JarEntry(JarFile.MANIFEST_NAME);
    je.setTime(timestamp);
    outputJar.putNextEntry(je);
    manifest.write(outputJar);

    int numKeys = publicKey.length;
    for (int k = 0; k < numKeys; ++k) {
        // CERT.SF / CERT#.SF
        je = new JarEntry(numKeys == 1 ? CERT_SF_NAME : (String.format(
                CERT_SF_MULTI_NAME, k)));
        je.setTime(timestamp);
        outputJar.putNextEntry(je);
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        //确定CERT.SF文件的内容
        writeSignatureFile(manifest, baos, getDigestAlgorithm(publicKey[k]));
        byte[] signedData = baos.toByteArray();
        //将输出流写入到CERT.SF文件当中
        outputJar.write(signedData);

        // CERT.{EC,RSA} / CERT#.{EC,RSA}
        //CERT.RSA文件相关
        final String keyType = publicKey[k].getPublicKey().getAlgorithm();
        je = new JarEntry(numKeys == 1 ? (String.format(CERT_SIG_NAME,
                keyType))
                : (String.format(CERT_SIG_MULTI_NAME, k, keyType)));
        je.setTime(timestamp);
        outputJar.putNextEntry(je);
        //往CERT.RSA写入
        writeSignatureBlock(new CMSProcessableByteArray(signedData),
                publicKey[k], privateKey[k], outputJar);
    }
}

在addDigestsToManifest方法中得到的Manifest 对象将在signFile方法中写入MANIFEST.MF文件当中。

JarEntry je = new JarEntry(JarFile.MANIFEST_NAME);
manifest.write(outputJar);
//JarFile类常量
public static final String MANIFEST_NAME = "META-INF/MANIFEST.MF";

因为这个JarEntry 对应着MANIFEST.MF文件,查看manifest的write方法,即为写文件,可知最后写入MANIFEST.MF是在此处完成。查看META-INF/MANIFEST.MF文件,发现其内容正是记录apk各文件的数据摘要:

接下来继续看对CERT.SF文件的处理:

/** Write a .SF file with a digest of the specified manifest. */
private static void writeSignatureFile(Manifest manifest, OutputStream out,
        int hash) throws IOException, GeneralSecurityException {
    Manifest sf = new Manifest();
    Attributes main = sf.getMainAttributes();
    main.putValue("Signature-Version", "1.0");
    main.putValue("Created-By", "1.0 (Android SignApk)");

    MessageDigest md = MessageDigest
            .getInstance(hash == USE_SHA256 ? "SHA256" : "SHA1");
    PrintStream print = new PrintStream(new DigestOutputStream(
            new ByteArrayOutputStream(), md), true, "UTF-8");

    // Digest of the entire manifest
    //为MANIFEST.MF文件内容做一次数据摘要,并保存在sf对象中
    manifest.write(print);
    print.flush();
    main.putValue(hash == USE_SHA256 ? "SHA-256-Digest-Manifest"
            : "SHA1-Digest-Manifest", new String(
            Base64.encode(md.digest()), "ASCII"));

    Map<String, Attributes> entries = manifest.getEntries();
    for (Map.Entry<String, Attributes> entry : entries.entrySet()) {
        // Digest of the manifest stanza for this entry.
        //遍历MANIFEST.MF文件中的所有数据
        print.print("Name: " + entry.getKey() + "\r\n");
        for (Map.Entry<Object, Object> att : entry.getValue().entrySet()) {
            print.print(att.getKey() + ": " + att.getValue() + "\r\n");
        }
        print.print("\r\n");
        print.flush();
        //为MANIFEST.MF文件内的每一条数据重新做数据摘要并保存在sf中
        Attributes sfAttr = new Attributes();
        sfAttr.putValue(hash == USE_SHA256 ? "SHA-256-Digest"
                : "SHA1-Digest-Manifest",
                new String(Base64.encode(md.digest()), "ASCII"));
        sf.getEntries().put(entry.getKey(), sfAttr);
    }
    //将sf写入到cout的输出流当中
    CountOutputStream cout = new CountOutputStream(out);
    sf.write(cout);

    // A bug in the java.util.jar implementation of Android platforms
    // up to version 1.6 will cause a spurious IOException to be thrown
    // if the length of the signature file is a multiple of 1024 bytes.
    // As a workaround, add an extra CRLF in this case.
    if ((cout.size() % 1024) == 0) {
        cout.write('\r');
        cout.write('\n');
    }
}

Manifest对象是一种特殊的数据结构,它以键值对形式保存数据。在方法开头,新建一个Manifest对象,然后写入类似于头文件信息,然后再对MANIFEST.MF文件整体做一次数据摘要,并且保存到Manifest对象中,我们来看看CERT.SF文件的内容

紧接着,将遍历MANIFEST.MF文件中的所有条目内容,并且对这些条目内容重新计算数据摘要并保存到Manifest对象中

for (Map.Entry<String, Attributes> entry : entries.entrySet()) {
        // Digest of the manifest stanza for this entry.
        //遍历MANIFEST.MF文件中的所有数据
        print.print("Name: " + entry.getKey() + "\r\n");
        for (Map.Entry<Object, Object> att : entry.getValue().entrySet()) {
            print.print(att.getKey() + ": " + att.getValue() + "\r\n");
        }
        print.print("\r\n");
        print.flush();
        //为MANIFEST.MF文件内的每一条数据重新做数据摘要并保存在sf中
        Attributes sfAttr = new Attributes();
        sfAttr.putValue(hash == USE_SHA256 ? "SHA-256-Digest"
                : "SHA1-Digest-Manifest",
                new String(Base64.encode(md.digest()), "ASCII"));
        sf.getEntries().put(entry.getKey(), sfAttr);
    }

最后将Manifest写入输出流中,回到sign方法后,最终此输出流将内容写入到CERT.SF文件当中。

最后来查看下对CERT.RSA文件的处理

final String keyType = publicKey[k].getPublicKey().getAlgorithm();
        je = new JarEntry(numKeys == 1 ? (String.format(CERT_SIG_NAME,
                keyType))
                : (String.format(CERT_SIG_MULTI_NAME, k, keyType)));
        je.setTime(timestamp);
        outputJar.putNextEntry(je);
        // 往CERT.RSA写
        writeSignatureBlock(new CMSProcessableByteArray(signedData),
                publicKey[k], privateKey[k], outputJar);

注意,signdata是要写入CERT.SF文件的内容,将signdata、公钥、私钥以及一个输出流传到writeSignatureBlock方法中,接下来看看这个方法

private static void writeSignatureBlock(CMSTypedData data,
        X509Certificate publicKey, PrivateKey privateKey, OutputStream out)
        throws IOException, CertificateEncodingException,
        OperatorCreationException, CMSException {
    //处理公钥相关
    ArrayList<X509Certificate> certList = new ArrayList<X509Certificate>(1);
    certList.add(publicKey);
    JcaCertStore certs = new JcaCertStore(certList);
    //处理私钥相关
    CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
    ContentSigner signer = new JcaContentSignerBuilder(
            getSignatureAlgorithm(publicKey)).setProvider(
            sBouncyCastleProvider).build(privateKey);
    gen.addSignerInfoGenerator(new JcaSignerInfoGeneratorBuilder(
            new JcaDigestCalculatorProviderBuilder().setProvider(
                    sBouncyCastleProvider).build())
            .setDirectSignature(true).build(signer, publicKey));
    gen.addCertificates(certs);
    //使用生成的公钥私钥工具,对data数据进行加密
    CMSSignedData sigData = gen.generate(data, false);
    //将加密的数据生成一个二进制输入流对象
    ASN1InputStream asn1 = new ASN1InputStream(sigData.getEncoded());
    DEROutputStream dos = new DEROutputStream(out);
    //输出流写入文件CERT.RSA中
    dos.writeObject(asn1.readObject());
}

原来,CERT.RSA文件中保存的是使用公钥私钥加密过的CERT.SF文件内容。虽然这段代码看起来很复杂,对于加密相关的东西一点也不懂,但如果能抓住关键内容,依然能看懂。事实上,CERT.RSA确实是一个二进制文件,要用查看二进制文本的工具才能打开查看。

总结

apk签名过程最重要的三个步骤:

  • 对Apk中的每个文件做一次算法(数据摘要+Base64编码),保存到MANIFEST.MF文件中
  • 对MANIFEST.MF整个文件做一次算法(数据摘要+Base64编码),存放到CERT.SF文件的头属性中,在对MANIFEST.MF文件中各个属性块做一次算法(数据摘要+Base64编码),存到到一个属性块中。
  • 对CERT.SF文件做签名,内容存档到CERT.RSA中

那么,为什么要这么设计签名流程呢?

首先,如果你改变了apk包中的任何文件,那么在apk安装校验时,改变后的文件摘要信息与MANIFEST.MF的检验信息不同,于是验证失败,程序就不能成功安装。

其次,如果你对更改的过的文件相应的算出新的摘要值,然后更改MANIFEST.MF文件里面对应的属性值,那么必定与CERT.SF文件中算出的摘要值不一样,照样验证失败。

最后,如果你还不死心,继续计算MANIFEST.MF的摘要值,相应的更改CERT.SF里面的值,那么数字签名值必定与CERT.RSA文件中记录的不一样,还是失败。

那么能不能继续伪造数字签名呢?不可能,因为没有数字证书对应的私钥。
所以,如果要重新打包后的应用程序能再Android设备上安装,必须对其进行重签名。

从上面的分析可以得出,只要修改了Apk中的任何内容,就必须重新签名,不然会提示安装失败,当然这里不会分析,后面一篇文章会注重分析为何会提示安装失败。

最后感谢尼古拉斯赵四提供这么好的材料让大家学习,本人受益良多,谢谢

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

推荐阅读更多精彩内容