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.
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.

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

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)"
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
}
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.

(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.

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');
}
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.

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.

Two requests are made to complete this process:
- 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. - 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.

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.

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).


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.

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.

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. 😑

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!