Android应用的验签过程分析

0x01 回顾

分析验签过程之前,有必要先回顾一下Android应用的签名过程:

  1. 对APK包中的每个文件做一次运算(Hash+Base64编码),将结果保存到META-INF/MANIFEST.MF文件中;
  2. 对MANIFEST.MF整个文件做一次运算(Hash+Base64编码),将结果保存到META-INF/CERT.SF文件的头属性中,再对MANIFEST.MF文件中的各个属性块做同样的运算(Hash+Base64编码),存放到CERT.SF的属性块中。
  3. 开发者用自己的私钥对CERT.SF进行签名,并将签名信息和包含公钥信息的数字证书一同保存到META-INF/CERT.RSA文件中。

因此,应用的验签过程其实也是围绕这三步来进行的。

0x02 相关源码的位置(AOSP 5.0.1_r1)

  • frameworks/base/services/core/java/com/android/server/pm/PackageManagerService.java
  • frameworks/base/core/java/android/content/pm/PackageParser.java
  • libcore/luni/src/main/java/java/util/jar/StrictJarFile.java
  • libcore/luni/src/main/java/java/util/jar/JarVerifier.java
  • libcore/luni/src/main/java/java/util/jar/JarFile.java
  • libcore/luni/src/main/java/org/apache/harmony/security/utils/JarUtils.java

0x03 源码分析

APK的安装过程主要是由PackageManagerService这个核心服务类来完成的,所以我们可以从这个类入手,其中开始执行签名校验的在installPackageLI方法里,代码如下:

private void installPackageLI(InstallArgs args, PackageInstalledInfo res) {
        ......
        PackageParser pp = new PackageParser();
        ......
       try {
            pp.collectCertificates(pkg, parseFlags);
            pp.collectManifestDigest(pkg);
        } catch (PackageParserException e) {
            res.setError("Failed collect during installPackageLI", e);
            return;
        }
 }

在这个方法中可以看到一个用来解析传入的APK包的类PackageParser,并且这里调用了PackageParser.collectCertificates方法来进行签名的校验。于是进入该方法:

public void collectCertificates(Package pkg, int flags) throws PackageParserException {
        ......
        collectCertificates(pkg, new File(pkg.baseCodePath), flags);
       ......
    }

如上,该方法由调用了一个函数重载,代码如下:

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);
            ......
    }

由于该方法的代码较长,我们先分段看,先看上面的代码,很明显,是通过传入的apk文件来构造一个StrictJarFile对象,下面来看一下它的构造方法都做了些什么事情:

public StrictJarFile(String fileName) throws IOException {
        ......
        try {
            ......
            HashMap<String, byte[]> metaEntries = getMetaEntries();
            this.manifest = new Manifest(metaEntries.get(JarFile.MANIFEST_NAME), true);
            this.verifier = new JarVerifier(fileName, manifest, metaEntries);

            isSigned = verifier.readCertificates() && verifier.isSignedJar();
      ......
    }

如上,首先调用getMetaEntries()方法将META-INF目录下每一个文件的文件名及其数据流存放到metaEntries这个HashMap对象中;然后通过MANIFEST.MF文件的数据流构造一个Manifest对象;接着利用得到的metaEntries和manifest来构造一个JarVerifier对象,最后调用JarVerifier的readCertificates()方法和isSignedJar()方法。下面先看JarVerifier.readCertificates方法:

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;
    }

如上,该方法首先判断META-INF目录是否为空,如果为空表示根本没签名,直接返回false。不为空的话就对metaEntries对象进行遍历,如果是证书文件,则将其传入verifyCertificate()方法进行校验,JarVerifier.verifyCertificate()方法的代码如下:

private void verifyCertificate(String certFile) {
        // Found Digital Sig, .SF should already have been read
        String signatureFile = certFile.substring(0, certFile.lastIndexOf('.')) + ".SF";
        byte[] sfBytes = metaEntries.get(signatureFile);
        ......
        byte[] manifestBytes = metaEntries.get(JarFile.MANIFEST_NAME);
        ......
        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);
        }
        ......
    }

该方法首先通过传入的证书文件<CERT>.RSA的路径来获取<CERT>.SF的路径,然后通过前面得到的metaEntries来分别取得MANIFEST.MF、CERT.SF、CERT.RSA这三个文件的字节流:manifestBytes、sfBytes、sBlockBytes,然后将sfBytes和sBlockBytes传入JarUtils.verfySignature()方法中,进行数字签名的校验,校验的过程这里就不贴代码了,简单说就是用CERT.RSA这个文件中的包含的公钥对数字签名进行解密,将解密后的结果与CERT.SF文件hash运算后的结果进行比对,一致的话就返回证书链信息,并将证书链保存在certificates对象中,同时说明CERT.SF文件没有被篡改,另外,Jarverifier.isSignedJar()方法就是判断certificates是否为空,不为空返回true,空则返回false。否则就抛出GeneralSecurityException异常。接着上面继续看JarVerifier.verifyCertificate()方法:

        // Verify manifest hash in .sf file
        Attributes attributes = new Attributes();
        HashMap<String, Attributes> entries = new HashMap<String, Attributes>();
        try {
            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;
        String createdBy = attributes.getValue("Created-By");

        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.
        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;
                }
                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);

根据代码中的注释也能够很清晰的了解到,这段代码主要就是通过读取CERT.SF,然后来验证MANIFEST.MF文件是否被篡改。来先看一下CERT文件的部分内容:



结合上图,再回到JarVerifier.verifyCertificate()方法的代码中来分析一下具体的流程吧:

首先读取CERT.SF文件,并创建与之相关的两个对象attributes和entries;
接着就通过attributes对象判断CERT.SF文件中是否存在"Signature-Version"属性,没有的话直接返回;
再判断CERT.SF文件中的"Created-By"属性的值是否包含"signtool"子串,有的话表示该apk是用其他签名工具签的名;如上图,这里用的是JDK自带jarsigner签的名,所以不含signtool字符串,这样的话之后就会调用JarVerifier.verify()方法来判断是否有"SHA1-Digest-Manifest-Main-Attributes"属性,有的话就校验它的值,看它是否为MANIFEST.MF的头属性块运算(Hash+Base64编码)后的值。可以看到,JarVerifier.verify()方法的第三个参数传的就是MANIFEST.MF的字节流。
JarVerifier.verify()方法的代码如下:

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];
            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);
            return MessageDigest.isEqual(b, Base64.decode(hashBytes));
        }
        return ignorable;
    }

在这个方法中,由于不知道用的什么Hash算法,所以会遍历DIGEST_ALGORITHMS数组,该数组的内容如下:

private static final String[] DIGEST_ALGORITHMS = new String[] {
    "SHA-512",
    "SHA-384",
    "SHA-256",
    "SHA1",
};

将遍历到的算法名与字符串"-Digest-Manifest-Main-Attributes"组合,然后判断该属性是否存在,不存在则略过(continue;)。然后就是hash值的比对了。

再次回到JarVerifier.verifyCertificate()方法的代码:
接下来就是再次调用JarVerifier.verifier()方法,不过这次是对MANIFEST.MF整个文件的Hash与CERT.SF的"SHA1--Digest-Manifest"属性的值进行比对,如果一致,则说明MANIFEST.MF没有被篡改,并将CERT.SF文件的信息添加到metaEntries和signatures的属性中。如果不一致,则遍历所有的属性块,看是哪一个属性块的值不正确。

到这里,StrictJarFile的构造方法就完成了。从上面的分析可以看到,验签的三个步骤中,有两步是再StrictJarFile的构造方法中完成的,分别是:CERT.SF是否被篡改,MANIFEST.MF是否被篡改。

接下来,让我们再回到PackageParser.collectCertificates()方法中,继续完成后续的校验分析,代码如下:

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
            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
            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);
                }
            }

            for (ZipEntry entry : toVerify) {
                final Certificate[][] entryCerts = loadCertificates(jarFile, entry);
                ......
            }
        } 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);
        }
    }

如上,在创建了StrictJarFile对象后,就对该对象进行遍历,将除了目录和META-INF目录下的文件外的所有文件的ZipEntry对象添加到toVerify这个列表中。然后遍历该列表,将每一个文件代表的ZipEntry对象传入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);
        }
    }

这里调用了StrictJarFile.getInputStream()方法来获取InputStream对象,看下该方法:

public InputStream getInputStream(ZipEntry ze) {
        final InputStream is = getZipInputStream(ze);

        if (isSigned) {
            JarVerifier.VerifierEntry entry = verifier.initEntry(ze.getName());
            if (entry == null) {
                return is;
            }

            return new JarFile.JarFileInputStream(is, ze.getSize(), entry);
        }

        return is;
    }

这里主要是获取JarVerifier.VerifierEntry对象,最后返回一个JarFile.JarFileInputStream对象。看一下JarVerifier.initEntry()方法:

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.
        if (manifest == null || signatures.isEmpty()) {
            return null;
        }

        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()][]);

        for (int i = 0; i < DIGEST_ALGORITHMS.length; i++) {
            final String algorithm = DIGEST_ALGORITHMS[i];
            final String hash = attributes.getValue(algorithm + "-Digest");
            if (hash == null) {
                continue;
            }
            byte[] hashBytes = hash.getBytes(StandardCharsets.ISO_8859_1);

            try {
                return new VerifierEntry(name, MessageDigest.getInstance(algorithm), hashBytes,
                        certChainsArray, verifiedEntries);
            } catch (NoSuchAlgorithmException ignored) {
            }
        }
        return null;
    }

该方法就是创建一个JarVerifier.VerifierEntry对象:
第一个参数name是文件名;
第二个参数是用来产生摘要的对象MessageDigest,且摘要算法algorithm也是同前面的方法一样,从DIGEST_ALGORITHMS数组中遍历,再根据MANIFEST.MF文件的属性名来得到;
第三个参数是MANIFEST.MF中所保存的对应文件名的Hash值;
MANIFEST.MF的部分内容如下:


第四个参数是对该APK进行签名的所有证书链信息。它为什么是二维数组?是因为Android允许用多个证书对apk进行签名,且它们的证书文件名必须不同。
最后一个参数是已经验证过的文件列表,VerifierEntry在完成了对指定文件的摘要验证之后会将该文件的信息加到其中。

接着,来看一下JarFile.JarFileInputStream的构造方法:

JarFileInputStream(InputStream is, long size, JarVerifier.VerifierEntry e) {
            super(is);
            entry = e;

            count = size;
        }

只是几个赋值操作,其中将前面得到的JarVerifier.VerifierEntry对象传入并赋值给这里的entry。

将视线在回到PackageParser.loadCertificates()方法中,经过上面的分析,StrictJarFile.getInputStream()所返回的是JarFile.JarFileInputStream对象。接着将该对象传入PackageParser.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;
    }

看起来只是对传入的字节输入流对象进行读取,直到读完,然后返回读到的字节数。但由于传入的是InpuStream对象的子类对象JarFile.JarFileInputStream,而且它重写了read()方法,来看一下这个子类的read()方法做了什么事:

@override
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;
                    }
                    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;
            }
        }

如上,它会调用父类的read()方法进行读取,然后将读取到的数据传入entry.write()方法,最后在调用entry.verify()进行验证。这个entry就是前面创建的JarVerifier.VerifierEntry对象。来看一下JarVerifier.VerifierEntry.write()方法做了什么:

@Override
public void write(byte[] buf, int off, int nbytes) {
    digest.update(buf, off, nbytes);
}

就是对数据进行hash。再来看一下JarVerifier.VerifierEntry.verify()方法做了什么:

void verify() {
    byte[] d = digest.digest();
    if (!MessageDigest.isEqual(d, Base64.decode(hash))) {
        throw invalidDigest(JarFile.MANIFEST_NAME, name, name);
    }
    verifiedEntries.put(name, certChains);
}

该方法就是将文件的Hash与MANIFEST中对应文件的Hash值进行比对,一致的话则将文件名和证书链添加到verifiedEntries中;不一致的话就调用JarVerifier.invalidDigest()方法抛出SecurityException异常,如下:

private static SecurityException invalidDigest(String signatureFile, String name, 
          String jarName) {
    throw new SecurityException(signatureFile + " has invalid digest for " + name +
                " in " + jarName);
}

到这里,第三步,即校验APK所有文件是否有被篡改,也已完成。再次回到PackageParser.collectCertificates()方法中继续看:

        for (ZipEntry entry : toVerify) {
                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 {
                    if (!Signature.areExactMatch(pkg.mSignatures, entrySignatures)) {
                        throw new PackageParserException(
                                INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES, "Package " + apkPath
                                        + " has mismatched certificates at entry "
                                        + entry.getName());
                    }
                }
            }

loadCertificates()之后的代码,主要就是判断该APK是否原来安装过,如果没安装过,则保存该APK的签名信息;如果安装过,则比对前后两次安装的签名信息,如果签名信息一致,则继续安装;如果前后签名不一致,则抛出异常INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES异常。所以平时我们在开发或者测试过程中安装应用时,如果抛出该异常,则说明已经有该包名的应用安装在设备上,且签名与你现在要安装的不同。必须先卸载已安装在设备上的才能继续安装。(INCONSISTENT就是不一致的意思)

0x04 小结

至此,Android的应用验签过程总算分析完了,可以看到,验签的过程刚好和签名的过程是相反的,下面总结一下:

  1. 所有的验签动作都是在JarVerifier这个类里面完成的;
  2. 在JarVerifier.verifyCertificate()方法中完成了以下两步:
    1. 使用CERT.RSA校验CERT.SF,看CERT.SF是否被篡改;
    2. 使用CERT.SF校验MANIFEST.MF,看MANIFEST.MF是否被篡改;
  3. 在JarVerifier.VerifierEntry.verify()方法中完成最后一步:
    1. 使用MANIFEST.MF来校验所有文件,看有没有文件被篡改,或者有没有文件被删
      除,又或者有没有添加新的文件。

0x05 参考文献

http://blog.csdn.net/roland_sun/article/details/42029019
https://www.cnblogs.com/JeffreySun/archive/2010/06/24/1627247.html
http://netsecurity.51cto.com/art/201108/287971.htm

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

推荐阅读更多精彩内容