碎碎念

距离写这个小轮子已经过去半年多了,希望记录下来作为自己路上的一个沉淀,在行业里有人再次提起“越权扫描器”时能有一个从前端到后端、从代理到消费、从设计到使用的感性参考。

为什么要做这个东西?

  1. 因为个人认为IAST、DAST方向的安全产品主要解决了OWASP Top 10中传统的具备可规则化的安全漏洞,比如sql注入、xss、rce等;而越权漏洞本质上可以归结为“逻辑”漏洞,逻辑类型的漏洞想要通过传统的扫描器捕获,从技术原理上来说是比较难的。比如一个功能从提出需求、评审到研发、测试、上线,每个人对它的理解都是不同的,可能研发三天不看这个代码都会忘记这个功能具体做了什么事情,指望一个不具备“智慧”大脑的扫描器理解它,并找到漏洞更是不可能的,甚至这个产品功能本身就是一个逻辑错误(类似于伪需求)。

  2. 在成熟的互联网企业,统一的公共服务,标准的研发规范,成熟的自动化流水线,再加上代码框架正逐渐步入内生安全,这一切使得传统的Web应用安全漏洞在可视范围内会越来越少。而越权漏洞可能因为研发忘记对某个参数做逻辑或归属校验,漏洞发生的限制条件很低,而造成的危害可能是极大的。

通俗点讲就是自动化难检测、易发生、高危害,但我们可以力所能及自动化一部分“水平”或“垂直”越权漏洞。

产品设计

bacscanner
bacscanner
bacscanner

代理

谈到自动化,就少不了数据源的自动获取,比较常见的形式就是代理作为日志的生产者。市面上这么多类型的代理我们应该选择哪种既能满足高性能又能满足https的请求、响应体的全部呢?

MitmProxy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# -*- coding: utf-8 -*-
#!/usr/bin/env python3
import mitmproxy.http
from mitmproxy import ctx, http
import time

class ProxyScan:
def request(self, flow: mitmproxy.http.HTTPFlow)-> None:
print('--------------------')
print(flow.request.host)
print(flow.request.url)
print(flow.request.headers)
print(flow.request.get_text())
print(flow.request.get_content())
print(flow.request.raw_content)
# print(flow.request.path_components)
print('--------------------')

addons = [
ProxyScan()
]

mitmproxy4是官方维护的最新版本(调研时间2019年),重构过后的新版本不再向下兼容,更稳定,并发更高。

但在测试过程中,发现通过burpsuite代理mitmproxy开启200线程并发发包,再通过mitmproxy进行代理浏览网页就会发现打开网页速度变慢。

Openresty

跟同行(b5mali4)小明哥交流过程中,他当初落地实践的是openresty代理方案。

经测试发现,openresty并发非常高,在跟mitmproxy同样的测试条件下,再通过openresty进行代理浏览网页非常流畅。

Goproxy

Goproxy地址:https://github.com/goproxy/goproxy

在学习时找到了猪猪侠3年前写的代理工具:https://github.com/ring04h/wyproxy2, 基本上把所需的功能已经都已经实现,只不过它是入库mysql,我们需要将解析后的数据打进“消息队列”。

在接公司的Mafka消息队列时顺便修正了代码上的一些小问题:

在Go 1.6之前, 内置的map类型是部分goroutine安全的,并发的读没有问题,并发的写可能有问题。自go 1.6之后, 并发地读写map会报错,这在一些知名的开源库中都存在这个问题,所以go 1.9之前的解决方案是额外绑定一个锁,封装成一个新的struct或者单独使用锁都可以。
但是到了Go1.9发布,它有了一个新的特性,那就是sync.Map,它是原生支持并发安全的map,不过它的用法和以前我们熟悉的map完全不一样,主要还是因为sync.map封装了更为复杂的数据结构,以实现比之前加锁map更优秀的性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
package main

import (
"bytes"
"encoding/json"
"flag"
"fmt"
"github.com/elazarl/goproxy"
"io/ioutil"
"log"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
)

var (
// request.Body temp var
// RequestBodyMap = make(map[int64][]byte)
RequestBodyMap sync.Map

// http static resource file extension
static_ext []string = []string{
"js",
"css",
"ico",
"woff",
"ttf",
"map",
"woff2",
}

// media resource files type
media_types []string = []string{
"image",
"video",
"audio",
}

// http static resource files
static_types []string = []string{
"application/vnd.google.octet-stream-compressible",
"font/woff",
"font/woff2",
"text/css",
"text/javascript",
"baiduApp/json",
"application/javascript",
"application/x-javascript",
"application/msword",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
"application/x-ms-wmd",
"application/x-shockwave-flash",
}
)

func checkErr(err error) {
if err != nil {
log.Println(err)
}
}

type Response struct {
Origin string `json:"origin"`
Method string `json:"method"`
Status int `json:"status"`
ContentType string `json:"content_type"`
ContentLength uint `json:"content_length"`
Host string `json:"host"`
Port string `json:"port"`
URL string `json:"url"`
Scheme string `json:"scheme"`
Path string `json:"path"`
Extension string `json:"ext"`
ResponseHeader http.Header `json:"response_header,omitempty"`
ResponseBody string `json:"response_body,omitempty"`
RequestHeader http.Header `json:"request_header,omitempty"`
RequestBody string `json:"request_body,omitempty"`
DateStart time.Time `json:"date_start"`
DateEnd time.Time `json:"date_end"`
}

func handleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
reqbody, err := RequestBody(req)
checkErr(err)
// RequestBodyMap[ctx.Session] = reqbody
RequestBodyMap.Store(ctx.Session, reqbody)
// log.Println(req)

return req, nil
}

//func goHandleRequest()(*http.Request, *http.Response){
//
//
//}

func RequestBody(res *http.Request) ([]byte, error) {

buf, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, err
}
res.Body = ioutil.NopCloser(bytes.NewReader(buf))
// log.Printf(string(buf))
return buf, nil
}

// json.Marshal方法优化,不对html做转义处理
func MarshalHTML(v interface{}) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
err := enc.Encode(v)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}

func handleResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response {

// Getting the Body
reqbody, ok := RequestBodyMap.Load(ctx.Session)
RequestBodyMap.Delete(ctx.Session)
if ok != false && resp != nil {
respbody, err := ResponseBody(resp)
checkErr(err)
// Attaching capture tool.
if respbody != nil {
RespCapture := New(resp, reqbody.([]byte), respbody).Parser()

static := NewResType(
RespCapture.Extension,
RespCapture.ContentType).isStatic()
//log.Println(RespCapture)
//tmpRespCapture := RespCapture
if static != true {
jsonStr, err := MarshalHTML(RespCapture)
if err != nil {
log.Fatal()
}
//fmt.Println(jsonStr)
//SynProducerCase(RespCapture)
go func() {
//f, err := os.OpenFile("./log/scan.log", os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0666)
//checkErr(err)
//defer f.Close()
//w := bufio.NewWriter(f)
//w.WriteString(string(jsonStr))
//w.Flush()
SynProducerCase(string(jsonStr))
}()

}
}

// fmt.Printf("%s\n", jsonStr)
}
return resp
}
func ResponseBody(res *http.Response) ([]byte, error) {
if res != nil {
defer res.Body.Close()
}
buf, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, err
}
res.Body = ioutil.NopCloser(bytes.NewReader(buf))
return buf, nil
}

func toJsonHeader(header http.Header) string {
js, err := json.Marshal(header)
checkErr(err)
return string(js)
}
func New(resp *http.Response, reqbody []byte, respbody []byte) *ParserHTTP {
return &ParserHTTP{r: resp, reqbody: reqbody, respbody: respbody, s: time.Now()}
}

func NewResType(ext string, ctype string) *ResType {
var mtype string
if ctype != "" {
mtype = strings.Split(ctype, "/")[0]
}
return &ResType{ext, ctype, mtype}
}

type ParserHTTP struct {
r *http.Response
reqbody []byte
respbody []byte
s time.Time
}

type ResType struct {
ext string
ctype string
mtype string
}

func (parser *ParserHTTP) Parser() Response {

var (
ctype string
clength int
StrHost string
StrPort string
)

if len(parser.r.Header["Content-Type"]) >= 1 {
ctype = GetContentType(parser.r.Header["Content-Type"][0])
}

if len(parser.r.Header["Content-Length"]) >= 1 {
clength, _ = strconv.Atoi(parser.r.Header["Content-Length"][0])
}

SliceHost := strings.Split(parser.r.Request.URL.Host, ":")
if len(SliceHost) > 1 {
StrHost, StrPort = SliceHost[0], SliceHost[1]
} else {
StrHost = SliceHost[0]
if parser.r.Request.URL.Scheme == "https" {
StrPort = "443"
} else {
StrPort = "80"
}
}

now := time.Now()

r := Response{
Origin: parser.r.Request.RemoteAddr,
Method: parser.r.Request.Method,
Status: parser.r.StatusCode,
ContentType: string(ctype),
ContentLength: uint(clength),
Host: StrHost,
Port: StrPort,
URL: parser.r.Request.URL.String(),
Scheme: parser.r.Request.URL.Scheme,
Path: parser.r.Request.URL.Path,
Extension: GetExtension(parser.r.Request.URL.Path),
ResponseHeader: parser.r.Header,
ResponseBody: string(parser.respbody),
RequestHeader: parser.r.Request.Header,
RequestBody: string(parser.reqbody),
DateStart: parser.s,
DateEnd: now,
}

return r
}

func (r *ResType) isStatic() bool {
if ContainsString(static_ext, r.ext) {
return true
} else if ContainsString(static_types, r.ctype) {
return true
} else if ContainsString(media_types, r.mtype) {
return true
}
return false
}

func GetContentType(HeradeCT string) string {
ct := strings.Split(HeradeCT, "; ")[0]
return ct
}

func GetExtension(path string) string {
SlicePath := strings.Split(path, ".")
if len(SlicePath) > 1 {
return SlicePath[len(SlicePath)-1]
}
return ""
}

func ContainsString(sl []string, v string) bool {
for _, vv := range sl {
if vv == v {
return true
}
}
return false
}

func PathExists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}

func main() {

//stopper_cpu := profile.Start(profile.CPUProfile, profile.ProfilePath("."))
//defer stopper_cpu.Stop()
//stopper_mem := profile.Start(profile.MemProfile, profile.ProfilePath("."))
//defer stopper_mem.Stop()
//stopper_mutex := profile.Start(profile.MutexProfile, profile.ProfilePath("."))
//defer stopper_mutex.Stop()
//stopper_block := profile.Start(profile.BlockProfile, profile.ProfilePath("."))
//defer stopper_block.Stop()

fmt.Println("Proxy start")

// 定义代理日志目录
_dir := "log"
exist, err := PathExists(_dir)
if err != nil {
fmt.Printf("get dir error![%v]\n", err)
return
}
if exist {
fmt.Printf("Proxy log dir -> [%v]\n", _dir)
} else {
fmt.Printf("No proxy log dir -> [%v]\n", _dir)
// 创建代理目录
err := os.Mkdir(_dir, os.ModePerm)
if err != nil {
fmt.Printf("Mkdir proxy log failed![%v]\n", err)
} else {
fmt.Printf("Mkdir proxy log success!\n")
}
}
verbose := flag.Bool("v", false, "should every proxy request be logged to stdout")
addr := flag.String("l", ":8080", "on which address should the proxy listen")
flag.Parse()
proxy := goproxy.NewProxyHttpServer()
proxy.Verbose = *verbose
log.Printf("Listening %s \n", *addr)
log.Printf("proxy Start success... \n")
log.Println(goproxy.ReqHostMatches())
proxy.OnRequest(goproxy.ReqHostMatches(regexp.MustCompile(`^.*\.(test|dev)\.(gongsi|yuming)\.com:443$`))).HandleConnect(goproxy.AlwaysMitm)
proxy.OnRequest(goproxy.ReqHostMatches(regexp.MustCompile(`(.*\.(test|dev)\.(gongsi|yuming)\.com|10\.\d+\.\d+\.\d+)$`))).DoFunc(handleRequest)
proxy.OnResponse().DoFunc(handleResponse)
log.Fatal(http.ListenAndServe(*addr, proxy))

}

最终选择了goproxy,因为openresty相当于用nginx+lua开发,需要打补丁对https流量进行获取,打补丁后可以获取https的host,但始终无法获取请求体等。

Goproxy最终效果:
Charles+Http;Charles+Https;Burpsuite+Http;Burpsuite+Https均可以正常代理,数据进入消息队列。

越权扫描器

生产者流量有了,剩下就是核心越权扫描器引擎。

思路简单来说就是“换Cookie”,即替换请求凭证,这里可能是Cookie中的token字段值、可能是header中BA认证的字段值,每个公司的情况不一。我们公司叫token,你们公司可能叫session或者sid等,甚至可能还没统一的身份认证机制,那我们替换的就是整个cookie值。

这相当于根据“换Cookie”请求后响应的不同来判断是否存在越权,比如原始请求的响应为“phone=170221”,替换成别人cookie后的响应为仍然为“phone=170221”,那就极可能是一个越权漏洞,这也是大家常用来测试越权漏洞的方法(或者通过遍历参数,如orderid之类)。

详细思路

bacrequest

1)围绕着“换Cookie”的核心,我们将原始请求的响应叫做ResponseA,删除ssoid的响应叫做ResponseB,替换ssoid后的响应叫做ResponseC。
2)进一步通过删除ssoid、替换ssoid,对重新封装的请求分别发包,对3个Response的对比判断是否存在越权漏洞。
3)对比的方法我这里做了一个取巧的方式,通过相似度匹配,相似度定义为风险值,即相似度越高风险值也越大,越权漏洞发生的可能性越大。相似度匹配的算法使用ssdeep(ssdeep也常用于webshell检测)。

bacLogic.go

我们通过代码来梳理一下具体实现逻辑,在函数bacRequest中把流量日志logPayload反序列化成[]byte的json格式的reqLog,通过reqLog.RequestHeader取出header数据,然后通过processCookie函数,用change字符串“删”或“替换”作为入参判断,对header内关键的认证字段进行改变。接着下面代码会对原始请求取reqLog.Method判断是“GET”请求,还是“POST”请求,将改变后的header、原始reqLog.URL、原始的reqLog.RequestBody重发包,这时riskBac函数会对重发包的响应 []byte(r.String())与原始响应firstResp进行对比,计算riskValue风险值(相似度)。最后通过httpLogUpdate将需要的数据插入Mysql数据库做后续的结果展示等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
package main

import (
"encoding/json"
"fmt"
"github.com/imroc/req"
"log"
)

func bacRequest(logPayload string, change string, id int64) {
var reqLog Response
//fmt.Println(string(logPayload))

err := json.Unmarshal([]byte(logPayload), &reqLog) //把流量日志logPayload反序列化成[]byte的json格式的reqLog
if err != nil {
fmt.Println("json Unmarshal failed:", err)
}
//resJsonBool := strings.Contains(reqLog.ResponseHeader.Get("Content-Type"), "application/json")
header, err := processCookie(reqLog.RequestHeader, change)
if err != nil {
log.Println("处理header错误:", err)
return
}

firstResp := []byte(reqLog.ResponseBody) // firstResp 是原始请求里的Response
if reqLog.Method == "GET" {
r, _ := req.Get(reqLog.URL, header) // Request请求开始

//log.Println("修改请求的响应:",r.String()) //Mysql
riskValue := riskBac(firstResp, []byte(r.String()))
//log.Println("相似度的值为: ",riskValue)
reqHeader, _ := json.Marshal(r.Request().Header)
//reqBody, err := json.Marshal(r.Request().Body) //空
respHeader, _ := json.Marshal(r.Response().Header)
//log.Println(respHeader)
httpLogUpdate(string(reqHeader), reqLog.RequestBody, string(respHeader), r.String(), id, change, riskValue)

} else if reqLog.Method == "POST" {
// 1. 看Content-type,如果是json,就要用req的json方法请求;
header.Del("Content-Length")
r, err := req.Post(reqLog.URL, header, reqLog.RequestBody)

if err != nil {
log.Println("POST请求失败:", err)
}
riskValue := riskBac(firstResp, []byte(r.String()))

reqHeader, _ := json.Marshal(r.Request().Header)
//reqBody, err := json.Marshal(r.Request().Body) //空
respHeader, _ := json.Marshal(r.Response().Header)

httpLogUpdate(string(reqHeader), reqLog.RequestBody, string(respHeader), r.String(), id, change, riskValue)
} else if reqLog.Method == "OPTIONS" {
r, err := req.Options(reqLog.URL, header, reqLog.RequestBody)
if err != nil {
log.Println("OPTIONS请求失败:", err)
}
riskValue := riskBac(firstResp, []byte(r.String()))

reqHeader, _ := json.Marshal(r.Request().Header)
//reqBody, err := json.Marshal(r.Request().Body) //空
respHeader, _ := json.Marshal(r.Response().Header)
httpLogUpdate(string(reqHeader), reqLog.RequestBody, string(respHeader), r.String(), id, change, riskValue)

}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
package main

import (
"github.com/go-redis/redis"
"log"
"net/http"
"regexp"
"strings"
)

func getSSOid(keySsoid string) string {
// 建立redis连接
client := redis.NewClient(&redis.Options{
Addr: appConfig.redisAddr,
Password: appConfig.redisPass,
DB: 1,
})
defer client.Close()
ping, err := client.Ping().Result()
if err != nil {
log.Println("Redis client connect failed ping status:", err)
}
log.Println("ping status:", ping)
ssoid, err := client.Get(keySsoid).Result()
if err != nil {
log.Println("Get redis key value failed:", err)
}
if len(ssoid) < 5 {
log.Println("error: ssoid value length < 5")
return ";"
}
return ssoid
}

func processCookie(headerInput http.Header, change string) (http.Header, error) {
header := headerInput

// BA认证变更
Access_token := header.Get("access-token")
if len(Access_token) > 10 {
// 删除Cookie
if change == "del" {
// 把ssoid的值全部替换为空并替换Header头中的Cookie字段
header.Set("access-token", "")
//替换Cookie
} else {
newSSOid := getSSOid(change)
header.Set("access-token", newSSOid)
}
}

Cookie := header.Get("Cookie")

// 通过正则取出ssoid=xxx;
reg := regexp.MustCompile(`[.\w]*(ssoid|SSOID|SSO_ID|sso_id|sso_sid|SSO_SID|TGCX)=[0-9a-zA-Z-_*]+`)
//log.Println(reg.FindAllString(Cookie, -1))
client_id := reg.FindAllString(Cookie, -1)
//log.Println(client_id)
//log.Println(len(client_id))
if len(client_id) > 0 {
for _, ssoid_value := range client_id {
cookieArray := strings.Split(ssoid_value, "=") //将cookie=abc;根据等号分割成数组[cookie abc]
//ssoid := cookieArray[0] // com.hello.it.ead.cihah_ssoid
oldSsoidVal := cookieArray[1] // abc;
if len(oldSsoidVal) > 20 {
//删除Cookie
if change == "del" {
// 把ssoid的值全部替换为空并替换Header头中的Cookie字段
Cookie = strings.Replace(Cookie, oldSsoidVal, ";", -1)

//替换Cookie
} else if (change == "ssoid-offline") || (change == "ssoid-online") {
newSSOid := getSSOid(change)
Cookie = strings.Replace(Cookie, oldSsoidVal, newSSOid+";", -1)

} else {
log.Println("change 标识错误")
}
} else {
log.Println("Cookie获取失败:", cookieArray)
}
}
header.Set("Cookie", Cookie)
}
return header, nil

}

risk.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package main

import (
"github.com/glaslos/ssdeep"
"log"
)

//通过比较日志response和二次请求中的response相似作为越权风险值
func riskBac(firstResp []byte, afterResp []byte) int {

if len(firstResp) < 30 || len(afterResp) < 30 {
return 30
}

h1, err := ssdeep.FuzzyBytes(firstResp)
if err != nil {
log.Println("ssdeep h1 error:", err)
}
//log.Println(h1)
h2, err := ssdeep.FuzzyBytes(afterResp)
if err != nil {
log.Println("ssdeep h2 error:", err)
}
//log.Println(h2)

var score int
score, err = ssdeep.Distance(h1, h2)
if err != nil {
log.Println("ssdeep distance failed.")
}
log.Println(score)
return score

}

“替换的cookie”来自哪里?crontab.go

用来做第三者的“替换cookie”也是极其重要的,它决定了在越权检测中准确性的高低。针对公司内网使用SSO进行认证的应用,我在公司申请了虚拟账号,将此账号的权限设置成最低,通过定时任务每天凌晨对认证服务进行一次请求,获取“鲜活”的cookie,用于替换和删除。

从下面代码可以看到每天凌晨1点,去走一次认证流程,将凭证存入redis,其中对业务进行了区分,比如SSO的应用,C端的应用生活费、助贷、分期,对C端不同业务制造出不同状态的账号。为什么要这样?我将在文章最后进行简单解释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
package main

import (
"errors"
"github.com/go-redis/redis"
"github.com/imroc/req"
"github.com/jakecoffman/cron"
"io"
"log"
"os"
)

type cookieMT struct {
//ssoOnlineURL string
ssoOfflineURL string
}

type tokenMT struct {
tokenOfflineURL string // 线下C端用户中心passport生成token地址
expenses string // 生活费,已授信,未借款 账号
diversion string // 已开通助贷(马上),未借款 账号
instalment string // 已开通分期,未借款 账号
}

// 获取线下环境ssoid的值
func (c cookieMT) Runssoff() (string, error) {
var resp map[string]interface{}
// Request请求开始
r, err := req.Get(c.ssoOfflineURL) // 线下sso地址
if err != nil {
Error.Println("SSO Offline URL request failed:", err)
}

err = r.ToJSON(&resp)
if err != nil {
Error.Println("SSO Offline URL response r.ToJSON failed:", err)
}
// interface convert to string
if resp["data"] == nil {
return "null", errors.New("获取线下ssoid为空")
}
return resp["data"].(string), nil

}

func (t tokenMT) Runtokenoff(phone string) (string, error){
// Request请求开始
r, err := req.Get(t.tokenOfflineURL+phone) // 线下sso地址
if err != nil {
Error.Println("Token Offline URL request failed:", err)
}

resp := r.String()
return resp, nil
}


// 存线下的ssoid到reids里
func redisSsoOff(client *redis.Client, key string, ssoid string) {
setStatus := client.Set(key, ssoid, 0)
Info.Println("redis setStatus:", setStatus)
}

func redisTokenOff(client *redis.Client, key string, ssoid string) {
setStatus := client.Set(key, ssoid, 0)
Info.Println("redis setStatus:", setStatus)
}

var mainCron *cron.Cron

// 定义日志全局变量
var (
Info *log.Logger
Warning *log.Logger
Error *log.Logger
)

// 日志初始化配置
func init() {
errFile, err := os.OpenFile("errors.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
log.Fatalln("打开日志文件失败:", err)
}

Info = log.New(os.Stdout, "Info:", log.Ldate|log.Ltime|log.Lshortfile)
Warning = log.New(os.Stdout, "Warning:", log.Ldate|log.Ltime|log.Lshortfile)
Error = log.New(io.MultiWriter(os.Stderr, errFile), "Error:", log.Ldate|log.Ltime|log.Lshortfile)

}

func redisTask() {

cookies := cookieMT{
//ssoOnlineURL: "http://test.com/hahaservice/get?id=pirogue&password=",
ssoOfflineURL: "http://test.com/hahaservice/offline/get?id=pirogue&password=",
}

tokens := tokenMT{
tokenOfflineURL: "http://gege.test.com/api/token?q=",
expenses: "15xxxxxxxxx",
diversion: "13xxxxxxxxx",
instalment: "13xxxxxxxxx",
}

// 建立redis连接
client := redis.NewClient(&redis.Options{
Addr: "localhost:1234",
Password: "xxxxxxxxxx",
DB: 1,
})
ping, err := client.Ping().Result()
if err != nil {
Error.Println("Redis client connect failed ping status:", err)
}
Info.Println("ping status:", ping)

defer client.Close()

// 请求线上接口获取ssoid
//ssoidOnline, err := cookies.Runssonline()
//if err != nil {
// Error.Println(err)
//}

// 请求线下接口获取ssoid
ssoidOffline, err := cookies.Runssoff()
if err != nil {
Error.Println(err)
}

tokenOfflineExpenses , err := tokens.Runtokenoff(tokens.expenses)
if err != nil {
Error.Println(err)
}

tokenOfflineDiversion , err := tokens.Runtokenoff(tokens.diversion)
if err != nil {
Error.Println(err)
}

tokenOfflineInstalment , err := tokens.Runtokenoff(tokens.instalment)
if err != nil {
Error.Println(err)
}

redisSsoOff(client, "ssoid-offline", ssoidOffline)
redisTokenOff(client, "token-expenses", tokenOfflineExpenses)
redisTokenOff(client, "token-diversion", tokenOfflineDiversion)
redisTokenOff(client, "token-instalment", tokenOfflineInstalment)


//redisSsoOn(client, "ssoid-online", ssoidOnline)
}

func main() {


mainCron = cron.New()

// AddJob
tasktime := "0 0 1 * * ?" //每天凌晨1点
//tasktime := "0 0/1 * * * ? " //每2分钟

mainCron.AddFunc(tasktime, redisTask, "ssopassport")

mainCron.Start()
select {} //阻塞主线程不退出

}

扫描器后台

扫描器后台是直接提供给用户使用的,所以产品的界面核心功能(漏洞展示)是否直观、使用是否繁琐、是否有使用上的技术门槛直接决定了这款产品最终能否能被终端客户所接受。

为什么要提到“技术门槛”?

在日常工作中,我发现不同的人对使用上的“技术门槛”的接受程度是不一样的,有人觉得“Burpsuite”门槛就十分高了。如果你的产品存在此类“技术门槛”,到最后只能成为摆设或通过外包服务的方式变相使用,最终成为自己人用的产品。

核心功能是否直观?

在这个产品的设计过程中,核心功能就是越权漏洞的Response对比,如果能让人一眼看出哪些请求接口存在越权,那就成功了一半。但实际上我在使用的过程中虽然有“风险值”作为参考排序,通过肉眼判断对比response列表,点击列表展开仍然非常“繁琐”,甚至于接口太多导致手点的麻木了。后期为了设计一个人性化的界面,思考良久,也“偷窥”了一下行业内做的比较好的一家乙方产品,发现其类似功能也是需要点击列表进而查看漏洞比对的详情,所以这类核心功能要想最终能够较好的落地,是需要实践检验的,离不开开源交流和思想碰撞。

Demo展示

后台代码就非常多了,后端使用gin作为Web框架,vue作为前端框架,最终我也将awvs这个主动扫描器作为被动扫描器的引擎加入到后端,包括同事用python写的扫描器轮子。

bacscanner
bacscanner

点击“创建目标”创建扫描任务,创建完成扫描目标后,点击“常规扫描”将调用awvs进行常规漏洞扫描;点击“越权扫描”对任务进行后端“替换cookie”的配置
bacscanner

在弹窗的对话框中,选择是使用“SSO“的虚拟账号,还是选择”Passport“的“生活费”账号、生意贷账号等进行凭证的替换和删除。

bacscanner

可以看到风险值高的接口排在最上,其他字段还有host、method、url,是不是有点像web版本的burpsuite。点击蓝色的“结果”就会弹出3个Response的对比。如果这里的接口非常多,使用上将会非常麻烦,你就要点击上百次“结果”查看(今天工位的MAC触控板格外的烫手,富婆还是没有出现,我的心好累)。

bacscanner

抛砖引玉

没有服务意识的网络安全爱好者不是一个好的打工人…如果想从用户体验、功能实用的角度出发设计一个好的越权扫描器显然我写的轮子是失败的,越到后面功能上的细节考虑的越多,越要贴合业务。比如用户账号这一块,从QA小姐姐那里调研才知道一个BU的业务线不同产品的用户体系也会不同,账号的授信与否决定了后面逻辑是可以请求成功。

畅想一下未来,也许越权扫描也会出现对应的场景规则,比如贷款类业务、打车类业务、保险类业务,比如身份证号、银行卡号、手机号,沉淀规则,打磨框架,自动化越权检测更通用和便捷。