逆向小白的 Native 分析初体验

差点就花钱找人了……

过程记录

周末的时候心血来潮想获取一个 app 的数据,就试着看能不能逆一下。先是抓了个包,发现请求和响应都不是明文:

典型的 Base64,但解码发现是数据加密之后再 Base64 的,因此需要寻找加密算法。这个 app 没有加壳,直接拖到 JADX 里,先拿到 Java 层的源代码。跟了一下发现所有的请求和响应都有统一的加解密管理,业务层看到的是这样:

public EncryptJsonCallback<***Response> mCallBack = new EncryptJsonCallback<***Response>() { 
    @Override
    public void onError(int i, String str) {
        ***Presenter.this.callLoadAll***(-11);
    }

    @Override
    public void onStart(Request<***Response, ? extends Request> request) {
        super.onStart(request);
        ***Presenter.this.callLoadAll***(-10);
    }

    @Override
    public void onSuccess(Response<***Response> response) {
        if (response == null || response.body() == null || response.body().code != 0) {
            ***Presenter.this.callLoadAll***(-11);
        } else {
            ***Presenter.this.parse***(response.body().data, response.body());
        }
    }
};

在这里的 onSuccess() 方法中,参数已经是解密过的了,继续往下找,发现了解密响应的部分:

@Override
public T convertResponse(Response response) throws Throwable {
    // ...
    if (new JSONObject(string).optInt("isSign") == 1) {
        EncryptResponse encryptResponse = (EncryptResponse) GsonUtils.fromJson(string, (Class<Object>) EncryptResponse.class);
        IEncTools encTools = EncToolsFactory.getEncTools(OkGo.getContext());
        JSONObject jSONObject = new JSONObject();
        if (TextUtils.isEmpty(encryptResponse.data)) {
            jSONObject.put("data", (Object) null);
        } else {
            encryptResponse.data = encryptResponse.data.replaceAll("\n", "");
            if (TextUtils.isEmpty(this.encryptKey)) {
                return null;
            }
            encryptResponse.data = encTools.decode(encryptResponse.data, getServerFlag(), this.encryptKey);
            if (TextUtils.isEmpty(encryptResponse.data)) {
                return null;
            }
            jSONObject.put("data", new JSONObject(encryptResponse.data));
        }
        jSONObject.put("code", encryptResponse.code);
        // ...
    }
    // ...
}

这里大致的逻辑是从加密的响应中解密出来 data 放到一个新构造的 JSON 对象里。需要关注的是 encTools.decode(encryptResponse.data, getServerFlag(), this.encryptKey) 这一句。

getSeverFlag() 对于 Release 版恒为 1。encryptKey 经过尝试是一个固定的值,接下来只要逆出来加密算法就好了。跳到 IEncTools 的实现类,这其实也是个 Wrapper:

public class EncToolsImpl implements IEncTools {
    public static EncToolsImpl mEncToolsImpl;
    public static EncToolsNative mEncToolsNative;

    public EncToolsImpl(Context context) {
    }

    public static EncToolsImpl getInstance(Context context) {
        if (mEncToolsImpl == null && context != null) {
            synchronized (EncToolsImpl.class) {
                if (mEncToolsImpl == null) {
                    mEncToolsNative = new EncToolsNative();
                    mEncToolsImpl = new EncToolsImpl(context);
                }
            }
        }
        return mEncToolsImpl;
    }

    @Override
    public String decode(String str, int i, String str2) {
        if (str == null || str2 == null) {
            return new String("error input parameter");
        }
        return (str.length() == 0 || str2.length() == 0) ? new String("error input parameter") : mEncToolsNative.decode(str, i, str2);
    }

    @Override
    public String encode(String str, int i, String str2) {
        if (str == null || str2 == null) {
            return new String("error input parameter");
        }
        return (str.length() == 0 || str2.length() == 0) ? new String("error input parameter") : mEncToolsNative.encode(str, i, str2);
    }

    @Override
    public String sign(String str, String str2, int i, String str3, String str4) {
        if (str == null || str2 == null || str3 == null) {
            return new String("error input parameter");
        }
        return (str.length() == 0 || str2.length() == 0 || str3.length() == 0) ? new String("error input parameter") : mEncToolsNative.sign(str, str2, i, str3, str4);
    }
}

从命名上来看 encode 是加密,decode 是解密,sign 是签名。看到 Native 字眼的时候有一种很不好的感觉,点进去果然是 JNI:

public class EncToolsNative {
    public EncToolsNative() {
        System.loadLibrary("enc");
    }

    public native String decode(String str, int i, String str2);

    public native String encode(String str, int i, String str2);

    public native String sign(String str, String str2, int i, String str3, String str4);
}

先用 Frida 看看入参到底都是什么:

这里的 flag 就是前面提到的 getServerFlag() 的返回值。str 是密文,str2 是密钥。接下来就是大头,Native 层的算法逆向了。拉出来 IDA,先看看导出表:

三个 Java 开头的就是 JNI 的导出函数。这里注意到有很多 AES 相关的函数,以及 MD5 函数。结合之前的抓包,请求里的签名应该是对某种数据进行 MD5 之后的结果。加解密算法应该就是 AES 了,但至于是 CBC、ECB 还是 CTR,需要跟踪看一下。反编译一下 decode 看看:

__int64 __fastcall Java_***_EncToolsNative_decode(
        __int64 a1,
        __int64 a2,
        __int64 a3,
        unsigned int a4,
        __int64 a5)
{
    // ...
}

熟悉 JNI 的都知道,这个参数是不对的。用 JNI Helper 恢复一下参数:

jstring __fastcall Java_***_EncToolsNative_decode(
        JNIEnv *env,
        jobject this,
        jstring a1,
        jint a2,
        jstring a3)
{
    // ...
}

先看一下控制流:

很乱,显然经过了 OLLVM 的混淆。我对于 Native 逆向其实没什么经验,只能硬着头皮先看看,找到了一个真正负责解密的 decode 函数,跟进去:

void __usercall decode(unsigned int a1@<W1>, __int64 a2@<X2>, _QWORD *a3@<X8>)
{
    // ...
    v50 = *(_QWORD *)(_ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2)) + 40);
    base64_decode();
    std::string::basic_string(v42, a2);
    getSecKey(v42, a1);
    if ( (v42[0] & 1) != 0 )
    operator delete(ptr);
    if ( a1 )
    std::string::basic_string<decltype(nullptr)>((int)&v39, "20190314********");
    else
    std::string::basic_string<decltype(nullptr)>((int)&v39, "03920392********");
    if ( (v44 & 1) != 0 )
    v6 = v46;
    else
    v6 = v45;
    if ( (v39 & 1) != 0 )
    v7 = v41;
    else
    v7 = v40;
    AES_init_ctx_iv(v49, v6, v7);
    AES_CBC_decrypt_buffer(v49, v47, (unsigned int)((_DWORD)v48 - (_DWORD)v47));
    // ...
}

嗯,很标准的从 Base64 字符串解密的过程。先进行 Base64 解码得到原始密文,然后进行 AES CBC 解密。众所周知 AES CBC 的参量有数据、密钥和 IV。简单搜索了一下,看起来这里的 AES 像是用的 TinyAES in C。参考源码还原出来这些函数的签名:

void AES_init_ctx_iv(struct AES_ctx* ctx, const uint8_t* key, const uint8_t* iv);
void AES_ctx_set_iv(struct AES_ctx* ctx, const uint8_t* iv);
void AES_CBC_encrypt_buffer(struct AES_ctx* ctx, uint8_t* buf, size_t length);
void AES_CBC_decrypt_buffer(struct AES_ctx* ctx, uint8_t* buf, size_t length);

如此一来,上面的 v6 就是密钥,v7 就是 IV,v47 是密文。值得一提的是,a1 是之前一直传进来的 Flag,这里会根据它的值决定用 20190314******** 还是 03920392********。之前说过这个是 Release 标志位,所以可以默认这里构造了一个字符串 20190314********

这里最简单的办法是挂调试器断点看寄存器,我比较懒嫌麻烦,于是直接上 Frida。需要注意的是,Frida 在 Hook Native 方法的时候传入的方法名不是在 IDA 里看到的导出符号表,而是在反编译之后的伪代码里看到名称。

来抓一下 IV,写一段 Frida:

Interceptor.attach(aesInitCtxIvPtr, {
    onEnter: function (args) {
        console.log("*******_Z15AES_init_ctx_ivP7AES_ctxPKhS2_");
        console.log("args[0]=" + args[0]);
        console.log("key=" + args[1].readCString());
        // console.log(hexdump(args[1]))
        console.log("iv=" + args[2].readCString())
        // console.log(hexdump(args[2]))

    },
    onLeave: function (retval) {
    }
});

默认情况下打出来的是指针地址,对于 C 字符串,需要用 readCString() 来读出实际的内容。当然也可以使用 hexdump 来查看从这个地址起始的内存数据:

*******_Z15AES_init_ctx_ivP7AES_ctxPKhS2_
args[0]=0x783871b1b8
key=522A8D50********
             0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  0123456789ABCDEF
783871b189  35 32 32 41 38 44 35 30 xx xx xx xx xx xx xx xx  522A8D50********
783871b199  00 00 00 00 00 00 00 80 8e d5 8d 77 00 00 b4 30  ...........w...0
783871b1a9  de d5 8d 77 00 00 b4 80 0e d6 8d 77 00 00 b4 48  ...w.......w...H
783871b1b9  ae 91 5f 79 9b 02 00 e0 b4 71 38 78 00 00 00 00  .._y.....q8x....
783871b1c9  70 00 00 00 00 00 00 00 c0 71 38 78 00 00 00 6e  p........q8x...n
783871b1d9  40 00 00 00 00 00 00 27 00 00 00 00 00 00 00 c8  @......'........
783871b1e9  95 43 5f 77 00 00 00 50 6a 00 00 00 00 00 00 08  .C_w...Pj.......
783871b1f9  94 43 5f 77 00 00 00 c0 b3 71 38 78 00 00 00 00  .C_w.....q8x....
783871b209  c0 71 38 78 00 00 00 20 00 00 00 00 00 00 00 00  .q8x... ........
783871b219  03 b5 8d 77 00 00 b4 18 00 00 00 00 00 00 00 e0  ...w............
783871b229  02 b5 8d 77 00 00 b4 0d 02 60 74 79 00 00 00 e1  ...w.....`ty....
783871b239  bb b2 bb 79 1e ce 40 90 b2 71 38 78 00 00 00 e0  ...y..@..q8x....
783871b249  65 91 5f 79 49 7f 00 00 c0 71 38 78 00 00 00 18  e._yI....q8x....
783871b259  00 00 00 00 00 00 00 20 00 00 00 00 00 00 00 c0  ....... ........
783871b269  0b fc 5b 78 00 00 b4 00 03 b5 8d 77 00 00 b4 e1  ..[x.......w....
783871b279  bb b2 bb 79 1e ce 40 c0 b3 71 38 78 00 00 00 20  ...y..@..q8x... 
iv=20190314********
             0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  0123456789ABCDEF
783871b159  32 30 31 39 30 33 31 34 xx xx xx xx xx xx xx xx  20190314********
783871b169  00 00 00 00 00 00 00 21 00 00 00 00 00 00 00 18  .......!........
783871b179  00 00 00 00 00 00 00 20 03 b5 8d 77 00 00 b4 20  ....... ...w... 
783871b189  35 32 32 41 38 44 35 30 xx xx xx xx xx xx xx xx  522A8D50********
783871b199  00 00 00 00 00 00 00 80 8e d5 8d 77 00 00 b4 30  ...........w...0
783871b1a9  de d5 8d 77 00 00 b4 80 0e d6 8d 77 00 00 b4 48  ...w.......w...H
783871b1b9  ae 91 5f 79 9b 02 00 e0 b4 71 38 78 00 00 00 00  .._y.....q8x....
783871b1c9  70 00 00 00 00 00 00 00 c0 71 38 78 00 00 00 6e  p........q8x...n
783871b1d9  40 00 00 00 00 00 00 27 00 00 00 00 00 00 00 c8  @......'........
783871b1e9  95 43 5f 77 00 00 00 50 6a 00 00 00 00 00 00 08  .C_w...Pj.......
783871b1f9  94 43 5f 77 00 00 00 c0 b3 71 38 78 00 00 00 00  .C_w.....q8x....
783871b209  c0 71 38 78 00 00 00 20 00 00 00 00 00 00 00 00  .q8x... ........
783871b219  03 b5 8d 77 00 00 b4 18 00 00 00 00 00 00 00 e0  ...w............
783871b229  02 b5 8d 77 00 00 b4 0d 02 60 74 79 00 00 00 e1  ...w.....`ty....
783871b239  bb b2 bb 79 1e ce 40 90 b2 71 38 78 00 00 00 e0  ...y..@..q8x....
783871b249  65 91 5f 79 49 7f 00 00 c0 71 38 78 00 00 00 18  e._yI....q8x....

这样可以确定密钥和 IV 都是字符串,再来打一下内容:

*******_Z15AES_init_ctx_ivP7AES_ctxPKhS2_
args[0]=0x780f3fddd8
key=CRG*************
iv=20190314********
*******_Z15AES_init_ctx_ivP7AES_ctxPKhS2_
args[0]=0x780f3fe018
key=522A8D50********
iv=20190314********
*******_Z15AES_init_ctx_ivP7AES_ctxPKhS2_
args[0]=0x780f3fd978
key=CRG*************
iv=20190314********
*******_Z15AES_init_ctx_ivP7AES_ctxPKhS2_
args[0]=0x77aa3cff58
key=CRG*************
iv=20190314********
*******_Z15AES_init_ctx_ivP7AES_ctxPKhS2_
args[0]=0x77aa3d01b8
key=522A8D50********
iv=20190314********

IV 很眼熟,就是之前在 Native decode 函数里看到的根据 Release 标志位里取出来的。但密钥跟之前从 Java 层传过来的东西不太一样,猜测应该是通过 getSecKey() 算出来的。因为我只是为了拿加解密算法和 AES 参量,暂时就先没有去分析这部分了。

注意到这里有两个不同的密钥,哪一个是要用的呢?都试试就好了。这里推荐一个很好用的在线工具 CyberChef,快速分析各种加解密和编解码算法。尝试下来发现 522A8D50******** 是在我要的接口里用到的密钥:

到这里逆向响应就算完成了。尝试了一下,能正常取得响应的最小请求是这样:

{
  "timeStamp": "30A78E464DF4BE9DD58CF93E41832296",
  "sign": "7DEA936BCD70A1688017400521A8FCA6",
  "isSign":2,
  "params": "soRHDQqFLOi4njTO69WlnP***",
  "cguid": "3O+koZ5vtyEVgycRLcynBie***"
}

params 就是正常的请求 JSON 加密之后的结果。这里还需要搞清楚的几个东西是 timeStampcguidsign 三个参数。因为在 Native 层用不到后两个参数,我们先回到 Java 层。在发起请求的时候会统一进行加密:

private Response<T> encRequest() throws Throwable {
    String json;
    EncyptConvert encyptConvert = (EncyptConvert) this.request.getConverter();
    EncryptPostRequest encryptPostRequest = (EncryptPostRequest) this.request;
    EncryptRequest encryptRequest = new EncryptRequest();
    boolean hasEncrypt = encryptPostRequest.hasEncrypt();
    encryptRequest.timeStamp = getUUID();
    if (hasEncrypt) {
        encryptRequest.encrypt = 2;
    } else {
        encryptRequest.encrypt = 0;
    }
    Object upObject = encryptPostRequest.getUpObject();
    String keySync = EncryptKeyManager.getInstance().getKeySync();
    // ...
    if (upObject == null) {
        if (!hasEncrypt) {
            encryptRequest.paramsObj = null;
        }
    } else if (upObject instanceof ****BaseRequestModel) {
        // ...
        if (hasEncrypt) {
            json = GsonUtils.toJson(valueByKey);
            if (hasEncrypt) {
                if (TextUtils.isEmpty(json)) {
                    encryptRequest.params = "";
                } else {
                    try {
                        encryptRequest.params = EncToolsFactory.getEncTools(OkGo.getContext()).encode(json, getServerFlag(), keySync);
                        // ...
                    } catch (Exception e) {
                        // ...
                    }
                }
            }
            if (encryptPostRequest.hasCGuid()) {
                String cGuidSync = CGuidManager.getInstance().getCGuidSync();
                // ...
                try {
                    encryptRequest.sign = EncToolsFactory.getEncTools(OkGo.getContext()).sign(encryptRequest.timeStamp, keySync, getServerFlag(), encryptRequest.cguid, encryptRequest.params == null ? "" : encryptRequest.params);
                    // ...
                } catch (Exception e2) {
                    // ...
                }
            }
            / ...
        }
        encryptRequest.paramsObj = valueByKey;
    } else if (hasEncrypt) {
        // ...
    } else {
        encryptRequest.paramsObj = upObject;
    }
    // ...
}

private String getUUID() {
    String replace = UUID.randomUUID().toString().replace("-", "");
    return MD5Util.encrypt_string(replace + System.nanoTime());
}

timeStamp 的计算有点好笑,就是一个去掉 - 的 UUID 拼上当前时间然后进行 MD5,经过测试用任意字符串进行 MD5 都可以,只要保证流程中所有用到 timeStamp 值的地方保持一致即可。

cguid 是一个设备相关的东西,在生成之后似乎会传递给服务端,因此在重放请求的时候这里保持使用真实值不变。

密钥的来源是 EncryptKeyManager,里面会有一个请求去获取。

sign 的计算跟 timeStamp、密钥、cguid 和加密后的请求参数 params 有关,再次回到 Native 层,用 Frida 捞一下:

*******_Z4signNSt6__ndk112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEES5_iS5_S5_
arg0=0x780e4cbbf0
             0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  0123456789ABCDEF
780e4cbbf0  31 00 00 00 00 00 00 00 20 00 00 00 00 00 00 00  1....... .......
780e4cbc00  30 19 91 f1 75 00 00 b4 24 ac fe d7 78 83 45 00  0...u...$...x.E.
780e4cbc10  01 00 00 00 00 00 00 00 02 00 00 00 00 00 00 00  ................
780e4cbc20  98 f2 97 24 36 39 00 00 51 00 00 00 00 00 00 00  ...$69..Q.......
780e4cbc30  40 00 00 00 00 00 00 00 a0 61 48 bb 78 00 00 b4  @........aH.x...
780e4cbc40  51 00 00 00 00 00 00 00 4c 00 00 00 00 00 00 00  Q.......L.......
780e4cbc50  00 48 48 bb 78 00 00 b4 21 00 00 00 00 00 00 00  .HH.x...!.......
780e4cbc60  18 00 00 00 00 00 00 00 40 da eb 0e 78 00 00 b4  ........@...x...
780e4cbc70  31 00 00 00 00 00 00 00 20 00 00 00 00 00 00 00  1....... .......
780e4cbc80  60 19 91 f1 75 00 00 b4 6c a4 5a 6a 2c 4e af 25  `...u...l.Zj,N.%
780e4cbc90  00 bd 4c 0e 78 00 00 00 10 af 44 cc 78 00 00 00  ..L.x.....D.x...
780e4cbca0  48 be 4c 0e 78 00 00 00 10 be 4c 0e 78 00 00 00  H.L.x.....L.x...
780e4cbcb0  00 09 e0 d7 78 00 00 00 74 06 00 00 00 00 00 00  ....x...t.......
780e4cbcc0  72 75 71 52 78 00 00 00 c8 e8 01 23 78 00 00 b4  ruqRx......#x...
780e4cbcd0  00 00 00 00 00 00 00 00 00 e8 01 23 78 00 00 b4  ...........#x...
780e4cbce0  48 be 4c 0e 78 00 00 00 34 74 e2 d7 78 00 00 00  H.L.x...4t..x...
arg1=0x780e4cbbd0
             0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  0123456789ABCDEF
780e4cbbd0  21 00 00 00 00 00 00 00 18 00 00 00 00 00 00 00  !...............
780e4cbbe0  a0 79 a2 00 78 00 00 b4 6c a4 5a 6a 2c 4e af 25  .y..x...l.Zj,N.%
780e4cbbf0  31 00 00 00 00 00 00 00 20 00 00 00 00 00 00 00  1....... .......
780e4cbc00  30 19 91 f1 75 00 00 b4 24 ac fe d7 78 83 45 00  0...u...$...x.E.
780e4cbc10  01 00 00 00 00 00 00 00 02 00 00 00 00 00 00 00  ................
780e4cbc20  98 f2 97 24 36 39 00 00 51 00 00 00 00 00 00 00  ...$69..Q.......
780e4cbc30  40 00 00 00 00 00 00 00 a0 61 48 bb 78 00 00 b4  @........aH.x...
780e4cbc40  51 00 00 00 00 00 00 00 4c 00 00 00 00 00 00 00  Q.......L.......
780e4cbc50  00 48 48 bb 78 00 00 b4 21 00 00 00 00 00 00 00  .HH.x...!.......
780e4cbc60  18 00 00 00 00 00 00 00 40 da eb 0e 78 00 00 b4  ........@...x...
780e4cbc70  31 00 00 00 00 00 00 00 20 00 00 00 00 00 00 00  1....... .......
780e4cbc80  60 19 91 f1 75 00 00 b4 6c a4 5a 6a 2c 4e af 25  `...u...l.Zj,N.%
780e4cbc90  00 bd 4c 0e 78 00 00 00 10 af 44 cc 78 00 00 00  ..L.x.....D.x...
780e4cbca0  48 be 4c 0e 78 00 00 00 10 be 4c 0e 78 00 00 00  H.L.x.....L.x...
780e4cbcb0  00 09 e0 d7 78 00 00 00 74 06 00 00 00 00 00 00  ....x...t.......
780e4cbcc0  72 75 71 52 78 00 00 00 c8 e8 01 23 78 00 00 b4  ruqRx......#x...
arg2=0x1

很奇怪的是这里 Dump 出来的内存都不是想要的东西,而且参数数量也不对。那既然知道签名是 MD5 出来的,直接 Hook 最终 MD5 的地方,看看明文是什么:

*******_Z3md5PKvm
arg0 0xb400006c50a471a0 BA0D8BEC59FF4DAA747603179EB63DCDCRG***PRODQwe*******3O+koZ5vtyEVgycRLcynBie***ZSxI1NTbvRcs2Z9nyQb9W5H1Kc***
return:0xb4000075f2b23cc0

arg0 里这些就是待签名的数据,拆一下:

BA0D8BEC59FF4DAA747603179EB63DCD
CRG****
PRODQwe*******
3O+koZ5vtyEVgycRLcynBie***
ZSxI1NTbvRcs2Z9nyQb9W5H1Kc***

第一部分是 timeStamp,第二部分是 CRG**** 常量,第三部分是 PRODQwe******* 常量,第四部分是 cguid,第五部分是 params。因此算法可以描述为:

sign = md5(timeStamp + "CRG****PRODQwe*******" + cguid + params)

根据抓包的请求中的参数进行计算,验证通过。到这里想要的东西就算是逆向完了。

想法

说实话整个逆向过程还是显得相当小白,很多地方都懒得去看,能逆出来纯粹是因为信息很多。但其实这样也挺高效,控制流分析不出来就尝试看看数据流。作为第一次完整的逆向体验,当真正逆出来的那一瞬间整个人还是非常兴奋的。中间卡了半天以至于都再去想找人帮忙,但一听说要收四位数的价钱,想了想还是自己做了。

暂无评论

发送评论 编辑评论


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