Last active
December 11, 2025 16:19
-
-
Save zboralski/dac7b9ab2f97ac4fa2b4a1a65faf82e5 to your computer and use it in GitHub Desktop.
unxor.go
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
| // unxor - XOR key recovery and decryption for encrypted assets | |
| // | |
| // Uses Kasiski examination to find key length, then known-plaintext attack | |
| // with common file headers to recover the full key. | |
| // | |
| // Usage: | |
| // | |
| // unxor analyze <file> # Find key length and recover key | |
| // unxor decrypt <file> -k <key> # Decrypt with known key | |
| // unxor decrypt <file> -a # Auto-detect key and decrypt | |
| // | |
| // Examples: | |
| // unxor analyze main.98555.js | |
| // unxor decrypt main.98555.js -k c0f0205080b0e0104070a0d000306090 | |
| // unxor decrypt main.98555.js -a -o decrypted.js | |
| // unxor string "SGVsbG8gV29ybGQ=" -k 00 | |
| // unxor string "4a6f686e" --hex -b | |
| // unxor scan app.smali -k 69 | |
| // unxor scan decoded_apk/smali -r -k 69`) | |
| package main | |
| import ( | |
| "bytes" | |
| "encoding/base64" | |
| "encoding/hex" | |
| "errors" | |
| "flag" | |
| "fmt" | |
| "io" | |
| "os" | |
| "path/filepath" | |
| "regexp" | |
| "sort" | |
| "strings" | |
| ) | |
| // Common Cocos Creator file headers for known-plaintext attack | |
| var knownHeaders = [][]byte{ | |
| []byte("window.boot = function () {\n var settings"), // main.js bootstrap (long) | |
| []byte("window.boot = function () {"), // main.js bootstrap | |
| []byte("window.__require"), // bundled modules | |
| []byte("cc._RF.push(module"), // resource files | |
| []byte("System.register(["), // SystemJS modules | |
| []byte("(function(r,e,"), // browserify IIFE | |
| []byte("(function(){\"use strict\""), // strict IIFE | |
| []byte("\"use strict\";"), // strict mode | |
| []byte("var cc = cc || {};"), // Cocos variable | |
| []byte("<!DOCTYPE html>"), // HTML files | |
| []byte("<html lang="), // HTML files | |
| []byte("{\"launchScene\":"), // settings.json | |
| []byte("window._CCSettings = "), // settings.js | |
| []byte("cc.game.on(cc.game."), // game events | |
| } | |
| func main() { | |
| if err := run(os.Args[1:]); err != nil { | |
| fmt.Fprintln(os.Stderr, err) | |
| os.Exit(1) | |
| } | |
| } | |
| func run(args []string) error { | |
| if len(args) == 0 { | |
| printUsage(os.Stdout) | |
| return errors.New("no command specified") | |
| } | |
| switch args[0] { | |
| case "analyze": | |
| return analyzeCmd(args[1:]) | |
| case "decrypt": | |
| return decryptCmd(args[1:]) | |
| case "string": | |
| return stringCmd(args[1:]) | |
| case "scan": | |
| return scanCmd(args[1:]) | |
| case "help", "-h", "--help": | |
| printUsage(os.Stdout) | |
| return nil | |
| default: | |
| printUsage(os.Stderr) | |
| return fmt.Errorf("unknown command %q", args[0]) | |
| } | |
| } | |
| func printUsage(w io.Writer) { | |
| fmt.Fprintln(w, `unxor - XOR key recovery and decryption | |
| Usage: | |
| unxor analyze <file> Analyze file to find XOR key | |
| unxor decrypt <file> [options] Decrypt file | |
| unxor string <string> [options] Decrypt base64/hex encoded XOR string | |
| unxor scan <file> [options] Scan file for encrypted strings | |
| Decrypt Options: | |
| -k, --key <hex> XOR key in hex (e.g., c0f0205080b0e010) | |
| -a, --auto Auto-detect key using known headers | |
| -o, --output <f> Output file (default: <input>.dec) | |
| -w, --overwrite Overwrite input file with decrypted content | |
| String Options: | |
| -k, --key <hex> XOR key in hex (required, or use -b for brute force) | |
| -b, --brute Brute force single-byte XOR keys (0x00-0xff) | |
| --base64 Input is base64 encoded (default: auto-detect base64/hex) | |
| --hex Input is hex encoded | |
| Note: Input encoding is auto-detected (tries base64 first, then hex) | |
| Scan Options: | |
| -k, --key <hex> XOR key to try | |
| -b, --brute Try all single-byte XOR keys | |
| -r, --recursive Recursively scan directories for .smali files | |
| --min <n> Minimum string length (default: 4) | |
| Examples: | |
| unxor analyze main.98555.js | |
| unxor decrypt main.98555.js -k c0f0205080b0e0104070a0d000306090 | |
| unxor decrypt main.98555.js -a -o decrypted.js | |
| unxor string "SGVsbG8gV29ybGQ=" -k 00 | |
| unxor string "4a6f686e" --hex -b | |
| unxor scan app.smali -k 69 | |
| unxor scan decoded_apk/smali -r -k 69`) | |
| } | |
| func analyzeCmd(args []string) error { | |
| fs := newFlagSet("analyze", "Usage: unxor analyze <file>") | |
| if err := fs.Parse(args); err != nil { | |
| return err | |
| } | |
| if fs.NArg() < 1 { | |
| fs.Usage() | |
| return errors.New("analyze requires a file path") | |
| } | |
| file := fs.Arg(0) | |
| data, err := os.ReadFile(file) | |
| if err != nil { | |
| return fmt.Errorf("read %s: %w", file, err) | |
| } | |
| fmt.Printf("Analyzing %s (%d bytes)\n\n", file, len(data)) | |
| // Step 1: Find repeated sequences (Kasiski examination) | |
| fmt.Println("Kasiski examination...") | |
| keyLen := findKeyLength(data) | |
| if keyLen == 0 { | |
| fmt.Println(" No repeated patterns found, trying frequency analysis...") | |
| keyLen = frequencyKeyLength(data, 32) | |
| } | |
| fmt.Printf(" Key length: %d bytes\n\n", keyLen) | |
| // Step 2: Try known-plaintext attack | |
| fmt.Println("Known-plaintext attack...") | |
| key := recoverKey(data, keyLen) | |
| if key != nil { | |
| fmt.Printf(" Key: %s\n", hex.EncodeToString(key)) | |
| analyzeKeyPattern(key) | |
| // Show sample decryption | |
| fmt.Println("\nSample decryption (first 200 bytes):") | |
| fmt.Println("---") | |
| decrypted := xorDecrypt(data[:min(200, len(data))], key) | |
| fmt.Println(sanitizeOutput(decrypted)) | |
| fmt.Println("---") | |
| } else { | |
| fmt.Println(" Could not recover key with known headers") | |
| fmt.Println(" Try manual analysis or provide a known plaintext") | |
| } | |
| return nil | |
| } | |
| func decryptCmd(args []string) error { | |
| fs := newFlagSet("decrypt", "Usage: unxor decrypt <file> [-k key | -a] [-o output]") | |
| keyHex := fs.String("k", "", "XOR key in hex") | |
| fs.StringVar(keyHex, "key", "", "XOR key in hex") | |
| auto := fs.Bool("a", false, "Auto-detect key") | |
| fs.BoolVar(auto, "auto", false, "Auto-detect key") | |
| output := fs.String("o", "", "Output file") | |
| fs.StringVar(output, "output", "", "Output file") | |
| overwrite := fs.Bool("w", false, "Overwrite input file") | |
| fs.BoolVar(overwrite, "overwrite", false, "Overwrite input file") | |
| if err := fs.Parse(args); err != nil { | |
| return err | |
| } | |
| if fs.NArg() < 1 { | |
| fs.Usage() | |
| return errors.New("decrypt requires at least one file") | |
| } | |
| expandedFiles, err := expandGlobs(fs.Args()) | |
| if err != nil { | |
| return err | |
| } | |
| if len(expandedFiles) == 0 { | |
| return errors.New("no files matched") | |
| } | |
| key, err := decodeKey(*keyHex) | |
| if err != nil { | |
| return err | |
| } | |
| var encounteredError bool | |
| for _, file := range expandedFiles { | |
| if err := decryptFile(file, key, *auto, *overwrite, *output); err != nil { | |
| encounteredError = true | |
| fmt.Fprintln(os.Stderr, err) | |
| } | |
| } | |
| if encounteredError { | |
| return errors.New("one or more files failed to decrypt") | |
| } | |
| return nil | |
| } | |
| func decryptFile(file string, key []byte, auto bool, overwrite bool, output string) error { | |
| data, err := os.ReadFile(file) | |
| if err != nil { | |
| return fmt.Errorf("read %s: %w", file, err) | |
| } | |
| fileKey := key | |
| if auto || fileKey == nil { | |
| fileKey = autoDetectKey(data) | |
| if fileKey == nil { | |
| return fmt.Errorf("[%s] could not auto-detect key", file) | |
| } | |
| fmt.Printf("[%s] Auto-detected key: %s\n", file, hex.EncodeToString(fileKey)) | |
| } | |
| decrypted := xorDecrypt(data, fileKey) | |
| outPath := output | |
| if outPath == "" { | |
| if overwrite { | |
| outPath = file | |
| } else { | |
| outPath = file + ".dec" | |
| } | |
| } | |
| if err := os.WriteFile(outPath, decrypted, 0o644); err != nil { | |
| return fmt.Errorf("write %s: %w", outPath, err) | |
| } | |
| fmt.Printf("[%s] -> %s (%d bytes)\n", file, outPath, len(decrypted)) | |
| return nil | |
| } | |
| func autoDetectKey(data []byte) []byte { | |
| keyLen := findKeyLength(data) | |
| if keyLen == 0 { | |
| keyLen = frequencyKeyLength(data, 32) | |
| } | |
| return recoverKey(data, keyLen) | |
| } | |
| // stringCmd handles decryption of base64/hex encoded XOR strings | |
| func stringCmd(args []string) error { | |
| fs := newFlagSet("string", "Usage: unxor string <encoded-string> [-k key | -b]") | |
| keyHex := fs.String("k", "", "XOR key in hex") | |
| fs.StringVar(keyHex, "key", "", "XOR key in hex") | |
| brute := fs.Bool("b", false, "Brute force single-byte keys") | |
| fs.BoolVar(brute, "brute", false, "Brute force single-byte keys") | |
| isBase64 := fs.Bool("base64", false, "Input is base64 encoded") | |
| isHex := fs.Bool("hex", false, "Input is hex encoded") | |
| if err := fs.Parse(args); err != nil { | |
| return err | |
| } | |
| if fs.NArg() < 1 { | |
| fs.Usage() | |
| return errors.New("string requires an encoded string argument") | |
| } | |
| input := fs.Arg(0) | |
| // Decode the input | |
| var data []byte | |
| var err error | |
| var encoding string | |
| if *isHex { | |
| data, err = hex.DecodeString(input) | |
| encoding = "hex" | |
| } else if *isBase64 { | |
| data, err = base64.StdEncoding.DecodeString(input) | |
| encoding = "base64" | |
| } else { | |
| // Auto-detect: try base64 first, then hex | |
| data, err = base64.StdEncoding.DecodeString(input) | |
| if err == nil { | |
| encoding = "base64" | |
| } else { | |
| data, err = hex.DecodeString(input) | |
| if err == nil { | |
| encoding = "hex" | |
| } else { | |
| // Assume raw bytes | |
| data = []byte(input) | |
| encoding = "raw" | |
| } | |
| } | |
| } | |
| if err != nil { | |
| return fmt.Errorf("decode input: %w", err) | |
| } | |
| fmt.Printf("Input: %s (%d bytes, %s)\n", input, len(data), encoding) | |
| if *brute { | |
| // Brute force single-byte XOR | |
| fmt.Println("\nBrute forcing single-byte XOR keys...") | |
| fmt.Println("---") | |
| found := 0 | |
| for key := 0; key <= 0xff; key++ { | |
| decrypted := xorDecrypt(data, []byte{byte(key)}) | |
| if looksLikeText(decrypted) { | |
| fmt.Printf("0x%02x: %s\n", key, sanitizeOutput(decrypted)) | |
| found++ | |
| } | |
| } | |
| if found == 0 { | |
| fmt.Println("No readable results found") | |
| } | |
| fmt.Println("---") | |
| return nil | |
| } | |
| if *keyHex == "" { | |
| return errors.New("specify -k <key> or -b for brute force") | |
| } | |
| key, err := hex.DecodeString(*keyHex) | |
| if err != nil { | |
| return fmt.Errorf("invalid hex key: %w", err) | |
| } | |
| decrypted := xorDecrypt(data, key) | |
| fmt.Printf("Key: %s\n", hex.EncodeToString(key)) | |
| fmt.Printf("Decrypted: %s\n", sanitizeOutput(decrypted)) | |
| return nil | |
| } | |
| // Regex patterns for finding encoded strings | |
| var ( | |
| // Base64: at least 8 chars, valid base64 alphabet, optional padding | |
| base64Pattern = regexp.MustCompile(`[A-Za-z0-9+/]{8,}={0,2}`) | |
| // Hex: even number of hex chars, at least 8 | |
| hexPattern = regexp.MustCompile(`(?:0x)?([0-9a-fA-F]{2}){4,}`) | |
| // Smali array: 0xNNt bytes | |
| smaliArrayPattern = regexp.MustCompile(`0x([0-9a-fA-F]{1,2})t`) | |
| ) | |
| // scanCmd scans files for base64/hex encoded strings and tries to decrypt them | |
| func scanCmd(args []string) error { | |
| fs := newFlagSet("scan", "Usage: unxor scan <path> [-k key] [-b] [-r]") | |
| keyHex := fs.String("k", "", "XOR key in hex") | |
| fs.StringVar(keyHex, "key", "", "XOR key in hex") | |
| brute := fs.Bool("b", false, "Brute force single-byte keys") | |
| fs.BoolVar(brute, "brute", false, "Brute force single-byte keys") | |
| minLen := fs.Int("min", 4, "Minimum decoded string length") | |
| recursive := fs.Bool("r", false, "Recursively scan directories") | |
| fs.BoolVar(recursive, "recursive", false, "Recursively scan directories") | |
| if err := fs.Parse(args); err != nil { | |
| return err | |
| } | |
| if fs.NArg() < 1 { | |
| fs.Usage() | |
| return errors.New("scan requires a file or directory path") | |
| } | |
| var key []byte | |
| var err error | |
| if *keyHex != "" { | |
| key, err = hex.DecodeString(*keyHex) | |
| if err != nil { | |
| return fmt.Errorf("invalid hex key: %w", err) | |
| } | |
| } | |
| path := fs.Arg(0) | |
| info, err := os.Stat(path) | |
| if err != nil { | |
| return fmt.Errorf("stat %s: %w", path, err) | |
| } | |
| opts := &scanOptions{ | |
| key: key, | |
| brute: *brute, | |
| minLen: *minLen, | |
| recursive: *recursive, | |
| } | |
| if info.IsDir() { | |
| return scanDirectory(path, opts) | |
| } | |
| return scanFile(path, opts) | |
| } | |
| type scanOptions struct { | |
| key []byte | |
| brute bool | |
| minLen int | |
| recursive bool | |
| } | |
| // scanDirectory recursively scans a directory for smali files | |
| func scanDirectory(dir string, opts *scanOptions) error { | |
| var files []string | |
| // Get absolute path of base directory for computing relative paths | |
| absDir, err := filepath.Abs(dir) | |
| if err != nil { | |
| absDir = dir | |
| } | |
| walkFn := func(path string, d os.DirEntry, err error) error { | |
| if err != nil { | |
| return nil // Skip files we can't access | |
| } | |
| if d.IsDir() { | |
| return nil | |
| } | |
| // Only scan smali files in directories | |
| if strings.HasSuffix(path, ".smali") { | |
| files = append(files, path) | |
| } | |
| return nil | |
| } | |
| if opts.recursive { | |
| if err := filepath.WalkDir(dir, walkFn); err != nil { | |
| return fmt.Errorf("walk %s: %w", dir, err) | |
| } | |
| } else { | |
| entries, err := os.ReadDir(dir) | |
| if err != nil { | |
| return fmt.Errorf("read dir %s: %w", dir, err) | |
| } | |
| for _, e := range entries { | |
| if !e.IsDir() && strings.HasSuffix(e.Name(), ".smali") { | |
| files = append(files, filepath.Join(dir, e.Name())) | |
| } | |
| } | |
| } | |
| if len(files) == 0 { | |
| fmt.Printf("No smali files found in %s\n", dir) | |
| return nil | |
| } | |
| fmt.Printf("Scanning %d smali files in %s\n\n", len(files), dir) | |
| totalFound := 0 | |
| for _, file := range files { | |
| // Compute relative path for display | |
| relPath, err := filepath.Rel(absDir, file) | |
| if err != nil { | |
| relPath = file | |
| } | |
| found, err := scanFileWithResults(file, relPath, opts) | |
| if err != nil { | |
| fmt.Fprintf(os.Stderr, "Error scanning %s: %v\n", relPath, err) | |
| continue | |
| } | |
| totalFound += found | |
| } | |
| fmt.Printf("\nTotal: %d decrypted strings\n", totalFound) | |
| return nil | |
| } | |
| // scanFile scans a single file and prints results | |
| func scanFile(path string, opts *scanOptions) error { | |
| content, err := os.ReadFile(path) | |
| if err != nil { | |
| return fmt.Errorf("read %s: %w", path, err) | |
| } | |
| text := string(content) | |
| fmt.Printf("Scanning %s (%d bytes)\n\n", path, len(content)) | |
| // Check if this looks like a smali file | |
| if strings.Contains(text, ".array-data") || strings.Contains(text, "xor-int/lit8") { | |
| return scanSmaliFileWithPath(path, text, opts) | |
| } | |
| // Scan for base64 strings | |
| found := 0 | |
| seen := make(map[string]bool) | |
| fmt.Println("=== Base64 Strings ===") | |
| lines := strings.Split(text, "\n") | |
| for lineNum, line := range lines { | |
| for _, match := range base64Pattern.FindAllString(line, -1) { | |
| if seen[match] || len(match) < opts.minLen { | |
| continue | |
| } | |
| seen[match] = true | |
| decoded, decErr := base64.StdEncoding.DecodeString(match) | |
| if decErr != nil || len(decoded) < 4 { | |
| continue | |
| } | |
| results := tryDecrypt(decoded, opts.key, opts.brute) | |
| for _, r := range results { | |
| fmt.Printf("%s:%d: %s\n", path, lineNum+1, match) | |
| fmt.Printf(" -> [0x%02x] %s\n", r.key, r.plaintext) | |
| found++ | |
| } | |
| } | |
| } | |
| fmt.Printf("\nFound %d decodable strings\n", found) | |
| return nil | |
| } | |
| // scanFileWithResults scans a file and returns count of found strings | |
| func scanFileWithResults(path string, displayPath string, opts *scanOptions) (int, error) { | |
| content, err := os.ReadFile(path) | |
| if err != nil { | |
| return 0, err | |
| } | |
| text := string(content) | |
| // Check if this looks like a smali file | |
| if strings.Contains(text, ".array-data") || strings.Contains(text, "xor-int/lit8") { | |
| return scanSmaliFileCount(displayPath, text, opts) | |
| } | |
| return 0, nil | |
| } | |
| // scanSmaliFile handles smali files with .array-data sections (legacy, for tests) | |
| func scanSmaliFile(text string, key []byte, brute bool, minLen int) error { | |
| opts := &scanOptions{key: key, brute: brute, minLen: minLen} | |
| return scanSmaliFileWithPath("", text, opts) | |
| } | |
| // scanSmaliFileWithPath scans a smali file with path for line number output | |
| func scanSmaliFileWithPath(path string, text string, opts *scanOptions) error { | |
| fmt.Println("Detected smali file, scanning .array-data sections...") | |
| key := opts.key | |
| // Find xor key used in smali if not specified | |
| if key == nil && !opts.brute { | |
| xorKeyPattern := regexp.MustCompile(`xor-int/lit8\s+v\d+,\s*v\d+,\s*0x([0-9a-fA-F]+)`) | |
| if matches := xorKeyPattern.FindStringSubmatch(text); len(matches) > 1 { | |
| keyByte, _ := hex.DecodeString(matches[1]) | |
| if len(keyByte) > 0 { | |
| key = keyByte | |
| fmt.Printf("Auto-detected XOR key from smali: 0x%s\n", matches[1]) | |
| } | |
| } | |
| } | |
| // Find array data sections with their line numbers | |
| lines := strings.Split(text, "\n") | |
| arrayStartPattern := regexp.MustCompile(`^\s*:array_([0-9a-f]+)\s*$`) | |
| arrayDataPattern := regexp.MustCompile(`^\s*\.array-data\s+1\s*$`) | |
| type arrayInfo struct { | |
| name string | |
| lineNum int | |
| data []byte | |
| hexBytes string | |
| } | |
| var arrays []arrayInfo | |
| for i := 0; i < len(lines); i++ { | |
| // Look for :array_N label | |
| if matches := arrayStartPattern.FindStringSubmatch(lines[i]); matches != nil { | |
| arrayName := matches[1] | |
| arrayLine := i + 1 // 1-indexed | |
| // Next line should be .array-data 1 | |
| if i+1 < len(lines) && arrayDataPattern.MatchString(lines[i+1]) { | |
| // Collect bytes until .end array-data | |
| var byteValues []byte | |
| j := i + 2 | |
| for j < len(lines) && !strings.Contains(lines[j], ".end array-data") { | |
| byteMatches := smaliArrayPattern.FindAllStringSubmatch(lines[j], -1) | |
| for _, b := range byteMatches { | |
| val, _ := hex.DecodeString(fmt.Sprintf("%02s", b[1])) | |
| if len(val) > 0 { | |
| byteValues = append(byteValues, val[0]) | |
| } | |
| } | |
| j++ | |
| } | |
| if len(byteValues) >= opts.minLen { | |
| arrays = append(arrays, arrayInfo{ | |
| name: arrayName, | |
| lineNum: arrayLine, | |
| data: byteValues, | |
| hexBytes: hex.EncodeToString(byteValues), | |
| }) | |
| } | |
| i = j // Skip to end of array | |
| } | |
| } | |
| } | |
| if len(arrays) == 0 { | |
| fmt.Println("No .array-data sections found") | |
| return nil | |
| } | |
| fmt.Printf("Found %d array sections\n\n", len(arrays)) | |
| for _, arr := range arrays { | |
| results := tryDecrypt(arr.data, key, opts.brute) | |
| for _, r := range results { | |
| if path != "" { | |
| fmt.Printf("%s:%d: array_%s: %s\n", path, arr.lineNum, arr.name, arr.hexBytes) | |
| } else { | |
| fmt.Printf("array_%s: %s\n", arr.name, arr.hexBytes) | |
| } | |
| fmt.Printf(" -> [0x%02x] %s\n", r.key, r.plaintext) | |
| } | |
| } | |
| return nil | |
| } | |
| // scanSmaliFileCount scans and returns count (for directory scanning) | |
| func scanSmaliFileCount(path string, text string, opts *scanOptions) (int, error) { | |
| key := opts.key | |
| // Find xor key used in smali if not specified | |
| if key == nil && !opts.brute { | |
| xorKeyPattern := regexp.MustCompile(`xor-int/lit8\s+v\d+,\s*v\d+,\s*0x([0-9a-fA-F]+)`) | |
| if matches := xorKeyPattern.FindStringSubmatch(text); len(matches) > 1 { | |
| keyByte, _ := hex.DecodeString(matches[1]) | |
| if len(keyByte) > 0 { | |
| key = keyByte | |
| } | |
| } | |
| } | |
| // Find array data sections with their line numbers | |
| lines := strings.Split(text, "\n") | |
| arrayStartPattern := regexp.MustCompile(`^\s*:array_([0-9a-f]+)\s*$`) | |
| arrayDataPattern := regexp.MustCompile(`^\s*\.array-data\s+1\s*$`) | |
| found := 0 | |
| for i := 0; i < len(lines); i++ { | |
| // Look for :array_N label | |
| if matches := arrayStartPattern.FindStringSubmatch(lines[i]); matches != nil { | |
| arrayName := matches[1] | |
| arrayLine := i + 1 // 1-indexed | |
| // Next line should be .array-data 1 | |
| if i+1 < len(lines) && arrayDataPattern.MatchString(lines[i+1]) { | |
| // Collect bytes until .end array-data | |
| var byteValues []byte | |
| j := i + 2 | |
| for j < len(lines) && !strings.Contains(lines[j], ".end array-data") { | |
| byteMatches := smaliArrayPattern.FindAllStringSubmatch(lines[j], -1) | |
| for _, b := range byteMatches { | |
| val, _ := hex.DecodeString(fmt.Sprintf("%02s", b[1])) | |
| if len(val) > 0 { | |
| byteValues = append(byteValues, val[0]) | |
| } | |
| } | |
| j++ | |
| } | |
| if len(byteValues) >= opts.minLen { | |
| results := tryDecrypt(byteValues, key, opts.brute) | |
| for _, r := range results { | |
| fmt.Printf("%s:%d: array_%s: %s\n", path, arrayLine, arrayName, hex.EncodeToString(byteValues)) | |
| fmt.Printf(" -> [0x%02x] %s\n", r.key, r.plaintext) | |
| found++ | |
| } | |
| } | |
| i = j // Skip to end of array | |
| } | |
| } | |
| } | |
| return found, nil | |
| } | |
| type decryptResult struct { | |
| key byte | |
| plaintext string | |
| } | |
| // tryDecrypt attempts to decrypt data with given key or brute force | |
| func tryDecrypt(data []byte, key []byte, brute bool) []decryptResult { | |
| var results []decryptResult | |
| if brute { | |
| for k := 0; k <= 0xff; k++ { | |
| decrypted := xorDecrypt(data, []byte{byte(k)}) | |
| if looksLikeText(decrypted) { | |
| results = append(results, decryptResult{ | |
| key: byte(k), | |
| plaintext: sanitizeOutput(decrypted), | |
| }) | |
| } | |
| } | |
| } else if key != nil { | |
| decrypted := xorDecrypt(data, key) | |
| if looksLikeText(decrypted) { | |
| keyByte := byte(0) | |
| if len(key) > 0 { | |
| keyByte = key[0] | |
| } | |
| results = append(results, decryptResult{ | |
| key: keyByte, | |
| plaintext: sanitizeOutput(decrypted), | |
| }) | |
| } | |
| } | |
| return results | |
| } | |
| func expandGlobs(files []string) ([]string, error) { | |
| var expanded []string | |
| for _, pattern := range files { | |
| if strings.ContainsAny(pattern, "*?[") { | |
| matches, err := filepath.Glob(pattern) | |
| if err != nil { | |
| return nil, fmt.Errorf("expand %q: %w", pattern, err) | |
| } | |
| expanded = append(expanded, matches...) | |
| continue | |
| } | |
| expanded = append(expanded, pattern) | |
| } | |
| return expanded, nil | |
| } | |
| func decodeKey(keyHex string) ([]byte, error) { | |
| if keyHex == "" { | |
| return nil, nil | |
| } | |
| key, err := hex.DecodeString(keyHex) | |
| if err != nil { | |
| return nil, fmt.Errorf("invalid hex key: %w", err) | |
| } | |
| return key, nil | |
| } | |
| // findKeyLength uses Kasiski examination to find repeated sequences | |
| func findKeyLength(data []byte) int { | |
| if len(data) < 100 { | |
| return 0 | |
| } | |
| // Find repeated sequences of length 6-16 bytes | |
| distances := make(map[int]int) | |
| minSeqLen := 6 | |
| maxSeqLen := 16 | |
| for seqLen := minSeqLen; seqLen <= maxSeqLen; seqLen++ { | |
| seen := make(map[string][]int) | |
| for i := 0; i <= len(data)-seqLen; i++ { | |
| seq := string(data[i : i+seqLen]) | |
| seen[seq] = append(seen[seq], i) | |
| } | |
| for _, positions := range seen { | |
| if len(positions) < 2 { | |
| continue | |
| } | |
| for i := 1; i < len(positions); i++ { | |
| dist := positions[i] - positions[i-1] | |
| if dist > 0 && dist < 10000 { | |
| distances[dist]++ | |
| } | |
| } | |
| } | |
| } | |
| if len(distances) == 0 { | |
| return 0 | |
| } | |
| // Find GCD of most common distances | |
| type distCount struct { | |
| dist int | |
| count int | |
| } | |
| var dcs []distCount | |
| for d, c := range distances { | |
| dcs = append(dcs, distCount{d, c}) | |
| } | |
| sort.Slice(dcs, func(i, j int) bool { | |
| return dcs[i].count > dcs[j].count | |
| }) | |
| // Take top distances and find their GCD | |
| topN := min(20, len(dcs)) | |
| result := dcs[0].dist | |
| for i := 1; i < topN; i++ { | |
| result = gcd(result, dcs[i].dist) | |
| } | |
| // Common key lengths | |
| validLengths := []int{4, 8, 12, 16, 20, 24, 32} | |
| for _, vl := range validLengths { | |
| if result%vl == 0 || vl%result == 0 { | |
| // Prefer the valid length closest to our GCD | |
| if result >= vl { | |
| return vl | |
| } | |
| } | |
| } | |
| // If GCD is reasonable, use it | |
| if result >= 4 && result <= 64 { | |
| return result | |
| } | |
| return 16 // Default fallback | |
| } | |
| // frequencyKeyLength uses index of coincidence to estimate key length | |
| func frequencyKeyLength(data []byte, maxLen int) int { | |
| bestLen := 1 | |
| bestIC := 0.0 | |
| for keyLen := 1; keyLen <= maxLen; keyLen++ { | |
| var totalIC float64 | |
| for offset := 0; offset < keyLen; offset++ { | |
| // Extract every keyLen-th byte starting at offset | |
| var slice []byte | |
| for i := offset; i < len(data); i += keyLen { | |
| slice = append(slice, data[i]) | |
| } | |
| totalIC += indexOfCoincidence(slice) | |
| } | |
| avgIC := totalIC / float64(keyLen) | |
| // English text has IC ~0.065, random ~0.038 | |
| // Higher IC suggests correct key length | |
| if avgIC > bestIC { | |
| bestIC = avgIC | |
| bestLen = keyLen | |
| } | |
| } | |
| return bestLen | |
| } | |
| func indexOfCoincidence(data []byte) float64 { | |
| if len(data) < 2 { | |
| return 0 | |
| } | |
| freq := make([]int, 256) | |
| for _, b := range data { | |
| freq[b]++ | |
| } | |
| var sum float64 | |
| n := float64(len(data)) | |
| for _, f := range freq { | |
| sum += float64(f) * float64(f-1) | |
| } | |
| return sum / (n * (n - 1)) | |
| } | |
| // recoverKey attempts to recover the key using known plaintext headers | |
| func recoverKey(data []byte, keyLen int) []byte { | |
| if keyLen == 0 || len(data) < keyLen { | |
| return nil | |
| } | |
| // Try multiple key lengths (multiples of detected length) | |
| tryLengths := []int{keyLen} | |
| for mult := 2; mult <= 8; mult++ { | |
| if keyLen*mult <= 64 { | |
| tryLengths = append(tryLengths, keyLen*mult) | |
| } | |
| } | |
| for _, kl := range tryLengths { | |
| // Try each known header | |
| for _, header := range knownHeaders { | |
| if len(header) < kl { | |
| // Skip headers that are too short to derive a full key of length kl | |
| continue | |
| } | |
| // XOR ciphertext with expected plaintext to get key | |
| key := make([]byte, kl) | |
| for i := 0; i < kl; i++ { | |
| key[i] = data[i] ^ header[i] | |
| } | |
| // Validate: decrypt and check if result looks like text | |
| decrypted := xorDecrypt(data[:min(500, len(data))], key) | |
| if looksLikeText(decrypted) { | |
| return key | |
| } | |
| } | |
| } | |
| return nil | |
| } | |
| // xorDecrypt decrypts data with repeating XOR key | |
| func xorDecrypt(data, key []byte) []byte { | |
| if len(key) == 0 { | |
| return data | |
| } | |
| result := make([]byte, len(data)) | |
| for i, b := range data { | |
| result[i] = b ^ key[i%len(key)] | |
| } | |
| return result | |
| } | |
| // looksLikeText checks if data appears to be readable text/code | |
| func looksLikeText(data []byte) bool { | |
| if len(data) == 0 { | |
| return false | |
| } | |
| printable := 0 | |
| for _, b := range data { | |
| if (b >= 0x20 && b <= 0x7e) || b == '\n' || b == '\r' || b == '\t' { | |
| printable++ | |
| } | |
| } | |
| ratio := float64(printable) / float64(len(data)) | |
| return ratio > 0.85 | |
| } | |
| // analyzeKeyPattern looks for patterns in the recovered key | |
| func analyzeKeyPattern(key []byte) { | |
| if len(key) < 2 { | |
| return | |
| } | |
| // Check for arithmetic progression | |
| diff := int(key[1]) - int(key[0]) | |
| isArithmetic := true | |
| for i := 2; i < len(key); i++ { | |
| if int(key[i])-int(key[i-1]) != diff { | |
| isArithmetic = false | |
| break | |
| } | |
| } | |
| if isArithmetic { | |
| fmt.Printf(" Pattern: arithmetic progression (diff = 0x%02x)\n", byte(diff)&0xff) | |
| } | |
| // Check for repeating pattern | |
| for patLen := 1; patLen <= len(key)/2; patLen++ { | |
| if len(key)%patLen != 0 { | |
| continue | |
| } | |
| isRepeating := true | |
| pattern := key[:patLen] | |
| for i := patLen; i < len(key); i++ { | |
| if key[i] != pattern[i%patLen] { | |
| isRepeating = false | |
| break | |
| } | |
| } | |
| if isRepeating { | |
| fmt.Printf(" Pattern: repeating %d-byte sequence: %s\n", patLen, hex.EncodeToString(pattern)) | |
| break | |
| } | |
| } | |
| } | |
| func sanitizeOutput(data []byte) string { | |
| var buf bytes.Buffer | |
| for _, b := range data { | |
| if b >= 0x20 && b <= 0x7e { | |
| buf.WriteByte(b) | |
| } else if b == '\n' || b == '\r' || b == '\t' { | |
| buf.WriteByte(b) | |
| } else { | |
| buf.WriteString(fmt.Sprintf("\\x%02x", b)) | |
| } | |
| } | |
| return buf.String() | |
| } | |
| func gcd(a, b int) int { | |
| for b != 0 { | |
| a, b = b, a%b | |
| } | |
| return a | |
| } | |
| func min(a, b int) int { | |
| if a < b { | |
| return a | |
| } | |
| return b | |
| } | |
| func readStdin() ([]byte, error) { | |
| return io.ReadAll(os.Stdin) | |
| } | |
| func newFlagSet(name, usage string) *flag.FlagSet { | |
| fs := flag.NewFlagSet(name, flag.ContinueOnError) | |
| fs.SetOutput(io.Discard) | |
| fs.Usage = func() { | |
| fmt.Fprintln(os.Stderr, usage) | |
| } | |
| return fs | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment