ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

反连平台设计详解:DNSLog + HTTP 回调 + 异步关联,从零实现漏洞扫描 OOB 检测基础设施

反连平台设计详解:DNSLog + HTTP 回调 + 异步关联,从零实现漏洞扫描 OOB 检测基础设施

📋 目录

  • 背景介绍
  • 核心挑战
  • 解决方案
  • 完整实现
  • 测试策略
  • 最容易踩的5个坑
  • 面试高频考点
  • 总结与扩展

背景介绍

漏洞扫描中有一类问题无法只靠同步响应判断。

比如:

  • 目标服务器收到测试请求后,会在后台访问一个外部地址
  • 目标应用触发了异步任务,几秒后才产生网络访问
  • 目标环境没有把执行结果回显到 HTTP 响应里

这种场景需要反连平台(Reverse Connection Platform),也常被称为 OOB(Out-of-Band)检测基础设施。

它的核心思想是:

扫描器生成唯一回调地址 ↓ 检测插件把回调地址放入测试请求 ↓ 目标如果触发外联,会访问该地址 ↓ 反连平台记录回调 ↓ 扫描器根据唯一ID关联检测结果

xray 的 README 中也明确提到,XXE、SSRF 等检测模块会使用反连平台能力。对扫描器来说,反连平台不是单个插件,而是所有“无回显检测”的公共基础设施。


核心挑战

挑战1:如何生成唯一且可追踪的回调地址?

每一次检测都要有独立标识,否则多个插件、多个目标、多个任务之间会串结果。

扫描任务 A ├── 插件 ssrf │ └── token: a1b2c3 └── 插件 xxe └── token: d4e5f6 扫描任务 B └── 插件 ssrf └── token: g7h8i9

反连平台必须知道:

  • token 属于哪个扫描任务
  • token 属于哪个插件
  • token 绑定哪个目标
  • token 的有效期是什么

挑战2:HTTP 和 DNS 回调要同时支持

不同漏洞触发的外联形式不同:

HTTP回调 ├── 能拿到完整路径 ├── 能拿到Header ├── 能记录User-Agent └── 适合验证Web访问类外联 DNS回调 ├── 更轻量 ├── 出网限制下更容易触发 ├── 只能拿到域名查询 └── 适合验证解析行为

如果只支持 HTTP,会漏掉只做 DNS 解析但不发 HTTP 请求的场景;如果只支持 DNS,又拿不到更丰富的访问证据。

挑战3:检测是异步的

扫描器发出请求后,回调可能在几十毫秒后出现,也可能几秒后出现,还可能永远不出现。

所以不能写成:

sendPayload()ifcallbackExists(){reportVuln()}

更合理的方式是:

发送测试请求 ↓ 注册等待器 ↓ 在超时时间内监听回调事件 ↓ 有事件则确认,无事件则正常结束

挑战4:反连平台自身要安全

反连平台暴露在公网,天然会收到大量不可控流量。

需要重点考虑:

  • token 猜测与伪造
  • 日志污染
  • 大请求体占用内存
  • 高并发打满连接数
  • 敏感信息落盘

反连平台不是越“开放”越好,而是要在可观测和可控之间取平衡。


解决方案

架构设计

┌──────────────────────┐ │ Scanner │ │ plugin / task / url │ └──────────┬───────────┘ │ create token ▼ ┌──────────────────────┐ │ Reverse Client │ │ URL builder / wait │ └──────────┬───────────┘ │ ▼ ┌────────────────────────────────────┐ │ Reverse Platform │ │ │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ HTTP Server │ │ DNS Server │ │ │ └──────┬───────┘ └──────┬───────┘ │ │ └──────┬──────────┘ │ │ ▼ │ │ Event Store + Matcher │ └────────────────┬───────────────────┘ │ callback event ▼ ┌──────────────────────┐ │ Result Joiner │ │ token -> scan result │ └──────────────────────┘

核心数据流

1. 插件请求一个反连地址 2. 平台生成 token 与检测上下文 3. 插件把地址用于测试请求 4. HTTP/DNS 服务接收外部回调 5. 平台把回调写入事件存储 6. 扫描器等待 token 对应事件 7. 命中后生成漏洞结果和证据

完整实现

步骤1:Token 与上下文设计

pkg/reverse/token.go

packagereverseimport("crypto/rand""encoding/base32""strings""time")typeTokenContextstruct{TokenstringTaskIDstringPluginstringTargetstringCreatedAt time.Time ExpiresAt time.Time}funcNewTokenContext(taskID,plugin,targetstring,ttl time.Duration)(TokenContext,error){token,err:=randomToken(16)iferr!=nil{returnTokenContext{},err}now:=time.Now()returnTokenContext{Token:token,TaskID:taskID,Plugin:plugin,Target:target,CreatedAt:now,ExpiresAt:now.Add(ttl),},nil}funcrandomToken(sizeint)(string,error){buf:=make([]byte,size)if_,err:=rand.Read(buf);err!=nil{return"",err}encoded:=base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)returnstrings.ToLower(encoded),nil}

Token 需要足够随机,避免被猜测;同时要适合作为子域名前缀,所以使用小写字母和数字更方便。

步骤2:回调地址生成器

pkg/reverse/client.go

packagereverseimport("fmt""time")typeClientstruct{BaseDomainstringHTTPBasestringStore*Store}typeCallbackAddressstruct{TokenstringDNSNamestringHTTPURLstring}func(c*Client)NewCallback(taskID,plugin,targetstring)(CallbackAddress,error){ctx,err:=NewTokenContext(taskID,plugin,target,5*time.Minute)iferr!=nil{returnCallbackAddress{},err}c.Store.SaveContext(ctx)returnCallbackAddress{Token:ctx.Token,DNSName:fmt.Sprintf("%s.%s",ctx.Token,c.BaseDomain),HTTPURL:fmt.Sprintf("%s/%s",c.HTTPBase,ctx.Token),},nil}

插件只需要拿到回调地址,不应该关心反连平台内部如何记录事件。

步骤3:事件存储

pkg/reverse/store.go

packagereverseimport("sync""time")typeCallbackEventstruct{TokenstringProtocolstringRemoteIPstringHoststringPathstringUserAgentstringRawstringCreatedAt time.Time}typeStorestruct{mu sync.RWMutex contextsmap[string]TokenContext eventsmap[string][]CallbackEvent waitersmap[string][]chanCallbackEvent}funcNewStore()*Store{return&Store{contexts:map[string]TokenContext{},events:map[string][]CallbackEvent{},waiters:map[string][]chanCallbackEvent{},}}func(s*Store)SaveContext(ctx TokenContext){s.mu.Lock()defers.mu.Unlock()s.contexts[ctx.Token]=ctx}func(s*Store)AddEvent(event CallbackEvent)bool{s.mu.Lock()defers.mu.Unlock()ctx,ok:=s.contexts[event.Token]if!ok||time.Now().After(ctx.ExpiresAt){returnfalse}s.events[event.Token]=append(s.events[event.Token],event)for_,waiter:=ranges.waiters[event.Token]{select{casewaiter<-event:default:}}returntrue}func(s*Store)Wait(tokenstring,timeout time.Duration)(CallbackEvent,bool){ch:=make(chanCallbackEvent,1)s.mu.Lock()iflist:=s.events[token];len(list)>0{event:=list[0]s.mu.Unlock()returnevent,true}s.waiters[token]=append(s.waiters[token],ch)s.mu.Unlock()timer:=time.NewTimer(timeout)defertimer.Stop()select{caseevent:=<-ch:returnevent,truecase<-timer.C:returnCallbackEvent{},false}}

这里的Wait支持两种情况:

  • 回调已经到达,直接返回
  • 回调尚未到达,挂起等待直到超时

步骤4:HTTP回调服务

pkg/reverse/http_server.go

packagereverseimport("io""net""net/http""strings""time")typeHTTPServerstruct{Store*Store}func(s*HTTPServer)ServeHTTP(w http.ResponseWriter,r*http.Request){token:=strings.Trim(strings.Split(r.URL.Path,"/")[1]," ")iftoken==""{http.NotFound(w,r)return}body,_:=io.ReadAll(io.LimitReader(r.Body,4096))event:=CallbackEvent{Token:token,Protocol:"http",RemoteIP:clientIP(r),Host:r.Host,Path:r.URL.String(),UserAgent:r.UserAgent(),Raw:string(body),CreatedAt:time.Now(),}if!s.Store.AddEvent(event){http.NotFound(w,r)return}w.WriteHeader(http.StatusOK)_,_=w.Write([]byte("ok"))}funcclientIP(r*http.Request)string{host,_,err:=net.SplitHostPort(r.RemoteAddr)iferr!=nil{returnr.RemoteAddr}returnhost}

注意点:

  • 请求体必须限制大小
  • 未知 token 不返回详细错误
  • 只记录必要证据
  • 不把任意请求内容直接渲染到页面

步骤5:DNS回调服务

pkg/reverse/dns_server.go

packagereverseimport("net""strings""time""github.com/miekg/dns")typeDNSServerstruct{BaseDomainstringStore*Store}func(s*DNSServer)HandleDNS(w dns.ResponseWriter,r*dns.Msg){msg:=new(dns.Msg)msg.SetReply(r)for_,q:=ranger.Question{token:=s.extractToken(q.Name)iftoken==""{continue}s.Store.AddEvent(CallbackEvent{Token:token,Protocol:"dns",RemoteIP:w.RemoteAddr().String(),Host:q.Name,Path:dns.TypeToString[q.Qtype],CreatedAt:time.Now(),})msg.Answer=append(msg.Answer,&dns.A{Hdr:dns.RR_Header{Name:q.Name,Rrtype:dns.TypeA,Class:dns.ClassINET,Ttl:1,},A:net.ParseIP("127.0.0.1"),})}_=w.WriteMsg(msg)}func(s*DNSServer)extractToken(namestring)string{name=strings.TrimSuffix(strings.ToLower(name),".")base:=strings.TrimSuffix(strings.ToLower(s.BaseDomain),".")if!strings.HasSuffix(name,base){return""}left:=strings.TrimSuffix(name[:len(name)-len(base)],".")parts:=strings.Split(left,".")iflen(parts)==0{return""}returnparts[0]}

真实实现中要补充:

  • DNS 服务器监听端口
  • 域名 NS 解析配置
  • IPv6、CNAME 等记录处理
  • 上游解析兜底策略

步骤6:检测插件中的等待逻辑

pkg/plugin/oob_check.go

packagepluginimport("context""time")funcRunOOBCheck(ctx context.Context,reverse*reverse.Client,targetstring)(*VulnResult,error){callback,err:=reverse.NewCallback("task-001","ssrf",target)iferr!=nil{returnnil,err}iferr:=sendSafeProbe(ctx,target,callback.HTTPURL);err!=nil{returnnil,err}event,ok:=reverse.Store.Wait(callback.Token,8*time.Second)if!ok{returnnil,nil}return&VulnResult{Target:target,Plugin:"ssrf",Evidence:event.Protocol+" callback from "+event.RemoteIP,},nil}

这里的sendSafeProbe代表插件发送的安全探测请求。文章重点是反连平台设计,不展开具体漏洞利用细节。


测试策略

单元测试:Token唯一性

funcTestTokenUnique(t*testing.T){seen:=map[string]struct{}{}fori:=0;i<10000;i++{token,err:=randomToken(16)require.NoError(t,err)_,exists:=seen[token]assert.False(t,exists)seen[token]=struct{}{}}}

单元测试:等待回调

funcTestWaitCallback(t*testing.T){store:=NewStore()ctx,_:=NewTokenContext("task","plugin","target",time.Minute)store.SaveContext(ctx)gofunc(){time.Sleep(50*time.Millisecond)store.AddEvent(CallbackEvent{Token:ctx.Token,Protocol:"http"})}()event,ok:=store.Wait(ctx.Token,time.Second)assert.True(t,ok)assert.Equal(t,"http",event.Protocol)}

安全测试:未知Token不记录

funcTestUnknownTokenIgnored(t*testing.T){store:=NewStore()ok:=store.AddEvent(CallbackEvent{Token:"unknown"})assert.False(t,ok)}

最容易踩的5个坑

坑1:Token太短导致串结果

错误示例

token:=strconv.Itoa(rand.Intn(10000))

正确做法

token,err:=randomToken(16)

Token 必须使用密码学安全随机数,不能使用时间戳、自增 ID 或普通随机数。

坑2:等待逻辑阻塞扫描主流程

错误示例

time.Sleep(30*time.Second)checkCallback()

正确做法

event,ok:=store.Wait(token,8*time.Second)

等待必须有超时,并且应以事件通知为主,不要靠固定 sleep。

坑3:没有设置Token过期

错误示例

contexts[token]=ctx

正确做法

ctx.ExpiresAt=time.Now().Add(5*time.Minute)

过期策略可以减少内存占用,也可以降低旧回调污染新扫描的风险。

坑4:把回调内容直接展示在报告中

错误示例

<pre>{{ .RawCallback }}</pre>

正确做法

safeEvidence:=html.EscapeString(limit(event.Raw,1024))

反连平台收到的数据不可信,进入报告前必须转义和限长。

坑5:DNS服务没有部署验证

错误示例

// 本地启动DNS服务后直接认为可用

正确做法

digtoken.example-oob.com @authoritative-dns

DNS 反连依赖域名 NS 配置,代码正确不代表公网解析链路正确。


面试高频考点

考点1:为什么反连平台能检测无回显漏洞?

回答要点

  1. 同步响应看不到结果时,观察目标是否产生外部网络行为
  2. 每次检测生成唯一 token
  3. 回调事件和扫描任务通过 token 关联
  4. HTTP 与 DNS 证据可以作为检测依据

考点2:HTTP回调和DNS回调有什么区别?

回答要点

  1. HTTP 证据更丰富,可以记录路径、Header、User-Agent
  2. DNS 更轻量,有些环境只会解析域名不发 HTTP 请求
  3. HTTP 依赖应用层访问,DNS 依赖解析链路
  4. 实际平台通常两者都支持

考点3:如何防止反连平台误报?

回答要点

  1. Token 必须唯一且不可猜测
  2. Token 绑定任务、插件和目标
  3. 设置合理过期时间
  4. 记录协议、来源 IP、时间等证据
  5. 必要时结合二次确认逻辑

总结与扩展

核心经验总结

  1. 反连平台是公共基础设施

    • 服务于 SSRF、XXE、无回显命令检测等场景
    • 插件只依赖回调地址和等待接口
    • 平台负责事件接收和关联
  2. 唯一Token是核心

    • 绑定扫描上下文
    • 防止串任务
    • 便于证据追踪
  3. HTTP和DNS要同时覆盖

    • HTTP 证据更完整
    • DNS 触达率更高
    • 两者互补
  4. 异步等待要可控

    • 事件通知优于固定 sleep
    • 每次等待必须有超时
    • 过期 token 要及时清理
  5. 平台自身要做安全防护

    • 限制请求体大小
    • 未知 token 静默处理
    • 报告展示前转义
    • 清理过期数据
返回列表