CVE-2019-12422 Shiro721 ( Apache Shiro RememberMe Padding Oracle 1.4.1 反序列化漏洞) 分析[ 上 ]

前言

之前分析了 Shiro550 反序列化漏洞 , 今天我们来分析一波 Shiro721 反序列化漏洞 , 这两个漏洞都是最近比较火的 Apache Shiro RCE 漏洞.

由于篇幅原因 , Apache Shiro721 的分析分为两部分 , 一部分为基础知识介绍 , 环境搭建 , 漏洞复现 , 一部分为漏洞代码调试分析.

本章内容为第一部分 : 基础知识介绍 , 环境搭建 , 漏洞复现 .


Apache Shiro721

Apache Shiro721 简介

与 Shiro550 类似 , Shiro721 同样来自于 apache.org 上的一个 Issue , 由 loopx9 前辈提交.

Apapche Shiro RememberMe Cookie 默认通过 AES-128-CBC 模式加密 , 这种加密模式容易受到 Padding Oracle Attack( Oracle 填充攻击 ) , 攻击者可以使用有效的 RememberMe Cookie 作为 Paddding Oracle Attack 的前缀 , 然后精心构造 RememberMe Cookie 值来实现反序列化漏洞攻击.

漏洞复现步骤 :

  1. 登录网站并且获取 RememberMe Cookie 值
  2. 使用 RememberMe Cookie 值来作为 Padding Oracle Attack 的前缀
  3. 通过 Padding Oracle Attack 的攻击方式精心构造可利用的 YSoSerial 反序列化数据
  4. 将构造好的反序列化数据填充到 RememberMe Cookie 字段中并发送 , 即可在目标服务器上执行任意代码.

我们能从上文的描述中得知该漏洞的利用存在局限性 :

  • 漏洞利用需要有效的 RememberMe Cookie 值 , 因此需要一个可登录 Shiro Web 的账户和口令

对于像我这样没有密码学基础的同学 , 需要了解如下两个知识点 :

  1. 什么是 AES-128-CBC 加密模式

  2. 什么是 Padding Oracle Attack( 填充 Oracle 攻击 )


Apache Shiro721 分析基础

这一块内容主要面向上文两个知识点 .

网上大部分漏洞分析文章在介绍这部分内容时都提到了 Rorot 前辈的 Padding Oracle Attack 一文 , 这篇文章讲的确实非常好 ; 但是由于语言文化差异等因素 , 一些内容直接翻译成中文可能不容易理解 , 所以这里我对 Rorot 前辈在文章中提到的内容做一下简单的归纳 .


AES/128/CBC/PKCS5Padding 加密

这得从密码体制的划分说起.

  1. 按照密钥特征的不同 , 密码体制可以分为两类 : "对称加密" 和 "非对称加密"

    对称加密 : 加密 , 解密过程使用的密钥相同

    非对称加密 : 加密 , 解密过程使用不同的密钥

  2. 按照加密方式的不同 , 密码体制可以分为两类 : "分组密码( 块密码 )" 和 "流密码( 序列密码 )"

    分组密码( 块密码 ) : 当加密明文时 , 会先将明文编码表示为二进制序列( 明文流 ) , 然后将其分为若干个固定长度的组 , 最后分别对每个组进行加密 , 生成密文流.

    流密码( 序列密码 ) : 当加密明文时 , 会先将明文编码表示为二进制序列( 明文流 ) , 然后由种子密钥生成一个密钥流 , 最后利用加密算法把明文流和密钥流加密,生成密文流.

而 Apache Shiro 中使用的 AES( 高级加密标准 ) 加密算法 , 就是一种对称加密的分组加密算法 .

那么运用时肯定需要考虑如下三个问题:

  • 加解密密钥的长度

  • 分组加密的加密模式

  • 加密模式对应的填充方式


先来回答第一个问题 : AES 加解密的密钥长算法度

在 AES 加密标准中 , 每个分组的长度固定为 128 bits , 即 16 Bytes . 而密钥的长度则可以变化 , 通常可以使用 128 bits , 192 bits , 256 bits , 不同的密钥长度对应了不同的加密轮数 .

不同密钥长度的 AES 加密标准被命名为不同的名称 , 上述三个密钥长度分别对应 AES-128 , AES-192 , AES-256.

TimeShatter 前辈的 AES加密算法的详细介绍与实现 一文中有个图标非常简洁明了 , 引用如下 :

AES加密标准 密钥长度 分组长度 加密轮数
AES-128 128 bits ( 4 Bytes × 32 bits/Bytes ) 128 bits 10
AES-192 192 bits ( 6 Bytes × 32 bits/Bytes ) 128 bits 12
AES-256 256 bits ( 8 Bytes × 32 bits/Bytes ) 128 bits 14

再来回答第二个问题 : AES 分组加密的加密模式

上面介绍分组加密时 , 我将加密过程统称为 "加密变换" ; 但是在实际加密过程中 , 我们可以选择不同的加密模式 .

分组加密算法中有 5 种可选的加密变换方式( 加密模式 )

  • ECS ( Electronic Codebook Book , 电话本模式 )
  • CBC ( Cipher Block Chaining , 密码分组链接模式 )
  • CTR ( Counter , 计算器模式 )
  • CFB ( Cipher FeedBack , 密码反馈模式 )
  • OFB ( Output FeedBack , 输出反馈模式 )

在分析 Shiro521 时我们就发现 , Apache Shiro 中使用 CBC 作为加密模式 , 因此这里仅需要关注 CBC 这个加密模式 .

简单学习一下什么是 CBC 加密模式 :

CBC 加密模式 : 将明文切分成若干小段 , 然后每一段分别与上一段的密文进行异或运算 , 再与密钥进行加密 , 生成本段明文的密文 , 这段密文用于下一段明文的加密 .

第一段明文没有对应的密文 , 为了确保分组的唯一性 , CBC 加密模式使用了初始化向量( IV , Initialization Vector ) . 初始化向量是一个固定长度的随机数 , 该向量会作为密文第一个块 , 随密文一同传输 .

CBC 模式中 , 初始化向量( IV ) 的长度与分组大小相同 , 为 16 Bytes( 128 bits ) , 因为链接模式中的异或操作是等长操作 .

下面是 CBC 模式的加密 / 解密流程图 , 后文会多次用到这两个图 .


最后来回答第三个问题 : 加密模式对应的填充方式

在分组加密时 , 需要把明文切割成多个分组 , 且每个分组的大小为 128 bits ( 16 Bytes ) ; 但是明文长度是不固定的 , 所以可能最后一个分组不足 16 Bytes . 例如下面例子2 :

例子1 : AAAAAAAABBBBBBBBCCCCCCCC : 刚好能够切割成 3 个分组

例子2 : AAAAAAAABBBBBBBBCCCCC : 能够切割成 3 个分组 , 但是最后一个分组不足 16 Bytes .

当遇到这种情况时 , 就需要用到 Padding 填充机制 . "Padding" 用于在最后一个分组的结尾填充一些额外的 bits , 使分组成为标准的 16 Bytes .

CBC 加密模式下可用的 Padding 方式有 3 个 :

  1. AES/CBC/NoPadding : 明文长度必须是 8 Bytes 的倍数 , 否则会报错 .

  2. AES/CBC/PKCS5Padding : 以完整字节填充 , 每个填充字节的值是用于填充的字节数 . 即要填充 N 个字节 , 每个字节都为 N.

举例 : 使用 PKCS5Padding 方式填充 3 个字节 : | AA BB CC DD EE 03 03 03 |

  1. AES/CBC/ISO10126Padding : 以随机字节填充 , 最后一个字节为填充字节的个数 .

举例 : 使用 ISO10126Padding 方式填充 5 个字节 : | AA BB CC A9 3B 78 04 05 |

同理 , 在研究 Apache Shiro 时 , 我们只需要关注 PKCS5Padding 方式即可 , 下面是一些经典的案例 :


Padding Oracle Attack( 填充 Oracle 攻击 )

知道了 AES/128/CBC/PKCS5Padding 的含义 , 那么到底什么是 Oracle 填充攻击呢?

首先需要说明 , 这里的 Oracle 与甲骨文公司没有任何关系 . Padding Oracle Attack 也不是针对某一种分组加密算法的攻击 , 而是针对 CBC 加密模式的 .

根据 Rorot 前辈的论文 , 密码学中的 Oracle 是一种通过接收特定加密数据 , 解密并验证填充是否正确的方式 .

上文这句话可能比较难理解 , 下面来说一个经典的场景:

  1. 客户端实现了用户登录功能 ; 用户登录后 , 客户端会将用户的ID进行 AES/CBC/PKCS5Padding 加密后发送到服务器处理 , 来验证用户是否存在.

    客户端将 AES 加密后的UID值放到URL中进行传输 : https://guildhab.top?uid=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

  2. 服务端会接收客户端发送的数据 , 对参数UID的值进行AES解密处理.

    假设服务端会产生如下三种 AES 解密结果 :

    • 密文有效 , 填充有效 ===> 用户存在 , 登录成功 . 服务端返回 HTTP 200

    • 密文无效 ===> 服务端返回用户不存在的报错信息 . 服务端返回 HTTP 500

    • 密文有效 , 填充无效 ===> 服务端返回后端自定义报错信息 . 服务端返回 HTTP 301

    玩过 SQL 注入的师傅很快便能发现问题 : 根据第一个返回结果和第三个返回结果 . 可以通过暴力破解的方式来确定有效的填充值 .

    当服务端返回 "用户存在 , 登录成功" 的信息时 . 填充数据即为有效填充值 . 这个概念非常类似 SQL 注入里的 "盲注" , 属于一种侧信道攻击 .

  3. 那么到底对哪个字段进行暴力破解呢? 我们结合加解密流程图来研究一下 .

    在看下面内容时 , 你需要先知道这些名词的含义 :

    • RawIV : 原始的IV , 解密时即为前一个密文分组 .

    • FuzzIV : 枚举的IV , 下文会通过枚举 IV 的方式来计算出明文的值

    • Key : 密钥

    • PlainText : 明文分组

    • CipherText : 密文分组

    • MediumValue : 我们把 CipherTextKey 进行 Block_Cipher_Decryption 运算后的值称为 MediumValue( 中间值 ).

    在解密时 , CipherText 会被密钥 Key 解密为 MediumValue , 然后 MediumValue 会与 RawIV 进行异或运算 , 得到该分组对应的 PlainText .

    但是我们不需要去解密( 如前文所说 : Oracle 的核心是提交数据让服务端解密 , 并验证解密后明文分组的 Padding 是否符合规范 ) , 因此我们创建一个新的 IV ( 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 ) 作为 FuzzIV , 并结合 CipherText 提交给服务端 .

    服务端会将接收到的 FuzzIV 与 解密后的 MediumValue 进行异或运算 , 得到一个明文分组 , 然后去验证这个明文分组的 Padding 是否有效 .

    注意 ! 上面的 "明文分组" 不是指 PlainText , 它只是用于验证异或计算结果是否满足 PKCS5Padding 规范 .

    根据 PKCS5Padding 规范 , Padding 值的范围在 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x000x08 0x08 0x08 0x08 0x08 0x08 0x08 0x08 之间

    在某一轮解密中 , 服务端异或运算后的明文分组为 0x29 0x34 0x5A 0x6B 0x07 0xA3 0xB2 0x3C , 这个分组的 Padding 不满足 PKCS5Padding 规范 , 因此这轮解密中提交的 FuzzIV 是无效的 , 服务端会返回 "密文有效 , 填充无效" 的报错信息.

    不断修正 FuzzIV 的值( 0x00 - 0xFF , 最多修正 255 次 ) . 直到某一轮解密中 , 服务端异或运算后的明文分组为 0x39 0x73 0x23 0x32 0x5A 0x7B 0x9C 0x01 , 这个 Padding 符合 PKCS5Padding 规范 , 因此这轮提交的 FuzzIV 是有效的 . 服务端会返回 "密文有效 , 填充有效" 的信息.

    当获取了有效的 FuzzIV 后我们能做什么呢 ? 我们能够根据等价代换得到如下公式 :

    ∵   FuzzIV[8]   ^   MediumValue[8]   =   0x01
    ∴   MediumValue[8]   =   FuzzIV[8]   ^   0x01

    现在我们得到了 MediumValue[8] 的值 , 让我们回到加密流程中.

    MediumValueRawIV 的异或运算结果就是真正的明文 ! 存在如下公式 :

    ∵   MediumValue[8]   ^   RawIV[8]   =   PlainText[8]
    ∴   PlainText[8]   =   RawIV[8]   ^   FuzzIV[8]   ^   0x01

    FuzzIV[8] , RawIV[8] 都是已知的 , 因此我们可以直接计算出 plainText[8] 的值 . 这就是 Padding Oracle Attack 的攻击原理 .

  4. 按照上面的步骤 , 我们可以依次计算其他位置的 PlainText.

    当计算 PlainText[7] 时, 我们希望异或运算后得到的明文分组的最后两个字节为 0x02 0x02 . 这样 Padding 就是有效的了 .

    由于我们已经计算了出了 PlainText[8] 的值 , 因此这轮解密中我们可以直接确定 FuzzIV[8] 的值 . 来看下面的公式 :

    ∵   MediumValue[8]   =   RawIV[8]   ^   PlainText[8]
    ∵   FuzzIV[8]   ^   MediumValue[8]   =   0x02
    ∴   FuzzIV[8]   =   RawIV[8]   ^   PlainText[8]   ^   0x02

    根据上述公式我们能够确定 FuzzIV[8] 的值 , 然后固定FuzzIV[8] , 去暴力破解 FuzzIV[7] 的值.

    FuzzIV[7]MediumValue[7] 异或运算为 0x02 时 , 根据如下公式可以得到 PlainText[7] 的值 .

    PlainText[7]   =   Raw[7]   ^   FuzzIV[7]   ^   0x02`

    后面的过程都是类似的 , 我们可以通过这种 Padding Oracle 的方法获取每一位明文的值.

至此 , Padding Oracle Attack 的攻击原理与攻击流程就已经介绍完了 , 整个过程比较繁琐枯燥 , 看不明白可以多看几遍 , 看明白后其实非常好玩的 .


CBC Byte-Flipping Attack ( CBC字节翻转攻击 )

通过上面提到的 Padding Oracle Attack , 我们可以在不知道密钥 Key 的情况下得到全部明文的值 . 但仅能得到明文值似乎还差点什么 , 我们是否能够篡改明文的值的 ?

标题出卖了一切 通过 CBC字节翻转攻击 可以实现对明文的篡改 !

回顾 CBC 加密模式的解密过程 , 我们不难发现如下特性

IV 向量影响第一个明文分组( IV 一般被放在密文开头 , 单独作为一个分组 )

第 n 个密文分组可以影响第 n+1 个明文分组 .

设第 n 个明文分组为 P(n) , 第 n 个密文分组为 C(n) , 则存在以下公式 :

P(n+1)   =   C(n)   ^   Block_Cipher_Decryption(C(n+1))

如果明文和密文已知 , 那么我们可以手动修改 C(n) 的值 , 使得 P(n+1) 变成任何值.

令   C(n)   =   C(n)   ^   P(n+1)   ^   Payload

∵   Block_Cipher_Decryption(C(n+1))   =   P(n+1)   ^   C(n)

∴   P(n+1)   =   C(n)   ^   P(n+1)   ^   Payload   ^   Block_Cipher_Decryption(C(n+1))
∴   P(n+1)   =   Block_Cipher_Decryption(C(n+1))   ^   Block_Cipher_Decryption(C(n+1))   ^   Payload
∴   P(n+1)   =   0   ^   Payload
∴   P(n+1)   =   Payload

大概就是上面这个意思 , 我们能够通过修改密文字节( C(n) = C(n) ^ P(n+1) ^ Payload ) , 实现篡改明文字节( P(n+1) = Payload ).

Daniel Regalado 前辈在 CBC Byte Flipping Attack—101 Approach 中的总结非常简练 :

CBC字节翻转攻击的原理 : 通过损坏密文字节来改变明文字节 .

基础知识学的差不多了 , 但是我们仍不清楚如何利用 Padding Oracle AttackCBC Byte-Flipping Attack 实现 Java 反序列化攻击 , 看上去这几个知识点是完全不相关的 . 看来只有通过调试代码 , 我们才能知道 Shiro721 的真正奥秘了~


一个简单的总结

上面讲的东西实在比较多 , 这里来简单总结一下吧~

  • AES 是指 "高级加密标准" , 是一种对称加密的分组加密算法 , 128是指密钥长度 , CBC 是指 "密码分组链接" 加密模式 , PKCS5Padding 是 Apache Shiro 中默认填充方式 , 最后一个明文分组缺少 N 个字节 , 则填充 N 个 0x0N .

  • 在 Apache Shiro 中默认使用 CBC 加密模式与 PKCS5Padding 填充方式 . CBC 加密模式容易遭到 Padding Oracle Attack , 攻击者可以通过枚举 IV 的方式计算出全部明文 , 并且可以通过 CBC Byte-Flipping Attack 篡改某一段的明文 .

  • Padding Oracle Attack 利用前提 :

    1. 攻击者能够获得密文( CipherText )与附带在密文前面的初始化向量( IV ).

    2. 服务端对密文解密后会判断 Padding 是否有效 . 并根据不同的判定结果返回不同的响应信息 .

  • CBC Byte-Flipping Attack 利用前提 :

    1. 明文和密文已知

下面 , 我们将步入正题 , 来看一看 Shiro721 反序列化漏洞到底是怎么一回事.


Apache Shiro721 漏洞复现

漏洞环境搭建

复现环境 : JDK1.7 + Tomcat7 + Shiro-1.4.1 .

  1. 首先还是从 Github 上下载一份 Shrio 源码 , 并切换到存在漏洞的分支( 1.4.1 )

  2. 然后直接通过 IDEA 打开 shiro/samples/web 目录即可 . Maven 会自动下载依赖.

  3. 手工添加 Apache 服务 , 并设置 samples-web:war 作为 Artifacts

  4. 运行 Apache 服务 , 即可看到 Apache Shiro 初始页面.


失败的漏洞复现

复现过程其实就是拿网上公开的 EXP 打一遍 , 这个没什么和好说的 . 我选择的是 3ND 前辈编写的 Shiro_exp.py 脚本

  1. 按照前辈的方法 , 首先通过 YSoSerial 生成可利用的 Payload . 由于之前配置 pom.xml 时没有添加任何组件 , 所以我们使用 URLDNS Payload 来探测漏洞是否存在.

  2. 接下来我们需要获取一个有效的 RememberMe Cookie . 因此需要通过 BurpSuite 拦截一个有效用户的 Cookie .

    先用一个正常用户登录 , 注意登录时需要勾选 Remember Me 选项框

    然后开启 BurpSuite 拦截功能 , 随便点击一个链接 , 即可看到 rememberMe Cookie.

    这个 Cookie 就是我们需要的 "有效 rememberMe Cookie" , 我们保存下来 .

  3. 填充 Python 脚本对应的参数并执行

    拿到脚本计算出的新 rememberMe Cookie 值 , 直接填充到正常的 HTTP 请求中并重放 .

    现在只需要去 Ceye 上查看是否能收到 DNS 请求就可以了 ! 本来我都准备收工吃饭了 , 但是等了半天也没有收到 DNS 请求 . 这啥情况 ? 难道是我的姿势不对 ?

    按照前辈的思路又跑了一边 Exp , 发现还是没有收到 DNS 请求 . 脸上的笑容逐渐消失 , 难不成是 URLDNS Payload 坏啦 ? 这肯定不可能呀 , 那么到底是什么原因导致利用不成功呢 ?

    反复试了好几次都不行 , 而且网上也没有前辈提到过这个问题 . 看来必须得静下心来调试一波代码了 !


凭什么 Exp 跑不成功 ?!


提交的 rememberMe Cookie 压根没有被处理

我们有调试 Shrio550 的经验 , 所以这里的调试并不难 , 根据经验 , 我们直接一发断点打在反序列化处( org.apache.shiro.io.DefaultSerializer.deserialize() ) , 并再次重放恶意请求 .

程序没有在断点处停下 , 看来提交的 rememberMe Cookie 解密后没有被反序列化 , 我们继续尝试其他断点 , 来看一看 rememberMe Cookie 有没有被服务器处理 .

按照这个思路 , 我们下一个断点打在 org.apache.shiro.web.mgt.CookieRememberMeManager.getRememberedSerializedIdentity() 方法中 , 该方法会对传入的 rememberMe Cookie 做 Base64 解密处理.

程序依旧没有停下 . 看来我们提交的 rememberMe Cookie 压根没被处理呀 , 这就非常奇怪了 !


resolvePrincipals() 中没有调用 getRememberedIdentity()

下一个断点暂时不知道打哪里 , 我们回顾一下 Shrio550 的函数调用栈 , 看能不能找到哪里出了问题.

根据这个调用栈 , 我们把断点打在 org.apache.shiro.mgt.DefaultSecurityManager.createSubject() 方法中 , 然后跟进 resolvePrincipals() 方法.

随即便能发现, 由于判断 isEmpty(principals) 的结果为 false , 因此直接进入 else 语句中 , 并未调用 this.getRememberedIdentity() 方法 .

所以 , 问题应该就出在这个 principals 变量中 !


isEmpty(principals) 返回 false

变量 principals 的值即 context.resolvePrincipals() 方法的返回值 , 我们跟进该方法 , 然后重放请求.

通过几步调试 , 可以发现原本变量 principalsnull , 但是经过下面这几不操作后 , 变量 principals 的值变了 , 这也导致 isEmpty(principals) 返回 false , 程序没有调用 getRememberedIdentity() 方法.

那么现在核心问题就转到这个 session 变量上了 ! 由于获取的 session 不为空 , 才导致变量 principals 的值被修改.


变量 session 值不为 null

跟进 this.resolveSession() 方法 , 发现由于 this.getSession() 返回值不为空 , 才导致变量 session 不为 null 的 .

跟进 this.getSession() 方法 . 发现该方法实际上是调用 this.getTypedValue() 方法来返回 Session.class 类型的 SESSION 值

继续跟进 this.getTypedValue() 方法一探究竟 .


JSESSIONID 的问题

先停一停 , 回顾一下我们这一步的目的 , 我们最初的目的是寻找变量 principals 不为空的原因 , 即变量 session 不为空的原因 , 即 this.getSession() 返回值不为空的原因 , 即 this.getTypedValue() 返回值不为空的原因 .

org.apache.shiro.util.MapContext.getTypedValue() 方法中 , 我们发现该函数的返回值由 this.backingMap.get(key) 方法控制 .

this.backingMap.get(key) 方法实际上是在 this.backingMap 这个 HashMap 寻找名为 org.apache.shiro.subject.support.DefaultSubjectContext.SESSION 的键值对 , 而调试时发现 this.backingMap 中恰好有这个键名

这个键值对是什么呢 ? 实际上就是HTTP请求中键名为 JSESSIONID 的 Cookie !

换句话说 , 如果我们想让前面一连串变量或方法的返回值为空 , 只要让提交的 HTTP 请求中没有这个 键名为 JSESSIONID 的 Cookie 即可 .


成功的漏洞复现

在提交的 HTTP 请求中删除键名为 JSESSIONID 的 Cookie 然后再次重放请求.

提前在 IDEA 中打上断点 , 然后一步到位~

Ceye 上也收到了久违的 DNS 请求 , 可以证明我们植入的 URLDNS Payload 被服务端执行 , 反序列化攻击成功 !

至此 , Apache Shiro721漏洞已经成功复现 !


为什么我没有删除 JSESSIONID , 却依旧复现成功了 ?

我这里出现了类似的情况 , 当我写完上述内容再去重放 HTTP 请求时 , 发现不删除 JSESSIONID 同样可以执行到反序列化 , 这是为什么呢 ?

其实原因很简单 : 原来的 JSESSIONID 失效了

如果想进一步研究 , 可以在 org.apache.shiro.mgt.DefaultSecurityManager.resolveSession() 方法中的 InvalidSessionException var3 异常处打上断点

异常信息 : Resolved SubjectContext context session is invalid. Ignoring and creating an anonymous (session-less) Subject instance.

我们重新请求主页面 , 并通过 BurpSuite 抓取 HTTP 请求 , 可以看到此时更换了另一个 JSESSIONID .

当我们将这个新的 JSESSIONID 填充到恶意 HTTP 请求中时 , 同样是无法利用成功的 . 因此还需我们手工删除 JSESSIONID Cookie , 恶意代码才能利用成功 .


总结

没想到通过一个 Shiro721 漏洞学到了这么多东西 , 不过弄明白原理后还真是爽啊 !

由于篇幅原因 , Shiro721 本身的环境调试就放到下一章节吧~ 希望本章内容能对各位师傅有所帮助~~

暂无评论

发送评论 编辑评论


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