反连平台设计详解: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-dnsDNS 反连依赖域名 NS 配置,代码正确不代表公网解析链路正确。
面试高频考点
考点1:为什么反连平台能检测无回显漏洞?
回答要点:
- 同步响应看不到结果时,观察目标是否产生外部网络行为
- 每次检测生成唯一 token
- 回调事件和扫描任务通过 token 关联
- HTTP 与 DNS 证据可以作为检测依据
考点2:HTTP回调和DNS回调有什么区别?
回答要点:
- HTTP 证据更丰富,可以记录路径、Header、User-Agent
- DNS 更轻量,有些环境只会解析域名不发 HTTP 请求
- HTTP 依赖应用层访问,DNS 依赖解析链路
- 实际平台通常两者都支持
考点3:如何防止反连平台误报?
回答要点:
- Token 必须唯一且不可猜测
- Token 绑定任务、插件和目标
- 设置合理过期时间
- 记录协议、来源 IP、时间等证据
- 必要时结合二次确认逻辑
总结与扩展
核心经验总结
反连平台是公共基础设施
- 服务于 SSRF、XXE、无回显命令检测等场景
- 插件只依赖回调地址和等待接口
- 平台负责事件接收和关联
唯一Token是核心
- 绑定扫描上下文
- 防止串任务
- 便于证据追踪
HTTP和DNS要同时覆盖
- HTTP 证据更完整
- DNS 触达率更高
- 两者互补
异步等待要可控
- 事件通知优于固定 sleep
- 每次等待必须有超时
- 过期 token 要及时清理
平台自身要做安全防护
- 限制请求体大小
- 未知 token 静默处理
- 报告展示前转义
- 清理过期数据
