Skip to content

Commit c91aa96

Browse files
committed
Add custom commands feature
Add new scripts Minor bugfix Add new command line arguments Improve README with usage and developing instructions Refactor code to fix linting errors
2 parents 63fd332 + 55d5a5c commit c91aa96

21 files changed

Lines changed: 791 additions & 123 deletions

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"python.createEnvironment.contentButton": "show",
1010
"python.terminal.activateEnvironment": true,
1111
"python.createEnvironment.trigger": "prompt",
12+
"python.analysis.typeCheckingMode": "standard",
1213
"ruff.importStrategy": "fromEnvironment",
1314
"python.terminal.activateEnvInCurrentTerminal": true,
1415
"explorer.excludeGitIgnore": true

CONTRIBUTING.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Contributing to WShell
2+
3+
We accept contributions of any kind, including bug reports, code patches, feature requests,
4+
new input/output scripts or custom commands. You can help this project also by using the
5+
development version of WShell and by reporting any bugs you might encounter.
6+
7+
## Reporting bugs
8+
9+
**It's important that you provide the full command argument list
10+
as well as the output of the failing command.**
11+
12+
Use the `--log=debug` flag and copy&paste both the command and its output
13+
to your bug report, e.g.:
14+
15+
```sh
16+
$ wshell --log=debug <COMPLETE ARGUMENT LIST THAT TRIGGERS THE ERROR>
17+
<COMPLETE OUTPUT>
18+
```
19+
20+
## Contributing code
21+
22+
Before working on a new feature or a bug, please browse [existing issues](https://github.com/unlock-security/wshell/issues)
23+
to see whether it has previously been discussed.
24+
25+
If your change alters WShell's behaviour or interface, it's a good idea to
26+
discuss it before you start working on it.
27+
28+
If you are fixing an issue not yet reported, the first step should be to create a
29+
new issue that documents the incorrect behaviour. That will also help you to build an
30+
understanding of the issue at hand.
31+
32+
### Development environment
33+
34+
#### Getting the code
35+
36+
Go to <https://github.com/unlock-security/wshell> and fork the project repository.
37+
38+
```sh
39+
# Clone your fork
40+
$ git clone git@github.com:<your-username>/wshell.git
41+
42+
# Enter the project directory
43+
$ cd wshell
44+
45+
# Enter the development branch
46+
$ git checkout dev
47+
48+
# Create a branch for your changes
49+
$ git checkout -b my_topical_branch
50+
```
51+
52+
#### Setup
53+
54+
To get started, run the commands below:
55+
56+
```sh
57+
# Creates an isolated Python virtual environment inside .venv
58+
$ python3 -m virtualenv .venv
59+
60+
# Enter the Python virtual environment
61+
$ source .venv/bin/activate
62+
63+
# installs all dependencies and also installs WShell
64+
# (in editable mode so that the wshell command will point to your working copy).
65+
$ pip install -e .
66+
```
67+
68+
### Making changes
69+
70+
Please make sure your changes conform to [Style Guide for Python Code](https://python.org/dev/peps/pep-0008/) (PEP8).
71+
72+
### Submitting changes
73+
74+
When you open a Pull Request, please make sure to follow the following rules:
75+
76+
- Write a clear and descriptive title
77+
- In the description, write a clear and detailed explanation of the changes
78+
- There are no conflicts in merging your branch on `dev`
79+
- If you are fixing a bug, please reference the issue number

README.md

Lines changed: 131 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -44,27 +44,46 @@ drwxr-xr-x 14 root root 4096 Mar 18 13:37 ..
4444
drwxrwx--- 25 www-data www-data 4096 Feb 18 15:31 app
4545
```
4646

47-
## Install
47+
### Real-world use cases
48+
49+
#### cmdchallenge.com
50+
51+
```sh
52+
$ wshell --input-scripts=base64_encode --output-scripts=unescape --delay=1.5 'https://cmdchallenge.com/c/r' 'cmd=WSHELL' 'slug=create_file'
53+
```
54+
55+
#### www.learnshell.org
56+
57+
```sh
58+
$ wshell --output-scripts=unescape --json 'https://www.learnshell.org/' 'code=WSHELL' 'language=bash'
59+
```
60+
61+
62+
## Install and update
4863

4964
```shell
50-
pipx install git+https://git@github.com/unlock-security/wshell
65+
# clone the repository and install it in a isolated virtual environment
66+
$ pipx install git+https://git@github.com/unlock-security/wshell
67+
68+
# update wshell using latest stable git version
69+
$ pipx upgrade wshell
5170
```
5271

5372
## Development
5473

55-
```
56-
git clone https://github.com/unlock-security/wshell
57-
cd wshell/
58-
python3 -m virtualenv .venv
59-
source .venv/bin/activate
60-
pip install -e .
74+
```sh
75+
$ git clone https://github.com/unlock-security/wshell
76+
$ cd wshell/
77+
$ python3 -m virtualenv .venv
78+
$ source .venv/bin/activate
79+
$ pip install -e .
6180
```
6281

6382
## Usage
6483

6584
```
66-
usage: wshell [-h] [-v] [--placeholder COMMAND_PLACEHOLDER] [--os {linux,win-cmd,win-psh}] [-m METHOD] [-t SECONDS] [--keep-alive] [--follow] [-ua USER_AGENT | -r] [-j | -f]
67-
[--log {critical,error,warning,info,debug}] [--list-scripts] [--input-scripts INPUT_SCRIPTS] [--output-scripts OUTPUT_SCRIPTS]
85+
usage: wshell [-h] [-v] [--placeholder COMMAND_PLACEHOLDER] [--os {linux,win-cmd,win-psh}] [-m METHOD] [-t SECONDS | --no-timeout] [-d DELAY] [--keep-alive] [--follow] [-ua USER_AGENT | -r]
86+
[-j | -f] [--log {critical,error,warning,info,debug}] [--list-scripts] [--input-scripts INPUT_SCRIPTS] [--output-scripts OUTPUT_SCRIPTS]
6887
URL [REQUEST ITEMS ...]
6988
7089
Turn a web-based {code,command,template} injection in a full featured shell with ease
@@ -85,6 +104,8 @@ HTTP arguments:
85104
-m, --method METHOD The HTTP method to be used for the requests (Default: POST if there is some data, GET otherwise)
86105
-t, --timeout SECONDS
87106
The connection timeout of the request in seconds (default: 3.0)
107+
--no-timeout Disable the connection timeout
108+
-d, --delay DELAY Delay in seconds between each HTTP request (default: 0.0)
88109
--keep-alive Use persistent connection (default: True)
89110
--follow Follow 30x Location redirects (default: True)
90111
-ua, --user-agent USER_AGENT
@@ -116,15 +137,15 @@ Example usage:
116137
## Scripts
117138

118139
WShell can run input and output scripts which are simple functions used to manipulate input command and output response.
119-
As an example, if the vulnerable page returns a base64-encoded result you can use `--output-scripts base64_decode` to get
140+
As an example, if the vulnerable page returns a base64-encoded result you can use `--output-scripts=base64_decode` to get
120141
the output as plain text.
121142

122-
Scripts can be chained and used more than once, for instance is totally fine to do something like `--output-scripts unescape,base64_decode,base64_decode`.
143+
Scripts can be chained and used more than once, for instance is totally fine to do something like `--output-scripts=unescape,base64_decode,base64_decode`.
123144

124145
### Developing a script
125146

126147
Developing a script for WShell is straightforward, just add a python file in `wshell/scripts/input` or `wshell/scripts/output` folder. The file name will
127-
be the name used to invoke the script from the command line (e.g. if you create `wshell/scripts/output/test.py` you can invoke it with `--output-scripts test`).
148+
be the name used to invoke the script from the command line (e.g. if you create `wshell/scripts/output/test.py` you can invoke it with `--output-scripts=test`).
128149

129150
Inside the file you have to create a function with the following signature `run(str) -> str`. A docstring to use as a description for the script is mandatory.
130151

@@ -133,29 +154,113 @@ As an example, the `base64_decode` output script corresponds to `wshell/scripts/
133154
```py
134155
import base64
135156

136-
137157
def run(output: str) -> str:
138158
"""Base64 decode output (requires --os to work)"""
139159
return base64.b64decode(output, validate=False).decode("utf-8", "ignore")
140160
```
141161

142-
## Contributing
162+
## Custom commands
163+
164+
Custom commands are special commands that you can run within the WShell prompt. They are not executed on the target system but within WShell itself. These commands are useful for performing actions that are not directly related to the remote shell, such as uploading or downloading files, or managing WShell's state.
165+
For instance, the built-in `download` command abstracts away the complexity of exfiltrating a file from different operating systems, providing a consistent interface for the user.
166+
167+
WShell automatically discovers and registers any custom command placed in a subdirectory of `wshell/commands`.
168+
You can check all the available custom commands by typing `help -v` in a WShell prompt:
169+
170+
```sh
171+
victim@vulnerable-server:/var/www/html/$ help -v
172+
173+
Documented commands (use 'help -v' for verbose/'help <topic>' for details):
174+
175+
File transfer
176+
======================================================================================================
177+
download Download remote file
178+
upload Upload local file
179+
180+
Uncategorized
181+
======================================================================================================
182+
help List available commands or provide detailed help for a specific command
183+
history View, run, edit, save, or clear previously entered commands
184+
quit Exit this application
185+
set Set a settable parameter or show current settings of parameters.
186+
shell Execute a command as if at the OS prompt
187+
```
188+
189+
A custom command can have its own help message and parameters:
190+
191+
```sh
192+
victim@vulnerable-server:/var/www/html/$ download -h
193+
usage: download [-h] -r FILENAME [-l FILENAME] [-c SIZE | -n]
194+
195+
Download remote file
143196

144-
We accept contributions of any kind, including bug fixes, feature requests, and new scripts.
197+
options:
198+
-h, --help show this help message and exit
199+
-r, --remote FILENAME
200+
Remote file to download
201+
-l, --local FILENAME Local file where to store the downloaded file (default: current folder, same name as remote)
202+
-c, --chunk SIZE Size of the chunk to download in bytes (default: 1024)
203+
-n, --no-chunk Do not split into chunks
204+
```
205+
206+
### Developing a custom command
207+
208+
To create a custom command, you need to create a new Python file with the name of your choice in a subdirectory of `wshell/commands` (e.g., `wshell/commands/system/my_command.py`). The subdirectory (`system` in this case) will be its category.
209+
210+
Inside the file, create a class that inherits from `wshell.commands.WShellCommandSet`, then you can follow the cmd2's [Modular Commands documentation](https://cmd2.readthedocs.io/en/stable/features/modular_commands/) for the specification.
211+
212+
Basically, you just need to implement a method starting with `do_` for each command you want to add. For example, a `do_phpinfo` method will create a `phpinfo` command.
213+
214+
Here is an example of a simple `phpinfo` command that create a PHP file named `info.php` executing `phpinfo()` in the current directory:
215+
216+
```python
217+
# wshell/commands/php/phpinfo.py
218+
import argparse
219+
220+
from cmd2 import with_argparser
221+
222+
from wshell.commands import WShellCommandSet
145223
146-
If you want to contribute to this project you can just:
147224
148-
1. Fork the repository
149-
2. Start a new branch starting from `dev`
150-
3. Make your changes
151-
4. Open a Pull Request
225+
class PHPInfoCommandSet(WShellCommandSet):
226+
227+
argument_parser = argparse.ArgumentParser(description="Create a new file into the current directory that executes `phpinfo()`")
228+
argument_parser.add_argument(
229+
"-f", "--filename",
230+
metavar="FILENAME",
231+
required=False,
232+
help="Name of the file",
233+
dest="filename",
234+
default="info.php"
235+
)
236+
237+
@with_argparser(argument_parser)
238+
def do_phpinfo(self, args) -> None:
239+
file_content = "<?php phpinfo();"
240+
self._dispatch("write_phpinfo_file", args.filename, file_content)
241+
242+
def _linux_write_phpinfo_file(self, filename, file_content):
243+
self._cmd.injector.execute(f"echo -n '{file_content}' > {filename}")
244+
245+
def _win_psh_write_phpinfo_file(self, filename, file_content):
246+
self._cmd.injector.execute(f"Set-Content -Path '{filename}' -Value '{file_content}'")
247+
248+
def _win_cmd_write_phpinfo_file(self, filename, file_content):
249+
self._cmd.injector.execute(f"echo {file_content} > {filename}")
250+
```
251+
252+
To make this command available in WShell in the `Php` category, you would save it as `wshell/commands/php/phpinfo.py`. Then, from the WShell prompt, you could run it by just typing `phpinfo`.
253+
254+
On top of `cmd2`'s modular command features, WShell overrides the `_cmd` object of the command set to provides access to the current WShell session, including the HTTP client (`self._cmd.injector`), target information, and more. This is useful for creating more complex commands that run commands on the remote system.
255+
256+
For more complex examples, see the implementation of the built-in `upload` and `download` commands in the `wshell/commands/file_transfer` directory.
257+
258+
## Contributing
152259
153-
When you open a Pull Request, please make sure to follow the next rules:
260+
Have a look through existing [Issues](https://github.com/unlock-security/wshell/issues) and [Pull Requests](https://github.com/unlock-security/wshell/pulls) that you could help with.
261+
If you'd like to request a feature or report a bug, please [create a GitHub Issue]() using one of the templates provided.
154262
155-
- Write a clear and descriptive title
156-
- In the description, write a clear and detailed explanation of the changes
157-
- There are no conflicts in merging your branch on `dev`
158-
- If you are fixing a bug, please reference the issue number
263+
[See contribution guide →](https://github.com/unlock-security/wshell/blob/main/CONTRIBUTING.md)
159264
160265
---
161266

setup.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ def long_description():
3535
],
3636
python_requires=">=3.12",
3737
install_requires=[
38-
"cmd2==2.5.11",
38+
"cmd2==2.7.0",
3939
"requests==2.32.4",
4040
"validator-collection==1.5.0",
41-
"colorlog==6.9.0",
42-
"platformdirs==4.3.7"
41+
"colorlog==6.10.1",
42+
"platformdirs==4.5.0"
4343
],
4444
platforms=["posix"],
4545
classifiers=[

wshell/cli.py

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
from base64 import b64decode
2-
from binascii import Error as BinasciiError
31

42
import cmd2
53

4+
from wshell import validators
5+
from wshell.commands import load_commands
6+
from wshell.commands.registry import command_registry
67
from wshell.injectors import CommandInjector
78

89

@@ -20,7 +21,7 @@ class WShellCmd(cmd2.Cmd):
2021
def __init__(
2122
self,
2223
injector: CommandInjector,
23-
persistent_history_file=None
24+
persistent_history_file: str=''
2425
):
2526
super().__init__(
2627
allow_cli_args=False, # To avoid using URL and HTTP parameters from the command line as commands
@@ -40,6 +41,11 @@ def __init__(
4041
for setting in set(self.settables) - set(interesting_settings):
4142
self.remove_settable(setting)
4243

44+
# Add wshell-specific settings
45+
self.add_settable(
46+
cmd2.Settable("timeout", validators.timeout, "Connection timeout in seconds (0 to disable)", injector)
47+
)
48+
4349
self.injector = injector
4450
self.prompt = self.injector.get_prompt()
4551

@@ -50,15 +56,10 @@ def __init__(
5056
# Hide alias and overridden commands from help menu
5157
self.hidden_commands.extend(["cd", "exit", "logout"])
5258

53-
# Overwrite `cat` (in Linux and Windows PSH) and `type` (in Windows CMD)
54-
# to get file content as base64 to avoid some issues when manipulating
55-
# the output (eg. escape \n)
56-
if self.injector.is_windows_cmd():
57-
self.do_type = self.base64_cat
58-
self.hidden_commands.append("type")
59-
else:
60-
self.do_cat = self.base64_cat
61-
self.hidden_commands.append("cat")
59+
# Discover, validate, and load all modular commands
60+
load_commands()
61+
for command_set_cls in command_registry.get_command_sets():
62+
self.register_command_set(command_set_cls())
6263

6364
def default(self, statement: cmd2.Statement) -> None:
6465
""" In case the user typed a non-builtin command, send it to the target. """
@@ -68,22 +69,10 @@ def default(self, statement: cmd2.Statement) -> None:
6869

6970
def emptyline(self) -> bool:
7071
""" Do nothing on empty command """
72+
return True
7173

72-
def do_cd(self, line):
74+
def do_cd(self, line: str):
7375
""" Change directory command implementation """
7476
actual_directory = self.injector.change_directory(line)
7577
self.poutput(actual_directory)
76-
self.prompt = self.injector.get_prompt()
77-
78-
def base64_cat(self, line):
79-
""" Print file content using base64 intermediate step """
80-
base64_output = self.injector.base64_cat(line)
81-
82-
# If the output is a valid base64 we got file content, if not we encountered an error
83-
# (eg. no permission on the file, file not exists, etc.). In this cases we just print
84-
# the error message to the user.
85-
try:
86-
# Merge all the lines in one to avoid base64 validation errors
87-
self.poutput(b64decode("".join(base64_output.splitlines()), validate=True).decode(encoding='utf-8', errors='replace'))
88-
except BinasciiError:
89-
self.poutput(base64_output)
78+
self.prompt = self.injector.get_prompt()

0 commit comments

Comments
 (0)