Gibbon v30.0.00: Authenticated SQL Injection and RCE

We go back to school to hunt down some SQL Injection, Local File Inclusion, and DoS in the Gibbon school management software.

Gibbon v30.0.00: Authenticated SQL Injection and RCE
School has never been so fun

Welcome back to another episode of CVE hunting. Our unsuspecting victim for today is the Gibbon school management platform, an open-source PHP app.

TL;DR: We discovered vulnerabilities that allows users with Teacher privileges and above to perform SQL Injection, LFI leading to RCE and Denial of Service attacks against Gibbon. These issues should be patched in v30.0.01.

SQL Injection - Getting Warmed Up

As usual, we'll kick off with a semgrep scan to quickly find any low-hanging fruit.

"quickly"

Unless we want to exhaust our standard-issue 2026 attention spans, we'll need to filter for the juiciest findings.

Where's the subway surfers splitscreen when you need it?

After giving our scrolling finger a good workout, we finally find an SQL injection issue that looks promising.

"WHERE complete='Y' AND completeDate<='".date('Y-m-d')."' AND (SELECT count(*) FROM gibbonScaleGrade WHERE gibbonScaleID=gibbonScale.gibbonScaleID)>3 AND ".$dataType."Value!='' AND ".$dataType."Value IS NOT NULL $departmentExtra_MB $personExtra_MB)"

https://github.com/GibbonEdu/core/blob/c431e25fdc874adece5d2dc7e408e9aa2d1abadb/modules/Tracking/graphing.php#L145

SQL query string concatenation AND interpolation? 💰

We need to make sure those values come from the user and that there's no validation logic to ruin our fun.

$gibbonPersonIDs = (isset($_POST['gibbonPersonIDs']))? $_POST['gibbonPersonIDs'] : null;
$gibbonDepartmentIDs = (isset($_POST['gibbonDepartmentIDs']))? $_POST['gibbonDepartmentIDs'] : null;
$dataType = (isset($_POST['dataType']))? $_POST['dataType'] : null;

// SNIP

if ($gibbonPersonIDs == null or $gibbonDepartmentIDs == null or ($dataType != 'attainment' and $dataType != 'effort')) {
    echo $page->getBlankSlate();
} else {

  // SNIP

  foreach ($gibbonDepartmentIDs as $gibbonDepartmentID) { //INCLUDE ONLY SELECTED DEPARTMENTS
      $dataDepartments['department_MB'.$gibbonDepartmentID] = $gibbonDepartmentID;
      $departmentExtra_MB .= 'gibbonDepartment.gibbonDepartmentID=:department_MB'.$gibbonDepartmentID.' OR ';
      $dataDepartments['department_IA'.$gibbonDepartmentID] = $gibbonDepartmentID;
      $departmentExtra_IA .= 'gibbonDepartment.gibbonDepartmentID=:department_IA'.$gibbonDepartmentID.' OR ';
  }
  if ($departmentExtra_MB != '') {
      $departmentExtra_MB = 'AND ('.substr($departmentExtra_MB, 0, -4).')';
  }
  if ($departmentExtra_IA != '') {
      $departmentExtra_IA = 'AND ('.substr($departmentExtra_IA, 0, -4).')';
  }

//SNIP

}

https://github.com/GibbonEdu/core/blob/c431e25fdc874adece5d2dc7e408e9aa2d1abadb/modules/Tracking/graphing.php#L45

Okay, so the inputs are pulled from the POST body. That's good. It looks like $dataType is checked to contain one of two valid values. That's bad.

Let's check the other values. $departmentExtra_MB is constructed from our gibbonDepartmentIDs body parameter, and the same with $personExtra_MB (not shown). This is promising. Let's tweak the code to print errors and try it out!

catch (PDOException $e) {
    echo 'Exception: '.$e;
}

Truly the pinnacle of error handling

To test this out, we'll visit the affected page and intercept the request to chuck our payload into the suspect POST field.

Injection, SQL Injection
(gibbonDepartment.gibbonDepartmentID=:department_MB0007 OR gibbonDepartment.gibbonDepartmentID=:department_MB0007 OR 1=(SELECT * from invalid_table))

Part of the query that will be produced

Notice that we've specified the same ID twice, once with the SQL payload. This is needed to ensure the :department_MB0007 SQL parameter is given a value, stopping any exceptions about unbound variables.

That was easy

Nice! We have a working SQL injection PoC with the bare minimum of effort. It would be a good idea to spend some time to do something useful with this. An even better idea would be to immediately jump to the next shiny new thing.

ONWARD

Local File Inclusion - The Next Shiny New Thing

Here at Project Black we appreciate the finer things in life. Expensive wines, caviar, shapes, and most importantly, remote code execution vulnerabilities.

What better way to achieve RCE in a PHP app, than via local file inclusion?

It's immediately notable that the app makes frequent use of inclusion. To access different areas of the app, the user passes a path to index.php which is then
executed via include. For example, the path of the page we used for SQL injection is /index.php?q=/modules/Tracking/graphing.php.

Let's check how this is validated.

public function isAddressValid($address, bool $strictPHP = true) : bool
{
    if ($strictPHP && stripos($address, '.php') === false) {
        return false;
    }

    return !(stripos($address, '..') !== false
        || stristr($address, 'installer')
        || stristr($address, 'uploads')
        || stristr($address, 'config.php')
        || in_array(strtolower($address), array('index.php', '/index.php', './index.php'))
        || strtolower(substr($address, -11)) == '// index.php'
        || strtolower(substr($address, -11)) == './index.php');
}

https://github.com/GibbonEdu/core/blob/c431e25fdc874adece5d2dc7e408e9aa2d1abadb/src/View/Page.php#L445

All PHP include paths provided by the user are checked using this function. A couple things to note here:

  • The function checks that the address is actually a PHP file. This ensures script kiddies can't, for example, upload a text file containing PHP code and then execute it via inclusion (plans? ruined). If you look closely, you'll see that this check is actually pretty sus. 🤔
  • The address isn't allowed to contain uploads, presumably to prevent the inclusion of uploaded files. Foiled again!

So probably not a quick win, but there's still hope. We're halfway there if we can find a feature that lets us upload outside of /uploads. Then we just need a file path that will pass the flimsy PHP check (file.pdf.php.zip.exe.rar.cxx.lbx.lib.o.rs.docx, that should do it).

Time to take a poke around.

No one will suspect a thing

A few minutes of aimless wandering later and we have a promising lead. The report archiving feature defaults to saving reports in /uploads/reports, but it looks like this path can be altered.

Now we just need to upload a ZIP of student reports.

It's all above board, I assure you

Two requests are made to complete this process:

  1. The first request uploads the ZIP to legit_files/temp/darkweb_reportcards_qhoGeMza9iMXfiTe.zip (note the random suffix). It is checked to contain at least one PDF that matches a student.
  2. The second request confirms the import, extracting the PDF files to legit_files/025-Future Dropouts.php/, each with a random suffix. The ZIP file is deleted.

LFI should be a simple case of renaming our PHP executable as <USERNAME>.pdf, cramming it into a ZIP, uploading, chucking the path of the extracted file into our query parameter, then cracking open a cold one (critical).

Unfortunately, the extracted filenames are appended with a random suffix that doesn't seem to be returned to the user.

Luckily, we still have the first step of the upload process to work with. It turns out that the path of the temporary uploaded ZIP file is returned to the user. 🥳

So all we need to do is cook up a spicy executable ZIP file. Let's see what we're working with here.

Hexdumps are the bestdumps

You'll notice that ZIP archives don't compress filenames, probably because the devs never expected anyone to store much data there (an oversight on their part).

Combine this with Linux's lax filename rules, and it's trivial to construct a file that is both a valid ZIP archive and a valid PHP executable.

Oneliners are the bestliners? That's the best I can do (I'm sorry)

Now to test it out. We can just complete step 1 of uploading the ZIP file as before, and include the path that is returned in the HTTP response (E.g. index.php?q=/legit_files/temp/spicy.php_x9IWBRoJbz53A2Fx.zip).

Time for that cold one we discussed
Yes, yes it is

Path Traversal - Bonus Round

In case you're still hungry for more (you should talk to a professional about that), the search for the above vulnerability also revealed two extra path traversal issues, one of which is usable as a DoS.

The first traversal lets us set the archive upload path to anywhere on the host.

I wish my report cards had been stored in /tmp

Being able to upload ZIPs and PDFs to /tmp turns out to not be particularly useful. 🥱

Denial of Service via Path Traversal

The next path traversal / unrestricted path is found in the second step of our report upload process.

After the report ZIP is uploaded to the temporary directory, a second request is required to confirm that the reports should be extracted.

Surely this won't break anything

As it turns out, you can put any path into this request (including ../ traversals) and it will attempt to treat it as a ZIP file, extracting the reports contained within.

Whether this extraction succeeds or fails, the app will helpfully delete the file afterwards. 😑

Oops 🧱

So what did we find?

  • Some basic SQL injection affecting the graphing page.
  • Local file inclusion of uploaded ZIP files embedded with PHP code.
  • Two path traversal vulnerabilities, one of which allows for deleting files on the host.

Not a bad haul for one app.


Project Black is a CREST accredited Australian penetration testing firm. Reach out if you have any penetration testing or application security requirements!