From 358027bf27ec1d74b4718e379975b25f4c44ecc0 Mon Sep 17 00:00:00 2001 From: omachy form the big pc republic Date: Tue, 1 Sep 2026 15:13:28 +0100 Subject: [PATCH] Document full static RE of Greenwood School (Boti Education) app 2.4.14 - PROTOCOL.md: base URL, login/session auth, request envelope, push, media - endpoints.md: 100 app endpoints extracted from the Capacitor JS bundle - FINDINGS.md: security findings incl. hardcoded dev account in build - tools: endpoint extractor + credential-safe login script - results: original analysis report (IDs redacted) --- README.md | 38 ++++++++++++- docs/FINDINGS.md | 49 +++++++++++++++++ docs/PROTOCOL.md | 94 +++++++++++++++++++++++++++++++ docs/endpoints.md | 110 +++++++++++++++++++++++++++++++++++++ results/report.md | 65 ++++++++++++++++++++++ tools/boti_login.sh | 44 +++++++++++++++ tools/extract_endpoints.py | 63 +++++++++++++++++++++ 7 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 docs/FINDINGS.md create mode 100644 docs/PROTOCOL.md create mode 100644 docs/endpoints.md create mode 100644 results/report.md create mode 100755 tools/boti_login.sh create mode 100755 tools/extract_endpoints.py diff --git a/README.md b/README.md index abdc7d6..77db2a1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,39 @@ # greenwood-school-re -Static reverse-engineering of the Greenwood School (Boti Education) parent app: API protocol, endpoints, findings \ No newline at end of file +Static reverse engineering of the Greenwood School parent app +(com.botieducation.greenwood), the "horrible mobile app" that is actually a +Capacitor web wrapper by vendor Boti Education (boti.education). + +Analyzed: 2.4.14 (versionCode 24140), XAPK from APKPure. +XAPK sha256: f46aa8b126f8f8856ea15e10ed12fa259d02e383f8921b0645139521b7820e69 + +## Contents + +- docs/PROTOCOL.md, full API protocol: base URL, auth, request envelope, + push registration, media URLs, quirks +- docs/endpoints.md, all 100 app endpoints with get/post and source chunk +- docs/FINDINGS.md, security findings (yes, including the hardcoded dev account) +- tools/extract_endpoints.py, regenerate the endpoint table from decompiled chunks +- tools/boti_login.sh, login + session key caching (credentials from + ~/.config/boti.ini, never in the repo) +- results/report.md, original analysis notes + +## TL;DR + +- The app is an Ionic/Angular SPA in a Capacitor 5 shell. All logic in JS. +- Backend: https://boti.education/p/greenwood/botiapi/ (per-school path under + one shared vendor backend) +- Auth: POST login (phone + plaintext password) -> keyToken (40 hex) used as + key= on every call. JWT access/refresh headers supported but usually + empty for parents. +- The build ships a hardcoded dev account in the login page component. +- Verified live with a single login call; no other requests were made against + the school server. + +## No credentials here + +The repository contains no credentials or personal data of the account holder. +The one phone/password pair quoted in docs/FINDINGS.md is vendor-shipped demo +content quoted as vulnerability evidence; it is present in every public copy of +the APK. Live credentials belong in ~/.config/boti.ini (chmod 600) on the +machine that runs the tools, never in this repo. diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md new file mode 100644 index 0000000..5e444b5 --- /dev/null +++ b/docs/FINDINGS.md @@ -0,0 +1,49 @@ +# Security findings (com.botieducation.greenwood 2.4.14) + +Vendor: Boti Education (boti.education). All findings from static analysis of +the publicly distributed APK; one live login call used to verify the protocol. + +## F1. Hardcoded credentials in the shipped production build [HIGH] + +extracted/assets/public/1028.js (login page component) contains: + + this.reconnectUser = {id:12745, phone:"0649014703", password:"0649014703"} + +This is a leftover dev/demo biometric-reconnect account with phone number as +password, compiled into the production bundle. Anyone with the APK has a +username and password. Status of this account on the server unknown; report to +vendor recommended. + +## F2. Plaintext credential transmission [MEDIUM] + +POST /login sends the password as a plaintext form field (password=...). +TLS protects the transport, but the credential sits in plaintext in any +intermediary logging form data, and the same pattern is reused by +forgot-password / change-password. No client-side hashing. Common for this +class of app, still worth flagging. + +## F3. Verbose production logging [LOW] + +The ApiService logs request payloads, headers and the access token to +console.log in the release build (visible via adb logcat / remote WebView +debugging if enabled). + +## F4. No certificate pinning, no obfuscation [INFO] + +No pinning config in the bundle, no network_security_config restrictions +beyond defaults, JS bundle fully readable. RE cost: about an hour. + +## F5. Broad but plausible permissions [INFO] + +CAMERA, fine+coarse LOCATION, microphone (voice recorder plugin), external +storage, FCM. Location use case is bus tracking (pick endpoints); camera for +photo upload. No SMS, contacts, or accessibility permission abuse observed. + +## Responsible disclosure notes + +- Vendor contact is via boti.education; the school itself (Green Wood School, + tel 0520744100 per the official welcome message) can escalate. +- F1 is the only finding with direct account-takeover potential and is + trivially verifiable by the vendor. +- Do not test F1 against the live server beyond confirming it exists in the + bundle; that crosses into unauthorized access. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..47a6610 --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,94 @@ +# Boti Education app API protocol (Green Wood School variant) + +Reverse engineered statically from com.botieducation.greenwood 2.4.14 +(Capacitor/Ionic/Angular bundle) and verified with one live login call on 2026-09-01. + +## Base URL + + https://boti.education/p/greenwood/botiapi/ + +Pattern per school: https://boti.education/p//botiapi/ +All Boti Education schools share one backend. Media host: media.boti.education +(signed expiring URLs, token appended to path). + +## Authentication + +### Login + + POST {base}/login Content-Type: multipart/form-data + +Fields: +- login phone number, e.g. 06xxxxxxxx +- password plaintext +- privacy bool ("true"/"false") +- remembreMe bool + +Response (HTTP 200, application/json): + + { + "status": 200, "msg": "Bienvenue !", "error": false, + "keyToken": "<40 hex chars>", // session key + "userId": "20887", + "parentId": "1376", // when role=parent + "eleveId": "1739", + "_acces": "parent", "role": "parent", + "eleves": [ { id, nomcomplet, prenom, nom, datenaissance, sexe, + niveau, img, parent_nom, parent_image } ], + "parent": { id, nomcomplet, tel, email, sexe, image }, + "accessToken": "", // present on some accounts, may be empty + "refreshToken": "" + } + +Errors: {"error": true, "msg": "..."} with HTTP 200. + +### Session use on every subsequent call + +- GET: query params, plus key= and user_id= +- POST: multipart/form-data fields, plus key= +- Optional modern path (supported by the ApiService, may be empty for parent + accounts): headers Authorization: Bearer and + X-refresh-token: Bearer + +### Server-side session control + +- Any response containing "disconnect": true means the session was killed + client-side (app navigates to login). +- "redirect": "" makes the app navigate there. +- "showSLides": "" pops a slider modal (onboarding/news screens). + +## Request envelope + +GET params observed in the bundle (per feature, subset applies): +user_id, key, acces, versionCode, versionNumber, paltform (sic, typo is in the +app), eleveId, lang, date, startDate, endDate, classe, id, search, page + +POST FormData conventions: +- flat fields appended as key=value +- nested object -> key[subkey]=value +- array -> key[0]=..., key[1]=... +- File/Blob -> appended raw under the field name +- key= auto-appended by the ApiService + +## Push notifications + + POST {base}/device_token fields: token=, user_id, key, uid= + +## Endpoint inventory + +See endpoints.md (100 app endpoints, get/post flags, source chunk per endpoint). +Endpoint names are stable Pascal/snake identifiers appended to the base URL. + +## Media / files + +- Files and images are fetched from media.boti.education with a signed, + expiring path (base64url token + timestamp appended). +- PDFs opened in-app via pdf.js (chunk 7482.js). +- getBlob(path, params, absolute) downloads raw blobs for attachments. + +## Quirks + +- The ApiService appends the endpoint as a raw string, so endpoint names in the + table are the complete server route (no /api prefix, no .php). +- Param typo "paltform" is sent by the app; server accepts it, do not "fix" it + blindly. +- Detailed console.log of params and headers ships in production. diff --git a/docs/endpoints.md b/docs/endpoints.md new file mode 100644 index 0000000..51478ff --- /dev/null +++ b/docs/endpoints.md @@ -0,0 +1,110 @@ +# Boti Education app API: endpoints extracted from decompiled bundle + +Source: assets/public/*.js of com.botieducation.greenwood 2.4.14. +100 app endpoints. The endpoint name is the 2nd argument of the ApiService +get()/post() calls; full URL = https://boti.education/p/greenwood/botiapi/. + +Excluded as pdf.js library false positives (all seen only in chunk 7482.js): BPC, CS, DP, F, FontFile2, H, I, IM, SM, W + +| endpoint | methods | chunk(s) | +|---|---|---| +| absences | get | 3058.js, 8317.js | +| absences-justification | post | 3058.js, 8317.js | +| acces_check | get | main.js | +| admin_absence_eleve | get | 6763.js | +| admin_demande_eleve | get, post | 1514.js, 9714.js, 9964.js | +| admin_discipline_eleve | get, post | 1172.js, 9964.js | +| admin_fiche_eleve | get | 9964.js | +| admin_nouveautes | get | 6154.js | +| admin_search_eleve | get | 6996.js | +| admin_suivi_eleve | get, post | 3555.js, 9964.js | +| album_photo | get | 6124.js, 6925.js | +| bibliotheque | get | 2662.js, 6587.js, 7856.js (+1 more) | +| cantine | get | 3218.js, 6510.js | +| cartable_numeriques | get | 1516.js, 8698.js, 9661.js | +| cartable_split | get | 3238.js | +| change-password | get, post | 5994.js | +| collaborateur_menu | get | 9246.js | +| collaborateur_nouveautes | get | 4450.js | +| competences | get | 219.js, 5502.js | +| compte | get | 2319.js, 7983.js, 8437.js | +| compte-eleve | get, post | 4186.js, 6943.js | +| compte-parent | get, post | 6941.js | +| compte-pick | get, post | 2758.js, 2881.js | +| compte-prof | get, post | 7324.js | +| contact | get | 112.js, 8171.js | +| cours_v2 | get | 6109.js, 6946.js | +| demandes | get | 1246.js | +| device_token | post | main.js | +| devoirs | get, post | 154.js, 3537.js, 4273.js (+1 more) | +| devoirs_date_v2 | post | 3537.js, 4273.js | +| discipline | get | 5482.js, 8072.js | +| encadrant_contact | get | 5962.js | +| encadrant_menu | get | 7110.js | +| encadrant_notifications | get | 6108.js | +| evaluations_releves | get, post | 1213.js, 6153.js | +| examen_collectif | get | 1531.js | +| examens_v2 | get | 2722.js, 7085.js | +| forgot-password | post | 1028.js | +| grille_apprentisage | get | 541.js | +| icon-top | get | 438.js | +| ios | get | 2175.js, 5817.js, 8136.js (+2 more) | +| login | post | main.js | +| login-page | get | 1028.js, 274.js, 4287.js | +| logout | post | main.js | +| messages | get | 1201.js, 3943.js, 4785.js (+1 more) | +| new_compte | post | 8437.js | +| new_compte/child | post | 8437.js | +| new_paiements | get | 4110.js | +| note_matieres | get | 215.js | +| notifications | get | 9963.js | +| notifications_new | get | 8052.js | +| nouveau-message | post | 1201.js, 2345.js, 9771.js | +| nouveautes | get, post | 1815.js, 1878.js, 1901.js (+8 more) | +| nouvelle-demande | get, post | 2253.js, 8302.js | +| objects | get, post | 7983.js | +| online_paiements | get | 4924.js | +| overlay | get | 8136.js | +| paiements | get, post | 1559.js, 1631.js, 2897.js (+4 more) | +| pick_enfants | get, post | 4495.js, 6292.js | +| pick_menu | get | 8897.js | +| pick_nouveautes | get | 9821.js | +| pick_trajets_tst_absent | get, post | 2568.js, 7347.js | +| pinned_posts | get | 8853.js, 9010.js | +| pointage | get, post | 3394.js, 8656.js | +| pointage_borne | get, post | 3151.js, 9073.js | +| post_view | get | 1815.js, 1878.js, 6834.js (+2 more) | +| prof-competences | get | 5507.js, common.js | +| prof-competences-details | get, post | 1272.js | +| prof-nouvelle-demande | get, post | 718.js | +| prof_cachier_text | get, post | 6919.js | +| prof_cartable_numeriques | get | 2891.js, 3358.js | +| prof_classes | get, post | 7116.js, 9516.js | +| prof_competence_form | get, post | 4225.js | +| prof_content | get, post | 4225.js, 6516.js, 6904.js (+2 more) | +| prof_contents | get | 7269.js, 9346.js, 9588.js | +| prof_cours | get | 5814.js | +| prof_cours_details | get, post | 8111.js | +| prof_demandes | get, post | 1665.js, 8329.js | +| prof_devoir_details | get | 6904.js, 950.js | +| prof_examen_form | get, post | 103.js | +| prof_examens_details | get, post | 1531.js, 5908.js | +| prof_fiche_eleve | get | 6889.js | +| prof_generale_appreciation | get, post | 2567.js, 712.js | +| prof_media_album | get, post | 322.js, 7859.js, 8667.js | +| prof_messages | get, post | 3733.js, 4722.js, 8690.js | +| prof_notifications | get | 3602.js | +| prof_nouveautes | get | 6657.js | +| prof_ressource_partage | get, post | 1267.js, 2984.js, 8638.js | +| prof_transmission | get, post | 2629.js | +| quiz | get, post | 1140.js, 93.js | +| ressource_details | get | 2921.js, 7424.js | +| ressources_v2 | get, post | 3641.js, 4655.js | +| shop | get, post | 1097.js, 6091.js, 9344.js (+1 more) | +| slides | get, post | 1356.js | +| slides_content | get | main.js | +| student-wallet | get, post | 2699.js, 333.js, 6277.js (+1 more) | +| suivi_pedagogique | get | 1414.js, 3965.js | +| timeline | get | 274.js, 4287.js | +| translations | get | 2757.js, 4674.js, 6113.js (+3 more) | +| transmission | get | 2109.js, 63.js | diff --git a/results/report.md b/results/report.md new file mode 100644 index 0000000..9eb596b --- /dev/null +++ b/results/report.md @@ -0,0 +1,65 @@ +# Greenwood School Android app, static RE report + +App: com.botieducation.greenwood 2.4.14 (versionCode 24140) +XAPK sha256: f46aa8b126f8f8856ea15e10ed12fa259d02e383f8921b0645139521b7820e69 +Worktree: ~/android_reverse/greenwood/ (original XAPK untouched, copy analyzed) + +## Architecture +- Capacitor 5 shell (assets/capacitor.config.json), Ionic + Angular SPA in assets/public/ +- Native layer is only capacitor plugins: camera, filesystem, geolocation, push (FCM), preferences, file-opener/picker, voice recorder +- All business logic and API access lives in the JS bundle: assets/public/main.js + ~200 lazy chunks (nNNN.js) +- ngsw.json: Angular service worker present (PWA origin) + +## Backend +- Base URL constant in main.js (module 35548 export JW): + https://boti.education/p/greenwood/botiapi/ +- Per-school path pattern: https://boti.education/p//botiapi/ +- Static/media host: media.boti.education (signed, expiring URLs) +- Vendor: Boti Education (boti.education), logo boti_robot.png, "boti" prefixes everywhere + +## Auth protocol (verified live 2026-09-01) +- POST {base}login, multipart/form-data: login=, password=, privacy, remembreMe +- Response: {status, msg, error, keyToken, userId, parentId, eleves[], _acces, parent{}} +- Session auth on later calls: key=<keyToken> (GET query param / POST form field) +- Also supported (code path present, empty at login here): Authorization: Bearer <accessToken>, X-refresh-token header +- Response with "disconnect":true forces navigateRoot("/") = server-side session kill +- Login page form controls: login, password, privacy, remembreMe (extracted/assets/public/1028.js) + +## API service (main.js, apiservice class) +- get(params, endpoint): URLSearchParams, Content-Type x-www-form-urlencoded +- post(params, endpoint): FormData; nested objects flattened as key[subkey]; arrays as key[i]; File/Blob appended raw; auto-appends key=<token> +- getBlob(path, params, absolute=false): raw blob download (used for media/PDF) +- ~110 endpoints total (endpoint name is 2nd arg of get/post in chunks). Full list via: + grep pattern '\.(post|get|getBlob)\(...,"endpoint"' over assets/public/*.js + +## Endpoints (GET unless noted) +acces_check, login(POST), logout(POST), device_token(POST), forgot-password(POST), +change-password(GET/POST), translations, slides(GET/POST), slides_content, icon-top, overlay, +nouveautes(GET/POST), pinned_posts, post_view, timeline, notifications, notifications_new, +messages, nouveau-message(POST), pick_enfants(GET/POST), pick_menu, pick_nouveautes, +pick_trajets_tst_absent(GET/POST), pointage(GET/POST), pointage_borne(GET/POST), +devoirs(GET/POST), devoirs_date_v2(POST), cours_v2, examens_v2, examen_collectif, +evaluations_releves(GET/POST), note_matieres, competences, suivi_pedagogique, +absences, absences-justification(POST), discipline, cantine, cartable_numeriques, cartable_split, +ressources_v2(GET/POST), ressource_details, bibliotheque, grille_apprentisage, quiz(GET/POST), +demandes, nouvelle-demande(GET/POST), paiements(GET/POST), new_paiements, online_paiements, +shop(GET/POST), student-wallet(GET/POST), album_photo, objects(GET/POST), +compte(GET/POST), compte-eleve(GET/POST), compte-parent(GET/POST), compte-prof(GET/POST), +compte-pick(GET/POST), new_compte(POST), new_compte/child(POST), +prof_* (teacher side: prof_classes, prof_cours, prof_devoir_details, prof_examens_details, +prof_competence_form, prof_messages, prof_nouveautes, prof_fiche_eleve, ...), +admin_* (admin side: admin_search_eleve, admin_fiche_eleve, admin_suivi_eleve, ...), +encadrant_* (bus supervisor side), collaborateur_menu, ios + +## Findings of interest +1. Hardcoded leftover dev/biometric demo account shipped in production build: + extracted/assets/public/1028.js: reconnectUser={id:12745, phone:"0649014703", password:"0649014703"} +2. Passwords sent in plaintext form fields over TLS (no hashing client-side); keyToken is a bare 40-hex session key +3. FCM device_token POST {token, user_id, key, uid} for push registration +4. Detailed console.log of params/headers left in production bundle +5. No certificate pinning, no obfuscation of the SPA + +## Verification +Live POST login with test credentials returned HTTP 200, error:false, keyToken, +parent record + _acces=parent. Protocol confirmed end-to-end. Internal IDs +redacted. diff --git a/tools/boti_login.sh b/tools/boti_login.sh new file mode 100755 index 0000000..c3181c5 --- /dev/null +++ b/tools/boti_login.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Log in to a Boti Education school API (Green Wood School variant) and cache the session token. +# Credentials are read from boti.ini in $XDG_CONFIG_HOME (default ~/.config), never hard-coded. +# +# boti.ini format: +# [boti] +# login = 0600000000 +# password = whatever +# +set -euo pipefail + +BASE="https://boti.education/p/greenwood/botiapi/" +CONF="${XDG_CONFIG_HOME:-$HOME/.config}/boti.ini" +CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/boti-key" + +if [[ ! -f "$CONF" ]]; then + echo "missing $CONF (see header of this script for format)" >&2 + exit 1 +fi + +login=$(awk -F= '/^login/{gsub(/[[:space:]]/,"",$2); print $2}' "$CONF") +password=$(awk -F= '/^password/{gsub(/[[:space:]]/,"",$2); print $2}' "$CONF") + +resp=$(curl -sS -m 20 "$BASE/login" \ + -F "login=$login" \ + -F "password=$password" \ + -F "privacy=true" \ + -F "remembreMe=false") + +if ! grep -q '"error":false' <<<"$resp"; then + echo "login failed: $(head -c 200 <<<"$resp")" >&2 + exit 1 +fi + +key=$(sed -n 's/.*"keyToken":"\([0-9a-f]*\)".*/\1/p' <<<"$resp") +if [[ -z "$key" ]]; then + echo "no keyToken in response" >&2 + exit 1 +fi + +umask 077 +printf '%s' "$key" > "$CACHE" +echo "session key cached at $CACHE (40 hex chars)" +echo "usage: curl \"$BASE/<endpoint>?user_id=<userId>&key=$(cat "$CACHE")\"" diff --git a/tools/extract_endpoints.py b/tools/extract_endpoints.py new file mode 100755 index 0000000..4611dbf --- /dev/null +++ b/tools/extract_endpoints.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Extract the Boti Education app API endpoints from decompiled Capacitor JS chunks. + +Usage: python3 extract_endpoints.py <dir-with-chunks> [--markdown] + +Scans *.js for the Angular ApiService call signature: + <http>.post(<payload>, "endpoint") / <http>.get(<payload>, "endpoint") +and emits a deduplicated endpoint -> methods table. +""" +import re +import sys +import glob +import os +import argparse + +CALL = re.compile( + r'\.(post|get|getBlob)\(\s*' + r'(?:"[^"]*"|`[^`]*`|[A-Za-z_$][\w$]*(?:\s*\.\s*[\w$]+)*(?:\([^()]*\))?|\{[^{}]*\})' + r'\s*,\s*"([^"]*)"' +) + + +def scan(directory): + found = {} # endpoint -> {method: [chunks]} + for path in sorted(glob.glob(os.path.join(directory, '*.js'))): + chunk = os.path.basename(path) + src = open(path, encoding='utf8', errors='replace').read() + for m in CALL.finditer(src): + method, ep = m.group(1), m.group(2) + d = found.setdefault(ep, {}) + d.setdefault(method, []).append(chunk) + return found + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('directory') + ap.add_argument('--markdown', action='store_true') + args = ap.parse_args() + + found = scan(args.directory) + if not found: + sys.exit('no endpoints found, wrong directory?') + + if args.markdown: + print(f'# API endpoints extracted from {os.path.basename(args.directory)}') + print() + print(f'{len(found)} unique endpoints.') + print() + print('| endpoint | methods | seen in chunk(s) |') + print('|---|---|---|') + for ep in sorted(found): + methods = ', '.join(sorted(found[ep])) + chunks = ', '.join(sorted({c for ms in found[ep].values() for c in ms})[:3]) + print(f'| {ep} | {methods} | {chunks} |') + else: + for ep in sorted(found): + print(ep, sorted(found[ep])) + print(f'TOTAL endpoints: {len(found)}', file=sys.stderr) + + +if __name__ == '__main__': + main()