PathAFL: Path-Coverage Assisted Fuzzing

文章来自 ASIA CCS2020

作者来自南开大学

Abstract

提出了PathAFL,有效地识别和利用 h-pathh-path 是一种边已经被覆盖过的新路径(覆盖了相同的边,但是路径不同)。PathAFL只插入一条汇编指令计算路径的哈希值,并使用选择性插桩策略降低执行路径的跟踪粒度。其次,设计了一种快速过滤算法,以从大量的h-path 中选择更高权重的路径,并将其添加到种子队列中。第三,种子选择算法和功率调度都是基于路径权重实现的。发现了6个CVE。

1. Introduction

PathAFL 在编译时使用静态插桩来跟踪程序执行路径并计算路径哈希以区分不同的h-path。PathAFL仅通过跟踪较大的函数和内存操作函数来进行选择性插桩。该方法减少了实际被跟踪的路径数量。该解决方案在跟踪路径粒度和模糊性能之间取得了平衡。

文章共享:

  • 阐述了AFL使用边覆盖的缺陷,讨论了路径覆盖的问题,在跟踪路径覆盖粒度和模糊性能之间进行了权衡。

  • 设计了一种路径过滤算法,只有那些满足特定条件并拥有高权重的路径才会被添加到种子队列中。

  • 基于路径权重来改进AFL,添加了基于路径权重的种子调度。

  • 开源

2. Overview

2.1 Overview of AFL

2.1.1 seed selection
2.1.2 coverage calculate

2.2 Overview of CollAFL

CollAFL论文阅读

2.3 Motivation of PathAFL

2.3.1 BBL Coverage
2.3.2Edge Coverage

一个例子

void vul (short ∗s) {if(s[0] == 0x6261) // abs[2] = 0x6665;   // efif(s[1] == 0x6463 ) // cdif(((int ∗)s)[1] == 0x21216665 ) // ef!!abort() ;
}

上面的程序有6个基本块,7条边,6条执行路径

先执行 A、C、G,再执行 A、B、C、G,再执行 A、C、E、G,则执行 A、B、C、E、G时将不会认为是新的覆盖,所以很难通过变异执行到F。

2.3.3 Path Coverage

将路径分为两类:

  • e-path:路径覆盖到了新的边
  • h-path:路径没有覆盖新的边,但是哈希值不同,即边的执行顺序或是数量有变化。

e-path比较好处理,因为比较少。但是h-path很多,所以作者仅将权重高的h-path添加到队列中。

3. Technology

3.1 How to Identify h-paths

最直观的方法是记录整个程序执行流程,即基本块的执行序列,但是,当执行路径太长时,它将花费大量内存。所以作者采用路径哈希,类似于AFL的运行时计算哈希值并存入共享内存。

3.2 How to Reduce the Number of h-paths

3.2.1 Selective Instrumentation

Path仅插桩部分基本块,并跟踪具有较高概率到达新基本块或触发新bug的路径

  • 要大致跟踪整个路径,应在执行路径中均匀分布插桩位置。插桩函数输入是一个不错的选择并且容易实现。
  • 插桩较大的函数,即代码量更多的函数,因为有更多的机会触发bug
  • 插桩具有更多内存操作的函数

具体的插桩选择策略:

  • 插桩20%最大的函数
  • 插桩具有内存操作的函数
  • 忽略掉太小的函数
  • 插桩其它函数的10%
3.2.2 Hash Algorithm

hash_path(p)=∑bb∈BSBID(bb)hash\_path(p)=\sum_{bb\in BS}BID(bb) hash_path(p)=bb∈BS∑​BID(bb)

p表示执行路径,BS表示插桩基本块的执行序列。就是简单的将所有基本块的ID相加。

3.3. Which h-paths Will Be Added to the Seed Queue?

策略需求:

  • 效率
  • e-pathh-path 更重要
  • 变异后覆盖到新边的h-path应该被选择

路经过滤算法:

  • e-path 直接加入到队列中
  • 如果当前队列中的种子数量少于 MIN_PATHS ,路径不加入队列。因为在初始阶段,模糊器应该先将大量的e-path 加入队列,h-path 不必加入队列。如果没有足够多的路径,则很难判断一条新路径的好坏。
  • 为了保持 h-path 的多样性, 三个连续的 h-path 是不允许出现的, 因为它们通常是通过同一种子变异产生的,执行路径会很相似。
  • CalcWight 最后调用,因为有计算开销。

权重计算:
weight_edge(<bb1,bb2>)=IsUntouched(<bb1,bb2>)?(1+CountCall(bb2)∗2):0weight\_edge(<bb1,bb2>)=IsUntouched(<bb1,bb2>)? (1+CountCall(bb2)*2):0 weight_edge(<bb1,bb2>)=IsUntouched(<bb1,bb2>)?(1+CountCall(bb2)∗2):0

weight_path=∑bb∈p,<bb,bbi>∈Eweight_edge(<bb.bbi>)weight\_path=\sum_{bb \in p, <bb,bb_i> \in E}weight\_edge(<bb.bb_i>) weight_path=bb∈p,<bb,bbi​>∈E∑​weight_edge(<bb.bbi​>)

CountCall 返回 bb1call 指令的调用次数

IsUntouched 检查是否是新的覆盖

E 表示边的集合

当路径权重大于 wight_high 时加入队列
weight_high=weight_avg+(wight_max−weight_avg)/wweight\_high=weight\_avg+(wight\_max-weight\_avg)/w weight_high=weight_avg+(wight_max−weight_avg)/w
weight_avg 表示队列中所有种子的平均权重

weight_max表示最大权重

w 是一个平衡参数,默认为3

很显然,wight_high是一个大于平均权重的阈值,这确保了添加到种子队列的 h-path 具有足够的邻居分支(没理解后半句)

3.4 Seed Selection Algorithm

Q=q0,q1,...,qi,...,q,...,qnQ={q_0,q_1,...,q_i,...,q,...,q_n} Q=q0​,q1​,...,qi​,...,q,...,qn​

AFL中存在的问题:上面为一个种子队列,q 表示当前正在fuzz的种子,如果在循环开始时F (偏爱种子集合)中不包含 qiq_iqi​,但是现在又包含了qiq_iqi​,则qiq_iqi​覆盖道德的唯一边会被忽略,导致v欸测试的偏爱种子不能包含所有的发现边,这种情况还很常见。(没看懂!!!)

设计了新的种子选择算法

(1)将种子对列根据当前位置分为 Q1Q_1Q1​ 和 Q2Q_2Q2​

(2)更新全局位图 C

(3)将 Q2Q_2Q2​ 中的种子按照权重降序排序

(4)遍历 Q2Q_2Q2​ ,有新覆盖加入到 F 中,否则从 F 中移除

3.5 Power Schedule

能量调度决定一个种子产生测试用例的数量。PathAFL给权重高的种子分配更多能量。具体地,根据权重分成六个部分,每一部分分配的能量都是不同的。

4. Implementation

Instrumentation

为了更准确的计算路径权重,实现了 CollAFL中的 FsingleFmul,没有实现 Fhash 是因为使用频率太低。实现了CollAFL中的未触及邻居分支权重计算方法,作为baseline。

Static Analysis

使用IDA进行静态分析。编写了IDAPython脚本,用于自动分析程序结构,获取边缘信息,并为目标程序生成数据文件。该文件记录所有边缘信息,作为PathAFL的输入来计算路径权重。

Path Filtering

算法2 在 afl-fuzz.c 中的 svae_if_interesting 函数中实现,每发现一个种子会计算参数 wight_high

Power Schedule

添加了参数选项 -p 决定是否开启能量分配策略

5. Evaluation

总体来讲就是覆盖了有明显提升,也发现了bug。没有比较吞吐量的实验,感觉应该会比原生AFL差一些。

6. Discussion

**Tracing Path:**仅插桩一部分函数,具有随机性,可能会错过一些重要路径。

**Path Hash:**使用简单的加法计算路径哈希值,无法区分边的执行顺序。

Filtering Path: h-path 有待优化 ,当前是仅根据权重进行选择。

二、源码分析

论文中给的GitHub上的代码不是很全!

1. 新增参数选项

  • -p:开启能量分配
  • -r:指定队列中h-path 最高占比
  • -b:开始将h-path 加入队列中的阈值
  • -w:指定计算权重阈值的平衡参数g_weight_high_param
  • -c:连续的h-path 加入队列的最大限制次数
  • -h:指定记录基本块父子关系信息的输入文件位置
  • -g:权重分配指导策略(0、1、2)

2. 一些变量

path_hash:queue_entry结构体新增属性,当前种子的路径哈希

next_hash:queue_entry结构体新增属性,一个链表,存储下一个路径哈希相同的种子

g_new_paths_ratio:队列中新路径的占比上限

g_new_path:当前路径的哈希值

g_consecutive_pat:连续放入队列的 h_path 数量

g_param_consecutive_pat:连续放入队列的 h_path 数量上限

g_path_hash:结构体数组,其实是一个存储路径哈希值的哈希表。用结构体数组应该是由足够多的位,保证不会溢出。

struct edge_neighbor { 存储边信息的结构体

hash:边哈希

hash_neighbor:邻居分支哈希

num_call:邻居分支数量

num_mem:内存操作数量

};

g_edge_info:所有的边信息

g_edge_info_numg_edge_info中的元素数量

g_edge_info_index:记录每条边对应文件中的第几个条目

g_guide_type:权重类型,0表示只计算后代,1表示计算邻居分支,2表示计算邻居分支+内存操作

g_begin_num:将 h_path 添加到队列中的阈值,大于才开始添加。如果为0,不过滤,全部添加

g_weight_high_param:计算权重阈值的平衡参数

g_nb_count:记录每条边邻居分支数量的数组

g_weight_high:权重阈值,大于阈值才会加入队列

g_weight_avg:队列中种子的权重平均值

3. save_if_interesting 修改

static u8 save_if_interesting(char** argv, void* mem, u32 len, u8 fault) {u8  *fn = "";u8  hnb;s32 fd;u8  keeping = 0, res;if (fault == crash_mode) {/* Keep only if there are new bits in the map, add to queue forfuture fuzzing, etc. */if (!(hnb = has_new_bits(virgin_bits))) {if (crash_mode) total_crashes++;// 没有覆盖新的边,判断是否是新的路径
#ifdef _1_PATH_HASH if ( g_begin_num )if ( FAULT_TMOUT == fault         // 超时造成路径不准确,提前判断|| queue_cur->var_behavior // 可变种子,直接跳过|| g_consecutive_pat >= g_param_consecutive_pat   // 3 consecutive pat files.|| queued_paths < g_begin_num    // 队列中的数量少于阈值// 超过队列的1/3|| g_new_paths >= queued_paths * g_new_paths_ratio / (100+g_new_paths_ratio)// || get_cur_ms() - last_path_time < 3000  // need last org afl path time// || R(100) < 67)return 0;if ( !has_new_path() ) return 0;    // 如果不是新路径// 如果 g_begin_num 不为0,即过滤,并且有邻居分支的信息if ( g_begin_num && g_edge_info_num)if ( calc_cur_path_neighbor() < g_weight_high )  // 路径权重小于阈值return 0;++g_new_paths;    // 计数加1hnb = 3;
#elsereturn 0;
#endif}......#ifdef _1_PATH_HASH    // 保存路径哈希queue_top->path_hash = *(u32*)(trace_bits + MAP_SIZE) & (MAP_SIZE - 1);
#endifqueue_top->exec_cksum = hash32(trace_bits, MAP_SIZE, HASH_CONST);......

4. has_new_path 判断是否是新路径

static inline u8 has_new_path(/*u32 cksum*/) {// 最后四字节按位与上全1计算出路径哈希值,没看出来按位与有什么意义u32 path_hash = *(u32*)(trace_bits + MAP_SIZE) & (MAP_SIZE - 1);if (!g_path_hash[path_hash])   // 如果是新路径return 1;return 0; // 否则,不是新路径
}

5. calc_cur_path_neighbor 计算当前路径的权重

static u64 calc_cur_path_neighbor() {u64 num = 0;// 其实trace_bits代表每一条边,如果表示基本块,则会有重复的,因为有些块有多个传入边。for(u32 i = 0; i < MAP_SIZE; ++i)  // 遍历每一个基本块if( trace_bits[i] )  // 如果覆盖到了num += g_nb_count[i];    // 累加邻居分支数量,重复的基本块也累加进来return num;
}

6. load_edge_neighbor_file 加载邻居分支信息文件

static int load_edge_neighbor_file(char *fn) {struct stat statbuf;if (stat(fn, &statbuf))        PFATAL("Unable to stat '%s'", fn);  // 文件状态复制到statbuf中s32 fd = open(fn, O_RDONLY);if (fd < 0) PFATAL("Unable to open '%s'", fn);g_edge_info = (struct edge_neighbor*)ck_alloc(statbuf.st_size);    // 分配空间ck_read(fd, g_edge_info, statbuf.st_size, fn);   // 读取文件内容至内存close(fd);memset(g_edge_info_index, 0xFF, sizeof(g_edge_info_index));   // 第一个元素初始化全1g_edge_info_num = statbuf.st_size / sizeof(struct edge_neighbor); // 包含了多少条边信息// 记录每条边对应文件中的第几个条目u32 last_hash = g_edge_info[0].hash;g_edge_info_index[last_hash] = 0;for(int i=1; i<g_edge_info_num; ++i){if(g_edge_info[i].hash == last_hash) // 因为是按照边的哈希值排好序的,所以与前一个比较进行去重continue;last_hash = g_edge_info[i].hash;g_edge_info_index[last_hash] = i;}return 0;
}

7. cull_queue 精简队列

static void cull_queue(void) {struct queue_entry* q;static u8 temp_v[MAP_SIZE >> 3];u32 i, j;if (dumb_mode || !score_changed || !queue_cur) return;score_changed = 0;memset(temp_v, 255, MAP_SIZE >> 3);queued_favored  = 0;pending_favored = 0;// PATHAFL// 当前种子不为null,有邻居分支信息,队列中种子数量大于阈值if (queue_cur && g_edge_info_num && queued_paths > g_begin_num) {u32 flag = 0;u32 max = 0;u32 sum = 0;u32 count = 0;static u64 time_cumulative = 0;u64 time_start = get_cur_time();for (q = queue; q != queue_cur; q = q->next) { // Q1if (!q->favored)continue;// set temp_v to zero for all the edges that the q coversj = MAP_SIZE >> 3;while (j--)if (q->trace_mini[j])temp_v[j] &= ~q->trace_mini[j];}struct queue_entry **array_entry = (struct queue_entry**)ck_alloc(sizeof(struct queue_entry*)* (queued_paths + g_new_paths) );  // 数组u32 count_array = 0;update_neighbor_count();for (q = queue_cur; q; q = q->next) {    // Q2q->favored = 0;q->neigbhor = calc_neighbor(q); // 根据邻居分支计算权重sum += q->neigbhor;   // 累加if (q->neigbhor > max)   // 最大值max = q->neigbhor;array_entry[count++] = q;}if (count) {if ( count > (queued_paths>>3) ) {    // Q2中的种子数量大于队列中种子总数的1/8flag = sum / count;    // 平均权重//flag += (max - flag) / 5;    g_weight_avg  = flag;g_weight_high = flag + (max - flag) / g_weight_high_param;  // 权重阈值}AFL_LOG("cur=%d count=%d avg=%d high=%d ", queue_cur->id, count, g_weight_avg, g_weight_high);count_array = count;// 按照权重降序排序qsort(array_entry, count_array, sizeof(struct queue_entry*), compare_neigbhor_score);count = 0;u32 untounch_edge = MAP_SIZE - count_non_255_bytes(virgin_bits);    // 未覆盖到的边数for (i = 0; i < count_array; i++) {q = array_entry[i];if (!q->trace_mini)continue;j = MAP_SIZE >> 3;while (j--)  // 有新的覆盖if ( (temp_v[j] & ~q->trace_mini[j]) != temp_v[j] ) { // has new covarage, set favoredq->favored = 1;       ++j;  // 置为偏爱的种子break;}if ( !q->favored ) continue;while (j--)temp_v[j] &= ~q->trace_mini[j];  // 同步当前偏爱种子的所有覆盖queued_favored++;if (!q->was_fuzzed) ++pending_favored;if (++count <= 3 || i >= count_array - 3)AFL_LOG("%d-%d ", q->id, q->neigbhor);if ( (i & 0xF) == 0xF && count_bits(temp_v, sizeof(temp_v)) == untounch_edge){break;}} ck_free(array_entry);if (count_bits(temp_v, sizeof(temp_v)) != untounch_edge)    // 如果覆盖了新的边AFL_LOG("virgin=%d temp_v=%d ", untounch_edge, count_bits(temp_v, sizeof(temp_v)));time_cumulative += get_cur_time() - time_start;AFL_LOG("i=%d fav=%d time=%llds\n", i, count, time_cumulative / 1000);}} else {     // 否则,采用AFL默认的策略q = queue;while (q) {q->favored = 0;q = q->next;}/* Let's see if anything in the bitmap isn't captured in temp_v.If yes, and if it has a top_rated[] contender, let's use it. */for (i = 0; i < MAP_SIZE; i++)if (top_rated[i] && (temp_v[i >> 3] & (1 << (i & 7)))) {u32 j = MAP_SIZE >> 3;/* Remove all bits belonging to the current entry from temp_v. */while (j--)if (top_rated[i]->trace_mini[j])temp_v[j] &= ~top_rated[i]->trace_mini[j];top_rated[i]->favored = 1;queued_favored++;if (!top_rated[i]->was_fuzzed) pending_favored++;}} // end of if (queue_cur && g_edge_info_num)q = queue;while (q) {mark_as_redundant(q, !q->favored);q = q->next;}
}

8. calc_edge_neighbor 根据边哈希计算邻居分支数量

static inline u32 calc_edge_neighbor(u32 hash) {u32 score = 0;// 根据边哈希值找到当前条目for (u32 j = g_edge_info_index[hash]; j < g_edge_info_num; ++j) {//index is overif (g_edge_info[j].hash != hash)    // 一个边哈希值可能存在多个条目,break;// 邻居已经被覆盖过了,不加分,直接跳过if ( 0xFF != virgin_bits[g_edge_info[j].hash_neighbor] )continue;if (0 == g_guide_type){   // score += 1;} else if (1 == g_guide_type) {   // 邻居分支指导权重score += 1 + (g_edge_info[j].num_call << 1);} else if (2 == g_guide_type) {   // 邻居分支+内存操作指导权重score += 1 + (g_edge_info[j].num_call << 1) + (g_edge_info[j].num_mem << 2);}}return score;
}

9. calc_neighbor 统计当前种子的权重

static u64 calc_neighbor(struct queue_entry *q) {u64 num = 0;if(!q->trace_mini) return num; // 不是优秀种子,直接返回,感觉有点多余for(u32 i = 0; i < MAP_SIZE; ++i)if( q->trace_mini[i>>3] & (1<<(i&7)) )  // 如果覆盖到了当前边num += g_nb_count[i];     // 累加上当前边的邻居数量return num;
}

10. compare_neigbhor_score 传给qsort的比较函数

static int compare_neigbhor_score(const void* p1, const void* p2) {// 注意参数是 void* 类型,所以要强制类型转换后解引用return (*(struct queue_entry**)p2)->neigbhor - (*(struct queue_entry**)p1)->neigbhor;
}

11. update_bitmap_score 修改

static void update_bitmap_score(struct queue_entry* q) {u32 i;u64 fav_factor = q->exec_us * q->len;/* For every byte set in trace_bits[], see if there is a previous winner,and how it compares to us. */for (i = 0; i < MAP_SIZE; i++)if (trace_bits[i]) {if (top_rated[i]) {/* Faster-executing or smaller test cases are favored. */if (fav_factor > top_rated[i]->exec_us * top_rated[i]->len) continue;/* Looks like we're going to win. Decrease ref count for theprevious winner, discard its trace_bits[] if necessary. */if (!--top_rated[i]->tc_ref) {// 这里没有清空释放trace_mini}}/* Insert ourselves as the new winner. */top_rated[i] = q;q->tc_ref++;score_changed = 1;}#if (defined _2_GUIDED_NEIGHBOR)// 改成了在判断条件外分配并计算trace_mini,也就是不管是不是top_rated都会计算,队列中的所有种子都会计算出trace_miniif (!q->trace_mini) {q->trace_mini = ck_alloc(MAP_SIZE >> 3);minimize_bits(q->trace_mini, trace_bits);}
#endif
}

PathAFL论文阅读+源码分析相关推荐

  1. FairFuzz 论文简读+源码分析+整体流程简述

    FairFuzz: A Targeted Mutation Strategy for Increasing Greybox Fuzz Testing Coverage 一.论文阅读 Abstract ...

  2. 朋友问我学习高并发需不需要阅读源码,我是这样分析的!!

    来自:冰河技术 写在前面 最近正在写[高并发专题]的文章,其中,在[高并发专题]中,有不少是分析源码的文章,很多读者留言说阅读源码比较枯燥!问我程序员会使用框架了,会进行CRUD了,是否真的有必要阅读 ...

  3. C++Primer Plus (第六版)阅读笔记 + 源码分析【目录汇总】

    C++Primer Plus (第六版)阅读笔记 + 源码分析[第一章:预备知识] C++Primer Plus (第六版)阅读笔记 + 源码分析[第二章:开始学习C++] C++Primer Plu ...

  4. jdk源码分析书籍 pdf_如何阅读源码?

    点击上方"IT牧场",选择"设为星标" 技术干货每日送达! 阅读源码是每个优秀开发工程师的必经之路,那么这篇文章就来讲解下为什么要阅读源码以及如何阅读源码. 首 ...

  5. 《深入理解Spark:核心思想与源码分析》——1.3节阅读环境准备

    本节书摘来自华章社区<深入理解Spark:核心思想与源码分析>一书中的第1章,第1.3节阅读环境准备,作者耿嘉安,更多章节内容可以访问云栖社区"华章社区"公众号查看 1 ...

  6. 【阅读源码系列】ConcurrentHashMap源码分析(JDK1.7和1.8)

    个人学习源码的思路: 使用ctrl+单机进入源码,并阅读源码的官方文档–>大致的了解一下此类的特点和功能 使用ALIT+7查看类中所有方法–>大致的看一下此类的属性和方法 找到重要方法并阅 ...

  7. PDF阅读器系列之--MuPDF源码分析过程(二)

    博客找回来了,在那更新 http://blog.csdn.net/sky_pjf 前 时间好快,又一周过了,发现自己太忙了,博客都没去管-- 序 *MuPDF开源框架现在一直都在维护,我一般都会隔一周 ...

  8. 以太坊源码阅读5——POW源码分析

    以太坊源码阅读5--POW源码分析 介绍 POW,proof of work,即工作量证明,是著名公bitcoin所采用的共识算法.简单来说,pow就是一个证明,由矿工使用算力进行计算(挖矿),竞争记 ...

  9. Transformer-XL解读(论文 + PyTorch源码)

    前言 目前在NLP领域中,处理语言建模问题有两种最先进的架构:RNN和Transformer.RNN按照序列顺序逐个学习输入的单词或字符之间的关系,而Transformer则接收一整段序列,然后使用s ...

最新文章

  1. Nginx 虚拟主机配置及负载均衡
  2. 手势追踪,高通走完其VR一体机的最后一里路
  3. viewPager开启界面导航之旅
  4. android配置文件说明
  5. 大厂python面试题_BAT大厂Python面试题精选,看完后离拿到offer只有一步之遥(含答案)...
  6. android坐侧菜单栏,SlidingLayoutDemo android左侧菜单栏的实现 - 下载 - 搜珍网
  7. Python随手记—各种方法的使用
  8. 当你不被上司信任和待见,工作无法正常开展
  9. 池化和反池化、卷积层的理解layers.Conv2D,可视化卷积padding
  10. 仿今日头条项目——个人中心
  11. 希捷和西数移动硬盘哪个好_希捷,西数哪个移动硬盘更好
  12. 中国峰会速递|亚马逊云科技【DEV DAY】认知地图正式发布!
  13. [JSP] 页面编写操作
  14. 基于Joplin+PicGo+阿里OSS搭建自己的云笔记
  15. 【SQL注入漏洞-01】SQL注入漏洞原理及分类
  16. IDEA Git和svn切换
  17. 昆虫繁殖(继续理解递推和递归)
  18. 数据结构(C语言版第2版)课后习题答案
  19. docker入门_Docker入门
  20. 外汇平台搭建,外汇交易社区,外汇支付通道,通过区块链如何改变外汇

热门文章

  1. Redis底层详解(一) 哈希表和字典
  2. .net 与directX
  3. “三门问题”背后的概率论原理解析
  4. 奇思妙想构造题 ARC145 D - Non Arithmetic Progression Set
  5. java入门之控制台输入人数成绩计算及格率(将成绩存入数组)与打印九九乘法表
  6. Python GUI设计 tkinter 笔记
  7. C++题解:蜗牛旅行
  8. C# 如何显示动态图片
  9. 大数据风控在金融科技中如何应用?难题何在?
  10. win10下删除多余UEFI启动项的方法