I have discovered several directory traversal vulnerabilities. The capabilities are as follows:
- Any
.md
file on the system can be read by an unauthenticated attacker
- The contents of sibling directories to the wiki directory can be listed by unauthenticated attacker in certain circumstances.
- Any
.md
file on the system can be overwritten by an unauthenticated attacker by default (authentication required if edit password protection is enabled)
- Any
.md
file on the system can be deleted by an unauthenticated attacker by default (authentication required if edit password protection is enabled)
- New directories and
.md
files can be created on the system with arbitrary contents and paths by an unauthenticated attacker by default (authentication required if edit password protection is enabled)
Details as follows:
Directory traversal in /list/
The /list/
request can be used to get a list of files in the wiki, or in a subdirectory of the wiki.
In wiki.py
the list_wiki
function that handles /list/
requests checks whether the canonical form of the requested path shares a common prefix with the configured "safe" wiki directory:
|
@app.route('/list/<path:folderpath>/', methods=['GET']) |
|
def list_wiki(folderpath): |
|
folder_list = [] |
|
safe_folder = os.path.realpath(cfg.wiki_directory) |
|
requested_path = os.path.join(cfg.wiki_directory,folderpath) |
|
if os.path.commonprefix((os.path.realpath(requested_path),safe_folder)) != safe_folder: |
This is intended to prevent directory traversal. However, this does not prevent traversal to sibling directories whose names start with the same name as the configured wiki directory.
For example, consider the following directory structure:
home
└── user
└── wikmd
├── wiki
│ ├── homepage.md
│ └── foo.md
└── wiki_secret
└── secret.md
If an attacker requests /list/../wiki_secret/
, the following happens:
folderpath
is set to ../wiki_secret/
cfg.wiki_directory
is configured to serve out of the wiki
directory, so safe_folder
becomes /home/user/wikmd/wiki
requested_path
is created by joining cfg.wiki_directory
and folderpath
, which results in /home/user/wikmd/wiki/../wiki_secret/
requested_path
is canonicalised with os.path.realpath()
, which returns /home/user/wikmd/wiki_secret
.
os.path.commonprefix()
is called on the canonicalised request path and safe_folder
, which searches character-by-character and returns the prefix substring up until the first difference. Since /home/user/wikmd/wiki
and /home/user/wikmd/wiki_secret
are the arguments, the result is /home/user/wikmd/wiki
- This is compared to
safe_folder
, which is /home/user/wikmd/wiki
, and therefore the check passes.
- The contents of
/home/user/wikmd/wiki_secret
are listed.
This can be demonstrated simply by sending GET /list/../wiki_secret/
and two enter key presses to the server using netcat.
Directory traversal in page display
There is no directory traversal protection inside the file_page
function. As such, any .md
file on the system can be displayed.
This can be trivially demonstrated with any default install by sending GET /../README
and two enter presses to the server using netcat. This will return the HTML rendered copy of the README.md
file in the wikmd directory.
Directory traversal in page edit
There is no directory traversal protection inside the edit
function. This can be used both for reading and writing any .md
file on the system. This function is unauthenticated by default, but may be configured to require a password.
When a GET request is sent to an /edit/
route, this function displays the existing contents of the file. This can be demonstrated by sending GET /edit/../README
and two enter presses to the server using netcat. This will display the HTML of the edit template with the contents of the README.md
file in the wikmd directory included.
When a POST request is sent to an /edit/
route, the following code path is taken:
|
filename = os.path.join(cfg.wiki_directory, page + '.md') |
|
if request.method == 'POST': |
|
page_name = fetch_page_name() |
|
if page_name != page: |
|
os.remove(filename) |
|
|
|
save(page_name) |
The fetch_page_name()
function acquires the page name from the PN
form variable.
The page
variable contains the portion of the URL after /edit/
. No traversal check is performed. If the value provided in the PN
form variable does not match the path in the URL, the file specified by the URL is deleted. Since the path is vulnerable to traversal, any .md
file on the system can be deleted using this method.
The save()
function saves the page. Since the page name is taken from the PN
form variable without validation, this allows any .md
file on the system to be written to. The save()
function creates the full directory path to the target file name, so this enables the creation of directories anywhere on the system.
Directory traversal in new page creation
The add_new()
function handles /add_new
. This function is unauthenticated by default, but may be configured to require a password.
The PN
form variable is used to specify the name/path of the page to create, but no traversal protection is performed. This function calls into save()
in the same way that the page edit functionality does, with the same impact.
Directory traversal in remove
The remove()
function handles /remove/
route requests. This function is unauthenticated by default, but may be configured to require a password.
The path is taken from the URL and is not validated or protected against traversal. This allows for any .md
file on the system to be deleted.
Remediation
The existing commonprefix()
traversal check in list_wiki()
is broken and should be removed.
The safe_join()
function provided by Flask should be utilised instead of os.path.join()
in all cases where one or more of the path arguments is untrusted. This function will raise a NotFound
exception if traversal out of the base directory is attempted.