fscan 可能是国内最早最知名的内网端口扫描器之一了,最早知道这个扫描器是因为他的速度,比起 nmap 来说是真的快了不少。
听说 fscan2 快要出了,想到自己还没学习过鼎鼎有名的 fscan 的源码,略感惭愧。于是心血来潮决定来对比学习一下第一代 fscan 和即将发布的 fscan2 之间。
略有困意
我们先看看经典的老版本 fscan 吧,看看多年实战历练的代码是什么样子的。
这个项目结构很简洁啊,一眼望去可以看到
- Plugins
- WebScan
- common
- main.go
没了,就这 4 个,大小写略有混乱无伤大雅,一眼望过去 common 就是我们的核心了,Plugins 明显是各种协议,设计成 Plugins 单独提出来,非常阳间,WebScan 是针对于 http 的一些专用探测和 poc,太简洁了,牛逼。
对的对的,那我们这就打开 common 看看吧,整体主逻辑肯定就在里面了跑不掉,非常符合我们的幻想啊,太棒了。
- Parse.go
- ParseIP.goo
- ParsePort.go
- config.go
- flag.go
- log.go
- proxy.go
这对吗,我的 Scanner 呢?
(沉默片刻)
或许我应该看看 main.go …
func main() {
start := time.Now()
var Info common.HostInfo
common.Flag(&Info)
common.Parse(&Info)
Plugins.Scan(Info)
fmt.Printf("[*] 扫描结束,耗时: %s\n", time.Since(start))
}
原来 Scanner 的主逻辑也是一个 Plugins,super 插件化,狠狠插拔,一切皆为插件,我的我的,太兴奋了没有先看该看的东西。
其实我们如果再看到 Plugins 包,会发现里面有一堆 xxx.go 丢在那。这也包括扫描器的核心逻辑在内,所以其实是一种大平层的结构,感觉和 plugins 关系不是很大。
func Scan(info common.HostInfo) {
fmt.Println("start infoscan")
Hosts, err := common.ParseIP(info.Host, common.HostFile, common.NoHosts)
if err != nil {
fmt.Println("len(hosts)==0", err)
return
}
// ...
}
第一步,解析IP段,对的对的,让我们跳进去拜读一下
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...)
}
}
//...
}
func ParseIPs(ip string) (hosts []string) {
if strings.Contains(ip, ",") {
IPList := strings.Split(ip, ",")
var ips []string
for _, ip := range IPList {
ips = parseIP(ip)
hosts = append(hosts, ips...)
}
} else {
hosts = parseIP(ip)
}
return hosts
}
先处理了带端口号的情景,甚至支持了从文件读取,看上去没有兼容v6,反正正经 v6 也没人拿来扫,正确的抉择,如果宝宝能用 net.SplitHostPort 就更好了。
OK没问题,我们再往下,看看ip是如何进一步被解析的。
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)
//...
}
}
我勒个手写 Parser,是标准库不好用吗?
有点不对哥们,这对吗? 192.168.0.0/8
这给我干哪去了,感觉这兴许是作者笔误吧,人之常情,都是可以理解的。
好戏开场
唉,我勒个 /8
再看看别的吧兄弟们, 人之常情不要鞭尸人家了。
我们回到前面吧,上面的 Parse 函数返回了一个 (?)
一个 []string
刚刚还在提 /8 呢,这得多少内存。不过没事的,我们 fscan 针对 /8 还做了启发式探测,实际扫描量级就不大了。
func parseIP(ip string) []string {
// ...
case strings.HasSuffix(ip, "/8"):
return parseIP8(ip)
// ...
}
不过很遗憾的是,似乎,仅仅为 /8
做了这个特例,那最坏无非就是 /9
罢了,什么 /7
这些内网里都不会有的,我们要清楚 fscan 的定位。
就让我们算一算,我们 golang 的字符串不就一个 ptr 一个 len 嘛,假设一个 IP String 长度为 xx.xxx.xxx.xx 比较平均的长度了,12 bytes,再搞个 length,搞个 ptr,就当是 32 位内存地址算他刚刚好 20 bytes 吧,哥们也不会内存对齐,就这么来吧,128256256*20 = 100663296 bytes = 160 MB。
一块 160MB 的内存,相信兄弟,这无伤大雅,这年头谁服务器没个 160MB 呢。就让他们爱写的写他们的 generator 去吧。
下面干啥呢,该探测 IP 存活了吧,都看到这里了,再看看吧
func CheckLive(hostslist []string, Ping bool) []string {
chanHosts := make(chan string, len(hostslist))
go func() {
for ip := range chanHosts {
if _, ok := ExistHosts[ip]; !ok && IsContain(hostslist, ip) {
ExistHosts[ip] = struct{}{}
//...
func IsContain(items []string, item string) bool {
for _, eachItem := range items {
if eachItem == item {
return true
}
}
return false
}
我的天,刚刚说完这个size,又拉了一块巨大的内存,这又得多少,64MB吧,这下子两百多了, 甚至还有无尽的 IsContain 大数组遍历,有这个闲工夫都能排个序了。
测试环境x64,占用较高于计算值
又不是不能用,哪有这种极端场景,正经用途扫个小段差不多得了,fscan 多少年了不是都用的好好的。
反复质疑
还是在看看 icmp 吧,icmp 确实很花活,用了三种吧,包括监听 icmp,发送 icmp,还有直接执行,方案很多写的确实可以吧,这个。
func ExecCommandPing(ip string) bool {
var command *exec.Cmd
switch runtime.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: //linux
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"
}
//...
}
哎,这不是喜闻乐见的字符串拼接嘛,早先听说的 rce 其实就出在这里,不过都执行 fscan 了还能还缺个命令执行吗,感觉一般不是特别缺,最多是那种集成 fscan 的 web,或许有个后台 RCE 吧。
https://github.com/shadow1ng/fscan/issues/321
这里其实有很多方法解决这个问题,大家都是做安全的,安全工具出安全问题其实不少,但是是最简单的命令拼接,总感觉有点罕见。
我们可以拿标准库转成 net.IP 再转回来,也可以强正则,也可以最简单的,扬了bash -c
, ping 的结果和 exit code 又不是不能编程获取,现在这个搞法得绑定 bash,假如对面容器只有 sh,又得烂了,实在是没任何好处,不理解为什么这么搞,总之不太满意这个方案。
哎,我们 IP 探活搞完了,作为一个扫描器,下面就要探端口了,去找系统搞点 SYN 包发发。
我们的 portscan,默认开了 600 个 worker 协程在那扫,然后两个 channel,一个给 worker 塞 port 一个取结果,这都可以接受。
//接收结果
go func() {
for found := range results {
AliveAddress = append(AliveAddress, found)
wg.Done()
}
}()
//多线程扫描
for i := 0; i < workers; i++ {
go func() {
for addr := range Addrs {
PortConnect(addr, results, timeout, &wg)
wg.Done()
}
}()
}
//添加扫描目标
for _, port := range probePorts {
for _, host := range hostslist {
wg.Add(1)
Addrs <- Addr{host, port}
}
}
wg.Wait()
甚至还有注释,不过这又傻眼,这咋一个 wg.Add 配了两个 Done, 定睛一看有个 &wg 被丢到 PortConnect 里面去了。不太常规的手法,看来很自信啊,那就看看 PortConnect 吧。
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
}
}
果然这里有个 Add(1) 除了混用 wg 有点小乱,本人功力不够,一眼看不过来。fscan 老版本还是经典,虽然不太符合幻想,但是还算是整整齐齐,没有什么大的问题。
愚昧之巅
是谁的愚昧之巅?
其实上面提到的这些都是小事情,又不是不能跑,fscan 毕竟是多年实战过的。
有一句话说得好,群众的眼睛是雪亮的,当然是好用才用啊,没啥特别大的问题,这倒显得我上面有点吹毛求疵了。
在开头,就提到我们要搞两个版本的 fscan 对比,还有一半内容呢。一代有点小错误那叫经典,现在还没发布的二代我不好说作者有多少开发能力在。
看看二代的文档吧。
目前已经完成的新增功能:
新增Telnet、VNC、Elasticsearch、RabbitMQ、Kafka、ActiveMQ、LDAP、SMTP、IMAP、POP3、SNMP、Zabbix、Modbus、Rsync、Cassandra、Neo4j扫描。
新增 SYN 和 UDP 端口扫描。
一些常规的协议新增,外加两个比较核心的 SYN 和 UDP 更新。
UDP 似乎前段时间被移除了,不然我还真挺好奇他有没有什么好方案做这玩意的端口扫描的。就剩下 SYN 了,看看 SYN 吧。
func SynScan(ip string, port int, timeout int64) (bool, error) {
ifName := getInterfaceName()
sendConn, err := net.ListenPacket("ip4:tcp", "0.0.0.0")
if err != nil {
return false, fmt.Errorf("发送套接字错误: %v", err)
}
defer sendConn.Close()
rawConn, err := ipv4.NewRawConn(sendConn)
if err != nil {
return false, fmt.Errorf("原始连接错误: %v", err)
}
dstIP := net.ParseIP(ip)
if dstIP == nil {
return false, fmt.Errorf("IP地址无效: %s", ip)
}
handle, err := pcap.OpenLive(ifName, 65536, true, pcap.BlockForever)
// ...
}
废话不多说,直冲关键函数,入参 ip port timeout,随后对每一个 port 去 OpenLive。
对每一个 port 去 OpenLive
已经两眼一黑了,对每一个 port 都要执行一波打开 pcap 和关闭的操作,这你看看,和我们去开个 tcp socket 比比看,该占 fd 的还是占用 fd,要速度嘛开关 pcap 还能比开关 tcp 有优势不成?这明显快不了一点,甚至更烂了。
我们再看看他 OpenLive 传的参,ifName 是 getInterfaceName()
来的,看看这个函数吧。
// 获取系统对应的接口名
func getInterfaceName() string {
switch runtime.GOOS {
case "windows":
return "\\Device\\NPF_Loopback"
case "linux":
return "lo"
case "darwin":
return "lo0"
default:
return "lo"
}
}
回环口…
在回环口监听我们内网探测的 SYN-ACK 的回包,这能监听到?
这不可能监听到的,这谁家好人流量是从回环口回来的。
我可以直接断言说,写这段代码的人肯定首先不懂网络,不知道什么是回环口。但是你说他又能整齐地写出多个系统特别是 windows 的回环口名称,这段代码大概率是 AI 补出来的,而且他肯定没测过,亦或者只测过本机,但凡测了就知道这是明显跑不了的。
说到 AI 其实还有一段代码 AI 味甚至有点冲了
func SynScan(ip string, port int, timeout int64) (bool, error) {
// ...
// TCP头部设置保持不变
tcpHeader := &ipv4.Header{
Version: 4,
Len: 20,
TotalLen: 40,
TTL: 64,
Protocol: 6,
Dst: dstIP,
}
// SYN包构造保持不变
synPacket := make([]byte, 20)
binary.BigEndian.PutUint16(synPacket[0:2], uint16(srcPort))
binary.BigEndian.PutUint16(synPacket[2:4], uint16(port))
binary.BigEndian.PutUint32(synPacket[4:8], uint32(1))
binary.BigEndian.PutUint32(synPacket[8:12], uint32(0))
synPacket[12] = 0x50
synPacket[13] = 0x02
binary.BigEndian.PutUint16(synPacket[14:16], uint16(8192))
binary.BigEndian.PutUint16(synPacket[16:18], uint16(0))
binary.BigEndian.PutUint16(synPacket[18:20], uint16(0))
checksum := calculateTCPChecksum(synPacket, tcpHeader.Src, tcpHeader.Dst)
// ...
}
func calculateTCPChecksum(tcpHeader []byte, srcIP, dstIP net.IP) uint16 {
// 创建伪首部
pseudoHeader := make([]byte, 12)
copy(pseudoHeader[0:4], srcIP.To4())
copy(pseudoHeader[4:8], dstIP.To4())
pseudoHeader[8] = 0
pseudoHeader[9] = 6 // TCP协议号
pseudoHeader[10] = byte(len(tcpHeader) >> 8)
pseudoHeader[11] = byte(len(tcpHeader))
// 计算校验和
var sum uint32
// 计算伪首部的校验和
for i := 0; i < len(pseudoHeader)-1; i += 2 {
sum += uint32(pseudoHeader[i])<<8 | uint32(pseudoHeader[i+1])
}
// 计算TCP头的校验和
for i := 0; i < len(tcpHeader)-1; i += 2 {
sum += uint32(tcpHeader[i])<<8 | uint32(tcpHeader[i+1])
}
// 如果长度为奇数,处理最后一个字节
if len(tcpHeader)%2 == 1 {
sum += uint32(tcpHeader[len(tcpHeader)-1]) << 8
}
// 将高16位加到低16位
for sum > 0xffff {
sum = (sum >> 16) + (sum & 0xffff)
}
// 取反
return ^uint16(sum)
}
之所以说他 AI 味道重,一个是直觉判断,这一眼望去注释加的很符合 AI 的习惯,不过这倒也还好,毕竟黑猫白猫捉到老鼠就是好猫猫嘛。
另一个原因则是这段代码的幻觉比较严重。
我们可以看到,在 SynScan 里,我们创建了 tcpHeader 但是注意,这个 tcpHeader 是没有 srcip 的,毕竟这段代码的作者连源接口都找不到,我也不指望他找到正确的源 ip。
随后呢幻觉就出现了,这里直接把 tcpHeader.Src 传入了 calculateTCPChecksum 来获取 checksum。
是的,他丢了一个零值的 net.IP 进去,我琢磨着人类的上下文有这么短吗,这就忘了自己前面没有 srcIP 了?这不小心丢进去的参数,估计是 0.0.0.0 吧,按照 go 的尿性,这算出来的 checksum 毫无疑问,肯定是错的,这下连 SYN 包本体都是错的了,这还怎么探测。
我敢断言,作者甚至连本地都没测过,就把代码推上来喂给我们吃了。
代码水平不高,没事,您慢慢写,这年头还有 AI 帮忙呢,慢慢折腾,代码乱点也就乱点,总是写得出来的,但是这测都测不通的喂给大家还是太过了点。
无独有偶
新加的 SYN 已经一坨了,gogo 有句话说得好,这给红队的内网扫描啊确实不该加 syn。也罢,不用 syn 就是了。
我们还有一些代码没看过,这主要是后续的服务探测,爆破以及poc部分。
那就来看看吧,挑一个最简单的,SSH,这总不可能出错吧。
func SshScan(info *Common.HostInfo) (tmperr error) {
// ...
// 遍历所有用户名密码组合
for _, user := range Common.Userdict["ssh"] {
for _, pass := range Common.Passwords {
tried++
pass = strings.Replace(pass, "{user}", user, -1)
Common.LogDebug(fmt.Sprintf("[%d/%d] 尝试: %s:%s", tried, total, user, pass))
// 重试循环
for retryCount := 0; retryCount < maxRetries; retryCount++ {
if retryCount > 0 {
Common.LogDebug(fmt.Sprintf("第%d次重试: %s:%s", retryCount+1, user, pass))
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(Common.Timeout)*time.Second)
done := make(chan struct {
success bool
err error
}, 1)
go func(user, pass string) {
success, err := SshConn(info, user, pass)
select {
case <-ctx.Done():
case done <- struct {
success bool
err error
}{success, err}:
}
}(user, pass)
var err error
select {
case result := <-done:
err = result.err
if result.success {
successLog := fmt.Sprintf("SSH认证成功 %v:%v User:%v Pass:%v",
info.Host, info.Ports, user, pass)
Common.LogSuccess(successLog)
time.Sleep(100 * time.Millisecond)
cancel()
return nil
}
case <-ctx.Done():
err = fmt.Errorf("连接超时")
}
// ...
}
}
}
Common.LogDebug(fmt.Sprintf("扫描完成,共尝试 %d 个组合", tried))
return tmperr
}
这段代码咋一看其实挺难理解的
首先它用 2 个 for 循环构造了用户名密码的遍历,然后再用一个 for 循环构造了重试。知道了这些外面的代码就不用看了,看三个 for 里面的部分就可以了。
里面呢先是一个限定时间的 contextWithTimeout,用来计时,他这个用法不敢苟同,不过至少能跑。
然后呢起了一个协程,这就是我们的扫描任务了,再是呢起了一个名为 done 的 channel 用来从这个协程中传出结果,还凑合,是吧?
先不提超时返回的时候 ssh 连接的那个协程还在跑的事情,这个时候就有点疑问了,何必呢?我们 ssh 包又不是没有超时控制的能力,可能这段代码的作者不知道吧,我想,那也能理解。
那他为啥不用 DialContext 呢?兴许不会用吧,也不是不能理解,我刚学 go 那会也不会用 context,指不定作者是个 php 转 go 呢。
func SshConn(info *Common.HostInfo, user string, pass string) (flag bool, err error) {
// ...
config := &ssh.ClientConfig{
User: user,
Auth: auth,
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
return nil
},
Timeout: time.Duration(Common.Timeout) * time.Millisecond,
}
client, err := ssh.Dial("tcp", fmt.Sprintf("%v:%v", info.Host, info.Ports), config)
if err != nil {
return false, err
}
defer client.Close()
// ...
}
好,我们看到 ssh 部分的主逻辑,刚刚还在想着我的 Timeout,这不出现了,那作者看起来是知道里面有个 Timeout 的。可能写着写着忘了,我又定睛一看,这里面外面的 Timeout 长得还不一样。
flag.Int64Var(&Timeout, "time", 3, "设置连接超时时间(单位:秒)")
time.Duration(Common.Timeout) * time.Millisecond //内
time.Duration(Common.Timeout)*time.Second //外
这里面咋是个 Millisecond,所以其实 ssh 的超时实质上变成了 3ms。
这下 SSH 彻底爆不出来了,您看怎么着?留着给作者爆本机docker里的sshd吧。
顺带一提 fscan1 在 ssh 部分处理得很朴实,而且功能实现都是没有问题的。这两个版本的 ssh 爆破代码结构非常接近,函数名也一样。也就是说,fscan2连照抄都是错的。
实在没多少耐心点评 fscan2 了,我们再纵观一下这段代码吧,fscan2 的 ssh,看看最后一个疑点
if err != nil {
errlog := fmt.Sprintf("SSH认证失败 %v:%v User:%v Pass:%v Err:%v",
info.Host, info.Ports, user, pass, err)
Common.LogError(errlog)
if retryErr := Common.CheckErrs(err); retryErr != nil {
if retryCount == maxRetries-1 {
return err
}
continue
}
}
这里可以看到有一个奇怪的 Common.CheckErrs 函数,其实就是他决定了要不要继续重试,这让我非常好奇,一个通用的函数如何判断好来自各个库各个协议的错误呢?一个处理不好就会直接失效。
// CheckErrs 检查是否为需要重试的错误
func CheckErrs(err error) error {
if err == nil {
return nil
}
// 已知需要重试的错误列表
errs := []string{
"closed by the remote host", "too many connections",
"EOF", "A connection attempt failed",
"established connection failed", "connection attempt failed",
"Unable to read", "is not allowed to connect to this",
"no pg_hba.conf entry",
"No connection could be made",
"invalid packet size",
"bad connection",
}
// 检查错误是否匹配
errLower := strings.ToLower(err.Error())
for _, key := range errs {
if strings.Contains(errLower, strings.ToLower(key)) {
time.Sleep(3 * time.Second)
return err
}
}
return nil
}
所以,他这个其实就是,把可能发生的需要重试的错误的文本,全部集中在这里,然后通过error的文本,我们判断是否需要重试
也是个方法吧,我已经没有什么不可接受的了
但是正如之前所说一个不好,处理漏了就烂了,按理说应该交给每个模块自己处理错误。
还是找找看是不是真的存在猜想的案例吧
这随便一搜就是一大把,很多连接超时的 error 是作者自己生成的,同时他忘记了自己的重试错误列表里没有中文版本的连接超时。
这会直接导致两个问题:
- 网络波动造成的偶尔连接超时会丢失掉一个用户名密码组合,丢失案例
- 服务确实连不上引发的连接超时得不到处理,扫描器会仍然会继续尝试其他组合,浪费时间
此外,搜索的时候,我们会发现他的插件结构几乎一模一样,有很多重复代码,但是有一块比较奇怪
我们上面看到
if retryErr := Common.CheckErrs(err); retryErr != nil {
if retryCount == maxRetries-1 {
return err
}
continue
}
如果触发太多次需要重试的错误(例如网络超时)就会直接返回,这个设计是非常正确的。因为这个时候往往服务已经不可用了,可能是被打挂了,可能是自己被ban了,总之确实是不该重试了直接跳出就好。
但是我们又看到
if retryErr := Common.CheckErrs(err); retryErr != nil {
if retryCount == maxRetries-1 {
continue
}
continue // 继续重试
}
这干啥呢,刚刚还觉得好的设计直接消失
这里写错问题很大吗?其实也不是特别大,但是确实这个心理和过山车一样,感觉不争气啊,好的设计留不住一点。
这也太多处了,同样的问题。感觉就像是…一次写错了,然后拿这个板子狠狠 copy paste,粗制滥造,最后搞的这里一坨那里一坨,这要是修也很麻烦。
实在不想看了,现在这个2,能跑起来也是一种奇迹了。
噩梦惊醒
上面提到了很多错误,或者不合适的地方。有些呢看上去只是笔误,有些呢是万万想不明白是怎么写出来的,还有些呢是代码复用差cv错代码导致的。
如果是小问题,本来想提 pr 的,修一修得了,你好我也好。但是这问题实在太多了,只能变成 blog 了。
我可以讲项目结构问题,很多细节可以改改,比如对于爆破类插件完全可以抽象出一个 base,去把这些外围的工作,生成用户名密码对啊,重试啊,还有这些杂七杂八的 channel 什么的在这个 base 的大框架下全部处理封装掉。每一个新插件只需要专注于协议实现就好了,这样就很少出现抄抄都抄错的现象了。
但是有时候转念一想,连一些明显跑不了的代码都能推上来,我再去和人讲什么项目结构这有意义吗。
这没有意义。
fscan1 在我印象里是一代著名扫描器,我真心不希望 fscan2.0 他出来是这样一个质量。