安卓逆向_6 --- Dalvik 字节码、Smali 详解

CTF Wiki smali:https://ctf-wiki.org/android/basic_operating_mechanism/java_layer/smali/smali/

深入理解 Dalvik 字节码指令及 Smali 文件:https://blog.csdn.net/dd864140130/article/details/52076515
安卓逆向入门教程(二)--- 初识 APK、Dalvik 字节码以及 Smali:https://www.52pojie.cn/thread-395689-1-1.html
超详细的 Dalvik 指令:https://blog.csdn.net/qq_32113133/article/details/8524614

Java 和 smali 相互转换

Android Studio 或者 IDEA 中安装 java2smaliJadx Android Decompiler插件,

  • java2smali:把 java 代码转成 smali 代码
  • Jadx Android Decompiler:把 smali 代码转成 java 代码

java 转 smali:新建一个 java 类,然后在 build 中找到 Compile to Smali 并点击 即可。

smali 代码转 java :在 smali 文件里 " 右键 ---> 在 jadx GUI 中反编译 " 即可。

参考

smali语法官方:http://pallergabor.uw.hu/androidblog/dalvik_opcodes.html
Android APP静态分析-Smali语法详解:http://nickycc.lofter.com/post/23e2a6_17d6a07
Smali语法官方:http://bbs.pediy.com/showthread.php?t=151769
Google wiki:http://code.google.com/p/smali/wiki/TypesMethodsAndFields

1、Android Dalvik 虚拟机

特点:

  • 体积小,占用内存空间小。
  • 专有 DEX 可执行文件。
  • 常量池采用32位索引值,寻址类方法名,字段名,常量更快。
  • 基于寄存器架构,并拥有一套完整的指令系统。
  • 提供生命周期管理、堆栈管理、线程管理、安全和异常管理以及垃圾回收等重要功能。
  • 所有的 Android 程序都运行在 Android 系统进程里,每个进程对应一个 Dalvik 虚拟机实例。

1.1 Dalvik虚拟机 与 Java虚拟机 的区别

  • Java虚拟机 JVM 是基于栈的,JVM 执行的是 java 字节码,
  • Dalvik 虚拟机运行的是 Dalvik 字节码。Dalvik字节码 由 Java字节码 转换而来,并打包成一个 DEX 可执行文件,通过虚拟机解释执行。【 Dalvik VM 是 基于寄存器的,Dalvik 有专属的文件执行格式dex (dalvik executable)。Dalvik vm 比 JVM 速度更快,占用空间更少 】

Dalvik 字节码是什么?

  • Dalvik 是 google 专门为 Android 操作系统设计的一个虚拟机,经过深度优化,虽然 Android 上的程序使用 Java 开发的,但是 Dalvik 和 标准的 Java 虚拟机JVM 是两回事。通过 Dalvik 字节码不能直接看到原来的逻辑代码,需要借助 apktool、dex2jar+jd_gui、jadx 等工具帮助查看。

注意:修改 apk 需要操作的是 smali 文件,而不是导出来的 java 文件。修改按 smali 文件后,需要重新编译打包

Hello.java文件:

public class Hello {
    public static void main(String[] args){
        //System.out.print("Hello");    
		int i=2;
		int j=3;
	}
}

通过 javac Hello.java 得到 Hello.class 文件

JDK 中提供了 javap 命令反汇编可以查看 class文件字节码:javap -c Hello -> demo.txt

将 Hello.class 字节码中的内容存放到 demo.txt 中,如下:

Compiled from "Hello.java"
public class Hello {
  public Hello();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return
 
  public static void main(java.lang.String[]);
    Code:
       0: iconst_2
       1: istore_1
       2: iconst_3
       3: istore_2
       4: return
}

上面就是 java 字节码的内容。

通过 dx 工具可以将 Hello.class 文件转为 dex 文件:

dx  --dex  --output=Hello.dex Hello.class

Dalvik 字节码我们无法直接查看,最终需要查看的是 samli 格式的文件,通过将 Hello.dex 文件转为 smali 文件,smali 文件是Dalvik 可以识别的核心代码,可以使用 AndroidStudio 自带的插件实现(或者使用 baksmali 也是可以的)。

smali 有自己的语法 (:https://blog.csdn.net/qq_32113133/article/details/85163277 )。以下就是相对应的 smali 文件格式:

.class public Lcom/example/administrator/myapplication/Hello;
.super Ljava/lang/Object;
.source "Hello.java"
 
 
# direct methods
.method public constructor <init>()V
    .registers 1
 
    .prologue
    .line 3
    invoke-direct {p0}, Ljava/lang/Object;-><init>()V
 
    return-void
.end method
 
.method public static main([Ljava/lang/String;)V
    .registers 3
    .param p0, "args"    # [Ljava/lang/String;
 
    .prologue
    .line 7
    const/4 v0, 0x2
 
    .line 8
    .local v0, "i":I
    const/4 v1, 0x3
 
    .line 9
    .local v1, "j":I
    return-void
.end method

通过比较可知:.class字节码 中的内容格式 和 Dalvik 中的字节码是不一样的,所以 Android 虚拟机加载的是 dex 字节码,而不能加载 java 字节码。即 Dalvik 不会去加载 .class字节码。

1.2 Dalvik可执行文件体积更小,执行速度更快

Android SDK中的 dx 工具在转换字节码时会消除类文件的冗余信息,避免虚拟机在初始化时出现重复的文件加载与解析过程。另外,dx工具会将所有的 Java类文件中的常量池进行分解,消除其中的冗余信息,组合成一个新的共享常量池。这使得文件体积和解析文件的效率都得到了提高。所以,Dalvik虚拟机比Java虚拟机执行速度快

通过实例来对比 Java字节码 和 Dalvik字节码 的不同

源码如下:

public class Hello {
    public static void main(String[] args){
        Hello hello = new Hello();
        System.out.println(hello.foo(5,3));
	}
 
	public int foo(int a,int b){
    	return (a+b) * (a-b);
	}
}

Java 字节码如下:

public int foo(int, int);
    Code:
       0: iload_1
       1: iload_2
       2: iadd
       3: iload_1
       4: iload_2
       5: isub
       6: imul
       7: ireturn

iload_1分为两部分:

  • 第一部分为下划线左边的 iload,它属于JVM(Java虚拟机)指令集中load系列找那个的一条,i是指令前缀,表示操作类型为int类型,load表示将局部变量存入java栈,与之类似的还有lload、fload、dload分别表示将long,float,double类型的数据进栈;
  • 第二部分为下划线有变动的 数字,表示要操作具体哪个局部变量,索引值从0开始计算,iload_1表示将第二个int类型的局部变量进栈,这里第二个局部变量是存放在局部变量区foo()函数的第二参数。

第二条指令iload_2取第三个参数。
第三条指令iadd从栈顶弹出两个int类型值,将值相加,然后把结果押回栈顶。
第四,第五条指令分别再次压入第二个参数与第三个参数。
第六条指令isub从栈顶弹出两个int类型值,然后相减,然后把结果压回栈顶。这时求值栈上有两个int值了。
第七条指令imul从栈顶弹出两个int类型值,将值相乘,然后把结果压回栈顶。
第八条指令ireturn函数返回一个int值。
参考Java字节码指令列表:https://en.wikipedia.org/wiki/Java_bytecode_instruction_listings

那如何查看生成的 Dalvik 字节码呢?

可以使用 dexdump.exe (位于SDK下的platform-tools目录,新版本可能在build-tools下的版本目录下),前提是得到了Hello.dex文件,执行如下命令:dexdump -d Hello.dex ->Hello.txt

整理如下:

0000: add-int v0, v3, v4
0002: sub-int v1, v3, v4
0004: mul-int/2addr v0, v1
0005: return v0
  • 第一条指令将 v3 和 v4 寄存器的值相加,保存到v0寄存器,v3和v4分表表示foo()函数的第一个参数和第二个参数,它们是Dalvik字节码参数表示法之一v命名法,另一种是p命名法。
  • 第二条指令sub-int将v3减去v4的值保存到v1寄存器。
  • 第三条指令mul-int/a2ddr将v0乘以v1的值保存到v0寄存器。
  • 第四条指令返回v0的值。
  • 通过比较,Dalvik虚拟机比Java虚拟机执行速度快。

1.3 Dalvik虚拟机Java虚拟机 的架构不同

Java 虚拟机的架构及参数传递

Java虚拟机基于栈架构。需要频繁的从栈上读写数据,在这个过程中需要更多的指令分派与内存访问次数,耗费CPU时间,消耗手机资源。

Java虚拟机的指令集被称为零地址,是指指令的源参数与目标参数都是隐含的,它通过Java虚拟机中提高的一种数据结构“求值栈”来传递的。

对于Java程序来说,每个线程在执行时都有一个PC计数器与一个Java栈。PC计数器以字节为单位记录当前运行位置距离方法开头的偏移量,与ARM架构和x86架构类似,通过栈帧对栈中的数据进行操作。

Java栈用于记录Java方法调用的“活动记录”,Java栈以帧为单位保存线程的运行状态,每调用一个方法就会分配一个新的栈帧压入Java栈上,每从一个方法返回则弹出并撤销响应的栈帧。

每个栈帧包括局部变量区、求值栈(JVM规范中将其称为“操作数栈”)和其他一些信息。局部变量区用于存储方法的参数与局部变量,其中参数按源码中从左到右顺序保存在局部变量区开头的几个slot中。

求栈值用于保存求值的中间结果和调用别的方法的参数等,JVM运行时它的状态如下图

每条指令占用一个字节空间,foo()函数Java字节码左边的偏移量就是程序执行到每一行代码时PC的值,并且Java虚拟机最多只支撑0xff条指令。

仔细分析第一条指令 iload_1 的结构

iload_1可分成两部分,第一部分为下划线左边的iload,属于JVM(Java虚拟机)指令集中load系列中的一条,i是指令前缀,表示操作类型为int类型,load表示将局部变量存入Java栈。第二部分为下划线右边的数字,表示要操作具体哪个变量,索引值从0开始计数,iload_1表示将第二个int类型的局部变量进栈参数。

Dalvik虚拟机的架构及参数传递

Dalvik虚拟机基于寄存器架构,数据访问通过寄存器间直接传递。比起Java虚拟机字节码要简洁很多。

以第一条指令为例简单分析一下。指令add-int将v3与v4寄存器的值相加,然后保存到v0寄存器,v3和v4代表调用函数的所使用的两个参数。这里用到的Dalvik字节码参数表示法是v命名法,另一种是p命名法。

Dalvik虚拟机运行时同样为每个线程维护一个PC计数器与调用栈,与Java虚拟机不同的是,这个调用栈维护一份寄存器列表,寄存器的数量在方法结构体的registers字段中给出,Dalvik虚拟机会根据这个值来创建一份虚拟的寄存器列表。

Dalvik虚拟机由于生成的代码指令减少了,程序执行速度会更快一些。

Dalvik 是如何执行程序的

        Android系统的架构采用分层思想,这样的好处是拥有减少各层之间的依赖性、便于独立分发、容易收敛问题和错误等优点。
        Android系统由linux内核、函数库、Android运行时、应用程序框架以及应用程序组成。
        Android系统加载完内核后,第一个执行的是init进程,init进程对设备进行初始化,读取init.rc文件并启动系统中重要外部程序Zygote。
        Zygote进程是Android所有进程的孵化器,启动后首先初始化Dalvik虚拟机,然后启动system_server并进入Zygote模式,通过socket等待命令。
        当执行一个Android应用程序时,system_server进程通过socket方式发送命令给Zygote,Zygote收到命令后通过fork自身创建一个Dalvik虚拟机的实例来执行应用程序的入口函数,这就是程序启动的流程。

Zygote 提供三种

  1. fork(),创建一个Zygote进程
  2. forkAndSpecialize(),创建一个非Zygote进程(不能再fork)
  3. forkSystemService(),创建一个系统服务进程(子进程跟随父进程终止)

fork之后,执行的工作交给Dalvik虚拟机。虚拟机通过loadClassFromDex()函数完成类的装载工作,每个类被成功解析后会拥有一个ClassObject类型的数据结构存储在运行时环境中,虚拟机使用gDvm.loadedClasses全局哈希表来存储与查询所有装载进来的类,随后,字节码验证器使用dvmVerifyCodeFlow()函数对装入的代码进行校验,接着虚拟机调用FindClass()函数查找并装载main方法类,随后调用dvmInterpret()函数初始化解释器并执行字节码流。

Dalvik 虚拟机是如何执行程序的 ?

  当进程fork成功后:
                      Dalvik虚拟机
                          |   通过loadClassFromDex()函数完成类的装载工作,完成后会每个类会拥有一个ClassObject类型的数据结构存储在运行时环境中
                          |   虚拟机使用gDvm.loadedClasses全局哈希表来存储与查询所有装载进来的类
                      装载程序类
                          |    字节验证码器使用dvmVerifyCodeFlow()函数对装入的代码进行校验
                          |
                      验证字节码
                          |    调用FindClass()函数查找并装载main方法类
                          |
                      查找主类
                          |    调用dvmInterpret()函数初始化解释器并执行字节码流
                          |
                      执行字节码流
                          |
                          |
                        结束

Dalvik虚拟机执行程序流程:

虚拟机线程   --->   装载程序类   --->   验证字节码   --->   查找主类   --->   执行字节码流   --->   结束

关于 Dalvik 虚拟机 JIT

JIT是即时编译(动态编译),是通过在运行时将字节码翻译为机器码的技术,使得程序的执行速度更快。

主流的JIT包含两种字节码编译方式:

  • method方式:以函数或方法为单位进行编译。
  • trace方式:以trace为单位进行编译。

执行代码分为冷路径(在实践运行过程中很少被执行的)和热路径(执行比较频繁的路径),method会编译整个方法,trace编译的是获取的热路径的代码,节省内存。

Dalvik 运行在 Arm 的 CPU 上,那么有必要了解一下 ARM

Arm:一家 1990 年成立的英国公司,主要进行 Cpu 授权,是一种知识产权公司,占据移动 95% 的市场,主要靠设备抽成盈利。

产权领域:指令集架构、微处理器、GPU、互连架构等

特点:低功耗、低成本,使用RISC(Reduced Instruction Set Computer,通常仅执行1或2个指令即可完成意图),

和big.LITTLE架构(适配高性能-64位的内核和低性能-32位的内核切换),方便其他品牌贴标生产

授权方式:架构、内核、使用三种形式,分别针对大、中、小型公司

合作伙伴:高通、联发科等蚂蚁联盟

合作形式:实行OEM(Original Equipment Manufacture)

对手劣势:1968年成立的Intel使用CISC(Complex Instruction Set Computer,通常仅执行3或4个指令即可完成意图)架构的x86功耗高、掉电快

适配执行:从上面介绍可以看出,Arm占据绝对多数市场,因此mips和x86基础不需要考虑适配,而且它们会主动解析Arm指令成自己能使用的指令(使得自身效率更低,不得不赞叹规模效应的强大),big.LITTLE的高低性能内核切换,可以进行有效省电(计算量小用低性能、大用高性能,性能越高越耗电)。而64位寄存器的使用,减少内存存取的次数,提高CPU处理效率,用于RISC架构高性能的处理器。

Mips:1998 年成立,同样依据 RISC 架构,中科院设计的 “龙芯” 与它 95% 类似,涉及侵权,而准备收购其估值1亿的20%股份。

在 Android Studio 下,则可以通过以下的构建方式指定需要类型的 SO 库。

2、Dalvik 寄存器

Dalvik 中用的寄存器都是 32位,64位类型数据则用两个相邻的32位寄存器表示,也就是对于double这种64位类型的数据,需要用到两个32位寄存器来存储。

2.1 虚拟 寄存器

        Dalvik 最多支持 65536 个寄存器 ( 编号从0~65535 ),但是在 ARM 架构的 cpu 中只存在 37 个寄存器,那么这种不对称是怎么解决的呢 ?Dalvik 中的寄存器是虚拟寄存器, 通过映射真实的寄存器来实现。我们知道每个 Dalvik 维护了一个调用栈,该调用栈就是用来支持虚拟寄存器 和 真实寄存器相互映射的。在执行具体函数时,Dalvik会根据 .registers 指令来确定该函数要用到的寄存器数目。具体的原理,可以自行参考 Davilk 的实现。

下面说的 寄存器 都是 虚拟寄存器。

2.2 寄存器的命名( V命名法 P命名法  )

寄存器使用规则

对于一个使用 m个寄存器 (m局部变量寄存器个数 x  + 参数寄存器个数 y) 的方法而言:

  • 局部寄存器 使用从 v0 开始的 x 个寄存器,
  • 参数寄存器 则使用 最后的 y 个寄存器

示例说明:假设实例方法 test(String a,String b) 一共使用了5个寄存器:0,1,2,3,4,那么参数寄存器是能使用 2,3,4 这三个寄存器,如图:

寄存器 有两种不同的命名方法(这两种命名法仅仅是影响了字节码的可读性):

  1. v 字命名法。v 表示本地寄存器(局部变量寄存器)。( v 表示 var,即 变量 )
  2. p 字命名法。p 表示参数寄存器(一般都用 p 命名法)。( p 表示 param,即 参数)。

V命名法 和 P命名法 

public class Hello {
    public static void main(String[] args){
        Hello hello = new Hello();
        System.out.println(hello.foo(5,3));
	}
 
	public int foo(int a,int b){
    	return (a+b) * (a-b);
	}
}

main 函数 1个 形参,foo 函数  2 个形参,总共需要 3 个参数寄存器。针对这个代码,v 命名法 和 p 命名法对比:

  • v 命名法:v 命名法用到 v0、v1、v2、v3、v4 五个寄存器。v0 和 v1 用来表示函数的局部变量寄存器,v2 表示被传入的 Hello 对象的引用,v3 和 v4 分别表示两个传入的整形参数。( 参数寄存器 使用 最后的 y 个寄存器。 )
  • p 命名法:p 命名法 对 函数的 局部变量寄存器 命名 没有影响,对于函数中引入的参数命名从 p0 开始,依次递增。p 命名法用到了 v0,v1,p0,p1,p2 五个寄存器,v0 和 v1 用来表示函数的局部变量寄存器,p0 表示被传入的 Hello 对象的引用,p1 和p2 分别表示两个传入的整形参数。

使用 p 命名法表示的 Dalvik 汇编代码,通过寄存器的前缀更容易判断寄存器到底是局部变量寄存器还是参数寄存器,在 Dalvik 汇编代码较长,使用寄存器较多的情况下,这种优势更加明显。

v 字命名法

以小写字母 v 开头的方式:表示 方法中 使用的 局部变量参数

对于上面实例方法 test(String a, String b)来说:v0,v1 为局部变量能够使用的寄存器。v2,v3,v4 为参数能够使用的寄存器:

p 字命名法

以小写字母 p 开头的方式:表示 参数,参数名称从 p0 开始,依次增大。局部变量能够使用的寄存器仍然是以 v 开头。

对于上面实例方法 test(String a, String b)来说:v0,v1 为局部变量能够使用的寄存器。p0,p1,p2 为参数能够使用的寄存器。

p 命名法:寄存器采用 v 和 p 来命名,

如果一个非静态方法有两个本地变量,有三个参数,需要的寄存器关系如下:

  • v0   第一个本地寄存器
  • v1   第二个本地寄存器
  • p0   // 指代调用这个方法的this对象。
  • p1   第一个参数
  • p2   第二个参数
  • p3   第三个参数

如果是静态方法,那么就不需要 this 对象了,需要的寄存器是v0, v1, p0, p1, p2。

  •  .registers     使用这个指令指定方法中寄存器的总数。寄存器的数量可以多,但是不能少。
  •  .locals          使用这个指定表明方法中非参寄存器的总数,放在方法的第一行。

3、Dalvik 描述符

与 JVM 类似,Dalvik 字节码中同样有一套用于描述 类型、方法、字段 的方法,这些方法结合 Dalvik 的指令便形成了完整的汇编代码。

3.1 字节码数据类型

Dalvik 字节码 只有 2 种类型:

  1. 基本类型。
  2. 引用类型。

除了 对象数组 属于 引用类型 外,其他的 Java 类型都是基本类型。

Dalvik 使用这2种类型来表示 Java 语言的全部类型。

Davilk 中对字节码类型的描述 和 JVM 中的描述符规则一致:

  • 对于 基本类型无返回值的void类型 都是用一个大写字母表示。
  • 对象类型 则 用 字母 L 加 对象的 全限定名 来表示。
  • 数组类型[ 来表示。

全限定名 是 什么?

                以 String 为例,其完整名称是 java.lang.String,那么其 全限定名 就是 java/lang/String;
                即 java.lang.String 的  "." 用 "/" 代替,并在末尾添加分号 ”;” 做结束符。

Java类型 和 类型描述符 

java 类型 类型描述符
byte B
short S
int I
long J
oat F
double D
char C
boolean Z
void V
数组 [
object L + / 分割的全类名路径

每个 Dalvik 寄存器都是 32 位,对于小于或等于 32 位长度的类型来说,一个寄存器就可以存放该类型的值;像 J、D 等 64位 类型的值,它们的值使用相邻两个寄存器来存储,如 v0 和 v1。

示例解释:

  • L  可以表示 Java 类型中 的 任何类,在 Java 中以 package.name.ObjectName 表示。
            在 Dalvik 汇编代码中,以 Lpackage/name/ObjectName;  形式来表示,最后有个分号。
            如  Ljava/lang/String; 相当于 java.lang.String。
  • [   表示 所有基本类型 的 数组  [I  表示一个整型数组,相当于  Java  中的  int[]。 [[I  表示  int[][] ,多维数组最大为 255 个。[  与  L  可以同时表示对象数组,如  [Ljava/lang/String  表示  Java  中的字符串数组。

图 示:

这里重点解释 对象类型数组类型。

3.1.1 对象类型

L 可以表示 java 类型中 的 任何类

  • 在 java 代码中以 package.name.ObjectName 的方式引用。( L 即上面定义的 java类 类型,表示后面跟着的是 类 的 全限定名。比如:java 中的 java.lang.String 对应的描述是 Ljava/lang/String; . )
  • 而在 Davilk 中其描述则是以 Lpackage/name/ObjectName; 的形式表示。

对象类型

形式:Lxxx/yyy/zzz;

  • L  表示这是一个对象类型
  • xxx/yyy 是该对象所在的包
  • zzz 是对象名称
  • 标识对象名称的结束

如:Ljava/lang/String;

3.1.2 数组类型

[  用来表示 所有基本类型 数组,[ 后跟着是基本类型的描述符。每一维度使用一个前置的 [

比如:java 中的 int[] 用汇编码表示便是 [I; 。二维数组 int[][] 为 [[I; ,三维数组则用 [[[I; 表示。

对于对象数组来说,[ 后跟着对应类的全限定符。比如 java 当中的 String[] 对应的是 [java/lang/String; 

数组类型

形式:[XXX

  • [I 表示一个 int 型的一维数组,相当于int[]
  • 增加一个维度增加一个 [,如 [[I 表示 int[][]
  • 数组每一个维度最多 255 个;
  • 对象数组表示也是类似

如:String 数组的表示是 [Ljava/lang/String

3.2 字段 的 描述(字段 即 变量)

Dalvik 中 对字段的描述 分为两种:

  1. 基本类型字段描述
  2. 引用类型描述

但 两者 的 描述格式 一样:

对象类型描述符; -> 字段名:类型描述符;

比如 com.sbbic.Test 类 中存在 String类型的 name字段 及 int 类型的 age 字段,那么其描述为:

Lcom/sbbic/Test;->name:Ljava/lang/String;
Lcom/sbbic/test;->age:I

字段 的 描述

形式:Lxxx/yyy/zzz;->FieldName:Lxxx/yyy/zzz;

  • Lxxx/yyy/zzz;    表示对象的类型
  • FieldName        表示字段名称
  • Lxxx/yyy/zzz     表示字段类型

例如:ff = "aa";   把 字符串 aa 赋值给 变量 ff。 转换后是 Lcom/example/reforceapk/MyLog;->ff:Ljava/lang/String

示例: Lpackage/name/ObjectName;->FieldName:Ljava/lang/String;

  • 类型:Lpackage/name/ObjectName;
  • 字段名:ObjectName
  • 字段类型:FieldName:Ljava/lang/String;

3.3 方法 的 描述

Java 中方法的签名包括 方法名参数 及 返回值。在 Davilk 相应的描述规则为:

对象类型描述符 -> 方法名(参数类型描述符)返回值类型描述符

方法格式:Lpackage/name/ObjectName;->MethodName(III)Z

  • Lpackage/name/ObjectName;    是一个类型,
  • MethodName    为具体方法的方法名,
  • 括号内的三个III为方
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值