/
wpscan.go
231 lines (206 loc) · 6.78 KB
/
wpscan.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
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
package wpscan
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"strings"
"time"
"github.com/ca-risken/common/pkg/logging"
"github.com/cenkalti/backoff/v4"
"github.com/vikyd/zero"
)
type WpscanConfig struct {
ResultPath string
logger logging.Logger
retryer backoff.BackOff
}
func NewWpscanConfig(
resultPath string,
l logging.Logger,
) *WpscanConfig {
// ref: https://pkg.go.dev/github.com/cenkalti/backoff/v4#ExponentialBackOff
retryer := backoff.NewExponentialBackOff()
retryer.InitialInterval = 1 * time.Second
retryer.MaxInterval = 10 * time.Second
retryer.MaxElapsedTime = 30 * time.Second
return &WpscanConfig{
ResultPath: resultPath,
logger: l,
retryer: retryer,
}
}
func (w *WpscanConfig) run(ctx context.Context, target string, wpscanSettingID uint32, options wpscanOptions) (*wpscanResult, error) {
now := time.Now().UnixNano()
filePath := fmt.Sprintf("%s/%v_%v.json", w.ResultPath, wpscanSettingID, now)
args := []string{"--clear-cache", "--disable-tls-checks", "--url", target, "-e", "u1-5", "--wp-version-all", "-f", "json", "-o", filePath}
if options.Force {
args = append(args, "--force")
}
if options.RandomUserAgent {
args = append(args, "--random-user-agent")
}
if !zero.IsZeroVal(options.WpContentDir) {
args = append(args, "--wp-content-dir", options.WpContentDir)
}
cmd := exec.Command("wpscan", args...)
err := w.execWPScan(ctx, cmd)
if err != nil {
w.logger.Errorf(ctx, "Scan failed,target: %s, err: %v", target, err)
return nil, err
}
bytes, err := readAndDeleteFile(filePath)
if err != nil {
return nil, err
}
var wpscanResult wpscanResult
if err := json.Unmarshal(bytes, &wpscanResult); err != nil {
w.logger.Errorf(ctx, "Failed to parse scan results. error: %v", err)
return nil, err
}
wpscanResult.CheckAccess, err = w.checkOpen(ctx, target)
if err != nil {
return nil, err
}
return &wpscanResult, nil
}
type wpscanError struct {
Code int
StdOut *bytes.Buffer
StdErr *bytes.Buffer
}
func (w *wpscanError) Error() string {
return fmt.Sprintf("Failed to wpscan, code=%d, stdout=%s, stderr=%s", w.Code, w.StdOut.String(), w.StdErr.String())
}
func (w *WpscanConfig) execWPScan(ctx context.Context, cmd *exec.Cmd) error {
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
return fmt.Errorf("failed to run command, err=%w", err)
}
exitCode := cmd.ProcessState.ExitCode()
if exitCode != 0 && exitCode != 5 {
w.logger.Errorf(ctx, "Failed exec WPScan. exitCode: %v", exitCode)
return &wpscanError{Code: exitCode, StdOut: &stdout, StdErr: &stderr}
}
return nil
}
func (w *WpscanConfig) checkOpen(ctx context.Context, wpURL string) (*checkAccess, error) {
checkAccess := getAccessList(wpURL)
for i, target := range checkAccess.Target {
goal := target.Goal
if zero.IsZeroVal(target.Goal) {
goal = target.URL
}
if target.Method != "GET" && target.Method != "POST" {
return nil, fmt.Errorf("invalid checkAccessTarget method: %v", target.Method)
}
req, err := http.NewRequest(target.Method, target.URL, nil)
if err != nil {
return nil, err
}
resp, err := w.httpRequestWithRetry(ctx, req)
if err != nil {
// `/wp-admin` などの特定pathはサーバ側でレスポンスを返さないような設定をしているケースもある。
// その場合、必ずリクエストが失敗するがWordpressの設定としては問題ないためスキャンエラーにはしない
w.logger.Errorf(ctx, "Failed to request with retries: err=%+v", err)
continue
}
defer resp.Body.Close()
if resp.StatusCode == 200 && strings.Contains(resp.Request.URL.String(), goal) {
checkAccess.Target[i].IsAccessible = true
checkAccess.isFoundAccesibleURL = true
}
}
return checkAccess, nil
}
func (w *WpscanConfig) httpRequestWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
operation := func() (*http.Response, error) {
return httpRequest(req)
}
return backoff.RetryNotifyWithData(operation, w.retryer, w.newRetryLogger(ctx, "httpRequestWithRetry"))
}
func httpRequest(req *http.Request) (*http.Response, error) {
client := new(http.Client)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("http request error: url=%+v, err=%+v", req.URL, err)
}
return resp, err
}
func readAndDeleteFile(fileName string) ([]byte, error) {
bytes, err := os.ReadFile(fileName)
if err != nil {
return nil, err
}
if err := os.Remove(fileName); err != nil {
return nil, err
}
return bytes, nil
}
type wpscanOptions struct {
Force bool `json:"force"`
RandomUserAgent bool `json:"random-user-agent"`
WpContentDir string `json:"wp-content-dir"`
}
type wpscanResult struct {
InterestingFindings []interestingFindings `json:"interesting_findings"`
Version *version `json:"version"`
Maintheme mainTheme `json:"main_theme"`
Users map[string]interface{} `json:"users"`
CheckAccess *checkAccess
}
type interestingFindings struct {
URL string `json:"url"`
ToS string `json:"to_s"`
Type string `json:"type"`
InterstingEntries []string `json:"intersting_entries"`
References map[string]interface{} `json:"references"`
}
type version struct {
Number string `json:"number"`
Status string `json:"status"`
InterstingEntries []string `json:"intersting_entries"`
Vulnerabilities []vulnerability `json:"vulnerabilities"`
}
type mainTheme struct {
InterstingEntries []string `json:"intersting_entries"`
Vulnerabilities []vulnerability `json:"vulnerabilities"`
}
type vulnerability struct {
Title string `json:"title"`
FixedIn string `json:"fixed_in"`
References map[string]interface{} `json:"references"`
URL []string `json:"url"`
}
type checkAccess struct {
Target []checkAccessTarget
isFoundAccesibleURL bool
isUserFound bool
}
type checkAccessTarget struct {
URL string
IsAccessible bool
Goal string `json:"-"`
Method string `json:"-"`
}
func getAccessList(wpURL string) *checkAccess {
wpURL = strings.TrimSuffix(wpURL, "/")
checkAccess := &checkAccess{
Target: []checkAccessTarget{{URL: wpURL + "/wp-admin/", Goal: "wp-login.php", Method: "GET"},
{URL: wpURL + "/admin/", Goal: "wp-login.php", Method: "GET"},
{URL: wpURL + "/wp-login.php", Goal: "", Method: "GET"},
},
}
return checkAccess
}
func (w *WpscanConfig) newRetryLogger(ctx context.Context, funcName string) func(error, time.Duration) {
return func(err error, t time.Duration) {
w.logger.Warnf(ctx, "[RetryLogger] %s error: duration=%+v, err=%+v", funcName, t, err)
}
}