Last active
June 1, 2026 23:59
-
-
Save s3rgeym/eb0fa6f75f3afad28763ae2c96ce399d to your computer and use it in GitHub Desktop.
Go VNC Scanner
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package main | |
| import ( | |
| "bufio" | |
| "context" | |
| "crypto/des" | |
| "encoding/binary" | |
| "flag" | |
| "fmt" | |
| "io" | |
| "net" | |
| "net/http" | |
| "net/url" | |
| "os" | |
| "os/signal" | |
| "path/filepath" | |
| "slices" | |
| "strconv" | |
| "strings" | |
| "sync" | |
| "syscall" | |
| "time" | |
| ) | |
| const ( | |
| colorReset = "\033[0m" | |
| colorBlack = "\033[30m" | |
| colorRed = "\033[31m" | |
| colorGreen = "\033[32m" | |
| colorYellow = "\033[33m" | |
| colorBlue = "\033[34m" | |
| colorMagenta = "\033[35m" | |
| colorCyan = "\033[36m" | |
| colorWhite = "\033[37m" | |
| colorGray = "\033[90m" | |
| ) | |
| const ( | |
| RFB_VERSION_LEN = 12 | |
| AUTH_NONE = 1 | |
| AUTH_VNC = 2 | |
| ) | |
| var ( | |
| connTimeout time.Duration | |
| workerCount int | |
| rps int | |
| verbose bool | |
| passwordsPath string | |
| portsFlag string | |
| tgTokenFlag string | |
| tgChatIDFlag int64 | |
| outputPath string | |
| permits chan struct{} | |
| ) | |
| func init() { | |
| flag.DurationVar(&connTimeout, "timeout-conn", 5*time.Second, "Connection timeout (also for read/write)") | |
| flag.DurationVar(&connTimeout, "t", 5*time.Second, "Connection timeout (short)") | |
| flag.IntVar(&workerCount, "workers", 50, "Number of concurrent workers") | |
| flag.IntVar(&workerCount, "w", 50, "Number of concurrent workers (short)") | |
| flag.IntVar(&rps, "rps", 150, "Requests per second limit") | |
| flag.IntVar(&rps, "r", 150, "Requests per second limit (short)") | |
| flag.BoolVar(&verbose, "v", false, "Verbose logging") | |
| flag.StringVar(&passwordsPath, "passwords", "", "Path to the passwords file") | |
| flag.StringVar(&passwordsPath, "P", "", "Path to the passwords file (short)") | |
| flag.StringVar(&portsFlag, "ports", "", "Comma-separated list of ports or ranges (e.g., 5900-5910). Default: 5900,5901,5902") | |
| flag.StringVar(&portsFlag, "p", "", "Ports (short)") | |
| flag.StringVar(&tgTokenFlag, "tg-token", "", "Telegram bot token") | |
| flag.Int64Var(&tgChatIDFlag, "tg-chat-id", 0, "Telegram chat ID") | |
| flag.StringVar(&outputPath, "output", "", "Output file for results (default: stdout)") | |
| flag.StringVar(&outputPath, "o", "", "Output file (short)") | |
| } | |
| func getEnv(key, defaultValue string) string { | |
| if val := os.Getenv(key); val != "" { | |
| return val | |
| } | |
| return defaultValue | |
| } | |
| type Result struct { | |
| IP string | |
| Port int | |
| Password string | |
| } | |
| func main() { | |
| flag.Parse() | |
| if rps <= 0 { | |
| logError("RPS must be positive (got %d)", rps) | |
| os.Exit(1) | |
| } | |
| if workerCount <= 0 { | |
| logError("Worker count must be positive (got %d)", workerCount) | |
| os.Exit(1) | |
| } | |
| if connTimeout <= 0 { | |
| logError("Connection timeout must be positive (got %v)", connTimeout) | |
| os.Exit(1) | |
| } | |
| args := flag.Args() | |
| if len(args) < 1 { | |
| logError("At least one host, IP, IP range, or CIDR must be specified") | |
| fmt.Fprintf(os.Stderr, "Usage: %s [flags] <target1> [target2 ...]\n", filepath.Base(os.Args[0])) | |
| os.Exit(1) | |
| } | |
| var outputFile *os.File | |
| if outputPath != "" { | |
| var err error | |
| outputFile, err = os.OpenFile(outputPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) | |
| if err != nil { | |
| logError("Failed to open output file: %v", err) | |
| os.Exit(1) | |
| } | |
| defer outputFile.Close() | |
| } else { | |
| outputFile = os.Stdout | |
| } | |
| tgToken := tgTokenFlag | |
| if tgToken == "" { | |
| tgToken = getEnv("TG_TOKEN", "") | |
| } | |
| tgChatID := tgChatIDFlag | |
| if tgChatID == 0 { | |
| if envChatID := getEnv("TG_CHAT_ID", ""); envChatID != "" { | |
| if id, err := strconv.ParseInt(envChatID, 10, 64); err == nil { | |
| tgChatID = id | |
| } | |
| } | |
| } | |
| enableTelegram := tgToken != "" && tgChatID != 0 | |
| logDebug("Telegram notifications: %s", map[bool]string{true: "on", false: "off"}[enableTelegram]) | |
| var ports []int | |
| var err error | |
| if portsFlag != "" { | |
| ports, err = parsePorts(portsFlag) | |
| if err != nil { | |
| logError("Parsing ports failed: %v", err) | |
| os.Exit(1) | |
| } | |
| } else { | |
| ports = []int{5900, 5901, 5902} | |
| } | |
| logDebug("Scanning ports: %v", ports) | |
| ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | |
| defer stop() | |
| var passwords []string | |
| if passwordsPath != "" { | |
| passwords, err = loadPasswords(passwordsPath) | |
| if err != nil { | |
| logError("Loading passwords failed: %v", err) | |
| os.Exit(1) | |
| } | |
| } else { | |
| passwords = []string{ | |
| "admin", | |
| "root", | |
| "password", | |
| "qwerty", | |
| "secret", | |
| "vnc", | |
| "vncvnc", | |
| "realvnc", | |
| "123456", | |
| "12345678", | |
| } | |
| } | |
| tasks := make(chan string, workerCount*2) | |
| go func() { | |
| defer close(tasks) | |
| if err := generateAddresses(ctx, args, tasks); err != nil { | |
| logError("Address generation failed: %v", err) | |
| os.Exit(1) | |
| } | |
| }() | |
| resultsChan := make(chan Result) | |
| var outputWg sync.WaitGroup | |
| outputWg.Go(func() { | |
| for res := range resultsChan { | |
| var outputMsg string | |
| if res.Password == "" { | |
| outputMsg = fmt.Sprintf("%s:%d", res.IP, res.Port) | |
| } else { | |
| outputMsg = fmt.Sprintf("%s:%d - %s", res.IP, res.Port, res.Password) | |
| } | |
| fmt.Fprintln(outputFile, outputMsg) | |
| if outputFile != os.Stdout { | |
| if err := outputFile.Sync(); err != nil { | |
| logWarn("Failed to sync file to disk: %v", err) | |
| } | |
| } | |
| if enableTelegram { | |
| if err := sendTelegramMessage(ctx, tgToken, tgChatID, fmt.Sprintf("VNC Found: %s", outputMsg)); err != nil { | |
| logWarn("Failed to send Telegram message: %v", err) | |
| } | |
| } | |
| } | |
| }) | |
| tickerInterval := time.Second / time.Duration(rps) | |
| ticker := time.NewTicker(tickerInterval) | |
| defer ticker.Stop() | |
| permits = make(chan struct{}, 1) | |
| go func() { | |
| for range ticker.C { | |
| select { | |
| case permits <- struct{}{}: | |
| default: | |
| } | |
| } | |
| }() | |
| var workerWg sync.WaitGroup | |
| logDebug("Starting verification process using %d workers, rate limit %d RPS", workerCount, rps) | |
| for i := 0; i < workerCount; i++ { | |
| workerWg.Go(func() { | |
| for { | |
| select { | |
| case <-ctx.Done(): | |
| return | |
| case ip, ok := <-tasks: | |
| if !ok { | |
| return | |
| } | |
| processAddress(ctx, ip, ports, passwords, resultsChan) | |
| } | |
| } | |
| }) | |
| } | |
| workerWg.Wait() | |
| close(resultsChan) | |
| outputWg.Wait() | |
| logSuccess("Finished!") | |
| } | |
| func parsePorts(portsStr string) ([]int, error) { | |
| var allPorts []int | |
| parts := strings.SplitSeq(portsStr, ",") | |
| for part := range parts { | |
| part = strings.TrimSpace(part) | |
| if part == "" { | |
| continue | |
| } | |
| if strings.Contains(part, "-") { | |
| ports, err := parsePortRange(part) | |
| if err != nil { | |
| return nil, err | |
| } | |
| allPorts = append(allPorts, ports...) | |
| } else { | |
| p, err := strconv.Atoi(part) | |
| if err != nil || p < 1 || p > 65535 { | |
| return nil, fmt.Errorf("invalid port number: %s", part) | |
| } | |
| allPorts = append(allPorts, p) | |
| } | |
| } | |
| if len(allPorts) == 0 { | |
| return nil, fmt.Errorf("no valid ports specified") | |
| } | |
| slices.Sort(allPorts) | |
| allPorts = slices.Compact(allPorts) | |
| return allPorts, nil | |
| } | |
| func parsePortRange(part string) ([]int, error) { | |
| parts := strings.Split(part, "-") | |
| if len(parts) != 2 { | |
| return nil, fmt.Errorf("invalid port range: %s", part) | |
| } | |
| start, err := strconv.Atoi(strings.TrimSpace(parts[0])) | |
| if err != nil || start < 1 || start > 65535 { | |
| return nil, fmt.Errorf("invalid start port in range: %s", parts[0]) | |
| } | |
| end, err := strconv.Atoi(strings.TrimSpace(parts[1])) | |
| if err != nil || end < 1 || end > 65535 { | |
| return nil, fmt.Errorf("invalid end port in range: %s", parts[1]) | |
| } | |
| if start > end { | |
| return nil, fmt.Errorf("start port (%d) > end port (%d)", start, end) | |
| } | |
| ports := make([]int, 0, end-start+1) | |
| for p := start; p <= end; p++ { | |
| ports = append(ports, p) | |
| } | |
| return ports, nil | |
| } | |
| func loadPasswords(path string) ([]string, error) { | |
| file, err := os.Open(path) | |
| if err != nil { | |
| return nil, err | |
| } | |
| defer file.Close() | |
| var passwords []string | |
| scanner := bufio.NewScanner(file) | |
| for scanner.Scan() { | |
| text := strings.TrimSpace(scanner.Text()) | |
| if text != "" { | |
| passwords = append(passwords, text) | |
| } | |
| } | |
| if len(passwords) == 0 { | |
| return nil, fmt.Errorf("password file contains no non-empty lines") | |
| } | |
| return passwords, scanner.Err() | |
| } | |
| func generateAddresses(ctx context.Context, args []string, tasks chan<- string) error { | |
| for _, arg := range args { | |
| if strings.Contains(arg, "/") { | |
| _, ipNet, err := net.ParseCIDR(arg) | |
| if err != nil { | |
| return fmt.Errorf("invalid CIDR %q: %v", arg, err) | |
| } | |
| _, bits := ipNet.Mask.Size() | |
| if bits != 32 { | |
| return fmt.Errorf("IPv6 CIDR %s is not supported", arg) | |
| } | |
| ip := ipNet.IP.Mask(ipNet.Mask) | |
| for ipNet.Contains(ip) { | |
| select { | |
| case <-ctx.Done(): | |
| return nil | |
| default: | |
| } | |
| if isEdgeAddress(ip, ipNet) { | |
| incrementIP(ip) | |
| continue | |
| } | |
| select { | |
| case <-ctx.Done(): | |
| return nil | |
| case tasks <- ip.String(): | |
| } | |
| incrementIP(ip) | |
| } | |
| } else { | |
| if arg == "" { | |
| return fmt.Errorf("empty target string") | |
| } | |
| select { | |
| case <-ctx.Done(): | |
| return nil | |
| case tasks <- arg: | |
| } | |
| } | |
| } | |
| return nil | |
| } | |
| func isEdgeAddress(ip net.IP, ipNet *net.IPNet) bool { | |
| ip4 := ip.To4() | |
| if ip4 == nil { | |
| return false | |
| } | |
| ones, bits := ipNet.Mask.Size() | |
| if bits == 32 && ones < 31 { | |
| network := ipNet.IP.Mask(ipNet.Mask) | |
| broadcast := make(net.IP, len(network)) | |
| for i := range network { | |
| broadcast[i] = network[i] | ^ipNet.Mask[i] | |
| } | |
| if ip.Equal(network) || ip.Equal(broadcast) { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| func incrementIP(ip net.IP) { | |
| for j := len(ip) - 1; j >= 0; j-- { | |
| ip[j]++ | |
| if ip[j] > 0 { | |
| break | |
| } | |
| } | |
| } | |
| func processAddress(ctx context.Context, ip string, ports []int, passwords []string, results chan<- Result) { | |
| for _, port := range ports { | |
| select { | |
| case <-ctx.Done(): | |
| return | |
| default: | |
| } | |
| ok, password := checkVNC(ctx, ip, port, passwords) | |
| if ok { | |
| select { | |
| case <-ctx.Done(): | |
| return | |
| case results <- Result{IP: ip, Port: port, Password: password}: | |
| } | |
| return | |
| } | |
| } | |
| } | |
| func checkVNC(ctx context.Context, ip string, port int, passwords []string) (bool, string) { | |
| address := fmt.Sprintf("%s:%d", ip, port) | |
| timeoutCtx, cancel := context.WithTimeout(ctx, connTimeout) | |
| defer cancel() | |
| authType, _, err := getVNCAuthType(timeoutCtx, address) | |
| if err != nil { | |
| logWarn("Failed to get auth type from %s: %v", address, err) | |
| return false, "" | |
| } | |
| switch authType { | |
| case AUTH_NONE: | |
| ok, err := verifyNoAuth(timeoutCtx, address) | |
| if err == nil && ok { | |
| logSuccess("Passwordless authentication succeeded: %s", address) | |
| return true, "" | |
| } | |
| logWarn("AUTH_NONE advertised but verification failed: %v", err) | |
| return false, "" | |
| case AUTH_VNC: | |
| for _, password := range passwords { | |
| select { | |
| case <-timeoutCtx.Done(): | |
| return false, "" | |
| default: | |
| } | |
| logDebug("Trying password on %s: %s", address, password) | |
| if checkVNCPassword(timeoutCtx, address, password) { | |
| logSuccess("Authentication succeeded: %s - %s", address, password) | |
| return true, password | |
| } | |
| } | |
| return false, "" | |
| default: | |
| logDebug("Unknown auth type %d on %s", authType, address) | |
| return false, "" | |
| } | |
| } | |
| func getVNCAuthType(ctx context.Context, address string) (byte, byte, error) { | |
| conn, err := connectVNC(ctx, address) | |
| if err != nil { | |
| return 0, 0, err | |
| } | |
| defer conn.Close() | |
| if deadline, ok := ctx.Deadline(); ok { | |
| conn.SetDeadline(deadline) | |
| } else { | |
| conn.SetDeadline(time.Now().Add(connTimeout)) | |
| } | |
| serverVersion := make([]byte, RFB_VERSION_LEN) | |
| if _, err := io.ReadFull(conn, serverVersion); err != nil { | |
| return 0, 0, err | |
| } | |
| verStr := string(serverVersion) | |
| logDebug("Server version: %s", strings.Trim(verStr, "\r\n")) | |
| clientVersion := []byte("RFB 003.008\n") | |
| if _, err := conn.Write(clientVersion); err != nil { | |
| return 0, 0, err | |
| } | |
| if strings.HasPrefix(verStr, "RFB 003.003") { | |
| var secType uint32 | |
| if err := binary.Read(conn, binary.BigEndian, &secType); err != nil { | |
| return 0, 0, err | |
| } | |
| switch secType { | |
| case AUTH_NONE: | |
| return AUTH_NONE, byte(secType), nil | |
| case AUTH_VNC: | |
| return AUTH_VNC, byte(secType), nil | |
| default: | |
| return 0, 0, fmt.Errorf("unsupported security type %d", secType) | |
| } | |
| } | |
| var numSecTypes uint8 | |
| if err := binary.Read(conn, binary.BigEndian, &numSecTypes); err != nil { | |
| return 0, 0, err | |
| } | |
| secTypes := make([]byte, numSecTypes) | |
| if _, err := io.ReadFull(conn, secTypes); err != nil { | |
| return 0, 0, err | |
| } | |
| for _, t := range secTypes { | |
| if t == AUTH_NONE { | |
| return AUTH_NONE, t, nil | |
| } | |
| } | |
| for _, t := range secTypes { | |
| if t == AUTH_VNC { | |
| return AUTH_VNC, t, nil | |
| } | |
| } | |
| return 0, 0, fmt.Errorf("no supported auth type") | |
| } | |
| func verifyNoAuth(ctx context.Context, address string) (bool, error) { | |
| conn, err := connectVNC(ctx, address) | |
| if err != nil { | |
| return false, err | |
| } | |
| defer conn.Close() | |
| if deadline, ok := ctx.Deadline(); ok { | |
| conn.SetDeadline(deadline) | |
| } | |
| serverVersion := make([]byte, RFB_VERSION_LEN) | |
| if _, err := io.ReadFull(conn, serverVersion); err != nil { | |
| return false, err | |
| } | |
| clientVersion := []byte("RFB 003.008\n") | |
| if _, err := conn.Write(clientVersion); err != nil { | |
| return false, err | |
| } | |
| var numSecTypes uint8 | |
| if err := binary.Read(conn, binary.BigEndian, &numSecTypes); err != nil { | |
| return false, err | |
| } | |
| secTypes := make([]byte, numSecTypes) | |
| if _, err := io.ReadFull(conn, secTypes); err != nil { | |
| return false, err | |
| } | |
| if _, err := conn.Write([]byte{AUTH_NONE}); err != nil { | |
| return false, err | |
| } | |
| var authResult uint32 | |
| if err := binary.Read(conn, binary.BigEndian, &authResult); err != nil { | |
| return false, err | |
| } | |
| return authResult == 0, nil | |
| } | |
| func checkVNCPassword(ctx context.Context, address, password string) bool { | |
| conn, err := connectVNC(ctx, address) | |
| if err != nil { | |
| return false | |
| } | |
| defer conn.Close() | |
| if deadline, ok := ctx.Deadline(); ok { | |
| conn.SetDeadline(deadline) | |
| } else { | |
| conn.SetDeadline(time.Now().Add(connTimeout)) | |
| } | |
| serverVersion := make([]byte, RFB_VERSION_LEN) | |
| if _, err := io.ReadFull(conn, serverVersion); err != nil { | |
| return false | |
| } | |
| clientVersion := []byte("RFB 003.008\n") | |
| if _, err := conn.Write(clientVersion); err != nil { | |
| return false | |
| } | |
| var numSecTypes uint8 | |
| if err := binary.Read(conn, binary.BigEndian, &numSecTypes); err != nil { | |
| return false | |
| } | |
| secTypes := make([]byte, numSecTypes) | |
| if _, err := io.ReadFull(conn, secTypes); err != nil { | |
| return false | |
| } | |
| if !slices.Contains(secTypes, AUTH_VNC) { | |
| return false | |
| } | |
| if _, err := conn.Write([]byte{AUTH_VNC}); err != nil { | |
| return false | |
| } | |
| challenge := make([]byte, 16) | |
| if _, err := io.ReadFull(conn, challenge); err != nil { | |
| return false | |
| } | |
| response, err := encryptVNCChallenge(challenge, password) | |
| if err != nil { | |
| logDebug("Encryption failed for %s: %v", address, err) | |
| return false | |
| } | |
| if _, err := conn.Write(response); err != nil { | |
| return false | |
| } | |
| var authResult uint32 | |
| if err := binary.Read(conn, binary.BigEndian, &authResult); err != nil { | |
| return false | |
| } | |
| return authResult == 0 | |
| } | |
| func encryptVNCChallenge(challenge []byte, password string) ([]byte, error) { | |
| keyBytes := make([]byte, 8) | |
| copy(keyBytes, []byte(password)) | |
| for i := range 8 { | |
| keyBytes[i] = reverseBits(keyBytes[i]) | |
| } | |
| block, err := des.NewCipher(keyBytes) | |
| if err != nil { | |
| return nil, err | |
| } | |
| encrypted := make([]byte, 16) | |
| for i := 0; i < 16; i += 8 { | |
| block.Encrypt(encrypted[i:i+8], challenge[i:i+8]) | |
| } | |
| return encrypted, nil | |
| } | |
| func reverseBits(b byte) byte { | |
| var result byte | |
| for i := range 8 { | |
| result |= ((b >> i) & 1) << (7 - i) | |
| } | |
| return result | |
| } | |
| func connectVNC(ctx context.Context, address string) (net.Conn, error) { | |
| select { | |
| case <-ctx.Done(): | |
| return nil, ctx.Err() | |
| case <-permits: | |
| } | |
| var d net.Dialer | |
| return d.DialContext(ctx, "tcp", address) | |
| } | |
| func sendTelegramMessage(ctx context.Context, token string, chatID int64, text string) error { | |
| // Таймаут остаётся, но контекст уже может быть отменён раньше | |
| ctx, cancel := context.WithTimeout(ctx, 10*time.Second) | |
| defer cancel() | |
| apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token) | |
| data := url.Values{} | |
| data.Set("chat_id", fmt.Sprintf("%d", chatID)) | |
| data.Set("text", text) | |
| req, err := http.NewRequestWithContext(ctx, "POST", apiURL, strings.NewReader(data.Encode())) | |
| if err != nil { | |
| return err | |
| } | |
| req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| resp, err := http.DefaultClient.Do(req) | |
| if err != nil { | |
| return err | |
| } | |
| defer resp.Body.Close() | |
| if resp.StatusCode != http.StatusOK { | |
| return fmt.Errorf("telegram API returned %d", resp.StatusCode) | |
| } | |
| return nil | |
| } | |
| func logMessage(color, prefix, format string, args ...any) { | |
| msg := fmt.Sprintf(format, args...) | |
| fmt.Fprintf(os.Stderr, "%s[%s] %s%s\n", color, prefix, msg, colorReset) | |
| } | |
| func logDebug(format string, args ...any) { | |
| if verbose { | |
| logMessage(colorBlue, "D", format, args...) | |
| } | |
| } | |
| func logWarn(format string, args ...any) { | |
| logMessage(colorYellow, "!", format, args...) | |
| } | |
| func logError(format string, args ...any) { | |
| logMessage(colorRed, "E", format, args...) | |
| } | |
| func logSuccess(format string, args ...any) { | |
| logMessage(colorGreen, "+", format, args...) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment