tcpreplay介绍以及源码分析

11 篇文章 0 订阅
1 篇文章 0 订阅

tcpreplay介绍

tcpreplay主要用于重放pcap数据包,还可以对pcap文件进行修改,比如修改ip地址和端口号等,其中主要包含了一下几个模块:

  • tcpreplay:pcap重放模块,其中提供了包重放速度控制,循环控制,重放模式等功能。
  • tcpreweite: 修改网络中mac,IP地址,端口信息。
  • tcpbrige:利用tcprewrite的功能实现两个网络部分的桥

tcpreplay的作者在写sendpacket()函数时说:希望写一个通用的数据包发送api接口支持BPF, libpcap, libdnet, and Linux's PF_PACKET,因为libnet缺乏活动性,libpcap支持模块比较新,并且缺乏非linux支持,所以作者决定同时支持这四个,他们的匹配顺序如下,如果平台支持其中最先匹配的函数,就使用它发包。由于libpcap不提供可靠的方法获取MAC地址,所以使用PF_PACKET or BPF代替。

 * 1. PF_PACKET  send()                    (int)send(sp->handle.fd, (void *)data, len, 0); linux上面使用
 * 2. BPF send()                                            write(sp->handle.fd, (void *)data, len);    freebsd上使用
 * 3. libdnet eth_send()                      eth_send(sp->handle.ldnet, (void*)data, (size_t)len);
  * 4. pcap_inject()                            pcap_inject(sp->handle.pcap, (void*)data, len);{

                                                                     return (p->inject_op(p, buf, size));

                                                                      }

                                                                 handle->inject_op = pcap_inject_linux;

                                                                  pcap_inject_linux(pcap_t *handle, const void *buf, size_t size){

                                                                ret = send(handle->fd, buf, size, 0);

                                                                   }
  * 5. pcap_sendpacket()     pcap_sendpacket(sp->handle.pcap, data, (int)len); /* out of buffers, or hit max PHY speed, silently retry */从缓存发送或者使用最大速度发送

   {
if (p->inject_op(p, buf, size) == -1)
return (-1);
return (0);
}

(1)PF_PACKET:linux上使用,不经过协议栈,直接到用户层收发数据数据

(2)BPF:是类Unix系统上数据链路层的一种原始接口,提供原始链路层封包的收发,bsd系统上面使用,不经过协议栈,直接到用户层收发数据


(3)libnet,一个小型的接口函数库,建立一个简单统一的网络编程接口以屏蔽不同操作系统低层网络编程的差别,libnet目前可以在Linux、FreeBSD、Solaris、WindowsNT等操作系统上运行,并且提供了统一的接口

(4) libpcap:一个网络捕获数据包的开放源码,被tcpdump、snort等著名软件包使用。可以在绝大多数类unix平台下工作,如果希望libpcap能在linux上正常工作,则必须使内核支持"packet"协议,也即在编译内核时打开配置选项 CONFIG_PACKET(选项缺省为打开)。windows版本为winpcap。


pcap_inject()来源于OpenBSD,调用p->inject_op,pcap_inject_linux,send发送数据;

pcap_send-packet() 则来源于WinPcap, 与pcap_inject实现一样,只是更改了接口,,返回0表示成功,-1表示失败。两个函数都提供是为了兼容。


下面介绍一下tcpreplay的实现流程:

其中main()函数如下:

int main(int argc, char *argv[])
{
    int i, optct = 0;
    int rcode;
    char buf[1024];

    fflush(NULL);

    ctx = tcpreplay_init();
#ifdef TCPREPLAY
    optct = optionProcess(&tcpreplayOptions, argc, argv);
#elif defined TCPREPLAY_EDIT
    optct = optionProcess(&tcpreplay_editOptions, argc, argv);
#endif
    argc -= optct;
    argv += optct;

    fflush(NULL);
    rcode = tcpreplay_post_args(ctx, argc);   // 此处函数很重要,设置了ctx中的很多参数
    if (rcode <= -2) {
        warnx("%s", tcpreplay_getwarn(ctx));
    } else if (rcode == -1) {
        errx(-1, "Unable to parse args: %s", tcpreplay_geterr(ctx));
    }

    fflush(NULL);
#ifdef TCPREPLAY_EDIT
    /* init tcpedit context */
    if (tcpedit_init(&tcpedit, sendpacket_get_dlt(ctx->intf1)) < 0) {
        errx(-1, "Error initializing tcpedit: %s", tcpedit_geterr(tcpedit));
    }

    /* parse the tcpedit args */
    rcode = tcpedit_post_args(tcpedit);
    if (rcode < 0) {
        errx(-1, "Unable to parse args: %s", tcpedit_geterr(tcpedit));
    } else if (rcode == 1) {
        warnx("%s", tcpedit_geterr(tcpedit));
    }

    if (tcpedit_validate(tcpedit) < 0) {
        errx(-1, "Unable to edit packets given options:\n%s",
               tcpedit_geterr(tcpedit));
    }
#endif

    if (ctx->options->preload_pcap && ! HAVE_OPT(QUIET)) {
        notice("File Cache is enabled");
    }

    /*
     * Setup up the file cache, if required
     */
    if (ctx->options->preload_pcap) {
        /* Initialise each of the file cache structures */
        for (i = 0; i < argc; i++) {
            ctx->options->file_cache[i].index = i;
            ctx->options->file_cache[i].cached = FALSE;
            ctx->options->file_cache[i].packet_cache = NULL;
        }
    }

    for (i = 0; i < argc; i++) {
        tcpreplay_add_pcapfile(ctx, argv[i]);

        /* preload our pcap file? */
        if (ctx->options->preload_pcap) {
            preload_pcap_file(ctx, i);
        }
    }

#ifdef TCPREPLAY_EDIT
    /* fuzzing init */
    fuzzing_init(tcpedit->fuzz_seed, tcpedit->fuzz_factor);
#endif

    /* init the signal handlers */
    init_signal_handlers();

    /* main loop */
    rcode = tcpreplay_replay(ctx);

    if (rcode < 0) {
        notice("\nFailed: %s\n", tcpreplay_geterr(ctx));
        exit(rcode);
    } else if (rcode == 1) {
        notice("\nWarning: %s\n", tcpreplay_getwarn(ctx));
    }

    if (ctx->stats.bytes_sent > 0) {
        packet_stats(&ctx->stats);
        if (ctx->options->flow_stats)
            flow_stats(ctx);
        sendpacket_getstat(ctx->intf1, buf, sizeof(buf));
        printf("%s", buf);
        if (ctx->intf2 != NULL) {
            sendpacket_getstat(ctx->intf2, buf, sizeof(buf));
            printf("%s", buf);
        }
    }
    tcpreplay_close(ctx);
    return 0;
}   /* main() */
其中 tcpreplay_init()函数有get_interface_list(),调用pcap_findalldevs()找到所有网卡设备信息
tcpreplay_t *
tcpreplay_init()
{
    tcpreplay_t *ctx;
    /* allocations will reset everything to zeros */
    ctx = safe_malloc(sizeof(tcpreplay_t));
    ctx->options = safe_malloc(sizeof(tcpreplay_opt_t));
    /* replay packets only once */
    ctx->options->loop = 1;
    /* Default mode is to replay pcap once in real-time */
    ctx->options->speed.mode = speed_multiplier;
    ctx->options->speed.multiplier = 1.0;
    /* Set the default timing method */
    ctx->options->accurate = accurate_gtod;
    /* set the default MTU size */
    ctx->options->mtu = DEFAULT_MTU;
    /* disable periodic statistics */
    ctx->options->stats = -1;
    /* disable limit send */
    ctx->options->limit_send = -1;
    /* default unique-loops */
    ctx->options->unique_loops = 1.0;
#ifdef ENABLE_VERBOSE
    /* clear out tcpdump struct */
    ctx->options->tcpdump = (tcpdump_t *)safe_malloc(sizeof(tcpdump_t));
#endif
    if (fcntl(STDERR_FILENO, F_SETFL, O_NONBLOCK) < 0)
        tcpreplay_setwarn(ctx, "Unable to set STDERR to non-blocking: %s", strerror(errno));
#ifdef ENABLE_PCAP_FINDALLDEVS
    ctx->intlist = get_interface_list();    // 获取所有的接口
#else
    ctx->intlist = NULL;
#endif
....
}
  然后tcpreplay_post_args()设置了相关参数,并使用了sendpacket_open(),在该函数中sendpacket_open_*()设置了ctx->inf1,ctx->intf2,其中int1,intf2中的fd是sockfd
int
tcpreplay_post_args(tcpreplay_t *ctx, int argc)
{
    char *temp, *intname;
    char *ebuf;
    tcpreplay_opt_t *options;
    int warn = 0;
    float n;
    int ret = 0;
    options = ctx->options;
    dbg(2, "tcpreplay_post_args: parsing command arguments");
    ebuf = safe_malloc(SENDPACKET_ERRBUF_SIZE);
#ifdef DEBUG
    if (HAVE_OPT(DBUG))
        debug = OPT_VALUE_DBUG;
#else
    if (HAVE_OPT(DBUG)) {
        warn ++;
        tcpreplay_setwarn(ctx, "%s", "not configured with --enable-debug.  Debugging disabled.");
    }
#endif
    options->loop = OPT_VALUE_LOOP;
    options->loopdelay_ms = OPT_VALUE_LOOPDELAY_MS;

    if (HAVE_OPT(LIMIT))
        options->limit_send = OPT_VALUE_LIMIT;

    if (HAVE_OPT(DURATION))
        options->limit_time = OPT_VALUE_DURATION;

    if (HAVE_OPT(TOPSPEED)) {
        options->speed.mode = speed_topspeed;
        options->speed.speed = 0;
    } else if (HAVE_OPT(PPS)) {
        n = atof(OPT_ARG(PPS));
        options->speed.speed = (COUNTER)(n * 60.0 * 60.0); /* convert to packets per hour */
        options->speed.mode = speed_packetrate;
        options->speed.pps_multi = OPT_VALUE_PPS_MULTI;
    } else if (HAVE_OPT(ONEATATIME)) {
        options->speed.mode = speed_oneatatime;
        options->speed.speed = 0;
    } else if (HAVE_OPT(MBPS)) {
        n = atof(OPT_ARG(MBPS));
        if (n) {
            options->speed.mode = speed_mbpsrate;
            options->speed.speed = (COUNTER)(n * 1000000.0); /* convert to bps */
        } else {
            options->speed.mode = speed_topspeed;
            options->speed.speed = 0;
        }
    } else if (HAVE_OPT(MULTIPLIER)) {
        options->speed.mode = speed_multiplier;
        options->speed.multiplier = atof(OPT_ARG(MULTIPLIER));
    }
    if (HAVE_OPT(MAXSLEEP)) {
        options->maxsleep.tv_sec = OPT_VALUE_MAXSLEEP / 1000;
        options->maxsleep.tv_nsec = (OPT_VALUE_MAXSLEEP % 1000) * 1000;
    }
#ifdef ENABLE_VERBOSE
    if (HAVE_OPT(VERBOSE))
        options->verbose = 1;

    if (HAVE_OPT(DECODE))
        options->tcpdump->args = safe_strdup(OPT_ARG(DECODE));
#endif
    if (HAVE_OPT(STATS))
        options->stats = OPT_VALUE_STATS;
    /*
     * preloading the pcap before the first run
     */

    if (HAVE_OPT(PRELOAD_PCAP)) {
        options->preload_pcap = true;
    }
    /* Dual file mode */
    if (HAVE_OPT(DUALFILE)) {
        options->dualfile = true;
        if (argc < 2) {
            tcpreplay_seterr(ctx, "%s", "--dualfile mode requires at least two pcap files");
            ret = -1;
            goto out;
        }
        if (argc % 2 != 0) {
            tcpreplay_seterr(ctx, "%s", "--dualfile mode requires an even number of pcap files");
            ret = -1;
            goto out;
        }
    }

#ifdef HAVE_NETMAP
    options->netmap_delay = OPT_VALUE_NM_DELAY;
#endif
    if (HAVE_OPT(NETMAP)) {
#ifdef HAVE_NETMAP
        options->netmap = 1;
        ctx->sp_type = SP_TYPE_NETMAP;
#else
         err(-1, "--netmap feature was not compiled in. See INSTALL.");
#endif
    }
    if (HAVE_OPT(UNIQUE_IP))
        options->unique_ip = 1;

    if (HAVE_OPT(UNIQUE_IP_LOOPS)) {
        options->unique_loops = atof(OPT_ARG(UNIQUE_IP_LOOPS));
        if (options->unique_loops < 1.0) {
            tcpreplay_seterr(ctx, "%s", "--unique-ip-loops requires loop count >= 1.0");
            ret = -1;
            goto out;
        }
    }
    /* flow statistics */
    if (HAVE_OPT(NO_FLOW_STATS))
        options->flow_stats = 0;

    if (HAVE_OPT(FLOW_EXPIRY)) {
        options->flow_expiry = OPT_VALUE_FLOW_EXPIRY;
    }
    if (HAVE_OPT(TIMER)) {
        if (strcmp(OPT_ARG(TIMER), "select") == 0) {
#ifdef HAVE_SELECT
            options->accurate = accurate_select;
#else
            tcpreplay_seterr(ctx, "%s", "tcpreplay_api not compiled with select support");
            ret = -1;
            goto out;
#endif
        } else if (strcmp(OPT_ARG(TIMER), "gtod") == 0) {
            options->accurate = accurate_gtod;
        } else if (strcmp(OPT_ARG(TIMER), "nano") == 0) {
            options->accurate = accurate_nanosleep;
        } else if (strcmp(OPT_ARG(TIMER), "abstime") == 0) {
            tcpreplay_seterr(ctx, "%s", "abstime is deprecated");
            ret = -1;
            goto out;
        } else {
            tcpreplay_seterr(ctx, "Unsupported timer mode: %s", OPT_ARG(TIMER));
            ret = -1;
            goto out;
        }
    }
#ifdef HAVE_RDTSC
    if (HAVE_OPT(RDTSC_CLICKS)) {
        rdtsc_calibrate(OPT_VALUE_RDTSC_CLICKS);
    }
#endif
    if (HAVE_OPT(PKTLEN)) {
        options->use_pkthdr_len = true;
        warn ++;
        tcpreplay_setwarn(ctx, "%s", "--pktlen may cause problems.  Use with caution.");
    }
   // tcp_replay_init()函数已经获取了接口链表
    if ((intname = get_interface(ctx->intlist, OPT_ARG(INTF1))) == NULL) {
        if (!strncmp(OPT_ARG(INTF1), "netmap:", 7) || !strncmp(OPT_ARG(INTF1), "vale", 4))
            tcpreplay_seterr(ctx, "Unable to connect to netmap interface %s. Ensure netmap module is installed (see INSTALL).",
                    OPT_ARG(INTF1));
        else
            tcpreplay_seterr(ctx, "Invalid interface name/alias: %s", OPT_ARG(INTF1));
        ret = -1;
        goto out;
    }
    if (!strncmp(intname, "netmap:", 7) || !strncmp(intname, "vale:", 5)) {
#ifdef HAVE_NETMAP
        options->netmap = 1;
        ctx->sp_type = SP_TYPE_NETMAP;
#else
        tcpreplay_seterr(ctx, "%s", "tcpreplay_api not compiled with netmap support");
        ret = -1;
        goto out;
#endif
    }
    options->intf1_name = safe_strdup(intname);
    /* open interfaces for writing */
    if ((ctx->intf1 = sendpacket_open(options->intf1_name, ebuf, TCPR_DIR_C2S, ctx->sp_type, ctx)) == NULL) {
        tcpreplay_seterr(ctx, "Can't open %s: %s", options->intf1_name, ebuf);
        ret = -1;
        goto out;
    }
#if defined HAVE_NETMAP
    ctx->intf1->netmap_delay = ctx->options->netmap_delay;
#endif
    ctx->intf1dlt = sendpacket_get_dlt(ctx->intf1);
    if (HAVE_OPT(INTF2)) {
        if (!HAVE_OPT(CACHEFILE) && !HAVE_OPT(DUALFILE)) {
            tcpreplay_seterr(ctx, "--intf2=%s requires either --cachefile or --dualfile", OPT_ARG(INTF2));
            ret = -1;
            goto out;
        }
        if ((intname = get_interface(ctx->intlist, OPT_ARG(INTF2))) == NULL) {
            tcpreplay_seterr(ctx, "Invalid interface name/alias: %s", OPT_ARG(INTF2));
            ret = -1;
            goto out;
        }
        options->intf2_name = safe_strdup(intname);

        /* open interface for writing */
        if ((ctx->intf2 = sendpacket_open(options->intf2_name, ebuf, TCPR_DIR_S2C, ctx->sp_type, ctx)) == NULL) {
            tcpreplay_seterr(ctx, "Can't open %s: %s", options->intf2_name, ebuf);
        }

  然后使用tcpreplay_replay()->tcpreplay_index() -> replay_file()->send_packets()->sendpacket(),然后使用send()或是write()等函数发送数据。

此处重点说一下发送pcap文件,在main函数中加载pcap file

    for (i = 0; i < argc; i++) {
        tcpreplay_add_pcapfile(ctx, argv[i]);
说明可以发送多个pcap文件,
int
tcpreplay_add_pcapfile(tcpreplay_t *ctx, char *pcap_file)
{
    assert(ctx);
    assert(pcap_file);
    // 最多1024个pcap file
    if (ctx->options->source_cnt < MAX_FILES) {
        ctx->options->sources[ctx->options->source_cnt].filename = safe_strdup(pcap_file);
        ctx->options->sources[ctx->options->source_cnt].type = source_filename;

        /*
         * prepare the cache info data struct.  This doesn't actually enable
         * file caching for this pcap (that is controlled globally via
         * tcpreplay_set_file_cache())
         */
        ctx->options->file_cache[ctx->options->source_cnt].index = ctx->options->source_cnt;
        ctx->options->file_cache[ctx->options->source_cnt].cached = false;
        ctx->options->file_cache[ctx->options->source_cnt].packet_cache = NULL;

        ctx->options->source_cnt += 1;


    } else {
        tcpreplay_seterr(ctx, "Unable to add more then %u files", MAX_FILES);
        return -1;
    }
    return 0;
}
tcpreplay_add_pcapfile()主要是获取pcap的filename及设置type。
int 
tcpr_replay_index(tcpreplay_t *ctx)
{
    int rcode = 0;
    int idx;
    assert(ctx);

    init_timestamp(&ctx->stats.last_time);

    /* only process a single file */
    if (! ctx->options->dualfile) {
        /* process each pcap file in order */
        for (idx = 0; idx < ctx->options->source_cnt && !ctx->abort; idx++) {
            /* reset cache markers for each iteration */
            ctx->cache_byte = 0;
            ctx->cache_bit = 0;
            switch(ctx->options->sources[idx].type) {
                case source_filename:
                    rcode = replay_file(ctx, idx);
                    break;
                case source_fd:
                    rcode = replay_fd(ctx, idx);
                    break;
                case source_cache:
                    rcode = replay_cache(ctx, idx);
                    break;
                default:
                    tcpreplay_seterr(ctx, "Invalid source type: %d", ctx->options->sources[idx].type);
                    rcode = -1;
            }
        }
    }
...
此处主要是根据source[index].type来确定调用大的函数,初始化为 source_filename, 根据idx索引循环发送pcap。
static int
replay_file(tcpreplay_t *ctx, int idx)
{
    char *path;
    pcap_t *pcap = NULL;
    char ebuf[PCAP_ERRBUF_SIZE];

    assert(ctx);
    assert(ctx->options->sources[idx].type == source_filename);

    path = ctx->options->sources[idx].filename;

    /* close stdin if reading from it (needed for some OS's) */
    if (strncmp(path, "-", 1) == 0)
        close(1);

    /* read from pcap file if we haven't cached things yet */
    if (!ctx->options->preload_pcap) {
        if ((pcap = pcap_open_offline(path, ebuf)) == NULL) {
            tcpreplay_seterr(ctx, "Error opening pcap file: %s", ebuf);
            return -1;
        }

        ctx->options->file_cache[idx].dlt = pcap_datalink(pcap);

#ifdef HAVE_PCAP_SNAPSHOT
        if (pcap_snapshot(pcap) < 65535)
            warnx("%s was captured using a snaplen of %d bytes.  This may mean you have truncated packets.",
                    path, pcap_snapshot(pcap));
#endif

    } else {
        if (!ctx->options->file_cache[idx].cached) {
            if ((pcap = pcap_open_offline(path, ebuf)) == NULL) {
                tcpreplay_seterr(ctx, "Error opening pcap file: %s", ebuf);
                return -1;
            }
            ctx->options->file_cache[idx].dlt = pcap_datalink(pcap);
        }
    }
.....
replay_file()调用了libpcap 库函数读取pcap, 然后使用send_packets()发送。
最后谈谈tcprewrite,
在main()中:
    /* rewrite packets */
    if (rewrite_packets(tcpedit, options.pin, options.pout) != 0)
        errx(-1, "Error rewriting packets: %s", tcpedit_geterr(tcpedit));
rewrite_packets()会调用tcpedit_packet()
    /* rewrite Layer 2 */
    if ((pktlen = tcpedit_dlt_process(tcpedit->dlt_ctx, pktdata, (*pkthdr)->caplen, direction)) == TCPEDIT_ERROR)
        errx(-1, "%s", tcpedit_geterr(tcpedit));

    /* unable to edit packet, most likely 802.11 management or data QoS frame */
    if (pktlen == TCPEDIT_SOFT_ERROR) {
        dbgx(3, "%s", tcpedit_geterr(tcpedit));
        return TCPEDIT_SOFT_ERROR;
    }

    /* update our packet lengths (real/captured) based on L2 length changes */
    lendiff = pktlen - (*pkthdr)->caplen;
    (*pkthdr)->caplen += lendiff;
    (*pkthdr)->len += lendiff;
    
    dst_dlt = tcpedit_dlt_dst(tcpedit->dlt_ctx);
    l2len = tcpedit_dlt_l2len(tcpedit->dlt_ctx, dst_dlt, packet, (*pkthdr)->caplen);

    dbgx(2, "dst_dlt = %04x\tsrc_dlt = %04x\tproto = %04x\tl2len = %d", dst_dlt, src_dlt, ntohs(l2proto), l2len);

    /* does packet have an IP header?  if so set our pointer to it */
    if (l2proto == htons(ETHERTYPE_IP)) {
        ip_hdr = (ipv4_hdr_t *)tcpedit_dlt_l3data(tcpedit->dlt_ctx, dst_dlt, packet, (*pkthdr)->caplen);
        if (ip_hdr == NULL) {
            return TCPEDIT_ERROR;
        }        
        dbgx(3, "Packet has an IPv4 header: %p...", ip_hdr);
    } else if (l2proto == htons(ETHERTYPE_IP6)) {
        ip6_hdr = (ipv6_hdr_t *)tcpedit_dlt_l3data(tcpedit->dlt_ctx, dst_dlt, packet, (*pkthdr)->caplen);
        if (ip6_hdr == NULL) {
            return TCPEDIT_ERROR;
        }
        dbgx(3, "Packet has an IPv6 header: %p...", ip6_hdr);
    } else {
        dbgx(3, "Packet isn't IPv4 or IPv6: 0x%04x", l2proto);
        /* non-IP packets have a NULL ip_hdr struct */
        ip_hdr = NULL;
    }

    /* The following edits only apply for IPv4 */
    if (ip_hdr != NULL) {
        
        /* set TOS ? */
        if (tcpedit->tos > -1) {
            uint16_t newval, oldval = *((uint16_t*)ip_hdr);
            ip_hdr->ip_tos = tcpedit->tos;
            newval = *((uint16_t*)ip_hdr);
            csum_replace2(&ip_hdr->ip_sum, oldval, newval);
        }
            
        /* rewrite the TTL */
        rewrite_ipv4_ttl(tcpedit, ip_hdr);

        /* rewrite TCP/UDP ports */
        if (tcpedit->portmap != NULL) {
            if ((retval = rewrite_ipv4_ports(tcpedit, &ip_hdr)) < 0)
                return TCPEDIT_ERROR;
        }
    }
    /* IPv6 edits */
    else if (ip6_hdr != NULL) {
        /* rewrite the hop limit */
        rewrite_ipv6_hlim(tcpedit, ip6_hdr);

        /* set traffic class? */
        if (tcpedit->tclass > -1) {
            /* calculate the bits */
            tclass = tcpedit->tclass << 20;
            
            /* convert our 4 bytes to an int */
            memcpy(&ipflags, &ip6_hdr->ip_flags, 4);
            
            /* strip out the old tclass bits */
            ipflags = ntohl(ipflags) & 0xf00fffff;

            /* add the tclass bits back */
            ipflags += tclass; 
            ipflags = htonl(ipflags);
            memcpy(&ip6_hdr->ip_flags, &ipflags, 4);
        }
上面是tcpedit_packet()函数的一部分,主要修改头结,更多内容可以查看源码。




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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值