不止是开放端口
传统清单只统计监听服务。现代攻击面还包括 HTTP 路由、GraphQL 解析器、文件上传、Webhook 接收端、第三方脚本、依赖包、CI runner 和云身份。任何接受输入或持有权限的东西都属于攻击面,无论是否出现在文档中。
工作机制
从清单开始:框架暴露的路由、公有云资源、DNS 记录、OAuth 客户端、CI 工作流和依赖树。按谁能触达、能做什么、信任什么来分类每个入口,然后削减:删除未使用路由、关闭未使用端口、缩小身份权限、关闭不需要的功能。
监控补足清单。访问日志、网关遥测和云资产清单能发现影子端点和漂移。功能开关打开后才出现的路由,仍然属于攻击面。
何时使用与取舍
发布功能前、收购后和事件响应中审查攻击面。当团队认为自身暴露很少时尤其有用,清单通常会给出不同答案。与威胁建模配合,不仅问暴露了什么,还要问如何被滥用。
削减攻击面可能移除客户依赖的功能。激进盘点会增加维护工作和误报。WAF 或 API 网关集中策略,却可能造成暴露代码已安全的错觉。内部服务仍需认证,因为网络边界不是信任边界。
示例与常见误解
团队在生产禁用管理界面,但路由仍然注册,并可通过内部负载均衡器访问。进入内网的攻击者找到它,用默认口令接管。攻击面其实从未缩小。
常见误解是攻击面是越小越好的数字。有些攻击面就是产品本身;目标是了解它、约束它并有意识地防御它。相关概念:威胁建模、纵深防御、爆炸半径。
代码示例
package main
import (
"crypto/sha256"
"crypto/subtle"
"fmt"
)
func ConstantTimeEqual(a, b string) bool {
left := sha256.Sum256([]byte(a))
right := sha256.Sum256([]byte(b))
return subtle.ConstantTimeCompare(left[:], right[:]) == 1
}
func main() {
secret := "s3cr3t-token"
fmt.Println(ConstantTimeEqual(secret, "s3cr3t-token"))
fmt.Println(ConstantTimeEqual(secret, "s3cr3t-tokeX"))
fmt.Println(ConstantTimeEqual(secret, "short"))
}
// SSRF guard: validate every serverSide request target before dialing
package main
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
)
var blockedZones = []string{
"127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12",
"192.168.0.0/16", "169.254.0.0/16", "::1/128", "fc00::/7",
}
var allowedHosts = map[string]bool{
"api.partner.com": true, "cdn.partner.net": true, "hooks.vendor.io": true,
}
func validateTarget(raw string) (*url.URL, error) {
parsed, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("parse url: %w", err)
}
if parsed.Scheme != "https" {
return nil, fmt.Errorf("scheme %q not allowed", parsed.Scheme)
}
if !allowedHosts[parsed.Hostname()] {
return nil, fmt.Errorf("host %q is not allowlisted", parsed.Hostname())
}
return parsed, nil
}
func dialGuard(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
for _, ip := range ips {
if !isPublic(ip.IP) {
return nil, fmt.Errorf("resolved %s to private address %s", host, ip.IP)
}
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
}
func isPublic(ip net.IP) bool {
for _, zone := range blockedZones {
_, network, _ := net.ParseCIDR(zone)
if network.Contains(ip) {
return false
}
}
return !ip.IsLoopback() && !ip.IsUnspecified() && !ip.IsLinkLocalUnicast()
}
func guardedClient() *http.Client {
transport := &http.Transport{
DialContext: dialGuard,
// 重定向必须重走校验:禁止自动跟随
DisableKeepAlives: true,
}
return &http.Client{
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return validateSameHost(via[0], req.URL)
},
Timeout: 8 * time.Second,
}
}
func validateSameHost(orig, next *url.URL) error {
if strings.EqualFold(orig.Hostname(), next.Hostname()) {
return nil
}
return fmt.Errorf("redirect to non-allowlisted host %q", next.Hostname())
}
func main() {
client := guardedClient()
if _, err := validateTarget("https://api.partner.com/v1/data"); err == nil {
resp, err := client.Get("https://api.partner.com/v1/data")
fmt.Println(resp, err)
}
_, err := validateTarget("http://169.254.169.254/latest/meta-data")
fmt.Println("metadata endpoint blocked:", err)
}