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)
This commit is contained in:
2026-09-01 15:13:28 +01:00
parent c0df965663
commit 358027bf27
7 changed files with 462 additions and 1 deletions
+37 -1
View File
@@ -1,3 +1,39 @@
# greenwood-school-re # greenwood-school-re
Static reverse-engineering of the Greenwood School (Boti Education) parent app: API protocol, endpoints, findings 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=<token> 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.
+49
View File
@@ -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.
+94
View File
@@ -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/<school-slug>/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": "<jwt>", // present on some accounts, may be empty
"refreshToken": "<jwt>"
}
Errors: {"error": true, "msg": "..."} with HTTP 200.
### Session use on every subsequent call
- GET: query params, plus key=<keyToken> and user_id=<userId>
- POST: multipart/form-data fields, plus key=<keyToken>
- Optional modern path (supported by the ApiService, may be empty for parent
accounts): headers Authorization: Bearer <accessToken> and
X-refresh-token: Bearer <refreshToken>
### Server-side session control
- Any response containing "disconnect": true means the session was killed
client-side (app navigates to login).
- "redirect": "<path>" makes the app navigate there.
- "showSLides": "<key>" 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=<keyToken> auto-appended by the ApiService
## Push notifications
POST {base}/device_token fields: token=<fcm token>, user_id, key, uid=<device uuid>
## 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.
+110
View File
@@ -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/<endpoint>.
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 |
+65
View File
@@ -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/<school-slug>/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=<phone>, password=<plaintext>, 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.
+44
View File
@@ -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")\""
+63
View File
@@ -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()