# 我对安全与NLP的实践和思考

先说结果，后谈过程。

## 结果

通过对安全与NLP的实践和思考，有以下三点产出：

首先，产出一种通用解决方案和轮子，一把梭实现对各种安全场景的安全检测。通用解决方案给出一类安全问题的解决思路，打造轮子来具体解决这一类问题，而不是使用单个技术点去解决单个问题。具体来说，将安全与NLP结合，在各种安全场景中，将其安全数据统一视作文本数据，从NLP视角，统一进行文本预处理、特征化、预训练和模型训练。例如，在Webshell检测中，Webshell文件内容，在恶意软件检测中，API序列，都可以视作长文本数据，使用NLP技术进行分词、向量化、预训练等操作，同理，在Web安全中，SQl、XSS等URL类安全数据，在DNS安全中，DGA域名、DNS隧道等域名安全数据，同样可以视作短文本数据。因此，只要安全场景中安全数据可以看作文本数据，这种通用解决方案和轮子就适用，轮子开源在我的github[仓库FXY](https://github.com/404notf0und/FXY)中，内置多种通用特征化方法和多种通用深度学习模型，以支持多种安全场景的特征化和模型训练，达到流水线式作业。

其次，是对应用能力和底层能力的思考。之前写过一篇文章《[应用型安全算法工程师的自我修养](https://4o4notfound.org/index.php/archives/188/)》，在我当时预期想法中，我理解的应用型，重点在于解决实际安全问题，不必苛求于对使用技术本身的理解深度，可以不具备研究型、轮子型的底层能力。映射到我自身，我做安全和算法，最初想法很好，安全和算法两者我都要做好，这里做好，仅仅指用好。之后，面试时暴露了问题，主管给出的建议是两者都要做好。这里做好，不单单指用好，还要知其所以然。举个例子，就是不仅要调包调参玩的6，还要掌握算法的底层原理，这就是底层能力。当时，懂，也不懂，似懂非懂，因为，说，永远是别人的，悟，才是自己的。在实现通用解决方案和轮子的过程中，遇到关于word2vec底层的非预期问题，才深刻体会到，底层能力对应用能力的重要性。过程中遇到的预期和非预期问题，下文会详述。现在我理解的应用型，重点还是在解决安全问题，以及对安全问题本身的理解，但应用型还需具备研究型、轮子型等上下游岗位的底层能力。安全算法是这样，其他细分安全领域也是一样，都需要底层能力，以发展技术深度。

最后，带来思考和认识的提升。从基于机器学习的XX检测，基于深度学习的XX检测，等各种单点检测，到基于NLP的通用安全检测，是一个由点到面的认知提升。从安全和算法都要做好，到安全和算法都要做好，其中蕴含着认知的提升。从之前写过一篇安全与NLP的文章《[当安全遇上NLP](https://www.4o4notfound.org/index.php/archives/190/)》，到现在这篇文章。对一件事物的认识，在不同阶段应该是不一样的，甚至可能完全推翻自己之前的认识。我们能做的，是保持思考，重新认识过去的经历，提升对事物的认知和认知能力。这个提升认知的过程，类似boosting的残差逼近和强化学习的奖惩，是一个基于不知道不知道->知道不知道>知道知道->不知道知道的螺旋式迭代上升过程。

## 起源

促成对安全与NLP的实践和思考，起源于以下三点：

第一，也是最初的起源，之前写过一篇文章《[FXY：Security-Scenes-Feature-Engineering-Toolkit](https://www.4o4notfound.org/index.php/archives/216/)》，FXY是一款安全领域的特征工程框架，用于支撑上层的机器学习算法。当时是希望对每种安全场景，发挥安全领域知识，定制统计特征工程方法，预期是像写poc一样写特征化方法，像集成poc脚本到pocsuite中一样，集成特征化方法到FXY特征工程框架中，现在想来，当时的想法有点幼稚。因为全定制化开发，就算是将别人相关工作中的特征化方法，按照一定格式改写到FXY特征工程框架中来，工作量也太大。因此，怎么把FXY打造的更加实用，是我一直在思考的问题。

第二，起源于两个github项目。第一个是一个单纯的文本分类项目，作者是对3个文本数据集，使用1种特征化方法，训练13种机器学习和深度学习模型。这里我关注的点不是13种模型，学术界主刷models，我关注的是1种特征化方法，一种特征方法对三个文本数据集。第二个是早之前阿里云安全webber师傅写的基于深度学习的XSS检测的两篇文章和github仓库。这两篇文章使用1种文本数据特征化方法，训练4种模型，文章我在三个时期看过很多遍，每次的理解都不相同，但主要的思想都还是把安全数据当文本处理。当两个仓库撞在一起，想到的是将单点的XSS+word2vec+深度学习模型，向前扩展，扩展至文本数据类安全场景+NLP特征化方法+深度学习模型。对各类安全数据求同存异，‘同’是能否看作文本数据，一般只要能看作文本数据，例如域名数据、文件数据、序列数据，就可以使用统一的NLP特征化方法，‘异’是采用不同特征化方法如人工提取的特征千差万别。求同存异后，就可以用一类通用方法，统一处理多个安全场景，而不必根据每个安全场景定制化开发特征工程方法。

下图是两点起源相遇时顺手写下的idea。

![image-20200410200426733](https://github.com/404notf0und/Always-Learning/blob/master/images/image-20200410200426733.png)

第三，指导思想是不断思考。第一次明确听到关于“思考”的字眼，是在实习刚入职那会，晚上七点左右主管找我的谈话，当时似乎是听懂了，现在回想起来，记得的只有两字“思考”。思考，是一种基础能力，促使我不断的对知识进行融合，思考每一种产生化学反应的可能性。

## 预期问题

基于以上三点起源，我开始从NLP视角重构FXY，争取打开突破口。下图为顺手记录过程中遇到的问题和解决方式。

![image-20200410210736459](https://github.com/404notf0und/Always-Learning/blob/master/images/image-20200410210736459.png)

总计有20+个问题，我们把问题归类，分成安全场景、数据特征化、模型三类，其中问题较多的是数据特征化部分，重点说说这部分。

![image-20200410214327089](https://github.com/404notf0und/Always-Learning/blob/master/images/image-20200410214327089.png)

按照流程的先后顺序，我们把问题划分在分词粒度、预训练前字典的建立、序列、词向量等部位。首先是分词粒度，粒度这里主要考虑字符粒度和词粒度。在不同的安全场景中，安全数据不同，采用的分词粒度也可能不同，例如用于恶意样本检测的动态API行为序列数据，需要进行单词粒度的划分。域名安全检测中的域名数据，最好采用字符粒度划分。URL安全检测中的URL数据，使用字符和单词粒度划分都可以。需要根据具体的安全场景，选择合适的粒度，FXY特征化类wordindex和word2vec中参数char_level实现了该功能。基于深度学习的XSS检测文中，是根据具体的XSS攻击模式，写成正则分词函数，对XSS数据进行划分，这是一种基于攻击模式的词粒度分词模式，但这种分词模式很难扩展到其他安全场景中。在其他安全场景中，可以根据此思路，写自定义的基于攻击模式的分词，但适用范围有限。我这里提供了两种通用词粒度分词模式，第一种是忽略特殊符号的简洁版分词模式，第二种是考虑全量特殊符号的完整版分词模式，这两种分词模式可以适用于各种安全场景中。FXY特征化类word2vec中参数punctuation的值‘concise’，‘all’和‘define’实现了两种通用分词和自定义安全分词功能。下文的实验部分，会测试不同安全场景中，使用字符粒度和词粒度，使用不同词粒度分词模式训练模型的性能对比。

其次是关于预训练前字典的建立。特征化类word2vec的预训练需求直接引发了字典建立的相关问题。在word2vec预训练前，需要考虑预训练数据的产生。基于深度学习的XSS检测文中，是通过建立一个基于黑样本数据的指定大小的字典，不在字典内的数据全部泛化为一个特定词，将泛化后的数据作为预训练的数据。这里我们将此思路扩充，增加使用全量数据建立任意大小的字典。具体到word2vec类中，参数one_class的True or False决定了预训练的数据来源是单类黑样本还是全量黑白样本，参数vocabulary_size的值决定了字典大小，如果为None，就不截断，为全量字典数据。下文的实验部分会测试是单类黑样本预训练word2vec好，还是全量数据预训练更占优势，是字典截断好，还是用全量字典来预训练好。

然后是关于序列的问题，具体地说，是长文本数据特征化需求，如下图中的webshell检测等安全场景，引发了序列截断和填充的问题。

![image-20200410225942891](https://github.com/404notf0und/Always-Learning/blob/master/images/image-20200410225942891.png)

短文本数据的特征化，可以保留所有原始信息。而在某些安全场景中的长文本数据，特征化比较棘手，保留全部原始信息不太现实，需要对其进行截断，截断的方式主要有字典截断、序列软截断、序列硬截断。字典截断已经在上段说过了，序列软截断是指对不在某个范围内（参数num_words控制范围大小）的数据，直接去除或填充为某值，长文本选择直接去除，缩短整体序列的长度，尽可能保留后续更多的原始信息。如果长本文数据非常非常长，那么就算有字典截断和序列软截断，截断后的序列也可能非常长，超出了模型和算力的承受范围，此时，序列硬截断（参数max_length控制）可以发挥实际作用，直接整整齐齐截断和填充序列，保留指定长度的序列数据。这里需要注意的是，为了兼容后文将说到的“预训练+微调”训练模式中的**预训练矩阵**，序列填充值默认为0。

最后，是词向量的问题，具体说，是词嵌入向量问题。词嵌入向量的产生有三种方式：词序列索引+有嵌入层的深度学习模型、word2vec预训练产生词嵌入向量+无嵌入层的深度学习模型、word2vec预训练产生预训练矩阵+初始化参数为预训练矩阵的嵌入层的深度学习模型。这里我把这三种方式简单叫做微调、预训练、预训练+微调，从特征工程角度，这三种方式是产生词嵌入向量的方法，从模型角度，也可以看作是模型训练的三种方法。第一种微调的方式实现起来比较简单，直接使用keras的文本处理类Tokenizer就可以分词，转换为词序列，得到词序列索引，输入到深度学习模型中即可。第二种预训练的方式，调个gensim库中word2vec类预训练，对于不在预训练字典中的数据，其词嵌入向量直接填充为0，第三种预训练+微调的方式，稍微复杂一点，简单来说就是前两种方式的组合，用第二种方式得到预训练矩阵，作为嵌入层的初始化权重矩阵参数，用第一种方式得到词序列索引，作为嵌入层的原始输入。下文的实验部分会测试并对比按这三种方式训练模型的性能，先说结论：预训练+微调>预训练>微调。

## 非预期问题

预期问题，说到底都是应用层面的问题，都比较好解，非预期问题比较难顶，有些涉及到底层的知识，这正是引发我对应用能力和底层能力思考的原因。

第一个非预期问题是，已知的库和函数不能满足我们的需求。一般来说，使用keras的文本处理类Tokenizer预处理文本数据，得到词序列索引，完全没有问题。但类Tokenizer毕竟是文本数据处理类，没有考虑到安全领域的需求。比如类Tokenizer的单词分词默认会过滤所有的特殊符号，仅保留单词，而特殊符号在安全数据中是至关重要的，很多payload的构成都有着大量特殊符号，忽略特殊符号会流失部分原始信息。虽然类Tokenizer的单词分词可以不过滤特殊符号，但其分词的自由度有限，我们需要对其魔改。首先阅读了keras的文本处理源码和序列处理源码，不仅搞懂了其结构和各函数的底层实现方式，还学到了一些trick和优质代码的特性。下图为Tokenizer类的结构。借鉴并改写Tokenizer类，加入了多种分词模式，我们实现了wordindex类。

![](https://github.com/404notf0und/Always-Learning/blob/master/images/keras-text-review.png)

第二个非预期问题是，对word2vec的理解不到位，尤其是其底层原理和代码实现，导致会有一些疑惑，无法得到验证，这是潜在的问题。虽然可以直接调用gensim库中的word2vec类暂时解决问题，但我还是决定把word2vec深究深究，一方面可以答疑解惑，另一方面，就算不能调用别人的库，自己也可以造轮子自给自足。限于篇幅问题，不多讲word2vec的详细原理，原理是我们私下里花时间可以搞清楚的，不算是干货，对原理有兴趣的话，这里给大家推荐几篇优质文章，在github仓库[Always-Learning](https://github.com/404notf0und/Always-Learning)中。

![image-20200411153824223](https://github.com/404notf0und/Always-Learning/blob/master/images/image-20200411153824223.png)

这里，只以其中的关键点之一“负采样”来举例。word2vec本质上是一个神经网络模型，具体来说此神经网络模型是一个输入层-嵌入层-输出层的三层结构，我们用到的词嵌入向量只是神经网络模型的副产物，是模型嵌入层的权重矩阵。以word2vec实现方式之一的skip-gram方法为例，此方法本质是通过中心词预测周围词。如果有一段话，要对这段话训练一个word2vec模型，那么很明显需要输入数据，还要是打标的数据。以这段话中的某个单词为中心词为例，在一定滑动窗口内的其他单词都默认和此单词相关，此单词和周围其他单词，一对多产生多个组合，默认是相关的，因此label为1，即是输入数据的y为1，而这些单词组合的one-hot编码是输入数据的x。那么很明显label全为1，全为positive sample，需要负采样来中和。这里的负采样不是简单地从滑动窗口外采样，而是按照词频的概率，取概率最小的一批样本来做负样本（这个概念下面马上要用到），因为和中心词毫不相关，自然label为0。负采样的原理到这里简单说完，talk “talk is cheap，show me your code” is cheap，看到对应代码的实现才心里踏实。word2vec的底层代码实现有多种方式，有tensorflow1.x版的，有keras版的，为了适应新版tensorflow，我用tensorflow2.x改写了tensorflow1.x版的word2vec，几种不同实现的代码在FXY仓库的[**tutorials**](https://github.com/404notf0und/FXY/tree/master/tutorials)文件夹。其实原理都相同，只是其中的写法不同。以原生的tensorflow2.x版为例，跟踪一下负采样的实现，tensorflow中的nce_loss函数实现了loss和负采样。

![image-20200411161310765](https://github.com/404notf0und/Always-Learning/blob/master/images/image-20200411161310765.png)

以负采样参数num_sampled为线索，跟进nce_loss函数，

![image-20200411162156323](https://github.com/404notf0und/Always-Learning/blob/master/images/image-20200411162156323.png)

跟进_compute_sampled_logits()函数，该函数负责采样

![image-20200411162357196](https://github.com/404notf0und/Always-Learning/blob/master/images/image-20200411162357196.png)

一直往下跟，遇到点问题，tensorflow的超底层代码太难懂，没有明确跟到负样本的产生。这里，联想上面我们说到负采样是按照词频的概率，取概率最小的一批样本来做负样本，再对应到tensorflow官方实现的word2vec代码中的下段代码，下段代码实现了词频排序。

![image-20200411165843584](https://github.com/404notf0und/Always-Learning/blob/master/images/image-20200411165843584.png)

那么log_uniform_candidate_sampler函数极有可能利用参数labels、num_sampled、num_classes生成一批接近vocabulary_size大小的随机数，作为负样本。

第三个非预期问题是装备太差。由于没带电脑回家，加上疫情的影响，只好租了台辣鸡笔记本，再搭载手机热点100K左右的强势网速，收集数据，跑代码，出现一些问题。比如4G内存跑代码的时候总报内存错误，只好使用不吃内存的代码重写了报错代码。受限于网速、硬件、软件等，一方面制约了学习，另一方面又无意中优化了代码，使得在辣鸡电脑上都能正常运行。

## 轮子的能力和构成

解决了这一系列问题，轮子的雏形终于显现，轮子内置多种通用特征化方法和多种通用深度学习模型，以支持多种安全场景的特征化和模型训练，达到流水线式作业。最初的考虑是只做特征工程，不向后兼容，即不加入模型部分，同学们自己做模型训练。之后细想，需要向后兼容，模型部分模型不在多，但起码要有几种模型作为demo，这样就形成了一个管道，一条龙作业。

轮子的具体构成包括：安全数据类、特征类、算法类。

首先是安全数据，目前收集、测试通过了6种安全数据，之后会继续扩充。

![image-20200411185410705](https://github.com/404notf0und/Always-Learning/blob/master/images/image-20200411185410705.png)

文件命名中第一个A/B标记了数据是否异源（这里对异源的定义是数据来自不同的github仓库），第二个A/B标记了训练集/测试集。

其次是特征类，目前实现了tfidf类、wordindex类和word2vec类，其中wordindex和word2vec是重点。

wordindex类参数主要有：num_words、char_level、max_length、punctuation

num_words：序列软截断参数，如果设置为1000，则字典大小为1000个词，不在此范围内的词会被泛化。

char_level：True or False，True指按字符粒度分词，False指按单词粒度分词。

max_length：序列硬截断参数，如果设置为100，则序列长度会被截断/填充到100。

punctuation：分词模式参数，需要char_level=False为前提，如果值为‘concise’，即忽略所有特殊符号，值为‘all’，将所有特殊符号都分词，值为‘define’，调用自定义分词模式。

word2vec类参数主要有：punctuation、tunning、one_class、out_dimension、vocabulary_size、num_words、max_length、embedding_size。

tunning：是否是”预训练+微调“模式，True or False。

one_class：预训练的来源是单类黑样本还是全量样本，True or False。

out_dimension:输出向量是三维向量还是二维向量，三维词嵌入向量是为了对接深度学习模型，二维词嵌入向量可以对接机器学习模型，2 or 3。

vocabulary_size：预训练前的字典的大小，可以保证快速预训练。

embedding_size：词嵌入向量的维度。

最后是算法类，目前包括lstm和textcnn两个模型，两个模型中内置3种训练方式，分别用于对接wordindex类、word2vec类的预训练模式、word2vec类的预训练+微调模式。

## 测试

使用其中part1A_url.csv和part1B_url.csv数据集，测试轮子鲁棒性的同时，给出系列参数设置及对应的结果，限于篇幅，详细测试报告在FXY仓库[docs](https://github.com/404notf0und/FXY/blob/master/docs/%E6%B5%8B%E8%AF%95%E7%94%A8%E4%BE%8B.md)文件夹中。

## 测试结果分析

- [x] 分词模式考虑全部特殊符号比忽略特殊符号，最终效果要好，因为在很多payload中特殊符号有一定占比。
- [x] 根据攻击模式自定义分词模式，效果最好。

- [x] 预训练的数据不是越多越好，字典也不是越大越好，随着数据量的增大，性能有上限，该截断就截断。
- [x] 训练模式效果对比一般有：预训练+微调>预训练>微调。
- [x] 无论是同源数据还是异源数据，只有其攻击模式类似，模型效果就可以泛化到。

这篇文章是对我部分工作的一个总结，把零零散散的单点串成线，站在一个更高的视角看待问题。真相在第五层，我以为我看到了第二层，但我可能还在第一层，我们能做的是，唯有不断思考。