My First Vulnerability

How I shipped my first vulnerability to production.

2025-04-30

I think everyone remembers their first major fumble. I haven't deleted a database yet, though I did accidentally delete an app server that I had to rebuild. This is a story about my first vulnerability that I wrote. I was a student working for my college IT department and recently had gotten promoted to a developer/sysadmin/database administrator position that had been vacant for about a year. I inherited a suite of PHP applications, mostly custom webpages and forms for the library catalog and staff.

One of those applications was an app to show the new library acquisitions: new books, journals, DVDs, etc. The marketing department was redesigning the library website, and since there was now someone on staff to update the custom pages (me), the library staff wanted the custom page to match the style of the new website. They also asked for a few other features, like importing book cover images if available, and exporting the list of new acquisitions in various file formats.

I knocked out the book cover images request quickly. It took me a little longer to replicate the style of the new website, especially since the PHP templates used <table> elements for positioning, but the new website used more modern (but still pre-Flexbox and Grid) CSS techniques, so I had to rework the styles myself. But I was pleased with the result, and the library staff were happy too.

And, of course, I was better than my predecessor: I was security-conscious, and I knew Linux from the bad ol' days of manually compiling drivers for every single device on your machine. The app had languished, running on an outdated version of PHP in an outdated version of Apache on an unsupported OS version. I wasn't able to update the server OS at that time, so I compiled current versions of Apache and PHP that could run on the server to get the latest security patches. This version now included PHP prepare statements, and I converted all the SQL statements to use it. And I removed unnecessary permissions from the user. I was feeling good, safe, and smug. But with all that work that I did, I missed that I had introduced a vulnerability that was obvious in hindsight.

Now Introducing: Arbitrary Read Vulnerability

The library staff wanted a feature to export the list of new books to print out announcement pages to post in the library, which saved them a lot of time compiling and editing the list manually each month. The export functionality was available to all users, since some patrons may have been interested in downloading the list to look at offline. (Sidenote: they weren't).

While implementing the export feature, I borrowed some code from another application that did a similar thing. However, I had to remove a dependency because it was no longer compatible with the new version of PHP, which required me to reimplement the file generation and download endpoints. You clicked the button, which sent a POST request to an export.php script, which ran the same SQL query that generated the website, formatted the results in CSV, TXT or DOCX, saved the file to a temporary file location, and finally, redirected the user to download the same file. Those of you wiser than me can probably guess which vulnerability I introduced: path traversal.

When I first learned about path traversal, I pronounced it "path transversal," which my boss kindly corrected. Thanks, P.

I thought through this from the perspective of the user; to them, it seemed atomic: click the button, get a file. It seemed secure to me. But it's important to think like an attacker in order to secure your applications against them. And obviously it wasn't atomic, since I had to write two different PHP files: export.php and download.php. I don't remember all the details, but the code was something like this:

<!-- export.php -->
<?php
  $format = $_POST["format"];
  $startRange = $_POST["start"];
  $endRange = $_POST["end"];


  $results = getNewAcquisitions($startRange, $endRange);
  $exportFile = tempnam($GLOBALS["exportDir"], "newacq-");
  switch ($format) {
    case "csv":
      // write to file as CSV
      break;
    case "txt":
      // write to file as TXT
      break;
    case "docx";
      // write to file as DOCX
      break;
  }
  $filename = pathinfo($exportFile, PATHINFO_BASENAME)
  header("Location: download.php?file=${filename}&format=${format}", true, 302);
?>
<!-- download.php -->
<?php
  $filename = $_GET["file"];
  $format = $_GET["format"];
  $contentType = "text/plain";
  if ($format == 'csv') {
    $contentType = "text/csv";
  } else if ($format == 'docx') {
    $contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
  }
  header("Content-Type: $contentType");
  $path = $GLOBALS["exportDir"] . DIRECTORY_SEPARATOR . $filename;
  readfile($path);
?>

The export endpoint generated the file and saved it, and the download endpoint took the filename of the newly generated file and concatenated it with the path to the export directory on the server. However, I failed to sanitize the input: I assumed that the input was a filename, but used it as if it were a relative path with a single component. The difference is that an attacker could add any number of components to have the PHP, including ../, which allowed them to traverse to any directory on the server.

So then instead of delivering /var/www/newacq/export/randomid.csv, the attacker could ask the server to deliver /var/www/newacq/export/randomid.csv/../../../../etc/passwd, for example. With this vulnerability, attackers could read any file that the web server user had access to, including database config files and other credentials.

Lessons Learned

I didn't find this out on my own; our IT department conducted our yearly penetration test with some security contractors. After they pointed it out, I slapped my forehead and constrained the filename parameter for the download input to exactly match the format generated by the export endpoint, and rotated the database credentials. After the fix was in place and the pentesters confirmed the remediation, I began digging into common vulnerabilities from OWASP beyond the top 10 (though this was on the list at the time) to develop a better sense of the attack vectors I should be watching out for.

I learned a few things from this incident:

  • Think like an attacker, not just a user or admin, when evaluating security.
  • Don't assume that your input only does what you think it does; analyze how you're using it, and if the set of inputs is larger than what you need it to be, make sure you constrain it to prevent unknown future bugs.
  • It's always good to conduct security reviews, whether by your internal team members or external teams.
  • Always be humble. Everyone can make mistakes, and we're all learning.