Android逆向实战:用Smali语法修改APK逻辑的5个经典案例

Android逆向实战:用Smali语法修改APK逻辑的5个经典案例

如果你已经掌握了Smali语法的基础知识,看着那些.smali文件不再感到完全陌生,那么恭喜你,你已经跨过了逆向工程最枯燥的理论门槛。但真正的乐趣和挑战,往往始于“动手修改”的那一刻。从理解语法到亲手改变一个应用的运行轨迹,这中间需要的是对指令流的敏锐洞察、对逻辑结构的拆解能力,以及一份敢于“破坏”并重建的勇气。

这篇文章不是另一本语法手册,而是一份面向实战的“手术刀指南”。我们将绕过泛泛而谈,直接切入五个在逆向分析中最常遇到的经典场景:从破解一个简单的VIP验证,到去除恼人的广告,再到解锁隐藏功能、修改应用行为,甚至修复因反编译导致的崩溃。每个案例都将构建一个完整的“定位-分析-修改-测试”工作流,你会看到如何像侦探一样追踪关键代码,如何像外科医生一样精准地修改Smali指令,以及如何验证你的修改是否真正生效。我们的目标很明确:让你不仅能读懂Smali,更能驾驭它,用几行简单的指令改写,让应用按照你的意愿运行。

1. 案例一:破解本地VIP验证逻辑

许多应用,尤其是单机工具或游戏,会在本地进行权限或会员状态的校验。这种校验逻辑往往清晰直接,是新手练习Smali修改的绝佳起点。

1.1 场景分析与关键代码定位

假设我们分析一个图片编辑应用,其“高级滤镜”功能需要VIP身份。反编译后,我们通常会从入口点开始搜索。一个高效的方法是使用jadx-gui等工具全局搜索与VIP、会员、解锁相关的字符串,例如“VIP”、“premium”、“isPro”、“isVip”等。

很快,我们可能定位到一个名为LicenseManagerUserStatus的类,其中包含一个关键方法:

// 反编译后的Java代码示意
public boolean isUserVip() {
    return this.vipExpiryTime > System.currentTimeMillis();
}

对应的Smali代码可能如下:

.method public isUserVip()Z
    .locals 4
    .prologue

    # 将this对象的vipExpiryTime字段加载到v0
    iget-wide v0, p0, Lcom/example/app/LicenseManager;->vipExpiryTime:J

    # 调用静态方法获取当前时间戳,结果存入v2
    invoke-static {}, Ljava/lang/System;->currentTimeMillis()J
    move-result-wide v2

    # 比较v0 (到期时间) 和 v2 (当前时间)
    cmp-long v0, v0, v2

    # 如果 v0 > v2 (到期时间晚于当前时间),跳转到标签:cond_0
    if-lez v0, :cond_0

    # 条件成立,用户是VIP,将v0设为1 (true)
    const/4 v0, 0x1

    # 跳转到返回处
    goto :goto_0

    :cond_0
    # 条件不成立,用户不是VIP,将v0设为0 (false)
    const/4 v0, 0x0

    :goto_0
    # 返回v0的值 (Z表示布尔型)
    return v0
.end method

这里的逻辑一目了然:比较vipExpiryTime和当前时间,如果未过期则返回true。我们的修改目标就是让这个方法始终返回true

1.2 修改策略与Smali手术

修改Smali时,最直接的方法并非粗暴地删除所有逻辑,而是进行最小化的、精准的干预,以保持代码结构的稳定。针对这个isUserVip()方法,我们有几种修改思路:

  • 方案A:强制返回常量true。这是最彻底的方案,直接忽略所有计算和判断。
  • 方案B:篡改比较结果。修改条件跳转指令,让程序无论时间对比结果如何,都走向返回true的分支。
  • 方案C:修改关键数据。直接给vipExpiryTime字段赋一个极大的未来值。

对于初学者,方案A最为简单可靠。我们直接清空方法体,只保留返回true的指令。修改后的Smali代码如下:

.method public isUserVip()Z
    .locals 1 # 我们只需要一个寄存器来存放返回值
    .prologue

    # 直接将寄存器v0设置为1 (true)
    const/4 v0, 0x1

    # 返回v0
    return v0
.end method

注意:修改后,.locals声明可能需要调整。原方法使用了4个本地寄存器(v0v3),但我们修改后只用到了v0,因此将.locals 4改为.locals 1。寄存器数量声明不准确可能导致编译或运行时错误。

1.3 测试验证与风险规避

修改完成后,使用apktool重新打包APK并签名安装。启动应用,尝试访问之前被锁定的“高级滤镜”功能。如果功能正常解锁,则修改成功。

在这个过程中,有几点需要特别注意:

  1. 备份原文件:修改任何.smali文件前,务必复制备份。
  2. 寄存器平衡:确保方法进入和退出时,寄存器的使用和声明保持一致。随意增减.locals数量是常见错误来源。
  3. 签名与安装:修改后的APK必须重新签名才能安装到非Root设备上。可以使用apksigneruber-apk-signer等工具。
  4. 逻辑副作用:有些应用会在多个地方调用isUserVip(),或者其UI显示依赖于这个返回值。强制返回true可能导致界面显示异常(例如仍显示“升级VIP”按钮)。如果出现这种情况,可能需要进一步查找并修改相关的UI控制逻辑。

2. 案例二:去除启动页与插屏广告

广告是免费应用的主要收入来源,但也极其影响用户体验。去除广告是逆向工程中非常普遍的需求。广告SDK(如穿山甲、优量汇等)的初始化、请求和展示通常有固定的调用模式。

2.1 逆向广告SDK的调用链

广告展示通常遵循“初始化->加载广告->展示广告”的流程。我们的目标是中断这个流程。首先,我们需要找到广告展示的触发点。一个有效的方法是搜索广告SDK的类名或方法名。

例如,搜索“TTAdNative”(穿山甲)或“showSplashAd”(开屏广告)等字符串。找到疑似加载或展示广告的方法后,需要分析其上下文。通常,广告展示前会有一个判断条件,比如广告是否加载成功、是否到达展示时机等。

假设我们找到了一个显示插屏广告的方法:

.method private showInterstitialAd()V
    .locals 3
    .prologue

    # 调用某个方法检查广告是否就绪,结果存入v0
    invoke-direct {p0}, Lcom/example/app/MainActivity;->isInterstitialAdReady()Z
    move-result v0

    # 如果v0为0(false,广告未就绪),跳转到方法结尾,不展示广告
    if-eqz v0, :cond_0

    # 广告就绪,执行展示逻辑
    const-string v0, "InterstitialAd"
    const-string v1, "Showing ad..."
    invoke-static {v0, v1}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I

    # 这里是实际调用SDK展示广告的代码
    iget-object v1, p0, Lcom/example/app/MainActivity;->mInterstitialAd:Lcom/bytedance/sdk/openadsdk/TTFullScreenVideoAd;
    invoke-interface {v1}, Lcom/bytedance/sdk/openadsdk/TTFullScreenVideoAd;->showFullScreenVideoAd(Landroid/app/Activity;)V

    :cond_0
    return-void
.end method

2.2 修改技巧:让广告永不就绪或跳过展示

从上面代码可以看出,控制流的关键在于if-eqz v0, :cond_0这条指令。如果isInterstitialAdReady()返回falsev0=0),则条件成立,跳转到:cond_0直接返回,广告不会展示。

因此,我们有两条攻击路径:

  1. 修改广告就绪检查:让isInterstitialAdReady()方法永远返回false
  2. 修改展示逻辑:在showInterstitialAd()方法中,让程序无论检查结果如何,都直接跳转到返回。

路径一:修改isInterstitialAdReady() 找到该方法,将其返回值强制改为false

.method private isInterstitialAdReady()Z
    .locals 1
    .prologue
    const/4 v0, 0x0 # 将返回值设为0 (false)
    return v0
.end method

路径二:修改showInterstitialAd() 将条件跳转改为无条件跳转,或者直接让方法提前返回:

.method private showInterstitialAd()V
    .locals 0 # 因为我们不执行任何逻辑,可以不需要寄存器
    .prologue
    # 方法一开始就直接返回,跳过所有广告逻辑
    return-void
.end method

提示:路径二通常更安全,因为它完全避免了广告SDK的任何代码执行,包括可能存在的计费回调。但有些应用可能会在广告未展示时弹出提示或采取其他行为,需要根据实际情况选择。

2.3 处理顽固广告与网络请求

有些广告集成得非常深,或者采用了异步加载、动态代理等复杂机制,简单的返回拦截可能无效。此时需要更深入的策略:

  • 查找广告初始化:在Application或主ActivityonCreate中,找到广告SDK的初始化代码(如TTAdSdk.init())。可以尝试将其注释掉,但需注意这可能导致依赖该SDK的其他功能(如数据分析)崩溃。
  • 拦截网络请求:广告必然伴随网络请求。可以查找广告相关的URL或主机名,然后修改网络请求库的相关代码,过滤掉这些请求。这需要更高级的HOOK或代码注入技术,超出了纯Smali修改的范围。
  • 使用更强大的工具:对于复杂的广告SDK,结合使用XposedFrida等运行时Hook框架往往是更高效的选择。Smali修改适合静态、持久的去除,而Hook适合动态、灵活的拦截。

下表对比了不同广告去除方案的优缺点:

修改目标实施难度效果稳定性潜在风险
广告就绪检查方法可能触发广告加载失败后的重试或报错
广告展示方法低,但可能漏掉异步回调触发的广告
广告SDK初始化高,可能导致应用其他功能异常或崩溃
网络请求过滤极高中,需要精准识别,否则可能影响应用正常功能

3. 案例三:解锁应用内付费或高级功能

许多应用将核心功能锁定,需要通过应用内购买(IAP)来解锁。服务器验证的IAP很难绕过,但很多应用为了离线可用,会在本地保存一个解锁状态标志。我们的任务就是找到并篡改这个标志。

3.1 定位功能锁与状态存储

功能锁可能以多种形式存在:

  • 一个布尔型字段:如isFeaturePurchased
  • 一个整型字段:如purchaseStatus1代表已购买。
  • 一个时间戳字段:如purchaseTime,与当前时间比较。
  • 一个特定的字符串或密钥

搜索关键词可以包括“purchase”、“unlock”、“premium”、“pro”、“locked”、“checkPurchase”等。通常,会有一个集中的PurchaseHelperFeatureManager类来管理这些状态。

假设我们找到一个检查“去水印”功能是否解锁的方法:

.method public isWatermarkRemovalPurchased()Z
    .locals 2
    .prologue

    # 从SharedPreferences中读取购买状态
    iget-object v0, p0, Lcom/example/app/PurchaseManager;->mSharedPrefs:Landroid/content/SharedPreferences;
    const-string v1, "key_watermark_purchased"
    const/4 v1, 0x0 # 默认值false
    invoke-interface {v0, v1}, Landroid/content/SharedPreferences;->getBoolean(Ljava/lang/String;Z)Z
    move-result v0

    # 返回读取到的值
    return v0
.end method

3.2 修改Smali:伪造购买状态

针对从SharedPreferences读取的场景,修改方法很简单:让方法直接返回true

.method public isWatermarkRemovalPurchased()Z
    .locals 1
    .prologue
    const/4 v0, 0x1 # 直接返回true
    return v0
.end method

但是,有些应用可能会在多个地方进行校验,或者在校验前从服务器同步状态。更稳妥的方法是找到写入这个状态的地方,确保它被正确设置。我们可以搜索key_watermark_purchased这个键名,找到设置它的地方(例如在成功的支付回调中),确保那里的逻辑会将值设为true

3.3 应对复杂校验与签名验证

进阶的挑战是应用可能对购买凭证(purchaseToken)进行本地或远程的签名验证。单纯修改布尔值可能无效,因为应用在启动或使用功能时会重新验证凭证。

  • 本地签名验证:应用可能将Google Play返回的purchaseTokendata连同签名一起保存在本地,每次校验时用内置的公钥验证签名。要绕过这个,你需要找到验证签名的方法,并修改其返回值。通常是一个返回布尔值的verifyPurchase()方法,将其改为永远返回true
  • 远程验证:应用将购买凭证发送到自己的服务器进行验证。这很难通过静态修改APK来绕过,因为验证逻辑在服务器端。可能的思路包括:
    • 模拟服务器响应:修改应用内发送网络请求和解析响应的代码,使其总是接收到“验证成功”的响应。这需要你分析网络请求的API和响应格式。
    • 禁用网络校验:找到发起校验的网络请求调用处,直接让其跳过或返回成功。例如,找到类似callPurchaseVerifyAPI()的方法,将其改为直接调用成功回调。
# 假设这是发起验证请求的方法
.method private verifyPurchaseWithServer(Ljava/lang/String;)V
    .locals 3
    .prologue
    ... # 复杂的网络请求构建代码
    # 修改点:在发起真实请求前,直接模拟成功并返回
    # 注释掉真实的网络调用代码
    # invoke-virtual {p0, ...}, ...;->enqueue(...)V

    # 手动调用成功的回调方法
    const/4 v0, 0x1
    invoke-direct {p0, v0}, Lcom/example/app/PurchaseManager;->onPurchaseVerified(Z)V
    return-void
.end method

这种修改需要对应用逻辑有更深入的理解,风险也更高,可能因模拟的数据结构不完整而导致崩溃。

4. 案例四:修改应用行为与界面逻辑

有时我们不想破解,而是想定制。比如修改某个按钮的行为,改变应用的主题颜色,或者调整某个算法的参数。这需要对Smali代码有更细致的控制。

4.1 追踪事件响应与条件判断

以修改一个“分享”按钮的动作为例。首先在jadx中通过资源ID(R.id.btn_share)找到对应的onClick监听器,或者搜索setOnClickListener的调用。

找到对应的Smali方法后,分析其逻辑。它可能调用系统的分享Intent,也可能调用应用内自定义的分享面板。我们的目标是将分享内容从“分享到微信”改为“复制到剪贴板”。

原始分享逻辑的Smali可能如下:

.method public onClick(Landroid/view/View;)V
    .locals 3
    .prologue
    ... # 获取View ID等代码
    # 假设v1寄存器中存储了要分享的文本
    const-string v1, "这是要分享的文本"

    # 创建ACTION_SEND类型的Intent
    new-instance v0, Landroid/content/Intent;
    invoke-direct {v0}, Landroid/content/Intent;-><init>()V
    const-string v2, "android.intent.action.SEND"
    invoke-virtual {v0, v2}, Landroid/content/Intent;->setAction(Ljava/lang/String;)Landroid/content/Intent;
    const-string v2, "text/plain"
    invoke-virtual {v0, v2}, Landroid/content/Intent;->setType(Ljava/lang/String;)Landroid/content/Intent;
    const-string v2, "android.intent.extra.TEXT"
    invoke-virtual {v0, v2, v1}, Landroid/content/Intent;->putExtra(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;

    # 启动分享选择器
    iget-object v2, p0, Lcom/example/app/MainActivity$1;->this$0:Lcom/example/app/MainActivity;
    const-string v3, "分享到"
    invoke-static {v0, v3}, Landroid/content/Intent;->createChooser(Landroid/content/Intent;Ljava/lang/CharSequence;)Landroid/content/Intent;
    move-result-object v0
    invoke-virtual {v2, v0}, Lcom/example/app/MainActivity;->startActivity(Landroid/content/Intent;)V
    return-void
.end method

4.2 精准编辑:替换方法调用与参数

我们要将启动分享选择器的逻辑,替换为将文本复制到剪贴板的逻辑。这需要引入新的类和方法调用。

修改后的Smali代码需要做以下事情:

  1. 获取系统的ClipboardManager服务。
  2. 创建ClipData对象。
  3. 将文本设置到剪贴板。

修改后的代码片段示例如下:

.method public onClick(Landroid/view/View;)V
    .locals 4 # 可能需要更多寄存器
    .prologue
    ... # 前面获取文本到v1的代码不变
    const-string v1, "这是要分享的文本"

    # --- 修改开始:替换分享逻辑为复制逻辑 ---
    # 获取上下文(Activity),通常p0或某个字段持有
    iget-object v0, p0, Lcom/example/app/MainActivity$1;->this$0:Lcom/example/app/MainActivity;

    # 获取系统剪贴板服务
    const-string v2, "clipboard"
    invoke-virtual {v0, v2}, Landroid/app/Activity;->getSystemService(Ljava/lang/String;)Ljava/lang/Object;
    move-result-object v0
    check-cast v0, Landroid/content/ClipboardManager;

    # 创建ClipData
    const-string v2, "label"
    invoke-static {v2, v1}, Landroid/content/ClipData;->newPlainText(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;
    move-result-object v2

    # 设置到剪贴板
    invoke-virtual {v0, v2}, Landroid/content/ClipboardManager;->setPrimaryClip(Landroid/content/ClipData;)V

    # 可选:显示一个Toast提示
    iget-object v0, p0, Lcom/example/app/MainActivity$1;->this$0:Lcom/example/app/MainActivity;
    const-string v2, "已复制到剪贴板"
    const/4 v3, 0x0
    invoke-static {v0, v2, v3}, Landroid/widget/Toast;->makeText(Landroid/content/Context;Ljava/lang/CharSequence;I)Landroid/widget/Toast;
    move-result-object v0
    invoke-virtual {v0}, Landroid/widget/Toast;->show()V
    # --- 修改结束 ---

    return-void
.end method

注意:修改后需要确保.locals声明的寄存器数量足够。原来的代码可能用了3个寄存器(v0, v1, v2),我们新增了v3用于Toast,所以需要将.locals 3改为.locals 4。同时,原分享相关的代码(创建Intent、启动Activity)需要被删除或注释掉。

4.3 调试与验证修改效果

这类行为修改的测试相对直观。重新打包安装应用后,点击目标按钮,观察是否弹出复制成功的Toast,而不是系统的分享菜单。然后打开任意文本编辑器,尝试粘贴,确认文本是否正确。

如果应用崩溃,需要查看logcat日志。常见错误包括:

  • 寄存器溢出.locals声明过小。
  • 类型转换错误check-cast失败,例如获取的服务对象类型不对。
  • 空指针异常:某个对象(如this$0)为null
  • 方法签名错误:调用的方法名或参数类型不匹配。

耐心阅读logcat中的堆栈跟踪信息,它能精确指出崩溃发生在哪一行Smali代码,是调试的最重要工具。

5. 案例五:修复反编译导致的崩溃与兼容性问题

逆向工程并非总是为了“破解”。有时,我们反编译一个应用是为了学习、分析,或者修复其自身的一些小问题(比如移除某个不兼容的库)。在修改或分析过程中,我们可能会意外破坏应用的稳定性,这就需要我们具备修复的能力。

5.1 识别常见的Smali编辑错误

在手动编辑Smali时,以下几个错误最为常见:

  1. 寄存器分配错误:这是新手最容易犯的错误。每个方法开头声明的.locals N.registers M定义了可用的寄存器数量(v0v(N-1)v(M-1))。如果你在代码中使用了一个超出范围的寄存器(例如声明了.locals 2却试图使用v2),编译或运行时会报错。

    • 修复:根据方法体内实际使用的最大寄存器索引,调整.locals.registers的值。注意p寄存器(参数寄存器)也包含在.registers总数中,但不包含在.locals中。
  2. 跳转标签错误:Smali中的跳转目标(如:cond_0, :goto_0)必须在同一方法内定义。如果删除了包含标签的代码块,或者标签名拼写错误,会导致汇编失败。

    • 修复:确保所有被引用的标签都存在且唯一。使用文本编辑器的查找功能检查标签定义和引用。
  3. 方法签名不匹配:当你修改了方法的参数列表或返回值类型,但没有更新方法的签名((参数类型)返回类型),或者调用该方法的地方没有相应更新,就会导致链接错误。

    • 修复:同步修改方法定义处和所有调用处的签名。例如,将()V改为(I)V,意味着方法从一个无参无返回值,变成了需要一个整型参数。

5.2 修复资源引用与类加载问题

反编译再回编译的过程,有时会导致资源ID(0x7f0xxxxx)发生变化,或者某些类的引用路径出错。

  • 资源ID错乱:如果应用在运行时崩溃,报错信息指向Resources$NotFoundException,很可能是资源ID没对应上。使用apktool时,确保使用-r(不解码资源)或-s(不解码源码)参数进行部分解码,可以减少此类问题。如果必须修改资源,最好使用Android Studio配合反编译的resources.arsc进行谨慎操作。
  • 类加载失败:如果崩溃日志显示ClassNotFoundExceptionNoClassDefFoundError,可能是你修改或删除的类被其他代码依赖。需要回溯调用链,恢复必要的类,或者修改调用处的代码,使其不再依赖缺失的类。

5.3 高级技巧:使用Smali插桩进行动态分析

当静态分析难以理解复杂逻辑时,Smali插桩是一种强大的动态分析手段。它指的是在不改变原有逻辑的前提下,向Smali代码中插入额外的日志输出或状态记录代码,以便在运行时观察程序的执行流程和变量值。

例如,你想知道某个关键方法的输入参数和返回值:

.method public calculateScore(ILjava/lang/String;)I
    .locals 5 # 增加寄存器数量以容纳日志代码
    .parameter "param1"
    .parameter "param2"
    .prologue

    # --- 插入的日志:打印输入参数 ---
    const-string v3, "DEBUG_TAG"
    new-instance v4, Ljava/lang/StringBuilder;
    invoke-direct {v4}, Ljava/lang/StringBuilder;-><init>()V
    const-string v5, "calculateScore called with: "
    invoke-virtual {v4, v5}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
    move-result-object v4
    invoke-virtual {v4, p1}, Ljava/lang/StringBuilder;->append(I)Ljava/lang/StringBuilder;
    move-result-object v4
    const-string v5, ", "
    invoke-virtual {v4, v5}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
    move-result-object v4
    invoke-virtual {v4, p2}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
    move-result-object v4
    invoke-virtual {v4}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;
    move-result-object v4
    invoke-static {v3, v4}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
    # --- 插入结束 ---

    ... # 原有的计算逻辑
    move-result v0 # 假设结果在v0

    # --- 插入的日志:打印返回值 ---
    const-string v1, "DEBUG_TAG"
    new-instance v2, Ljava/lang/StringBuilder;
    invoke-direct {v2}, Ljava/lang/StringBuilder;-><init>()V
    const-string v3, "calculateScore returning: "
    invoke-virtual {v2, v3}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
    move-result-object v2
    invoke-virtual {v2, v0}, Ljava/lang/StringBuilder;->append(I)Ljava/lang/StringBuilder;
    move-result-object v2
    invoke-virtual {v2}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;
    move-result-object v2
    invoke-static {v1, v2}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
    # --- 插入结束 ---

    return v0
.end method

插桩后,运行应用并使用logcat过滤DEBUG_TAG,就能清晰地看到该方法的调用情况。这是理解黑盒逻辑、定位关键判断点的利器。记住,插桩时要仔细管理寄存器,避免覆盖原有逻辑正在使用的寄存器(通常使用靠后的寄存器如v3, v4等),并相应增加.locals的数量。

通过这五个从易到难的案例,我们走完了一个完整的Smali实战修改闭环。从最简单的布尔值翻转,到复杂的逻辑替换和行为定制,再到最后的调试与修复,每一步都需要耐心、细心和对指令集的熟悉。真正的熟练来自于反复的练习和踩坑。下次当你面对一个想要修改的应用时,不妨先问问自己:它的“命门”最可能在哪里?是那个返回布尔值的方法,还是那个启动特定活动的调用?找到它,然后拿起Smali这把手术刀,精准地实施你的“改造计划”。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值