SKIP TO CONTENT
PIXZ.DEV
← NOTEBOOKENTRY 001 / 001
VERIFIED — SELF-HOSTEDSECURITY AUDITSEPTEMBER 2026

Garuda CBT — Security Teardown

A HackerOne-style vulnerability disclosure report against Garuda CBT — an open-source school exam and report-card application built on CodeIgniter 3 — audited entirely on a locally hosted instance built from the public repository. Nine findings, eight verified dynamically: three Critical (unauthenticated admin creation at CVSS 9.8, unauthenticated RCE through installer config injection at 9.9, and arbitrary SQL execution escalating into a full webshell chain at 10.0), three High, two Medium, and one Low/Info. Static review of the de-obfuscated source traced the sinks; a local PHP 8.3 + MariaDB 11.8 bench confirmed every critical claim through dynamic reproduction (reproduction detail redacted from this publication).

APPSECPHPCODEIGNITERSQLIRCE

SEVERITY PROFILE

8 OF 9 VERIFIED DYNAMICALLY
3CRITICAL
3HIGH
2MEDIUM
1LOW / INFO
PEAK CVSS10.0

01  CONTEXT

Garuda CBT is a real, actively used school platform — exam delivery, report cards, student records — distributed as open-source PHP on CodeIgniter 3, installed and run by schools themselves. That combination (sensitive data, broad deployment, legacy framework) is exactly the profile worth auditing before a bad actor does it first. The question was simple: what does an attacker actually need to own a school's exam data? The answer turned out to be: no credentials, one HTTP request, and — in the worst case — nothing but the public repository.

02  METHOD

The shipped source is obfuscated with hex-escaped payloads, so step one was decoding it back into readable PHP. From there the audit ran as a loop:

  1. Decode the distributed source into readable PHP.
  2. Identify the dangerous sinks — file writes, raw query concatenation, execution-adjacent calls.
  3. Trace user input forward to each sink.
  4. Rank what actually matters.
  5. Verify every candidate dynamically against a locally built instance.
  6. Confirm against the database general log — ground truth for what actually executed.

The bench: PHP 8.3.28 built-in server, MariaDB 11.8.8, the full 74-table schema, test accounts created by me, ENVIRONMENT left at its shipped default. Every claim was exercised as plain curl requests against the local instance. Cleanup came last — webshells removed, test tables dropped.

VerificationFindings
Dynamically verified on the local instance8
Static code review only1
Total9

SCOPE & ENVIRONMENT

TARGETgithub.com/garudacbt/cbt @ 2d96e28f (master)
APPLICATIONGaruda CBT 1.5.3 — CodeIgniter 3.1.x
TEST BENCH127.0.0.1:8080 — PHP 8.3.28 built-in server
DATABASEMariaDB 11.8.8 — DB cbt, schema 74 tables
ACCOUNTSadmin / siswa_test / guru_test — self-created
PRODUCTION TOUCHEDNONE — self-hosted instance only

FINDINGS INDEX — 09

CVSS
  • GBT-001CRITICAL9.8

    Unauthenticated Admin Account Creation

  • GBT-002CRITICAL9.9

    Unauthenticated RCE — PHP Injection into database.php

  • GBT-003CRITICAL10.0

    Unauthenticated SQL Execution to Webshell — Forged Encryption, Public Key

  • GBT-004HIGH8.6

    Blind Time-Based SQL Injection — /siswa/getPost

  • GBT-005HIGH8.6

    Blind Time-Based SQL Injection — /bukurapor

  • GBT-006HIGH8.2

    CSRF Disabled on Material Endpoints — Forced Webshell Upload

  • GBT-007MEDIUM6.1

    Stored XSS in Announcements

  • GBT-008MEDIUM5.3

    Hardcoded Encryption Key + Development Default

  • GBT-009LOW / INFO

    Code-Review Grab Bag — Authless Dev Tools and Friends

GBT-001CRITICALCVSS 9.8VERIFIED — DYNAMIC POCCOMPONENT
application/controllers/Install.php — createAdmin()

Unauthenticated Admin Account Creation

SUMMARY

The Install controller is never disabled after installation completes, and createAdmin() carries no authentication guard. Anyone, without logging in, can POST a new account into the admin group of a live instance — then simply log in with it. The shipped check_installer() guard exists only on index(), not on the methods that actually matter.

IMPACT

Total application takeover: manage teachers and students, read and edit every grade and report card, export the exam bank with its answers, download database backups — and with GBT-003, arbitrary SQL and code execution. No credentials, no victim interaction.

REMEDIATION

Lock the installer after installation (lock file or an INSTALLED constant) and reject createAdmin, createApp, createSetting, and checkDatabase once an admin exists — the guard belongs on every method, not just the landing page.

EVIDENCE — REDACTED

Redacted for security. The reproduction steps and observed outputs were verified on a self-hosted instance and are withheld from this publication while patches propagate — the exploit detail goes to the maintainer as part of coordinated disclosure, not to the open web.

GBT-002CRITICALCVSS 9.9VERIFIED — DYNAMIC POCCOMPONENT
Install::checkDatabase() — write_db_config() + assets/app/db/database.php

Unauthenticated RCE — PHP Injection into database.php

SUMMARY

checkDatabase() takes POSTed connection values, string-replaces them into a PHP config template, and rewrites application/config/database.php — the very file the install guide instructs you to chmod 777. The xss_clean filter blocks obvious calls like system( and eval(, but the blacklist is trivially sidestepped: popen() plus stream_get_contents() executes commands just as happily. One request writes the payload; the next request includes it.

IMPACT

OS command execution as the web-server user, without authentication. Reverse shells, config reads, persistence — and because the installation flow itself demands a writable config, the vulnerable condition is the default state of real deployments.

REMEDIATION

Lock the installer (see GBT-001); stop interpolating raw strings into executable PHP — validate inputs strictly (a hostname belongs to [a-zA-Z0-9.:-]) and write values with var_export(); after install, restore sane permissions and remove the installer.

EVIDENCE — REDACTED

Redacted for security. The reproduction steps and observed outputs were verified on a self-hosted instance and are withheld from this publication while patches propagate — the exploit detail goes to the maintainer as part of coordinated disclosure, not to the open web.

GBT-003CRITICALCVSS 10.0VERIFIED — FULL CHAINCOMPONENT
application/controllers/Update.php — createTable()/runQuery() · config.php:45 hardcoded key

Unauthenticated SQL Execution to Webshell — Forged Encryption, Public Key

SUMMARY

The Update controller ships with no authentication guard. Its methods decrypt an incoming POST payload and feed it straight into mysqli::multi_query() — stacked queries on command. The catch is supposed to be that payloads are encrypted; but the encryption key is hardcoded in the public repository, identical on every installation. I re-implemented the CI3 HKDF-SHA512 key derivation offline, forged valid encrypted payloads, and the server executed the SQL inside them. With the DB user's FILE privilege, SELECT ... INTO OUTFILE then wrote a PHP webshell into the webroot — full chain, from public repo to RCE.

IMPACT

Dump the entire database — password hashes, student records, exam banks, answers, grades. Insert admin users, alter or drop any table. Where the DB user holds the FILE privilege (common on shared hosting), a webshell lands in the webroot. This is the maximum-severity finding of the audit: CVSS 10.0, verified end to end.

REMEDIATION

Mandatory admin guard and update-state nonce on every Update method; a random per-installation encryption key generated at install time; validate the payload's JSON schema against a whitelist instead of concatenating into multi_query; deny FILE privilege to the application's DB user and set a strict secure_file_priv.

EVIDENCE — REDACTED

Redacted for security. The reproduction steps and observed outputs were verified on a self-hosted instance and are withheld from this publication while patches propagate — the exploit detail goes to the maintainer as part of coordinated disclosure, not to the open web.

GBT-004HIGHCVSS 8.6VERIFIED — DYNAMIC POCCOMPONENT
controllers/Siswa.php — getPost() · models/Post_model.php — getPostForUser()

Blind Time-Based SQL Injection — /siswa/getPost

SUMMARY

The kelas GET parameter passes through xss_clean — which is not SQL escaping — and is concatenated directly into a LIKE clause inside the WHERE string. A student-level session (the lowest role in the system) can close the quote, balance the parentheses, comment out the remainder, and drive a timing oracle: SLEEP(4) is answered in four seconds, SLEEP(0) instantly. Character-by-character extraction follows.

IMPACT

Any student can extract the whole database through automated blind retrieval: credential hashes, personal data of classmates and teachers, exam banks with answers. Verbose SQL errors (GBT-008) make the mapping easier.

REMEDIATION

Query Builder binding — where("a.kepada LIKE", "%$kelas%") — or prepared statements; never concatenate input into a WHERE string. Audit every get_where() call that interpolates a variable.

EVIDENCE — REDACTED

Redacted for security. The reproduction steps and observed outputs were verified on a self-hosted instance and are withheld from this publication while patches propagate — the exploit detail goes to the maintainer as part of coordinated disclosure, not to the open web.

GBT-005HIGHCVSS 8.6VERIFIED — DYNAMIC POCCOMPONENT
controllers/Bukurapor.php — GET tp/smt · Dashboard_model::getTahunById()

Blind Time-Based SQL Injection — /bukurapor

SUMMARY

Same sink pattern, different door: the report-book controller passes tp (academic year) and smt (semester) into model lookups that build get_where() clauses by raw concatenation. One AND SLEEP(4)# in the tp parameter holds the response for four seconds. The identical pattern repeats across several other controllers — one sink, many routes.

IMPACT

The same class of full-database extraction as GBT-004, reachable by any authenticated role that can open the report book.

REMEDIATION

Parameter binding in getTahunById() and getSemesterById() plus an audit of every caller; treat this as one systemic fix, not two tickets.

EVIDENCE — REDACTED

Redacted for security. The reproduction steps and observed outputs were verified on a self-hosted instance and are withheld from this publication while patches propagate — the exploit detail goes to the maintainer as part of coordinated disclosure, not to the open web.

GBT-006HIGHCVSS 8.2VERIFIED — FULL CHAINCOMPONENT
config.php csrf_exclude_uris · controllers/Kelasmateri.php — saveMateri()/uploadFile()

CSRF Disabled on Material Endpoints — Forced Webshell Upload

SUMMARY

Three state-changing endpoints — saveMateri, uploadfile, deletefile — are excluded from CSRF protection to keep an AJAX progress bar simple. A page controlled by the attacker can therefore drive a logged-in teacher's browser to upload files, and worse: saveMateri processes material content as HTML, decodes every base64 <img> data URI, and writes it to disk with the file extension taken from the attacker-controlled MIME type. application/php sails through as a .php file — a webshell, stored and served.

IMPACT

One visit to a booby-trapped page while logged in as a teacher or admin hands the attacker a PHP webshell on the server — RCE with a single victim click. deletefile doubles as arbitrary material-file deletion.

REMEDIATION

Remove all three URIs from csrf_exclude_uris and make the handlers CSRF-compatible (token via AJAX header); never derive file extensions from client-controlled MIME types — whitelist image extensions and force img_*.jpg|png; disable PHP execution inside uploads/ as mandatory defense in depth.

EVIDENCE — REDACTED

Redacted for security. The reproduction steps and observed outputs were verified on a self-hosted instance and are withheld from this publication while patches propagate — the exploit detail goes to the maintainer as part of coordinated disclosure, not to the open web.

GBT-007MEDIUMCVSS 6.1VERIFIED — DYNAMIC POCCOMPONENT
controllers/Pengumuman.php — save() · views/pengumuman/data.php:180

Stored XSS in Announcements

SUMMARY

The announcement text field is saved without an XSS filter and rendered back with raw <?= ?> output — no html_escape in sight. A posted <script> tag survives all the way to the announcement wall, where every role in the school reads it.

IMPACT

Script execution in every viewer's browser — admin, teacher, student. Cookie theft is blunted by HttpOnly, but UI actions on behalf of victims, defacement, and phishing all remain on the table.

REMEDIATION

html_escape() at output; if rich text is genuinely required, sanitize server-side with an HTML purifier rather than trusting the input.

EVIDENCE — REDACTED

Redacted for security. The reproduction steps and observed outputs were verified on a self-hosted instance and are withheld from this publication while patches propagate — the exploit detail goes to the maintainer as part of coordinated disclosure, not to the open web.

GBT-008MEDIUMCVSS 5.3VERIFIED — ENABLERCOMPONENT
application/config/config.php:45 · index.php:39 · installer chmod 777

Hardcoded Encryption Key + Development Default

SUMMARY

Three enablers in one finding. The encryption key is hardcoded in the public repo — the same value on every installation, and the direct key to forging GBT-003 payloads. ENVIRONMENT ships as development, so db_debug renders full SQL errors — error numbers, file paths, model lines — straight to the user. And the install guide instructs chmod 777 on database.php, leaving GBT-002's door standing open on real deployments.

IMPACT

Not a direct breach on its own, but it multiplies every other finding: forged crypto, verbose recon, and a writable config are the load-bearing walls of the Critical chain.

REMEDIATION

Generate a random encryption key during installation; ship ENVIRONMENT=production as the default; rewrite the install instructions with sane permissions.

EVIDENCE — REDACTED

Redacted for security. The reproduction steps and observed outputs were verified on a self-hosted instance and are withheld from this publication while patches propagate — the exploit detail goes to the maintainer as part of coordinated disclosure, not to the open web.

GBT-009LOW / INFOCVSS STATIC REVIEWCOMPONENT
controllers/Compare.php · Dbmanager::hapusBackup() · input->post(..., false) pattern

Code-Review Grab Bag — Authless Dev Tools and Friends

SUMMARY

Static-review findings that did not need dynamic proof: a Compare dev utility that can create and drop tables without authentication (dangerous wherever its DB groups exist); Dbmanager::hapusBackup() deleting files without basename() validation; the blanket input->post(..., false) pattern disabling XSS filtering on content fields across controllers; a non-HttpOnly csrf_cookie; and client-controlled max-size on uploads.

IMPACT

Each is a hardening gap rather than a demonstrated breach — but together they sketch an application where security was never part of the build loop.

REMEDIATION

Remove dev utilities from production releases; basename() plus a whitelist in backup deletion; re-enable filtering on content fields with output-side encoding; server-side upload size limits.

EVIDENCE — REDACTED

Redacted for security. The reproduction steps and observed outputs were verified on a self-hosted instance and are withheld from this publication while patches propagate — the exploit detail goes to the maintainer as part of coordinated disclosure, not to the open web.

03  ROOT CAUSE

Five patterns explain all nine findings:

FindingsRoot cause
GBT-001 · GBT-002The installer is never locked after installation — every dangerous Install/Update method stays callable on a running instance
GBT-003An internal "update database" endpoint with no auth, plus a secret (the encryption key) shared with the public through the repo
GBT-004 · GBT-005String concatenation into WHERE clauses in the models — the classic CodeIgniter anti-pattern
GBT-006CSRF exclusions added for an AJAX progress bar, with nothing compensating for the loss
GBT-007 · GBT-009.3No centralized output-encoding strategy

04  REMEDIATION

Priority one — ship as a v1.5.4 hotfix. Lock the installer after installation, put an admin guard on every Update method, remove the CSRF exclusions, and parameter-bind the injected query paths (getPostForUser, getTahunById, getSemesterById) plus an audit of every get_where(... . $var) call.

Priority two — per-installation secrets. Generate the encryption key at install time, default the environment to production, disable PHP execution inside uploads/, and html_escape at output in the views.

Priority three — keep it from regressing. Security regression tests for every install and update endpoint, plus a full audit of every input-to-query sink in the codebase.

The report is packaged for coordinated disclosure — maintainer advisory first, a patch window, then public credit. Suggested timeline:

DayAction
0Report sent to the maintainer (GitHub security advisory / email)
1–7Confirmation & triage with the maintainer
≤ 14Patch release (v1.5.4) for GBT-001..003
30–90Public disclosure with credit (coordinated)

05  ETHICS

Everything ran on a self-hosted instance built from the public repository. No production system was touched, no third-party target was tested, and no real school's data was ever at risk from this work. Exploitation artifacts — webshells and test tables — were removed once verification finished. The point of the exercise is the patch, not the shell.

MORE ENTRIES LAND AS WORK COMPLETES.