扫描器解析日记之目标探测
奇安信攻防社区
2024-11-04 09:19:56
收藏
本篇文章以几款扫描器为例,分析其前期对目标探测的模块进行入手学习。
Fscan
=====
在读取完各种参数后,进入到解析ip中
![image.png](https://shs3.b.qianxin.com/attack_forum/2024/10/attach-a933f94e5b5ba824ef618ffc5ce1095a5845c7a2.png)
若传入的不是文件且包含端口的ip,则先分割出ip和port然后丢入ParseIPs进行解析,如果没有携带端口则直接进入ParseIPs,若是文件则进行文件处理后再解析,所以我们跟进到ParseIPs中
```java
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
}
```
![image.png](https://shs3.b.qianxin.com/attack_forum/2024/10/attach-d5a7bcb5a0b32947f46231d904e4dfbb9278794f.png)
主要是这个对于A段扫描时处理不够完善,fscan为了避免扫描过多的ip采用了随机扫描的方式,如果用户就是需要扫描整个/8网段,则可能会遗漏,我们可以对其修改如下
```java
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的写法改写了
```java
func parseIP8(ip string) []string {
var AllIP []string
for _, i := range CIDRToIP(ip) {
AllIP = append(AllIP, i.String())
}
return AllIP
}
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)))
}
return IPs
}
```
效果如下
![image.png](https://shs3.b.qianxin.com/attack_forum/2024/10/attach-c02946d0755baf6f22cf61f8e59389b0ec4ef191.png)
接着就是初始化一些http客户端的参数
web poc的线程数 ThreadsNum、代理类型 DownProxy 和超时时间 Timeout。主要目的是配置一个 http.Client 实例,在进行 HTTP 请求时使用指定的代理和超时设置
![image.png](https://shs3.b.qianxin.com/attack_forum/2024/10/attach-8c9c7e9856ea7ca19b4ad9afa003c205f74639e9.png)
如果noping参数为false,或者扫描参数为icmp则进行CheckLive的存活探测
common.LogWG.Wait()这个是日志同步,等待所有日志记录操作完成。确保所有日志记录操作都已完成,避免日志丢失。
![image.png](https://shs3.b.qianxin.com/attack_forum/2024/10/attach-f15bb6b9f9fbb1ac84fd2a6217b4572b5e0bdd11.png)
![image.png](https://shs3.b.qianxin.com/attack_forum/2024/10/attach-1b14046b13cbda99de4c20892f8541b0c59054f4.png)
监听一个通道(chanHosts)以接收IP地址。当接收到一个IP地址时,它检查该IP是否不在ExistHosts映射中,并且是否在hostslist中。如果两个条件都为真,则将IP添加到ExistHosts和AliveHosts中,并打印一条消息。
这里利用一个ExistHosts\[ip\] = struct{}{}
以利用Go语言的映射(map)特性来实现一个集合(set)的功能。由于映射中的值可以是任意类型,而这里使用的是空结构体 struct{}{},所以实际上我们并不关心值本身,而是关心键(即IP地址)是否存在。
通过这种方式,我们可以快速地检查一个IP地址是否已经存在于 ExistHosts 中,如果存在,则不需要再次添加。同时,由于空结构体不占用任何内存空间,所以这种做法也非常节省内存。
```java
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 {
if Ping == false {
fmt.Printf("(icmp) Target %-15s is alive\n", ip)
} else {
fmt.Printf("(ping) Target %-15s is alive\n", ip)
}
}
AliveHosts = append(AliveHosts, ip)
}
livewg.Done()
}
}()
```
下面就是选择用ping还是icmp进行探测
默认ping参数为false,所以优先尝试监听本地icmp, 进入RunIcmp1
这个函数主要逻辑就是
1. 遍历hostslist切片中的每个IP地址。
2. 对于每个IP地址,发送一个ICMP回显请求到该IP地址。
3. 在后台运行一个goroutine,监听ICMP回显应答。
4. 如果收到ICMP回显应答,表示目标IP地址存活,将其添加到AliveHosts列表中。
5. 等待一段时间,如果AliveHosts列表中的IP地址数量与hostslist中的IP地址数量相匹配,则表示所有IP地址都已探测完成。
![image.png](https://shs3.b.qianxin.com/attack_forum/2024/10/attach-cd77bc2af530c3982bf440d50f22bd67b6b425c1.png)
若是权限不够的话则会选择使用RunPing进行探测
具体实现在ExecCommandPing函数
根据回显,返回 true 则 ping 成功,否则返回 false。
```java
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) {
if ExecCommandPing(host) {
livewg.Add(1)
chanHosts <- host
}
<-limiter
wg.Done()
}(host)
}
wg.Wait()
}
```
![image.png](https://shs3.b.qianxin.com/attack_forum/2024/10/attach-45c1dac3c6bb621a1585192f155913da810d46aa.png)
在某些环境配置中会有echo 1 > /proc/sys/net/ipv4/icmp\_echo\_ignore\_all 这种就不会返回任何ICMP的响应。而fscan默认的扫描策略强依赖于ICMP协议,所以有可能在一些情况漏掉部分资产。
![image.png](https://shs3.b.qianxin.com/attack_forum/2024/10/attach-bec6b739ef1bb01d69af8395513bfe8fa29fb9b8.png)
具体实现,通过建立一个TCP的连接,如果连接成功则会记录一个成功消息并将主机地址发送到一个通道。
```java
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的包装器
```java
func WrapperTcpWithTimeout(network, address string, timeout time.Duration) (net.Conn, error) {
d := &net.Dialer{Timeout: timeout}
return WrapperTCP(network, address, d)
}
func WrapperTCP(network, address string, forward *net.Dialer) (net.Conn, error) {
//get conn
var conn net.Conn
if Socks5Proxy == "" {
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的默认直接扫描逻辑
![image.png](https://shs3.b.qianxin.com/attack_forum/2024/10/attach-993a539045549f1c289dd54725dedb888961abf4.png)
跟进plugin.Dispatch(result)
```java
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
} else if result.Port == "135" || result.Port == "wmi" {
wmiScan(result)
return
} else if result.Port == "oxid" {
oxidScan(result)
return
} else if result.Port == "icmp" || result.Port == "ping" {
icmpScan(result)
return
} else if result.Port == "snmp" || result.Port == "161" {
snmpScan(result)
return
} else if result.Port == "445" || result.Port == "smb" {
smbScan(result)
if RunOpt.Exploit == "ms17010" {
ms17010Scan(result)
} else if RunOpt.Exploit == "smbghost" || RunOpt.Exploit == "cve-2020-0796" {
smbGhostScan(result)
} else if RunOpt.Exploit == "auto" || RunOpt.Exploit == "smb" {
ms17010Scan(result)
smbGhostScan(result)
}
return
} else if result.Port == "mssqlntlm" {
mssqlScan(result)
return
} else if result.Port == "winrm" {
winrmScan(result)
return
} else {
initScan(result)
}
....
...
```
可以看到,它对一些特定端口服务针对性的做了一些定制化的扫描,比如135(wmi)、161(snmp), 一般大部分情况来说是是不会有其他情况占用的。
![image.png](https://shs3.b.qianxin.com/attack_forum/2024/10/attach-3827d92d0badb7b1ba6a5f91277d770b53a8a815.png)
跟进到默认的initScan
```java
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()
if RunOpt.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.1\r\nHost: %s\r\n\r\n", 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")
} else if strings.HasPrefix(result.Status, "3") {
systemHttp(result, "http")
}
return
}
```
这里不关注代理功能先,我们看默认是进入了pkg.NewSocket进行探测,封装了一个Socket的结构体,使用了go自带的net库实现的TCP的连接
```java
func NewSocket(network, target string, delay int) (*Socket, error) {
s := &Socket{
Timeout: time.Duration(delay) * time.Second,
}
var conn net.Conn
var err error
if ProxyDialTimeout != 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 Socket struct {
Conn net.Conn
Count int
Timeout time.Duration
}
```
最后还会将所有的30x,400,以及非http协议的开放端口都送到http包尝试获取更多信息。
![image.png](https://shs3.b.qianxin.com/attack_forum/2024/10/attach-dad829c224f2254735faacae1bce472a7087c873.png)
可以发现,gogo的话其实为了尽可能的优化体积以及兼容性,绝大部分功能都是采用go自带库的进行实现,对性能的占用也能达到一个比较好的效果。
其设计理念和细节值得我们去慢慢学习。
Dddd
====
dddd呢则是更偏向于外网的扫描器,我们也是重点就看他的主要扫描逻辑
```java
// 端口扫描
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探测端口扫描,跟进之后发现其实实现代码大致是相同的
![image.png](https://shs3.b.qianxin.com/attack_forum/2024/10/attach-433c316a4dbd96b86c0dcd84710047a5f188abe5.png)
![image.png](https://shs3.b.qianxin.com/attack_forum/2024/10/attach-b98b6cfeca52fac9a2510f9abccf8a22771bef51.png)
但是他这里比fscan多了一种SYN的扫描,是调用masscan进行SYN端口扫描
![image.png](https://shs3.b.qianxin.com/attack_forum/2024/10/attach-75aa19120a990429ba849635b07d4c87aec01006.png)
后面对结果经过处理后,调用Httpx进行获取相关ip的http响应
![image.png](https://shs3.b.qianxin.com/attack_forum/2024/10/attach-06e1955424934e24321e852114baaa795789c937.png)
总结
==
其前期的探测逻辑也就到此为止,后续都是Web扫描,漏洞扫描,目录爆破等等功能,我们留到后面的文章进行分析。
本篇主要是对一些扫描器的代码进行阅读分析,并且从中发现一些借鉴学习的点或者其中一些不足之处,能够进行相关优化的地方,为想要以后自己开发扫描器的师傅提供一点学习的思路。站在巨人的肩膀上走的更远。
参考
==
<https://chainreactors.github.io/wiki/gogo>
<https://github.com/shadow1ng/fscan>
<https://github.com/SleepingBag945/dddd>
<https://xz.aliyun.com/t/15318>
侵权请联系站方: [email protected]