开发和代码是作为一名安全人员不可或缺的能力,而我们现在学习就可以以前人开发的工具入手,学习其代码逻辑,设计理念等等。从而写出更好的属于自己的工具。
本篇文章以几款扫描器为例,分析其前期对目标探测的模块进行入手学习。
Fscan
在读取完各种参数后,进入到解析ip中
若传入的不是文件且包含端口的ip,则先分割出ip和port然后丢入 ParseIPs
进行解析,如果没有携带端口则直接进入ParseIPs,若是文件则进行文件处理后再解析,所以我们跟进到 ParseIPs
中
func ParseIP(host string, filename string, nohosts ...string)(hosts []string, err error){
if filename ==""&& strings.Contains(host,":"){
//192.168.0.0/16:80
hostport := strings.Split(host,":")
if len(hostport)==2{
host = hostport[0]
hosts =ParseIPs(host)
Ports= hostport[1]
}
}else{
hosts =ParseIPs(host)
if filename !=""{
var filehost []string
filehost, _ =Readipfile(filename)
hosts = append(hosts, filehost...)
}
}
//nohosts不扫描的ip
//..篇幅省略
//去重
hosts =RemoveDuplicate(hosts)
if len(hosts)==0&& len(HostPort)==0&& host !=""&& filename !=""{
err =ParseIPErr
}
return
}
主要是这个对于A段扫描时处理不够完善,fscan为了避免扫描过多的ip采用了随机扫描的方式,如果用户就是需要扫描整个/8网段,则可能会遗漏,我们可以对其修改如下
func parseIP(ip string)[]string {
reg := regexp.MustCompile(`[a-zA-Z]+`)
switch{
case ip =="192":
return parseIP("192.168.0.0/8")
case ip =="172":
return parseIP("172.16.0.0/12")
case ip =="10":
return parseIP("10.0.0.0/8")
// 扫描/8时,只扫网关和随机IP,避免扫描过多IP
case strings.HasSuffix(ip,"/8"):
return parseIP8(ip)
//解析 /24 /16 /8 /xxx 等
case strings.Contains(ip,"/"):
return parseIP2(ip)
//可能是域名,用lookup获取ip
case reg.MatchString(ip):
// _, err := net.LookupHost(ip)
// if err != nil {
// return nil
// }
return[]string{ip}
//192.168.1.1-192.168.1.100
case strings.Contains(ip,"-"):
return parseIP1(ip)
//处理单个ip
default:
testIP := net.ParseIP(ip)
if testIP == nil {
return nil
}
return[]string{ip}
}
}
这里我直接就参考dddd的写法改写了
func parseIP8(ip string)[]string {
var AllIP[]string
for _, i := range CIDRToIP(ip){
AllIP= append(AllIP, i.String())
}
returnAllIP
}
func CIDRToIP(cidr string)(IPs[]net.IP){
_, network, _ := net.ParseCIDR(cidr)
first :=FirstIP(network)
last :=LastIP(network)
return pairsToIP(first, last)
}
func FirstIP(network *net.IPNet) net.IP {
return network.IP
}
func LastIP(network *net.IPNet) net.IP {
firstIP :=FirstIP(network)
mask, _ := network.Mask.Size()
size := math.Pow(2, float64(32-mask))
lastIP := toIP(toInt(firstIP)+ uint32(size)-1)
return net.ParseIP(lastIP)
}
func toIP(i uint32) string {
buf := bytes.NewBuffer([]byte{})
_ = binary.Write(buf, binary.BigEndian, i)
b := buf.Bytes()
return fmt.Sprintf("%v.%v.%v.%v", b[0], b[1], b[2], b[3])
}
func toInt(ip net.IP) uint32 {
var buf =[]byte(ip)
if len(buf)>12{
buf = buf[12:]
}
buffer := bytes.NewBuffer(buf)
var i uint32
_ = binary.Read(buffer, binary.BigEndian,&i)
return i
}
func pairsToIP(ip1, ip2 net.IP)(IPs[]net.IP){
start := toInt(ip1)
end := toInt(ip2)
for i := start; i <= end; i++{
IPs= append(IPs, net.ParseIP(toIP(i)))
}
returnIPs
}
效果如下
接着就是初始化一些http客户端的参数
web poc的线程数 ThreadsNum
、代理类型 DownProxy
和超时时间 Timeout
。主要目的是配置一个 http.Client
实例,在进行 HTTP 请求时使用指定的代理和超时设置
如果noping参数为false,或者扫描参数为icmp则进行 CheckLive
的存活探测
common.LogWG.Wait()
这个是日志同步,等待所有日志记录操作完成。确保所有日志记录操作都已完成,避免日志丢失。
监听一个通道( chanHosts
)以接收IP地址。当接收到一个IP地址时,它检查该IP是否不在 ExistHosts
映射中,并且是否在 hostslist
中。如果两个条件都为真,则将IP添加到 ExistHosts
和 AliveHosts
中,并打印一条消息。
这里利用一个 ExistHosts[ip]=struct{}{}
利用Go语言的映射(map)特性来实现一个集合(set)的功能。由于映射中的值可以是任意类型,而这里使用的是空结构体 struct{}{}
,所以实际上我们并不关心值本身,而是关心键(即IP地址)是否存在。
通过这种方式,我们可以快速地检查一个IP地址是否已经存在于 ExistHosts
中,如果存在,则不需要再次添加。同时,由于空结构体不占用任何内存空间,所以这种做法也非常节省内存。
func CheckLive(hostslist []string,Ping bool)[]string {
//创建一个缓冲通道,容量为 hostslist 的长度
chanHosts := make(chan string, len(hostslist))
go func(){
for ip := range chanHosts {
if _, ok :=ExistHosts[ip];!ok &&IsContain(hostslist, ip){
ExistHosts[ip]=struct{}{}
if common.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进行探测
默认ping参数为false,所以优先尝试监听本地icmp, 进入 RunIcmp1
这个函数主要逻辑就是
-
遍历
hostslist
切片中的每个IP地址。 -
对于每个IP地址,发送一个ICMP回显请求到该IP地址。
-
在后台运行一个goroutine,监听ICMP回显应答。
-
如果收到ICMP回显应答,表示目标IP地址存活,将其添加到
AliveHosts
列表中。 -
等待一段时间,如果
AliveHosts
列表中的IP地址数量与hostslist
中的IP地址数量相匹配,则表示所有IP地址都已探测完成。
若是权限不够的话则会选择使用 RunPing
进行探测
具体实现在 ExecCommandPing
函数
根据回显,返回 true
则 ping 成功,否则返回 false
。
func RunPing(hostslist []string, chanHosts chan string){
var wg sync.WaitGroup
limiter := make(chan struct{},50)
for _, host := range hostslist {
wg.Add(1)
limiter <-struct{}{}
go func(host string){
ifExecCommandPing(host){
livewg.Add(1)
chanHosts <- host
}
<-limiter
wg.Done()
}(host)
}
wg.Wait()
}
在某些环境配置中会有 echo1>/proc/sys/net/ipv4/icmp_echo_ignore_all
这种就不会返回任何ICMP的响应。而fscan默认的扫描策略强依赖于ICMP协议,所以有可能在一些情况漏掉部分资产。
具体实现,通过建立一个TCP的连接,如果连接成功则会记录一个成功消息并将主机地址发送到一个通道。
func PortConnect(addr Addr, respondingHosts chan<- string, adjustedTimeout int64, wg *sync.WaitGroup){
host, port := addr.ip, addr.port
conn, err := common.WrapperTcpWithTimeout("tcp4", fmt.Sprintf("%s:%v", host, port), time.Duration(adjustedTimeout)*time.Second)
if err == nil {
defer conn.Close()
address := host +":"+ strconv.Itoa(port)
result := fmt.Sprintf("%s open", address)
common.LogSuccess(result)
wg.Add(1)
respondingHosts <- address
}
WrapperTcpWithTimeout
实现了一个TCP的包装器
func WrapperTcpWithTimeout(network, address string, timeout time.Duration)(net.Conn, error){
d :=&net.Dialer{Timeout: timeout}
returnWrapperTCP(network, address, d)
}
func WrapperTCP(network, address string, forward *net.Dialer)(net.Conn, error){
//get conn
var conn net.Conn
ifSocks5Proxy==""{
var err error
conn, err = forward.Dial(network, address)
if err != nil {
return nil, err
}
}else{
dailer, err :=Socks5Dailer(forward)
if err != nil {
return nil, err
}
conn, err = dailer.Dial(network, address)
if err != nil {
return nil, err
}
}
return conn, nil
}
GoGo
看完了fscan的探测逻辑,接下来可以看看其他工具的这部分的相关逻辑,这里拿dddd以及gogo为例
直接跟进到gogo的默认直接扫描逻辑
跟进 plugin.Dispatch(result)
func Dispatch(result *pkg.Result){
defer func(){
if err := recover(); err != nil {
logs.Log.Errorf("scan %s unexcept error, %v", result.GetTarget(), err)
panic(err)
}
}()
atomic.AddInt32(&RunOpt.Sum,1)
if result.Port=="137"|| result.Port=="nbt"{
nbtScan(result)
return
}elseif result.Port=="135"|| result.Port=="wmi"{
wmiScan(result)
return
}elseif result.Port=="oxid"{
oxidScan(result)
return
}elseif result.Port=="icmp"|| result.Port=="ping"{
icmpScan(result)
return
}elseif result.Port=="snmp"|| result.Port=="161"{
snmpScan(result)
return
}elseif result.Port=="445"|| result.Port=="smb"{
smbScan(result)
ifRunOpt.Exploit=="ms17010"{
ms17010Scan(result)
}elseifRunOpt.Exploit=="smbghost"||RunOpt.Exploit=="cve-2020-0796"{
smbGhostScan(result)
}elseifRunOpt.Exploit=="auto"||RunOpt.Exploit=="smb"{
ms17010Scan(result)
smbGhostScan(result)
}
return
}elseif result.Port=="mssqlntlm"{
mssqlScan(result)
return
}elseif result.Port=="winrm"{
winrmScan(result)
return
}else{
initScan(result)
}
....
...
可以看到,它对一些特定端口服务针对性的做了一些定制化的扫描,比如135(wmi)、161(snmp), 一般大部分情况来说是是不会有其他情况占用的。
跟进到默认的initScan
func initScan(result *pkg.Result){
var bs []byte
target := result.GetTarget()
if pkg.ProxyUrl!= nil && strings.HasPrefix(pkg.ProxyUrl.Scheme,"http"){
// 如果是http代理, 则使用http库代替socket
conn := result.GetHttpConn(RunOpt.Delay)
resp, err := pkg.HTTPGet(conn,"http://"+target)
if err != nil {
return
}
if err != nil {
result.Err= err
return
}
result.Open=true
pkg.CollectHttpResponse(result, resp)
}else{
defer func(){
// 如果进行了各种探测依旧为tcp协议, 则收集tcp端口状态
if result.Protocol=="tcp"{
if result.Err!= nil {
result.Error= result.Err.Error()
ifRunOpt.Debug{
result.ErrStat= handleError(result.Err)
}
}
}
}()
conn, err := pkg.NewSocket("tcp", target,RunOpt.Delay)
if err != nil {
result.Err= err
return
}
defer conn.Close()
result.Open=true
// 启发式扫描探测直接返回不需要后续处理
if result.SmartProbe{
return
}
result.Status="open"
bs, err = conn.Read(RunOpt.Delay)
if err != nil {
senddataStr := fmt.Sprintf("GET /%s HTTP/1.1rnHost: %srnrn", result.Uri, target)
bs, err = conn.Request([]byte(senddataStr),DefaultMaxSize)
if err != nil {
result.Err= err
}
}
pkg.CollectSocketResponse(result, bs)
}
//所有30x,400,以及非http协议的开放端口都送到http包尝试获取更多信息
if result.Status=="400"|| result.Protocol=="tcp"||(strings.HasPrefix(result.Status,"3")&& bytes.Contains(result.Content,[]byte("location: https"))){
systemHttp(result,"https")
}elseif strings.HasPrefix(result.Status,"3"){
systemHttp(result,"http")
}
return
}
这里不关注代理功能先,我们看默认是进入了pkg.NewSocket进行探测,封装了一个Socket的结构体,使用了go自带的net库实现的TCP的连接
func NewSocket(network, target string, delay int)(*Socket, error){
s :=&Socket{
Timeout: time.Duration(delay)* time.Second,
}
var conn net.Conn
var err error
ifProxyDialTimeout!= nil {
conn, err =ProxyDialTimeout(network, target, s.Timeout)
}else{
conn, err = net.DialTimeout(network, target, s.Timeout)
}
if err != nil {
return nil, err
}
s.Conn= conn
return s, nil
}
type Socketstruct{
Conn net.Conn
Countint
Timeout time.Duration
}
最后还会将所有的30x,400,以及非http协议的开放端口都送到http包尝试获取更多信息。
可以发现,gogo的话其实为了尽可能的优化体积以及兼容性,绝大部分功能都是采用go自带库的进行实现,对性能的占用也能达到一个比较好的效果。
其设计理念和细节值得我们去慢慢学习。
Dddd
dddd呢则是更偏向于外网的扫描器,我们也是重点就看他的主要扫描逻辑
// 端口扫描
if len(ips)>0{
if!structs.GlobalConfig.SkipHostDiscovery{
var ICMPAlive[]string
// ICMP 探测存活
if!structs.GlobalConfig.NoICMPPing{
ICMPAlive= common.CheckLive(ips,false)
}
// TCP 探测存活
var TCPAlive[]string
if structs.GlobalConfig.TCPPing{
// 获取没有存活的进行探测
var uncheck []string
for _, ip := range ips {
index := utils.GetItemInArray(ICMPAlive, ip)
if index ==-1{
uncheck = append(uncheck, ip)
}
}
gologger.Info().Msg("TCP存活探测")
common.PortScan=false
tcpAliveIPPort := common.PortScanTCP(uncheck,"80,443,3389,445,22",
structs.GlobalConfig.NoPortString,
structs.GlobalConfig.TCPPortScanTimeout)
for _, tIPPort := range tcpAliveIPPort {
t := strings.Split(tIPPort,":")
TCPAlive= append(TCPAlive, t[0])
}
}
首先是进行ICMP的探测存活,包括后面的TCP探测端口扫描,跟进之后发现其实实现代码大致是相同的
但是他这里比fscan多了一种SYN的扫描,是调用masscan进行SYN端口扫描
后面对结果经过处理后,调用Httpx进行获取相关ip的http响应
总结
其前期的探测逻辑也就到此为止,后续都是Web扫描,漏洞扫描,目录爆破等等功能,我们留到后面的文章进行分析。
本篇主要是对一些扫描器的代码进行阅读分析,并且从中发现一些借鉴学习的点或者其中一些不足之处,能够进行相关优化的地方,为想要以后自己开发扫描器的师傅提供一点学习的思路。站在巨人的肩膀上走的更远。
参考
https://chainreactors.github.io/wiki/gogo
https://github.com/shadow1ng/fscan
https://github.com/SleepingBag945/dddd
https://xz.aliyun.com/t/15318
原文始发于微信公众号(稻草人安全团队):扫描器解析日记之目标探测
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论