Add readline support to interactive shells#21442
Conversation
|
I don't see benefit of this PR - would you mind elaborating? Is this related to any issue? |
|
@ShorterKing, we noticed that this did not run our cmd shell tests- this is not a problem for this PR, but we need to fix that in the background- #21484. |
There was a problem hiding this comment.
Pull request overview
This PR adds enhanced interactive shell input handling by leveraging pgets-based prompting (to enable readline-style editing when available) for both Msf::Sessions::CommandShell and Meterpreter InteractiveChannel, and registers additional word-navigation key bindings when RbReadline is present.
Changes:
- Add
RbReadlinekeybindings for Ctrl+Arrow word navigation and Ctrl+Delete word kill inRex::Ui::Text::Input::Readline. - Introduce a new “drain remote output + set local prompt + then read input via
pgets” interaction loop for Meterpreter interactive channels. - Introduce the same
pgets-driven prompt loop for command shell sessions, with a fallback for inputs withoutpgets.
Impact Analysis:
- Blast radius: high — affects interactive session UX for command shells and meterpreter interactive channels across console users; downstream behavior depends on the concrete
user_inputimplementation (TTY vs non-TTY, readline vs non-readline). - Data and contract effects: no schema changes; however, interactive I/O behavior (prompt display, newline handling, output display timing) is materially altered.
- Rollback and test focus: rollback is straightforward (revert interaction-loop changes); focus testing on non-readline inputs that still implement
pgets, correct prompt restoration after errors/backgrounding, and avoiding double-newline/hidden-output regressions.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
| lib/rex/ui/text/input/readline.rb | Adds RbReadline keybindings during readline input initialization. |
| lib/rex/post/meterpreter/ui/console/interactive_channel.rb | Adds a pgets-driven interaction loop that drains remote output and updates the local prompt. |
| lib/msf/base/sessions/command_shell.rb | Adds a pgets-driven command shell interaction loop with prompt parsing and fallback behavior. |
| elsif !user_input.respond_to?(:pgets) | ||
| interact_stream(self) | ||
| else | ||
| old_prompt = user_input.prompt | ||
| user_input.prompt = '' | ||
|
|
|
|
||
| begin | ||
| line = user_input.pgets | ||
| break unless line |
| user_input.prompt = '' | ||
|
|
||
| while self.interacting && _remote_fd(self) | ||
| data = '' | ||
| while self.interacting && _remote_fd(self) | ||
| sd = Rex::ThreadSafe.select([_remote_fd(self)], nil, nil, 0.1) | ||
| break unless sd | ||
| begin | ||
| chunk = self.lsock.sysread(16384) | ||
| break if chunk.nil? || chunk.empty? | ||
| data << chunk | ||
| rescue Exception | ||
| break | ||
| end | ||
| end | ||
|
|
||
| if data.length > 0 | ||
| self.on_print_proc.call(data.strip) if self.on_print_proc | ||
| self.on_log_proc.call(data.strip) if self.on_log_proc | ||
|
|
||
| lines = data.split("\n", -1) | ||
| prompt_part = lines.pop || '' | ||
|
|
||
| if lines.length > 0 | ||
| user_output.print(lines.join("\n") + "\n") | ||
| end | ||
|
|
||
| user_input.prompt = prompt_part | ||
| end | ||
|
|
||
| begin | ||
| line = user_input.pgets | ||
| break unless line | ||
| self.on_command_proc.call(line.strip) if self.on_command_proc | ||
| self.write(line + "\n") | ||
| rescue Exception | ||
| break | ||
| end | ||
| end | ||
|
|
||
| user_input.prompt = old_prompt if old_prompt |
| if !user_input.respond_to?(:pgets) | ||
| while self.interacting | ||
| sd = Rex::ThreadSafe.select(fds, nil, fds, 0.5) | ||
| next unless sd | ||
|
|
||
| if sd[0].include? rstream.fd | ||
| user_output.print(shell_read) | ||
| if sd[0].include? rstream.fd | ||
| user_output.print(shell_read) | ||
| end | ||
| if sd[0].include? user_input.fd | ||
| run_single((user_input.gets || '').chomp("\n")) | ||
| end | ||
| Thread.pass | ||
| end | ||
| if sd[0].include? user_input.fd | ||
| run_single((user_input.gets || '').chomp("\n")) | ||
| else | ||
| old_prompt = user_input.prompt | ||
| user_input.prompt = '' | ||
|
|
|
|
||
| begin | ||
| line = user_input.pgets | ||
| break unless line |
| user_input.prompt = '' | ||
|
|
||
| while self.interacting | ||
| data = '' | ||
| while self.interacting | ||
| sd = Rex::ThreadSafe.select([rstream.fd], nil, nil, 0.1) | ||
| break unless sd | ||
| begin | ||
| chunk = shell_read(-1, 0.01) | ||
| break if chunk.nil? || chunk.empty? | ||
| data << chunk | ||
| rescue Exception | ||
| break | ||
| end | ||
| end | ||
|
|
||
| if data.length > 0 | ||
| lines = data.split("\n", -1) | ||
| prompt_part = lines.pop || '' | ||
|
|
||
| if lines.length > 0 | ||
| user_output.print(lines.join("\n") + "\n") | ||
| end | ||
|
|
||
| user_input.prompt = prompt_part | ||
| end | ||
|
|
||
| begin | ||
| line = user_input.pgets | ||
| break unless line | ||
| run_single(line) | ||
| rescue Exception | ||
| break | ||
| end | ||
| end | ||
| Thread.pass | ||
|
|
||
| user_input.prompt = old_prompt if old_prompt |
| RbReadline.rl_parse_and_bind('"\e[1;5D": backward-word') | ||
| RbReadline.rl_parse_and_bind('"\e[1;5C": forward-word') | ||
| RbReadline.rl_parse_and_bind('"\e[3;5~": kill-word') | ||
| rescue Exception |
| if !user_input.respond_to?(:pgets) | ||
| while self.interacting | ||
| sd = Rex::ThreadSafe.select(fds, nil, fds, 0.5) | ||
| next unless sd | ||
|
|
||
| if sd[0].include? rstream.fd | ||
| user_output.print(shell_read) | ||
| if sd[0].include? rstream.fd | ||
| user_output.print(shell_read) |
|
Hey @msutovsky-r7, here's the issue this fixes. When you drop into an interactive shell or meterpreter channel, readline breaks entirely. Arrow keys print garbage like This just wires up the existing pgets path so readline works properly in shell sessions. Falls back to the old behavior for non-TTY/piped inputs so nothing breaks. No existing issue for it, just something that comes up in daily use. Happy to open one if needed. |
@bwatters-r7 Sounds good, I'll rebase once #21484 lands. |
3b74b8b to
4213d33
Compare

Adds readline support to interactive shells (
command_shelland meterpreterinteractive_channel) when the input driver supports it (pgets). Falls backto the existing behavior transparently when readline is not available, so
nothing is broken for existing users.
Also adds Ctrl+Arrow (word jump) and Ctrl+Delete (kill word) key bindings via
RbReadlinewhere available.Changes
lib/msf/base/sessions/command_shell.rb— readline path in_interact_stream,drains remote output and updates prompt dynamically before each
pgetscalllib/rex/post/meterpreter/ui/console/interactive_channel.rb— same patternfor meterpreter interactive channels
lib/rex/ui/text/input/readline.rb— registers word-navigation key bindingson init if
RbReadlineis presentVerification
msfconsoleuse exploit/multi/handler, set a reverse TCP payload, runshellTesting
Tested on:
meterpreter x64/windows→shellon Windows 10 (22H2)shell x64/linuxreverse shell on Linux (root, WSL2-based)Both readline interaction and fallback path confirmed working. Further testing
may be needed for edge cases such as background jobs (
exploit -j), raw TTYmode, and piped input.