SUSCTF2024-Redroid-出题笔记与解题思路

SUSCTF2024-Redroid-出题笔记与解题思路


描述:题目要求使用安卓13/14系统真机

Step1

Java层的逻辑比较简单,两个Activity
MainActivity读并验证password,正确即进入CheckActivity,同时会传递password
step1
password先后传入了util.init和util.calc,计算得到hash与已知的secret比较
Util实际上是HMAC-SHA1的标准哈希过程,只不过这里key和data同时为输入的password
step1
password长度为6,并且均为数字,于是可以爆破得到,为122608

import hashlib
import hmac
for i in range(1000000):
    s = str(i).encode()
    h = hmac.new(s, s, hashlib.sha1).hexdigest()
    if h == "59e7027803adaa7bcad8e40770c3c50f288ca5ac":
        print(i)
        break
# 122608

如果没有认出来HMAC-SHA1算法,也可以直接扣Java代码爆破,Util没有用到android类库

Step2

输入password进入CheckActivity
开头两个字符串,两个Native方法,加载libcheck.so
step2
onCreate通过intent读传入的password,然后和liblabel以及getAssets()一起传入init方法
只有init方法返回true,程序流才会继续向下去做flag的校验,因此要求password必须正确
init方法的第一个参数是个AssetManager,和资源相关,在assets目录下确实存在很多jpg,还不知道怎么处理
按钮onClick只是读flag并校验长度,然后传入check方法,返回true说明flag正确
step2
关键就是init和check两个native方法,因此开始分析libcheck.so
没有jni_onload,搜java只找到init方法,没有check方法
step2
这是因为check方法根本不在libcheck.so
libcheck.so实际上是一个壳so,init方法是在做解密以及模仿linker加载so
而通过libcheck.so加载的新so(librealcheck.so)才真正包含了check方法校验flag的逻辑
这也就是为什么Java层要判断init方法的返回值:因为加载librealcheck.so不成功的话就根本找不到check方法
且为什么要求使用安卓13/14系统真机:因为加载新so最后需要修复soinfo,soinfo结构成员在不同版本下偏移不同
于是这题要求对linker加载so的过程比较熟悉
参考:
[1]自定义Linker实现分析之路(一)
[2]基于linker实现so加壳技术基础上篇
[3]基于linker实现so加壳技术基础下篇
[4]自定义linker加固so
[5]获取soinfo+
配合源码食用更佳:
[6]AOSPXRef
回到题目,先看看源码正向的加密过程
编译出librealcheck.so后重命名为nevercheck114.jpg,放到assets目录下
为什么要改成jpg后缀:
一方面是隐藏在其他jpg里,顺便玩梗
另一方面是安卓认为jpg是已经压缩好的,它不会再去压缩,如果还是so后缀,它会压缩导致找不到资源文件
这里的key还是前面的password,作为加密密钥

// 加密:librealcheck.so => nevercheck114.jpg => nevercheck114.jpg.packed => nevercheck114.jpg
AAssetManager * aAssetManager = AAssetManager_fromJava(env, assetmanager);
AAsset * aAsset = AAssetManager_open(aAssetManager, "nevercheck114.jpg", AASSET_MODE_UNKNOWN);
off64_t start = 0, length = 0;
int fd = AAsset_openFileDescriptor64(aAsset, &start, &length);
lseek(fd, start, SEEK_CUR);
AAsset_close(aAsset);
if(fd!=-1){
    const char * key = env->GetStringUTFChars(libkey,0);
    shell ashell;
    if(ashell.pack(fd,length,key)){
        return JNI_TRUE;
    }
}

ashell.pack就是对so文件的变形以及加密,参考[4]
先定义一个CosELFHeader结构体,存储原始ELF头的偏移和大小,原始程序头表的偏移和大小
然后对原始ELF头和原始程序头表做RC4加密,顺便拷贝到加密so的末尾
对PT_LOAD的段做RC4加密,偏移不变,拷贝到加密so的对应位置
把自定义的CosELFHeader放到加密so开头,用来找原始ELF头和原始程序头表
最后填充随机字节到非PT_LOAD的其他部分,这对解密加载没有影响,只是不想让加密so看起来太奇怪(大片的0)

bool shell::pack(int libfd, size_t liblength, const char *libkey) {
    if(libkey!= nullptr){
        size_t libkeysize = strlen(libkey);
        size_t libsize = liblength;
        char * libdata = reinterpret_cast<char *>(malloc(libsize));
        if(libdata!= nullptr) {
            memset(libdata, 0, libsize);
            if (read(libfd, libdata, libsize) == libsize) {
                close(libfd);
                Elf64_Ehdr * oriELFHeader = reinterpret_cast<Elf64_Ehdr *>(libdata);
                CosELFHeader cosElfHeader;
                cosElfHeader.oriELFHeaderOff    = libsize + oriELFHeader->e_phnum * oriELFHeader->e_phentsize;
                cosElfHeader.oriELFHeaderSize   = oriELFHeader->e_ehsize;
                cosElfHeader.oriELFPHTOff       = libsize;
                cosElfHeader.oriELFPHTNum       = oriELFHeader->e_phnum;
                cosElfHeader.oriELFPHTSize      = oriELFHeader->e_phnum * oriELFHeader->e_phentsize;
                size_t libpacksize = libsize + cosElfHeader.oriELFPHTSize + cosElfHeader.oriELFHeaderSize;
                char * libpackdata = reinterpret_cast<char *>(calloc(libpacksize,1));
                if(libpackdata!= nullptr){
                    rc4(libpackdata+cosElfHeader.oriELFHeaderOff,libdata,cosElfHeader.oriELFHeaderSize,libkey,libkeysize);
                    rc4(libpackdata+cosElfHeader.oriELFPHTOff,libdata+oriELFHeader->e_phoff,cosElfHeader.oriELFPHTSize,libkey,libkeysize);
                    Elf64_Phdr * phdr = reinterpret_cast<Elf64_Phdr *>(libdata + oriELFHeader->e_phoff);
                    size_t phdrloadsize = 0;
                    for(size_t i = 0; i < oriELFHeader->e_phnum; i++){
                        if(phdr->p_type == PT_LOAD){
                            rc4(libpackdata+phdr->p_offset,libdata+phdr->p_offset,phdr->p_filesz,libkey,libkeysize);
                            phdrloadsize = phdrloadsize < phdr->p_offset + phdr->p_filesz ? phdr->p_offset + phdr->p_filesz : phdrloadsize;
                        }
                        phdr = reinterpret_cast<Elf64_Phdr *>((char*)phdr + sizeof(Elf64_Phdr));
                    }
                    memset(libpackdata,0, cosElfHeader.oriELFHeaderSize);
                    memcpy(libpackdata,&cosElfHeader,sizeof(Elf64_Ehdr));
                    srand(atoi(libkey));
                    for(char * ptr = libpackdata+phdrloadsize;ptr<libpackdata+cosElfHeader.oriELFPHTOff;ptr++){
                        *ptr = (char)rand()%0x100;
                    }
                    FILE * libpackfile = fopen("/data/user/0/com.susctf.redroid/cache/nevercheck114.jpg.packed","w");
                    if(libpackfile!= nullptr) {
                        fwrite(libpackdata, libpacksize, 1, libpackfile);
                        fclose(libpackfile);
                        return true;
                    }
                }
            }
        }
    }
    return false;
}

然后看源码的解密过程,也就是init方法
这里的nevercheck114.jpg就是经过变形和加密的librealcheck.so
key仍然是password,label是固定的"check",也就是壳so的名称,后面找壳so的soinfo时会用到

AAssetManager * aAssetManager = AAssetManager_fromJava(env, assetmanager);
AAsset * aAsset = AAssetManager_open(aAssetManager, "nevercheck114.jpg", AASSET_MODE_UNKNOWN);
off64_t start = 0, length = 0;
int fd = AAsset_openFileDescriptor64(aAsset, &start, &length);
lseek(fd, start, SEEK_CUR);
AAsset_close(aAsset);
if(fd!=-1){
    const char * label = env->GetStringUTFChars(liblabel,0);
    const char * key = env->GetStringUTFChars(libkey,0);
    shell ashell;
    if(ashell.unpack(fd,length,label,key)){
        return JNI_TRUE;
    }
}

ashell.unpack是对加密so的解密和加载
由加密so开头数据定义并填充CosELFHeader
然后通过CosELFHeader找原始ELF头和原始程序头表并解密,这里解密后没有将它们恢复到原始位置
程序头表解密后即可对PT_LOAD的段原地解密

bool shell::unpack(int libfd, size_t liblength, const char *liblabel, const char *libkey) {
    if(libkey!= nullptr){
        size_t libkeysize = strlen(libkey);
        size_t libpacksize = liblength;
        char * libpackdata = reinterpret_cast<char *>(malloc(libpacksize));
        if(libpackdata!= nullptr) {
            memset(libpackdata, 0, libpacksize);
            if (read(libfd, libpackdata, libpacksize) == libpacksize) {
                close(libfd);
                CosELFHeader cosElfHeader = {};
                memcpy(&cosElfHeader,libpackdata,sizeof(cosElfHeader));
                rc4(libpackdata+cosElfHeader.oriELFHeaderOff,libpackdata+cosElfHeader.oriELFHeaderOff,cosElfHeader.oriELFHeaderSize,libkey,libkeysize);
                rc4(libpackdata+cosElfHeader.oriELFPHTOff,libpackdata+cosElfHeader.oriELFPHTOff,cosElfHeader.oriELFPHTSize,libkey,libkeysize);
                Elf64_Phdr * phdr = reinterpret_cast<Elf64_Phdr *>(libpackdata + cosElfHeader.oriELFPHTOff);
                for(int i = 0; i < cosElfHeader.oriELFPHTNum; i++){
                    if(phdr->p_type == PT_LOAD){
                        rc4(libpackdata+phdr->p_offset,libpackdata+phdr->p_offset,phdr->p_filesz,libkey,libkeysize);
                    }
                    phdr = reinterpret_cast<Elf64_Phdr *>((char*)phdr + sizeof(Elf64_Phdr));
                }
                ElfReader elfReader;
                elfReader.SetFile(libpacksize - cosElfHeader.oriELFPHTSize - cosElfHeader.oriELFHeaderSize,libpackdata);
                elfReader.ReadElfHeader(cosElfHeader.oriELFHeaderOff);
                elfReader.ReadProgramHeaders(cosElfHeader.oriELFPHTOff);
                if(elfReader.ReserveAddressSpace() && elfReader.LoadSegments()){
                    ElfW(Phdr) * oriPhdr = reinterpret_cast<ElfW(Phdr) *>(malloc(cosElfHeader.oriELFPHTSize));
                    memcpy(oriPhdr,libpackdata + cosElfHeader.oriELFPHTOff,cosElfHeader.oriELFPHTSize);
                    elfReader.SetPhdr(oriPhdr);
                    std::string libname = "";
                    libname.append("lib");
                    libname.append(liblabel);
                    libname.append(".so");
                    ElfLoader elfLoader(libname);
                    intptr_t shellSoInfo = elfLoader.GetShellSoInfo();
                    if (shellSoInfo != 0) {
                        ElfFixer elfFixer(elfReader, shellSoInfo);
                        if (elfFixer.fix_soinfo() && elfFixer.prelink_image() && elfFixer.link_image()) {
                            return true;
                        }
                    }
                }
            }
        }
    }
    return false;
}

随后的ElfReader、ElfLoader和ElfFixer就是在对解密so做加载、链接、重定位以及修复soinfo,参考[1][2][3]
需要特别说明的是,获取libcheck.so的soinfo的方法参考[5]
主动调用__dl__Z15solist_get_headv获取solist头,然后通过next成员遍历寻找目标so
区别在于安卓13/14系统的soinfo结构成员的偏移不太一样,一个是next的偏移,另一个是so名称字符串的偏移
GetLibBase就是去扫/proc/self/maps,第一次匹配到目标so,返回起始地址

void ElfLoader::SetShellSoInfo(std::string libname) {
    ELFIO::elfio linker;
    if (linker.load("/system/bin/linker64")){
        ELFIO::symbol_section_accessor symbols(linker, linker.sections[".symtab"]);
        for (unsigned int i = 0; i < symbols.get_symbols_num(); ++i) {
            std::string name;
            ELFIO::Elf64_Addr value;
            ELFIO::Elf_Xword size;
            unsigned char bind;
            unsigned char type;
            ELFIO::Elf_Half section_index;
            unsigned char other;
            symbols.get_symbol(i, name, value, size, bind, type,section_index, other);
            if (name == "__dl__Z15solist_get_headv") {
                intptr_t linkerbase = GetLibBase("linker64");
                using solist_get_head_t = void *(*)();
                static solist_get_head_t solist_get_head = reinterpret_cast<solist_get_head_t>(linkerbase + value);
                intptr_t si = reinterpret_cast<intptr_t>(solist_get_head());
                const char * libpath = nullptr;
                while (si) {
                    libpath = (const char *) *(intptr_t *)(si + 0xD8);
                    if(strstr(libpath,libname.c_str())){
                        shellSoInfo = si;
                        break;
                    }
                    si = *(intptr_t *) (si + 0x28);
                }
            }
        }
    }
}

Step3

现在开始逆向,libcheck.so和librealcheck.so在编译时都做了控制流平坦化和字符串加密的混淆
d810可以去掉大部分平坦化混淆,字符串不多,手动解也能看
去混淆后的init方法还算看得清楚,与源码差别不大
step3
进来unpack函数会发现它的控制流几乎没有被混淆,只有开头的一点字符串解密,然后就是读jpgdata
step3
从jpgdata开头读原始ELF头的偏移和大小以及原始程序头表的偏移和大小
step3
对原始ELF头的RC4解密
step3
对原始程序头表的RC4解密
step3
编译出来的libcheck.so是把RC4过程直接内联进了unpack函数,看起来很庞大,实际上看到256大概能猜到是RC4
然后是对p_type==1的也就是PT_LOAD的段做RC4解密
step3
elfreader
step3
elfloader
step3
elffixer
step3
解密so的加载、链接、重定位以及soinfo的修复的识别需要比较强的逆向功底和比较多的逆向经验
这里能够大概感受到是读jpgdata然后做RC4解密然后被加载即可
加载的细节可以不必深入,但要求能够手动还原librealcheck.so
(可以尝试调试提取解密数据,然后拼起来,但是笔者调试发现断不下来
(按理说jni_onload都能断下,init在onCreate调用却断不下来,8太懂

from struct import unpack
from Crypto.Cipher import ARC4
from elftools.elf.elffile import ELFFile

# 读jpgdata
with open("nevercheck114.jpg", "rb") as f:
    jpgdata = f.read()

# 读原始ELF头偏移和大小
oriELFHeaderOff = unpack("<Q", jpgdata[:8])[0]
oriELFHeaderSize = unpack("<H", jpgdata[8:10])[0]
# 读原始程序头表偏移和大小
oriELFPHTOff = unpack("<Q", jpgdata[16:24])[0]
oriELFPHTNum = unpack("<H", jpgdata[24:26])[0]
oriELFPHTSize = unpack("<H", jpgdata[26:28])[0]

# RC4密钥
key = b"122608"

# 解密原始ELF头
rc4 = ARC4.new(key)
oriELFHeaderData = jpgdata[oriELFHeaderOff:oriELFHeaderOff+oriELFHeaderSize]
oriELFHeaderData = rc4.decrypt(oriELFHeaderData)

# 解密原始程序头表
rc4 = ARC4.new(key)
oriELFPHTData = jpgdata[oriELFPHTOff:oriELFPHTOff+oriELFPHTSize]
oriELFPHTData = rc4.decrypt(oriELFPHTData)

# 把原始ELF头和原始程序头表先写到一个文件,方便后续通过ELFFile读程序头表条目
with open("tmpelf", "wb") as f:
    f.write(oriELFHeaderData)
    f.write(oriELFPHTData)

# 初始化ELFFile
f = open("tmpelf", "rb")
elffile = ELFFile(f)

# 原始ELF文件
orielf = open("orielf", "wb")
for seg in elffile.iter_segments():
    # 只处理PT_LOAD段
    if seg.header["p_type"] == "PT_LOAD":
        seg_p_offset = seg.header["p_offset"]
        seg_p_filesz = seg.header["p_filesz"]
        rc4 = ARC4.new(key)
        orielf.write(rc4.decrypt(
            jpgdata[seg_p_offset:seg_p_offset+seg_p_filesz]))

f.close()
orielf.close()

这里orielf的前0x40字节,也就是ELF头,是不正确的,因为第一个PT_LOAD段包含了ELF头的范围
手动修复为oriELFHeaderData,也就是从tmpelf复制前0x40字节到orielf
step3
orielf保存后可以用ida打开,此时可以看到check方法
step3

Step4

开始分析check方法,输入长度应为32
前16字节正常赋值,后16字节与前16字节依次异或再赋值
前后16字节经过wbaes函数变换后分别与enc1和enc2比较,都相等说明输入正确,即为flag
step4
wbaes函数虽然没被混淆,但是很长,这里实际上是一个标准白盒AES的循环展开过程
白盒AES攻击关键是在第九轮列混淆前破坏状态矩阵中的一个字节,使得最终的密文被破坏四字节
进而恢复最后一轮密钥的四字节,如果选择破坏的字节合适,可以完整恢复出最后一轮密钥,进而恢复初始密钥
破坏字节需要hook程序流,这里选择unidbg模拟执行,然后hook
unidbg模拟的好处是不用去找解密so的加载地址,解密so的加载地址是在加载过程中由壳so分配的,不易获取
apk后缀改成zip,重命名orielf为librealcheck.so,然后加到zip,zip再改回apk
这里给出关键的攻击代码

public static byte[] hexStringToBytes(String hexString) {
    if (hexString.isEmpty()) {
        return null;
    }
    hexString = hexString.toLowerCase();
    final byte[] byteArray = new byte[hexString.length() >> 1];
    int index = 0;
    for (int i = 0; i < hexString.length(); i++) {
        if (index > hexString.length() - 1) {
            return byteArray;
        }
        byte highDit = (byte) (Character.digit(hexString.charAt(index), 16)
                & 0xFF);
        byte lowDit = (byte) (Character.digit(hexString.charAt(index + 1),
                16) & 0xFF);
        byteArray[i] = (byte) (highDit << 4 | lowDit);
        index += 2;
    }
    return byteArray;
}

public static String bytesTohexString(byte[] bytes) {
    StringBuffer sb = new StringBuffer();
    for (int i = 0; i < bytes.length; i++) {
        String hex = Integer.toHexString(bytes[i] & 0xFF);
        if (hex.length() < 2) {
            sb.append(0);
        }
        sb.append(hex);
    }
    return sb.toString();
}

public void callNative(){
    MemoryBlock inblock = androidEmulator.getMemory().malloc(16, true);
    UnidbgPointer inPtr = inblock.getPointer();
    byte[] stub = hexStringToBytes("30313233343536373839616263646566");
    assert stub != null;
    inPtr.write(0, stub, 0, stub.length);
    dalvikModule.getModule().callFunction(androidEmulator, 0x000000000007FC88, inPtr);
    String ret = bytesTohexString(inPtr.getByteArray(0, 0x10));
    System.out.println(ret);
    inblock.free();
}

/*
* 0x000000000008874C UC_ARM64_REG_X10
* 0x0000000000088450 UC_ARM64_REG_X8
* 0x00000000000876EC UC_ARM64_REG_X10
* 0x0000000000087B48 UC_ARM64_REG_X8
* */
public void attack(){
    androidEmulator.attach().addBreakPoint(dalvikModule.getModule().base + 0x0000000000087B48, new BreakPointCallback() {
        int count = 0;
        @Override
        public boolean onHit(Emulator<?> emulator, long address) {
            emulator.getBackend().reg_write(Arm64Const.UC_ARM64_REG_X8,count);
            count+=1;
            return true;
        }
    });
}

public static void main(String[] args){
    Redroid redroid = new Redroid();
    redroid.callNative();
    redroid.attack();
    for(int i = 0;i<10;i++){
        redroid.callNative();
    }
}

0x000000000008874C、0x0000000000088450、0x00000000000876EC和0x0000000000087B48
是四个破坏一字节而最终破坏密文四字节的地址
这里因为循环被展开了,轮加密的结果存在中间变量,而不是直接写回,不太好找第九轮列混淆
有个技巧是,从最后写回密文用到的中间变量开始找
比如这里用v478,对它交叉引用,找它最后一次写操作
如果破坏这个写操作,导致最后的密文只破坏了一字节,说明破坏晚了(从最后找肯定是晚的
然后找它倒数第二次写操作,如果破坏这个写操作,导致密文破坏四字节,说明破坏成功,保留模拟的几条结果
step4
依次类推,找不同的中间变量,直到找到最终密文的十六字节都能被破坏的至少四处写操作(就是上面的几个地址
用phoenixAES恢复最后一轮密钥

import phoenixAES
path = "tracefile"
with open(path, "wb") as f:
    f.write("""
59c604bb9a72247f3ac8ee52a5fd3e4f
59c649bb9a7c247fb3c8ee52a5fd3e30
59c659bb9a8a247ff4c8ee52a5fd3ef4
596704bb1572247f3ac8ee8da5fd254f
596004bbd972247f3ac8ee12a5fdc64f
3ac604bb9a7224e23ac81d52a5013e4f
98c604bb9a7224403ac85a52a56d3e4f
59c604339a72b97f3a12ee527efd3e4f
59c604b39a72097f3ad0ee52e1fd3e4f
""".encode("utf8"))
phoenixAES.crack_file(path, verbose=1)
# Last round key #N found:
# 89274E962B51F09F449F5E59868C63BF

Stark恢复初始密钥,为6A3C0545876924612949ED0B6CD34CDC

p1umh0@p1umh0:~/ctftools/Stark$ ./aes_keyschedule 89274E962B51F09F449F5E59868C63BF 10
K00: 6A3C0545876924612949ED0B6CD34CDC
K01: 0D1583158A7CA774A3354A7FCFE606A3
K02: 817A899F0B062EEBA833649467D56237
K03: 86D0131A8DD63DF125E5596542303B52
K04: 8A32133607E42EC7220177A260314CF0
K05: 5D1B9FE65AFFB12178FEC68318CF8A73
K06: F765104BAD9AA16AD56467E9CDABED9A
K07: D530A8F678AA099CADCE6E75606583EF
K08: 18DC772660767EBACDB810CFADDD9320
K09: C200C0B3A276BE096FCEAEC6C2133DE6
K10: 89274E962B51F09F449F5E59868C63BF

初始密钥实际上是一个md5
step4
最后解密aes即可

from Crypto.Cipher import AES
enc1 = [0x4d, 0xcd, 0xde, 0xb2, 0xc8, 0x35, 0x06, 0xdc,
        0x49, 0x2c, 0x7c, 0x35, 0x08, 0x9f, 0x46, 0x9b]
enc2 = [0x45, 0x9b, 0x79, 0x83, 0x6f, 0xc8, 0x62, 0xa1,
        0x87, 0x60, 0x3a, 0x9e, 0x56, 0x92, 0x61, 0x1c]
key = bytes.fromhex("6A3C0545876924612949ED0B6CD34CDC")
aes = AES.new(key, AES.MODE_ECB)
flag1 = list(aes.decrypt(bytes(enc1)))
aes = AES.new(key, AES.MODE_ECB)
flag2 = list(aes.decrypt(bytes(enc2)))
for i in range(len(flag2)):
    flag2[i] ^= flag1[i]
print("".join(chr(j) for j in flag1), end="")
print("".join(chr(j) for j in flag2))
# SUSCTF{u_4r3_m4573r_0f_4ndr01d!}

当然也可以用frida去hook,前提是将AndroidManifest.xml中的android:extractNativeLibs改为true并重打包
否则/proc/self/maps根本找不到libcheck.so模块,更别说找librealcheck.so的加载地址
AndroidManifest.xml编解码使用xml2axml
重打包后安装说没对齐

adb: failed to install redroid_Mod.apk: Failure [-124: Failed parse during installPackageLI: Targeting R+ (version 30 and above) requires the resources.arsc of installed APKs to be stored uncompressed and aligned on a 4-byte boundary]

参考zipalign对齐,再次安装说是没签名

adb: failed to install redroid_Mod_unsign.apk: Failure [INSTALL_PARSE_FAILED_NO_CERTIFICATES: Scanning Failed.: No signature found in package of version 2 or newer for package com.susctf.redroid]

使用MT管理器签名后安装成功
init是在onCreate被调用的,调用时机很早
一个可能hook到的时机是,在linker准备初始化libcheck.so,也就是准备调用call_constructors的时候去hook
用frida hook感觉很麻烦,感兴趣的读者可以一试

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

P1umH0

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值