├── bins ├── webscan_darwin ├── webscan_linux └── webscan_windows.exe ├── common └── types.go ├── main.go ├── readme.md └── timestamps ├── timestamps_darwin.go ├── timestamps_linux.go └── timestamps_windows.go /bins/webscan_darwin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tstillz/webshell-scan/c2cc998f5a9cebcb96951b92ad2a636b2c205b67/bins/webscan_darwin -------------------------------------------------------------------------------- /bins/webscan_linux: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tstillz/webshell-scan/c2cc998f5a9cebcb96951b92ad2a636b2c205b67/bins/webscan_linux -------------------------------------------------------------------------------- /bins/webscan_windows.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tstillz/webshell-scan/c2cc998f5a9cebcb96951b92ad2a636b2c205b67/bins/webscan_windows.exe -------------------------------------------------------------------------------- /common/types.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "time" 4 | 5 | func FormatTimestamp(dts time.Time)(cts string){ 6 | cts = dts.Format("2006-01-02 15:04:05") 7 | return cts 8 | } 9 | 10 | type OSInfo struct { 11 | Hostname string `json:"hostname"` 12 | EnvVars []string `json:"envVars"` 13 | Username string `json:"username"` 14 | UserID string `json:"userID"` 15 | RealName string `json:"realName"` 16 | UserHomeDir string `json:"userHomeDir"` 17 | } 18 | 19 | type FileObj struct { 20 | FilePath string `json:"filePath"` 21 | Size int64 `json:"size"` 22 | MD5 string `json:"md5"` 23 | Timestamps FileTimes `json:"timestamps"` 24 | Matches map[string]int `json:"matches"` 25 | RawContents string `json:"rawContents,omitempty"` 26 | } 27 | type Metrics struct { 28 | Scanned int `json:"scanned"` 29 | Matched int `json:"matches"` 30 | Clear int `json:"noMatches"` 31 | ScannedDir string `json:"directory"` 32 | ScanTime float64 `json:"scanDuration"` 33 | SystemInfo OSInfo `json:"systemInfo"` 34 | } 35 | 36 | type FileTimes struct { 37 | Birth string `json:"birth,omitempty"` 38 | Created string `json:"created,omitempty"` 39 | Modified string `json:"modified,omitempty"` 40 | Accessed string `json:"accessed,omitempty"` 41 | } -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | cm "./common" 5 | ft "./timestamps" 6 | "bufio" 7 | "bytes" 8 | "compress/gzip" 9 | "crypto/md5" 10 | "encoding/base64" 11 | "encoding/hex" 12 | "encoding/json" 13 | "flag" 14 | "fmt" 15 | "io" 16 | "io/ioutil" 17 | "log" 18 | "os" 19 | "os/user" 20 | "path/filepath" 21 | "regexp" 22 | "strings" 23 | "sync" 24 | "time" 25 | ) 26 | 27 | var matched = 0 28 | var cleared = 0 29 | 30 | var filesToScan = make(chan string, 1000) 31 | 32 | /* 33 | ### TODO 34 | Test Cases 35 | 36 | CPU/Mem Limits 37 | ### Server Detection (Scan Profiles) 38 | IIS 39 | - Web.Config parsing (ISAPI Filters/Handlers) 40 | -- Detect all web roots to scan automatically 41 | Apache 42 | - Detect web roots to scan 43 | Tomcat 44 | - Catalina Logs 45 | - War File Deployment Logs 46 | */ 47 | 48 | func processMatches(j string, r regexp.Regexp) (fileMatches map[string]int, size int64) { 49 | 50 | fileHandle, err := os.Open(j) 51 | if err != nil { 52 | log.Fatal(err) 53 | } 54 | defer fileHandle.Close() 55 | 56 | fi, err := os.Stat(j) 57 | if err != nil { 58 | log.Println(err) 59 | } 60 | 61 | fileScanner := bufio.NewScanner(fileHandle) 62 | fileMatches = make(map[string]int) 63 | 64 | for fileScanner.Scan() { 65 | matches := r.FindStringSubmatch(strings.ToLower(fileScanner.Text())) 66 | if len(matches) > 0 { 67 | for _, it := range matches { 68 | fileMatches[it] += 1 69 | } 70 | } 71 | } 72 | 73 | return fileMatches, fi.Size() 74 | } 75 | func md5HashFile(filePath string) (string, error) { 76 | var returnMD5String string 77 | file, err := os.Open(filePath) 78 | if err != nil { 79 | return returnMD5String, err 80 | } 81 | defer file.Close() 82 | hash := md5.New() 83 | if _, err := io.Copy(hash, file); err != nil { 84 | return returnMD5String, err 85 | } 86 | hashInBytes := hash.Sum(nil)[:16] 87 | returnMD5String = hex.EncodeToString(hashInBytes) 88 | return returnMD5String, nil 89 | } 90 | func compressEncode(filePath string, fileSize int64) string { 91 | 92 | fileItem, err := os.Open(filePath) 93 | if err != nil { 94 | return "" 95 | } 96 | defer fileItem.Close() 97 | 98 | buf := make([]byte, fileSize) 99 | fReader := bufio.NewReader(fileItem) 100 | fReader.Read(buf) 101 | 102 | var b bytes.Buffer 103 | gz := gzip.NewWriter(&b) 104 | if _, err := gz.Write(buf); err != nil { 105 | fmt.Println(err) 106 | return "" 107 | } 108 | if err := gz.Flush(); err != nil { 109 | fmt.Println(err) 110 | return "" 111 | } 112 | if err := gz.Close(); err != nil { 113 | fmt.Println(err) 114 | return "" 115 | } 116 | 117 | readBuf, _ := ioutil.ReadAll(&b) 118 | imgBase64Str := base64.StdEncoding.EncodeToString(readBuf) 119 | 120 | return imgBase64Str 121 | 122 | } 123 | func Scan_worker(r regexp.Regexp, wg *sync.WaitGroup, rawContents bool) { 124 | for j := range filesToScan { 125 | //fmt.Println("Worker:", id, "File:", j) 126 | //fmt.Println(len(filesToScan)) 127 | 128 | Jdata := cm.FileObj{} 129 | Jdata.FilePath = j 130 | fileMatches, size := processMatches(j, r) 131 | Jdata.Size = size 132 | Jdata.Matches = fileMatches 133 | if len(fileMatches) != 0 { 134 | matched = matched + 1 135 | } else if len(fileMatches) == 0 { 136 | cleared = cleared + 1 137 | continue 138 | } 139 | 140 | fHash, err := md5HashFile(j) 141 | if err != nil { 142 | log.Println(err) 143 | } 144 | Jdata.MD5 = fHash 145 | 146 | if rawContents { 147 | Jdata.RawContents = compressEncode(j, Jdata.Size) 148 | } 149 | 150 | // File Timestamps 151 | timestamps, err := ft.StatTimes(j) 152 | Jdata.Timestamps = timestamps 153 | 154 | // PROD 155 | data, err := json.Marshal(Jdata) 156 | if err != nil { 157 | log.Fatal(err) 158 | } 159 | fmt.Printf("%s\n", data) 160 | 161 | // DEVELOP 162 | //data, err := json.MarshalIndent(Jdata, "", " ") 163 | //fmt.Printf("%s\n", data) 164 | } 165 | wg.Done() 166 | } 167 | 168 | func main() { 169 | 170 | start := time.Now() 171 | var dir = flag.String("dir", "", "Directory to scan for webshells") 172 | var customRegex = flag.String("regex", "", "Override default regex with your own") 173 | var size = flag.Int64("size", 10, "Specify max file size to scan (default is 10 MB)") 174 | var exts = flag.String("exts", "", "Specify extensions to target. Multiple extensions should be passed with pipe separator (asp|aspx|php|cfm). Default is all extensions") 175 | var rawContents = flag.Bool("raw_contents", false, "If a match is found, grab the raw contents and base64 + gzip compress the file into the JSON object.") 176 | flag.Parse() 177 | 178 | if *dir == "" { 179 | flag.PrintDefaults() 180 | os.Exit(1) 181 | } 182 | 183 | regexString := "" 184 | 185 | if *customRegex == "" { 186 | regexString = `Filesman|eval\(|Load\(Request\.BinaryRead\(int\.Parse\(Request\.Cookies|Html \= Replace\(Html\, \"\%26raquo\;\"\, \"?\"\)|pinkok|internal class reDuh|c0derz shell|md5 cracker|umer rock|Function CP\(S\,D\)\{sf\=CreateObject\(\"java\"\,\"java\.io\.File|Arguments\=xcmd\.text|asp cmd shell|Maceo|TEXTAREA id\=TEXTAREA1 name\=SqlQuery|CMD Bilgileri|sbusqlmod|php assert\(\$\_POST\[|oWshShellNet\.UserName|PHP C0nsole|rhtools|WinX Shell|system\(\$\_GET\[\'cmd\'|Successfully uploadet|\'Are you sure delete|sbusqlcmd|CFSWITCH EXPRESSION\=\#Form\.chopper|php\\HFile|\"ws\"\+\"cr\"\+\"ipt\.s\"\+\"hell\"|eval\(request\(|string rootkey|uZE Shell|Copyed success\!|InStr\(\"\$rar\$mdb\$zip\$exe\$com\$ico\$\"|Folder dosen\'t exists|Buradan Dosya Upload|echo passthru\(\$\_GET\[\'cmd\'|javascript:Bin\_PostBack|The file you want Downloadable|arguments\=\"/c \#cmd\#\"|cmdshell|AvFBP8k9CDlSP79lDl|AK-74 Security Team Web Shell|cfexecute name \= \"\#Form\.cmd\#\"|execute any shell commandn|Gamma Web Shell|System\.Reflection\.Assembly\.Load\(Request\.BinaryRead\(int\.Parse\(Request\.Cookies|fcreateshell|bash to execute a stack overflow|Safe Mode Shell|ASPX Shell|dingen\.php|azrailphp|\$\_POST\[\'sa\']\(\$\_POST\[\'sb\']\)|AspSpy|ntdaddy|\.HitU\. team|National Cracker Crew|eval\(base64\_decode\(\$\_REQUEST\[\'comment\'|Rootshell|geshi\\tsql\.php|tuifei\.asp|GRP WebShell|No Permission :\(|powered by zehir|will be delete all|WebFileManager Browsing|Dive Shell|diez\=server\.urlencode|@eval\(\$\_POST\[\'|ifupload\=\"ItsOk\"|eval\(request\.item|\(eval request\(|wsshn\.username|connect to reDuh|eval\(gzinflate\(base64\_decode|Ru24PostWebShell|ASPXTOOL\"|aspshell|File upload successfully you can download here|eval request\(|if\(is\_uploaded\_file\(\$HTTP|Sub RunSQLCMD|STNC WebShell|doosib|WinExec\(Target\_copy\_of\_cmd|php passthru\(getenv|win\.com cmd\.exe /c cacls\.exe|TUM HAKLARI SAKLIDIR|Created by PowerDream|Then Request\.Files\(0\)\.SaveAs\(Server\.MapPath\(Request|cfmshell|\{ Request\.Files\[0]\.SaveAs\(Server\.MapPath\(Request|\%execute\(request\(\"|php eval\(\$\_POST\[|lama\'s\'hell|RHTOOLS|data\=request\(\"dama\"|digitalapocalypse|hackingway\.tk|\.htaccess stealth web shell|strDat\.IndexOf\(\"EXEC \"|ExecuteGlobal request\(|Deleted file have finished|bin\_filern|CurrentVersionRunBackdoor|Chr\(124\)\.O\.Chr\(124\)|does not have permission to execute CMD\.EXE|G-Security Webshell|system\( \"\./findsock|configwizard|textarea style\=\"width:600\;height:200\" name\=\"cmd\"|ASPShell|repair/sam|BypasS Command eXecute|\%execute\(request\(|arguments\=\"/c \#hotmail|Coded by Loader|Call oS\.Run\(\"win\.com cmd\.exe|DESERTSUN SERVER CRASHER|ASPXSpy|cfparam name\=\"form\.shellpath\"|IIS Spy Using ADSI|p4ssw0rD|WARNING: Failed to daemonise|C0mmand line|phpinfo\(\) function has non-permissible|letaksekarang|Execute Shell Command|DXGLOBALSHIT|IISSpy|execute request\(|Chmod Ok\!|Upload Gagal|awen asp\.net|execute\(request\(\"|oSNet\.ComputerName"` 187 | } else { 188 | regexString = *customRegex 189 | } 190 | 191 | r := regexp.MustCompile(regexString) 192 | 193 | totalFilesScanned := 0 194 | 195 | var wg sync.WaitGroup 196 | for w := 1; w <= 10; w++ { 197 | wg.Add(1) 198 | go Scan_worker(*r, &wg, *rawContents) 199 | } 200 | 201 | _ = filepath.Walk(*dir, func(path string, f os.FileInfo, err error) error { 202 | if err != nil { 203 | return err 204 | } 205 | if !f.IsDir() { 206 | if f.Size() < (*size * 1024 * 1024) { 207 | //fmt.Println(f.Size(), *size * 1024 * 1024) 208 | //fmt.Println(path, f.Size()) 209 | 210 | /// Scan all files with all extensions 211 | if *exts == "" { 212 | filesToScan <- path 213 | totalFilesScanned = totalFilesScanned + 1 214 | 215 | /// Scan files with specific extensions 216 | } else { 217 | items := strings.SplitAfter(*exts, "|") 218 | for _, e := range items { 219 | if strings.HasSuffix(path, e) { 220 | filesToScan <- path 221 | totalFilesScanned = totalFilesScanned + 1 222 | } 223 | } 224 | } 225 | } 226 | } 227 | return nil 228 | }) 229 | 230 | close(filesToScan) 231 | wg.Wait() 232 | 233 | metrics := cm.Metrics{} 234 | metrics.Scanned = totalFilesScanned 235 | metrics.Clear = cleared 236 | metrics.Matched = matched 237 | metrics.ScannedDir = *dir 238 | metrics.ScanTime = time.Since(start).Minutes() 239 | 240 | // Items empty if error 241 | osName, _ := os.Hostname() 242 | envVars := os.Environ() 243 | theUser, _ := user.Current() 244 | 245 | metrics.SystemInfo.Hostname = osName 246 | metrics.SystemInfo.EnvVars = envVars 247 | metrics.SystemInfo.Username = theUser.Username 248 | metrics.SystemInfo.UserID = theUser.Uid 249 | metrics.SystemInfo.RealName = theUser.Name 250 | metrics.SystemInfo.UserHomeDir = theUser.HomeDir 251 | 252 | data, err := json.Marshal(metrics) 253 | if err != nil { 254 | log.Fatal(err) 255 | } 256 | fmt.Printf("%s\n", data) 257 | } 258 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # About web shell scan 2 | Web shell scan is a cross platform standalone binary that recursively scans through a specified directory with either user defined or default regex. 3 | Web shell scan utilizes a pool of go routines (10 total) to read from a channel and speed up the scanner. Note, the regex supplied with the scanner isn't 100% and does not guarantee it will find every web shell on disk. 4 | 5 | This tool is related to the following write up: 6 | 7 | https://blog.stillztech.com/2018/08/analyzing-and-detecting-web-shells.html 8 | 9 | To test effectiveness of the scanner, it was tested against the tennc web shell repo: 10 | 11 | https://github.com/tennc/webshell 12 | 13 | ### Requirements 14 | None! Simply download the binary for your OS, supply the directory you wish to scan and let it rip. 15 | 16 | ### Running the binary 17 | Running `webscan` with no arguments shows the following arguments: 18 | 19 | /Users/beastmode$ ./webscan 20 | Options: 21 | -dir string 22 | Directory to scan for web shells 23 | -exts string 24 | Specify extensions to target. Multiple extensions should be passed with pipe separator (asp|aspx|php|cfm). Default is all extensions 25 | -raw_contents 26 | If a match is found, grab the raw contents and base64 + gzip compress the file into the JSON object. 27 | -regex string 28 | Override default regex with your own 29 | -size int 30 | Specify max file size to scan (default is 10 MB) (default 10) 31 | 32 | The only required argument is `dir`, but you can override the program defaults if you wish. 33 | 34 | The output of the scan will be written to console. Example below (For best results, send stdout to a json file and review/post process offline): 35 | 36 | /Users/beastmode$ ./webscan -dir /Users/beastmode/webshell-master 37 | 38 | {"filePath":"/Users/beastmode/webshell-master/xakep-shells/PHP/wacking.php.php.txt","size":142739,"md5":"9c5bb5e3a46ec28039e8986324e42792","timestamps":{"birth":"2019-02-03 02:02:22","created":"2019-03-17 13:18:52","modified":"2019-02-03 02:02:22","accessed":"2019-04-25 01:19:47"},"matches":{"eval(":2}} 39 | 40 | ### With STDOUT: 41 | 42 | /Users/beastmode$ ./webscan -dir /Users/beastmode/webshell-master -raw_contents=true > scan_results.json 43 | 44 | Once the scanner finishes, it will output the overall scan metrics to STDOUT, as shown in the example below: 45 | 46 | {"scanned":1575,"matches":501,"noMatches":1074,"directory":"/Users/beastmode$/Downloads/webshell-master","scanDuration":1.2528534718166666, 47 | "systemInfo":{"hostname":"beastmode","envVars":["...."],"username":"beastmode","userID":"12345","realName":"","userHomeDir":"/Users/beastmode"}} 48 | 49 | ### Custom regex 50 | You can also supply your own regex if you have some specific regex pattern you're looking for: 51 | 52 | ./webscan -dir=/opt/https -regex="eval\\(|cmd|exec\\(" -size=5 -raw_contents=true -exts=php|jsp 53 | 54 | ### Building the project 55 | If you decide to modify the source code, you can build the project using the following commands: 56 | 57 | cd 58 | ## Windows 59 | GOOS=windows GOARCH=386 go build -o webscan_windows.exe main.go 60 | ## Linux 61 | GOOS=linux GOARCH=386 go build -o webscan_linux main.go 62 | ## Darwin 63 | GOOS=darwin GOARCH=386 go build -o webscan_darwin main.go 64 | -------------------------------------------------------------------------------- /timestamps/timestamps_darwin.go: -------------------------------------------------------------------------------- 1 | package timestamps 2 | 3 | import ( 4 | "os" 5 | "syscall" 6 | "time" 7 | cm "../common" 8 | ) 9 | 10 | 11 | func StatTimes(filePath string) (wts cm.FileTimes, err error) { 12 | fi, err := os.Stat(filePath) 13 | if err != nil { 14 | return 15 | } 16 | 17 | stat := fi.Sys().(*syscall.Stat_t) 18 | wts.Modified = cm.FormatTimestamp(time.Unix(int64(stat.Mtimespec.Sec), int64(stat.Mtimespec.Nsec)).UTC()) 19 | wts.Accessed = cm.FormatTimestamp(time.Unix(int64(stat.Atimespec.Sec), int64(stat.Atimespec.Nsec)).UTC()) 20 | wts.Created = cm.FormatTimestamp(time.Unix(int64(stat.Ctimespec.Sec), int64(stat.Ctimespec.Nsec)).UTC()) 21 | wts.Birth = cm.FormatTimestamp(time.Unix(int64(stat.Birthtimespec.Sec), int64(stat.Birthtimespec.Nsec)).UTC()) 22 | return 23 | } 24 | -------------------------------------------------------------------------------- /timestamps/timestamps_linux.go: -------------------------------------------------------------------------------- 1 | package timestamps 2 | 3 | import ( 4 | "os" 5 | "syscall" 6 | "time" 7 | cm "../common" 8 | ) 9 | 10 | func StatTimes(filePath string) (wts cm.FileTimes, err error) { 11 | fi, err := os.Stat(filePath) 12 | if err != nil { 13 | return 14 | } 15 | 16 | stat := fi.Sys().(*syscall.Stat_t) 17 | wts.Modified = cm.FormatTimestamp(time.Unix(int64(stat.Mtim.Sec), int64(stat.Mtim.Nsec)).UTC()) 18 | wts.Accessed = cm.FormatTimestamp(time.Unix(int64(stat.Atim.Sec), int64(stat.Atim.Nsec)).UTC()) 19 | wts.Created = cm.FormatTimestamp(time.Unix(int64(stat.Ctim.Sec), int64(stat.Ctim.Nsec)).UTC()) 20 | return 21 | } 22 | -------------------------------------------------------------------------------- /timestamps/timestamps_windows.go: -------------------------------------------------------------------------------- 1 | package timestamps 2 | 3 | import ( 4 | cm "../common" 5 | "os" 6 | "syscall" 7 | "time" 8 | ) 9 | func StatTimes(filePath string) (wts cm.FileTimes, err error) { 10 | fi, err := os.Stat(filePath) 11 | if err != nil { 12 | return 13 | } 14 | 15 | // https://golang.org/src/os/types_windows.go Line:215 16 | tsInfo := fi.Sys().(*syscall.Win32FileAttributeData) 17 | wts.Created = cm.FormatTimestamp(time.Unix(0, tsInfo.CreationTime.Nanoseconds())) 18 | wts.Accessed = cm.FormatTimestamp(time.Unix(0, tsInfo.LastAccessTime.Nanoseconds())) 19 | wts.Modified = cm.FormatTimestamp(time.Unix(0, tsInfo.LastWriteTime.Nanoseconds())) 20 | return 21 | } 22 | --------------------------------------------------------------------------------