2 Commits
Author SHA1 Message Date
Omarchy-Big-PC 613be87800 docs: nouveautes bodies live in admin_nouveautes (19MB), not parent list 2026-09-14 22:46:47 +01:00
Omarchy-Big-PC b6f22ad7c1 Traduction française complète + courriel de divulgation responsable
- README, PROTOCOL, FINDINGS, endpoints, rapport et outils traduits en français
- docs/DISCLOSURE.md : courriel prêt à envoyer à hello@boti.education
  (compte démo codé en dur, transmission en clair, logging de production)
- extract_endpoints.py vérifié après traduction (100 points d'accès toujours extraits)
2026-09-01 17:29:25 +01:00
9 changed files with 396 additions and 225 deletions
+37 -30
View File
@@ -1,39 +1,46 @@
# greenwood-school-re # greenwood-school-re
Static reverse engineering of the Greenwood School parent app Ingénierie inverse statique de l'application parents Greenwood School
(com.botieducation.greenwood), the "horrible mobile app" that is actually a (com.botieducation.greenwood), la « horrible application mobile » qui est en
Capacitor web wrapper by vendor Boti Education (boti.education). réalité un wrapper web Capacitor de l'éditeur Boti Education (boti.education).
Analyzed: 2.4.14 (versionCode 24140), XAPK from APKPure. Version analysée : 2.4.14 (versionCode 24140), XAPK via APKPure.
XAPK sha256: f46aa8b126f8f8856ea15e10ed12fa259d02e383f8921b0645139521b7820e69 Empreinte XAPK sha256 : f46aa8b126f8f8856ea15e10ed12fa259d02e383f8921b0645139521b7820e69
## Contents ## Contenu
- docs/PROTOCOL.md, full API protocol: base URL, auth, request envelope, - docs/PROTOCOL.md, le protocole API complet : URL de base, authentification,
push registration, media URLs, quirks enveloppe des requêtes, notifications push, URLs médias, particularités
- docs/endpoints.md, all 100 app endpoints with get/post and source chunk - docs/endpoints.md, les 100 points d'accès de l'app avec méthodes get/post
- docs/FINDINGS.md, security findings (yes, including the hardcoded dev account) et chunk source
- tools/extract_endpoints.py, regenerate the endpoint table from decompiled chunks - docs/FINDINGS.md, constats de sécurité (oui, y compris le compte de démo
- tools/boti_login.sh, login + session key caching (credentials from codé en dur)
~/.config/boti.ini, never in the repo) - docs/DISCLOSURE.md, courriel de divulgation responsable prêt à envoyer
- results/report.md, original analysis notes - tools/extract_endpoints.py, régénère la table des points d'accès depuis les
chunks décompilés
- tools/boti_login.sh, connexion + mise en cache de la clé de session
(identifiants dans ~/.config/boti.ini, jamais dans le dépôt)
- results/report.md, notes d'analyse originales
## TL;DR ## En résumé
- The app is an Ionic/Angular SPA in a Capacitor 5 shell. All logic in JS. - L'application est une SPA Ionic/Angular dans une coquille Capacitor 5.
- Backend: https://boti.education/p/greenwood/botiapi/ (per-school path under Toute la logique est en JavaScript.
one shared vendor backend) - Backend : https://boti.education/p/greenwood/botiapi/ (chemin par école sur
- Auth: POST login (phone + plaintext password) -> keyToken (40 hex) used as un backend unique partagé entre tous les établissements Boti)
key=<token> on every call. JWT access/refresh headers supported but usually - Auth : POST login (téléphone + mot de passe en clair) -> keyToken (40 hex)
empty for parents. utilisé comme key=<token> sur chaque appel. Jetons JWT access/refresh
- The build ships a hardcoded dev account in the login page component. supportés mais généralement vides pour les comptes parents.
- Verified live with a single login call; no other requests were made against - La version livrée contient un compte de démonstration codé en dur dans le
the school server. composant de la page de connexion.
- Protocole vérifié en direct avec un seul appel de connexion ; aucune autre
requête n'a été faite au serveur de l'école.
## No credentials here ## Pas d'identifiants ici
The repository contains no credentials or personal data of the account holder. Le dépôt ne contient aucune donnée personnelle du titulaire du compte. La
The one phone/password pair quoted in docs/FINDINGS.md is vendor-shipped demo seule paire téléphone/mot de passe citée dans docs/FINDINGS.md est le contenu
content quoted as vulnerability evidence; it is present in every public copy of de démo livré par l'éditeur, cité comme preuve de vulnérabilité ; il figure
the APK. Live credentials belong in ~/.config/boti.ini (chmod 600) on the dans chaque copie publique de l'APK. Les identifiants réels vont dans
machine that runs the tools, never in this repo. ~/.config/boti.ini (chmod 600) sur la machine qui exécute les outils, jamais
dans ce dépôt.
+79
View File
@@ -0,0 +1,79 @@
Destinataires (contacts publics du développeur sur le Google Play Store) :
- hello@boti.education (contact général)
- cs@boti.education (support)
---
## Objet
Divulgation responsable : identifiants intégrés dans l'application Greenwood
School (com.botieducation.greenwood 2.4.14)
## Corps du message
Bonjour,
Je suis parent d'élève à la Greenwood School (Casablanca) et j'utilise
l'application mobile Greenwood School, éditée par Boti Education.
En examinant la version 2.4.14 de l'application Android
(com.botieducation.greenwood), distribuée publiquement, j'ai identifié
plusieurs problèmes de sécurité. Tous proviennent d'une analyse statique de
l'APK. Je n'ai accédé qu'à mon propre compte parent, conformément à une
démarche de divulgation responsable.
Constat principal (le plus grave) :
L'application livrée au public contient un compte de démonstration avec
identifiant et mot de passe en clair, codé en dur dans le code de la page de
connexion (fichier 1028.js du paquet JavaScript) :
reconnectUser = {id: 12745, phone: "0649014703", password: "0649014703"}
Ce compte de test biométrique est présent dans chaque copie de l'APK
distribuée sur le Play Store. Toute personne disposant de l'application
connaît donc un numéro de téléphone et un mot de passe valides pour votre
serveur boti.education. Si ce compte existe encore côté serveur, il permet
une prise de contrôle potentielle. Un correctif côté client est insuffisant :
le compte devrait être désactivé ou supprimé côté serveur.
Constats secondaires :
1. Le mot de passe est transmis en texte clair dans les champs du formulaire
de connexion (protégé par TLS, mais visible dans tout journal intermédiaire
qui enregistre les données de formulaire).
2. La version de production contient des appels console.log détaillés qui
affichent les paramètres de requête et les jetons d'accès.
3. Aucun épinglage de certificat ni obfuscation du code : l'ensemble du
protocole API (environ 100 points d'accès sur
https://boti.education/p/greenwood/botiapi/) se laisse documenter en
quelques heures.
Documentation technique complète (protocole API, liste des points d'accès,
détail des constats) : dépôt public
https://gitea.oimcloud.myaddr.tools/Omarchy-Big-PC/greenwood-school-re
Je n'ai publié ce dépôt qu'après avoir retiré toute donnée personnelle ; seul
le compte de démonstration livré par vos soins y est cité, comme preuve.
Je propose une divulgation coordonnée : merci de m'indiquer un délai de
correction raisonnable (90 jours est l'usage) et une personne de contact.
Je mettrai à jour le dépôt public avec le statut du correctif et la réponse
de votre équipe.
Cordialement,
[Votre nom]
Parent d'élève, Greenwood School
[Votre courriel]
[Votre téléphone]
---
## Suivi recommandé
1. Envoyer à hello@boti.education, copie cs@boti.education.
2. Noter la date d'envoi ici : ____________
3. Relancer après 14 jours sans réponse.
4. Après correction : mettre à jour docs/FINDINGS.md avec le statut de chaque
constat (corrigé / non corrigé) et le numéro de version du correctif.
+39 -34
View File
@@ -1,49 +1,54 @@
# Security findings (com.botieducation.greenwood 2.4.14) # Constats de sécurité (com.botieducation.greenwood 2.4.14)
Vendor: Boti Education (boti.education). All findings from static analysis of Éditeur : Boti Education (boti.education). Tous les constats proviennent de
the publicly distributed APK; one live login call used to verify the protocol. l'analyse statique de l'APK distribué publiquement ; un seul appel de
connexion réel a servi à vérifier le protocole.
## F1. Hardcoded credentials in the shipped production build [HIGH] ## F1. Identifiants codés en dur dans la build de production [GRAVE]
extracted/assets/public/1028.js (login page component) contains: extracted/assets/public/1028.js (composant de la page de connexion) contient :
this.reconnectUser = {id:12745, phone:"0649014703", password:"0649014703"} reconnectUser={id:12745, phone:"0649014703", password:"0649014703"}
This is a leftover dev/demo biometric-reconnect account with phone number as Il s'agit d'un compte de démonstration oublié (reconnexion biométrique) avec
password, compiled into the production bundle. Anyone with the APK has a le numéro de téléphone comme mot de passe, compilé dans le bundle de
username and password. Status of this account on the server unknown; report to production. Quiconque possède l'APK connaît donc un identifiant et un mot de
vendor recommended. passe. Statut réel de ce compte côté serveur : inconnu ; signalement à
l'éditeur recommandé.
## F2. Plaintext credential transmission [MEDIUM] ## F2. Transmission des identifiants en clair [MOYEN]
POST /login sends the password as a plaintext form field (password=...). POST /login envoie le mot de passe comme champ de formulaire en clair
TLS protects the transport, but the credential sits in plaintext in any (password=...). TLS protège le transport, mais l'identifiant reste en texte
intermediary logging form data, and the same pattern is reused by clair dans tout journal intermédiaire qui enregistre les données de
forgot-password / change-password. No client-side hashing. Common for this formulaire ; même schéma dans forgot-password et change-password. Pas de
class of app, still worth flagging. hachage côté client. Courant pour cette classe d'application, à signaler
quand même.
## F3. Verbose production logging [LOW] ## F3. Journalisation verbeuse en production [FAIBLE]
The ApiService logs request payloads, headers and the access token to L'ApiService journalise (console.log) les paramètres, les en-têtes et le
console.log in the release build (visible via adb logcat / remote WebView jeton d'accès dans la build release (visibles via adb logcat / débogage
debugging if enabled). WebView distant s'il est activé).
## F4. No certificate pinning, no obfuscation [INFO] ## F4. Pas d'épinglage de certificat, pas d'obfuscation [INFO]
No pinning config in the bundle, no network_security_config restrictions Aucune configuration de pinning dans le bundle, aucune restriction
beyond defaults, JS bundle fully readable. RE cost: about an hour. network_security_config au-delà des valeurs par défaut, bundle JS entièrement
lisible. Coût de la rétro-ingénierie : environ une heure.
## F5. Broad but plausible permissions [INFO] ## F5. Permissions larges mais plausibles [INFO]
CAMERA, fine+coarse LOCATION, microphone (voice recorder plugin), external CAMERA, localisation fine+grossière, micro (plugin enregistreur vocal),
storage, FCM. Location use case is bus tracking (pick endpoints); camera for stockage externe, FCM. Cas d'usage de la localisation : suivi des trajets
photo upload. No SMS, contacts, or accessibility permission abuse observed. (points d'accès pick*) ; caméra pour l'envoi de photos. Aucun abus observé
(SMS, contacts, accessibilité).
## Responsible disclosure notes ## Notes de divulgation responsable
- Vendor contact is via boti.education; the school itself (Green Wood School, - Contact éditeur via boti.education ; l'école (Green Wood School,
tel 0520744100 per the official welcome message) can escalate. tél 0520744100 d'après le message officiel d'accueil) peut relayer.
- F1 is the only finding with direct account-takeover potential and is - F1 est le seul constat avec un potentiel de prise de contrôle de compte et
trivially verifiable by the vendor. il est trivialement vérifiable par l'éditeur.
- Do not test F1 against the live server beyond confirming it exists in the - Ne pas tester F1 contre le serveur réel au-delà de confirmer sa présence
bundle; that crosses into unauthorized access. dans le bundle : au-delà, cela devient un accès non autorisé.
+66
View File
@@ -0,0 +1,66 @@
# Nouveautés : corps des annonces et endpoint admin_nouveautes
Constat du 13-14 septembre 2026, vérifié en production avec les identifiants
parent (aucune donnée personnelle stockée ici).
## Le piège : la liste `nouveautes` ne contient pas les corps
Le endpoint `nouveautes` (GET) renvoie la liste des 10 derniers posts avec
seulement : id, title, categorie, date, intro, files, image. L'intro ne
contient que le badge "Vu le ..." et éventuellement rien du tout. Les vrais
corps ("Chers parents, ...") n'y figurent PAS.
Beaucoup d'annonces n'ont pas de texte du tout côté liste : elles semblent
vides alors qu'elles ont un `description` complet côté admin.
## Où sont les corps : `admin_nouveautes`
`GET https://boti.education/p/greenwood/botiapi/admin_nouveautes` avec les
params standard (user_id, parent_id, eleve_id, key) renvoie TOUTES les
annonces de l'école depuis 2021, avec le champ `description` complet
(HTML) et les fichiers joints. Attention :
- **~19 Mo de JSON** pour Greenwood. Ne pas le poller en boucle, c'est
le genre de requête qui finit sur une liste de blocage.
- C'est un endpoint "admin" côté nom mais il répond très bien au compte
parent standard. Probablement l'endpoint utilisé par la vue détaillée
d'un post dans l'app.
Les posts récents y apparaissent PARFOIS avant d'être visibles dans la
liste parent `nouveautes` (constaté : note "Emploi du temps" du 14/09
16:37 visible dans admin_nouveautes, absente de nouveautes).
## Fichiers joints
- `files[].link` sont des URLs media.boti.education/view/... signées,
DOUBLE-encodées comme d'habitude : décoder exactement une fois avec
parse_qs, garder %2B/%2F/%3D littéraux (server.viewer_to_real()).
- Expiration ~15-20 min : télécharger dans la même exécution du script.
- Beaucoup d'annonces n'ont pas de PDF : le contenu est uniquement dans
`description` (HTML avec emojis, <p style="text-align: justify">).
## Historique des emplois du temps
L'école publie les EDT classe par classe via des notes "Emploi du temps"
dans les nouveautés (PDF joints), généralement la 1re quinzaine de
septembre. Le grille `cours_v2` reste vide en parallèle jusqu'à
renseignement par l'admin. 2026 : publiés du 07/09 (maternelle GS/MS) au
14/09 (TPS/PS) ; lycée/TC pas encore paru au 14/09.
## Script de récupération des corps
```python
import sys, html, re
sys.path.insert(0, "/home/iliasmouhcine/dev/boti-client")
import server
c = server.BotiClient()
if not c.key: c.login()
d = server.http_get("admin_nouveautes", {"user_id": c.user_id,
"parent_id": c.parent_id, "eleve_id": c.eleve_id, "key": c.key})
for p in d["data"][:10]:
body = html.unescape(p.get("description") or "")
body = re.sub(r"<br\s*/?>", "\n", re.sub(r"</p>", "\n", body))
body = re.sub(r"<[^>]+>", "", body)
print("=====", p["title"], "|", p["date"])
print(body.strip())
```
+61 -54
View File
@@ -1,94 +1,101 @@
# Boti Education app API protocol (Green Wood School variant) # Boti Education app API : protocole (variante Green Wood School)
Reverse engineered statically from com.botieducation.greenwood 2.4.14 Rétro-ingénierie statique à partir de com.botieducation.greenwood 2.4.14
(Capacitor/Ionic/Angular bundle) and verified with one live login call on 2026-09-01. (bundle Capacitor/Ionic/Angular), vérifiée avec un seul appel de connexion
réel le 2026-09-01.
## Base URL ## URL de base
https://boti.education/p/greenwood/botiapi/ https://boti.education/p/greenwood/botiapi/
Pattern per school: https://boti.education/p/<school-slug>/botiapi/ Motif par école : https://boti.education/p/<slug-école>/botiapi/
All Boti Education schools share one backend. Media host: media.boti.education Tous les établissements Boti Education partagent un même backend. Hôte
(signed expiring URLs, token appended to path). média : media.boti.education (URLs signées à durée limitée, token dans le
chemin).
## Authentication ## Authentification
### Login ### Connexion
POST {base}/login Content-Type: multipart/form-data POST {base}/login Content-Type: multipart/form-data
Fields: Champs :
- login phone number, e.g. 06xxxxxxxx - login numéro de téléphone, ex. 06xxxxxxxx
- password plaintext - password en clair
- privacy bool ("true"/"false") - privacy booléen ("true"/"false")
- remembreMe bool - remembreMe bool
Response (HTTP 200, application/json): Réponse (HTTP 200, application/json) :
{ {
"status": 200, "msg": "Bienvenue !", "error": false, "status": 200, "msg": "Bienvenue !", "error": false,
"keyToken": "<40 hex chars>", // session key "keyToken": "<40 caractères hex>", // clé de session
"userId": "20887", "userId": "20887",
"parentId": "1376", // when role=parent "parentId": "1376", // quand role=parent
"eleveId": "1739", "eleveId": "1739",
"_acces": "parent", "role": "parent", "_acces": "parent", "role": "parent",
"eleves": [ { id, nomcomplet, prenom, nom, datenaissance, sexe, "eleves": [ { id, nomcomplet, prenom, nom, datenaissance, sexe,
niveau, img, parent_nom, parent_image } ], niveau, img, parent_nom, parent_image } ],
"parent": { id, nomcomplet, tel, email, sexe, image }, "parent": { id, nomcomplet, tel, email, sexe, image },
"accessToken": "<jwt>", // present on some accounts, may be empty "accessToken": "<jwt>", // présent sur certains comptes, peut être vide
"refreshToken": "<jwt>" "refreshToken": "<jwt>"
} }
Errors: {"error": true, "msg": "..."} with HTTP 200. Erreurs : {"error": true, "msg": "..."} avec HTTP 200.
### Session use on every subsequent call ### Utilisation de la session sur chaque appel suivant
- GET: query params, plus key=<keyToken> and user_id=<userId> - GET : paramètres d'URL, plus key=<keyToken> et user_id=<userId>
- POST: multipart/form-data fields, plus key=<keyToken> - POST : champs multipart/form-data, plus key=<keyToken>
- Optional modern path (supported by the ApiService, may be empty for parent - Voie moderne optionnelle (prise en charge par l'ApiService, souvent vide
accounts): headers Authorization: Bearer <accessToken> and pour les comptes parents) : en-têtes Authorization: Bearer <accessToken> et
X-refresh-token: Bearer <refreshToken> X-refresh-token: Bearer <refreshToken>
### Server-side session control ### Contrôle de session côté serveur
- Any response containing "disconnect": true means the session was killed - Toute réponse contenant "disconnect": true signifie que la session a été
client-side (app navigates to login). tuée (l'app navigue vers la page de connexion).
- "redirect": "<path>" makes the app navigate there. - "redirect": "<chemin>" fait naviguer l'app vers ce chemin.
- "showSLides": "<key>" pops a slider modal (onboarding/news screens). - "showSLides": "<clé>" ouvre une modale de diapositives (écrans d'accueil ou
d'actualités).
## Request envelope ## Enveloppe des requêtes
GET params observed in the bundle (per feature, subset applies): Paramètres GET observés dans le bundle (par fonctionnalité, sous-ensemble) :
user_id, key, acces, versionCode, versionNumber, paltform (sic, typo is in the user_id, key, acces, versionCode, versionNumber, paltform (sic, la faute de
app), eleveId, lang, date, startDate, endDate, classe, id, search, page frappe est dans l'app), eleveId, lang, date, startDate, endDate, classe, id,
search, page
POST FormData conventions: Conventions POST (FormData) :
- flat fields appended as key=value - champs plats ajoutés comme clé=valeur
- nested object -> key[subkey]=value - objet imbriqué -> clé[sous-clé]=valeur
- array -> key[0]=..., key[1]=... - tableau -> clé[0]=..., clé[1]=...
- File/Blob -> appended raw under the field name - File/Blob -> ajouté brut sous le nom du champ
- key=<keyToken> auto-appended by the ApiService - key=<keyToken> ajouté automatiquement par l'ApiService
## Push notifications ## Notifications push
POST {base}/device_token fields: token=<fcm token>, user_id, key, uid=<device uuid> POST {base}/device_token champs : token=<jeton fcm>, user_id, key, uid=<uuid appareil>
## Endpoint inventory ## Inventaire des points d'accès
See endpoints.md (100 app endpoints, get/post flags, source chunk per endpoint). Voir endpoints.md (100 points d'accès de l'app, méthodes get/post et chunk
Endpoint names are stable Pascal/snake identifiers appended to the base URL. source par point d'accès). Les noms sont stables et ajoutés tels quels à
l'URL de base.
## Media / files ## Médias / fichiers
- Files and images are fetched from media.boti.education with a signed, - Fichiers et images servis par media.boti.education avec un chemin signé à
expiring path (base64url token + timestamp appended). durée limitée (token base64url + horodatage ajoutés).
- PDFs opened in-app via pdf.js (chunk 7482.js). - Les PDF sont ouverts en interne via pdf.js (chunk 7482.js).
- getBlob(path, params, absolute) downloads raw blobs for attachments. - getBlob(path, params, absolute) télécharge les blobs bruts pour les pièces
jointes.
## Quirks ## Particularités
- The ApiService appends the endpoint as a raw string, so endpoint names in the - L'ApiService ajoute le nom du point d'accès comme chaîne brute : les noms
table are the complete server route (no /api prefix, no .php). du tableau sont la route serveur complète (pas de préfixe /api, pas de .php).
- Param typo "paltform" is sent by the app; server accepts it, do not "fix" it - La coquille de paramètre « paltform » est envoyée par l'app ; le serveur
blindly. l'accepte, ne pas la « corriger » aveuglément.
- Detailed console.log of params and headers ships in production. - Des console.log détaillés (paramètres, en-têtes) subsistent dans la version
de production.
+9 -7
View File
@@ -1,12 +1,14 @@
# Boti Education app API: endpoints extracted from decompiled bundle # API de l'app Boti Education : points d'accès extraits du bundle décompilé
Source: assets/public/*.js of com.botieducation.greenwood 2.4.14. Source : assets/public/*.js de com.botieducation.greenwood 2.4.14.
100 app endpoints. The endpoint name is the 2nd argument of the ApiService 100 points d'accès de l'app. Le nom du point d'accès est le 2e argument des
get()/post() calls; full URL = https://boti.education/p/greenwood/botiapi/<endpoint>. appels get()/post() de l'ApiService ; URL complète =
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 Écartés comme faux positifs pdf.js (tous vus uniquement dans le chunk
7482.js) : BPC, CS, DP, F, FontFile2, H, I, IM, SM, W
| endpoint | methods | chunk(s) | | endpoint | méthodes | chunk(s) |
|---|---|---| |---|---|---|
| absences | get | 3058.js, 8317.js | | absences | get | 3058.js, 8317.js |
| absences-justification | post | 3058.js, 8317.js | | absences-justification | post | 3058.js, 8317.js |
@@ -15,7 +17,7 @@ Excluded as pdf.js library false positives (all seen only in chunk 7482.js): BPC
| admin_demande_eleve | get, post | 1514.js, 9714.js, 9964.js | | admin_demande_eleve | get, post | 1514.js, 9714.js, 9964.js |
| admin_discipline_eleve | get, post | 1172.js, 9964.js | | admin_discipline_eleve | get, post | 1172.js, 9964.js |
| admin_fiche_eleve | get | 9964.js | | admin_fiche_eleve | get | 9964.js |
| admin_nouveautes | get | 6154.js | | admin_nouveautes | get | 6154.js | (renvoie TOUT l'historique avec le corps `description`, ~19 Mo Greenwood ; voir docs/NOUVEAUTES_BODIES.md)
| admin_search_eleve | get | 6996.js | | admin_search_eleve | get | 6996.js |
| admin_suivi_eleve | get, post | 3555.js, 9964.js | | admin_suivi_eleve | get, post | 3555.js, 9964.js |
| album_photo | get | 6124.js, 6925.js | | album_photo | get | 6124.js, 6925.js |
+58 -55
View File
@@ -1,65 +1,68 @@
# Greenwood School Android app, static RE report # Rapport d'analyse statique : app Android Greenwood School
App: com.botieducation.greenwood 2.4.14 (versionCode 24140) App : com.botieducation.greenwood 2.4.14 (versionCode 24140)
XAPK sha256: f46aa8b126f8f8856ea15e10ed12fa259d02e383f8921b0645139521b7820e69 XAPK sha256 : f46aa8b126f8f8856ea15e10ed12fa259d02e383f8921b0645139521b7820e69
Worktree: ~/android_reverse/greenwood/ (original XAPK untouched, copy analyzed) Arborescence d'analyse : ~/android_reverse/greenwood/ (XAPK original intact,
copie analysée)
## Architecture ## Architecture
- Capacitor 5 shell (assets/capacitor.config.json), Ionic + Angular SPA in assets/public/ - Coquille Capacitor 5 (assets/capacitor.config.json), SPA Ionic + Angular
- Native layer is only capacitor plugins: camera, filesystem, geolocation, push (FCM), preferences, file-opener/picker, voice recorder dans assets/public/
- All business logic and API access lives in the JS bundle: assets/public/main.js + ~200 lazy chunks (nNNN.js) - La couche native n'est que des plugins capacitor : camera, filesystem,
- ngsw.json: Angular service worker present (PWA origin) geolocation, push (FCM), preferences, file-opener/picker, voice recorder
- Toute la logique métier et l'accès API vivent dans le bundle JS :
assets/public/main.js + ~200 chunks paresseux (nNNN.js)
- ngsw.json : service worker Angular présent (origine PWA)
## Backend ## Backend
- Base URL constant in main.js (module 35548 export JW): - Constante d'URL de base dans main.js (module 35548, export JW) :
https://boti.education/p/greenwood/botiapi/ https://boti.education/p/greenwood/botiapi/
- Per-school path pattern: https://boti.education/p/<school-slug>/botiapi/ - Motif par école : https://boti.education/p/<slug-école>/botiapi/
- Static/media host: media.boti.education (signed, expiring URLs) - Hôte média/statique : media.boti.education (URLs signées expirantes)
- Vendor: Boti Education (boti.education), logo boti_robot.png, "boti" prefixes everywhere - Éditeur : Boti Education (boti.education), logo boti_robot.png, préfixes
« boti » partout
## Auth protocol (verified live 2026-09-01) ## Protocole d'authentification (vérifié en direct le 2026-09-01)
- POST {base}login, multipart/form-data: login=<phone>, password=<plaintext>, privacy, remembreMe - POST {base}login, multipart/form-data : login=<téléphone>,
- Response: {status, msg, error, keyToken, userId, parentId, eleves[], _acces, parent{}} password=<en clair>, privacy, remembreMe
- Session auth on later calls: key=<keyToken> (GET query param / POST form field) - Réponse : {status, msg, error, keyToken, userId, parentId, eleves[],
- Also supported (code path present, empty at login here): Authorization: Bearer <accessToken>, X-refresh-token header _acces, parent{}}
- Response with "disconnect":true forces navigateRoot("/") = server-side session kill - Auth de session sur les appels suivants : key=<keyToken> (paramètre GET /
- Login page form controls: login, password, privacy, remembreMe (extracted/assets/public/1028.js) champ POST)
- Voie moderne également prise en charge (présente dans le code, vide à la
connexion ici) : Authorization: Bearer <accessToken>, en-tête
X-refresh-token
- Réponse avec "disconnect":true force navigateRoot("/") = kill de session
côté serveur
- Champs du formulaire de connexion : login, password, privacy, remembreMe
(extracted/assets/public/1028.js)
## API service (main.js, apiservice class) ## Service API (main.js, classe apiservice)
- get(params, endpoint): URLSearchParams, Content-Type x-www-form-urlencoded - get(params, endpoint) : URLSearchParams, Content-Type
- post(params, endpoint): FormData; nested objects flattened as key[subkey]; arrays as key[i]; File/Blob appended raw; auto-appends key=<token> x-www-form-urlencoded
- getBlob(path, params, absolute=false): raw blob download (used for media/PDF) - post(params, endpoint) : FormData ; objets imbriqués aplatis en
- ~110 endpoints total (endpoint name is 2nd arg of get/post in chunks). Full list via: key[subkey] ; tableaux en key[i] ; File/Blob ajoutés bruts ; ajoute
grep pattern '\.(post|get|getBlob)\(...,"endpoint"' over assets/public/*.js automatiquement key=<token>
- getBlob(path, params, absolute=false) : téléchargement de blob brut
(médias/PDF)
- ~110 points d'accès au total (le nom du point d'accès est le 2e argument
de get/post dans les chunks). Liste complète via l'expression
'\.(post|get|getBlob)\(...,"endpoint"' sur assets/public/*.js
## Endpoints (GET unless noted) ## Constats notables
acces_check, login(POST), logout(POST), device_token(POST), forgot-password(POST), 1. Compte de démo/développement oublié, codé en dur dans la build de
change-password(GET/POST), translations, slides(GET/POST), slides_content, icon-top, overlay, production : extracted/assets/public/1028.js :
nouveautes(GET/POST), pinned_posts, post_view, timeline, notifications, notifications_new, reconnectUser={id:12745, phone:"0649014703", password:"0649014703"}
messages, nouveau-message(POST), pick_enfants(GET/POST), pick_menu, pick_nouveautes, 2. Mots de passe envoyés en champs de formulaire en clair sur TLS (pas de
pick_trajets_tst_absent(GET/POST), pointage(GET/POST), pointage_borne(GET/POST), hachage côté client) ; keyToken est une clé de session hex nue de 40
devoirs(GET/POST), devoirs_date_v2(POST), cours_v2, examens_v2, examen_collectif, caractères
evaluations_releves(GET/POST), note_matieres, competences, suivi_pedagogique, 3. POST device_token {token, user_id, key, uid} pour l'enregistrement push
absences, absences-justification(POST), discipline, cantine, cartable_numeriques, cartable_split, FCM
ressources_v2(GET/POST), ressource_details, bibliotheque, grille_apprentisage, quiz(GET/POST), 4. console.log détaillés des paramètres/en-têtes laissés dans le bundle de
demandes, nouvelle-demande(GET/POST), paiements(GET/POST), new_paiements, online_paiements, production
shop(GET/POST), student-wallet(GET/POST), album_photo, objects(GET/POST), 5. Pas d'épinglage de certificat, pas d'obfuscation de la SPA
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 ## Vérification
1. Hardcoded leftover dev/biometric demo account shipped in production build: Un POST login réel avec des identifiants de test a renvoyé HTTP 200,
extracted/assets/public/1028.js: reconnectUser={id:12745, phone:"0649014703", password:"0649014703"} error:false, keyToken, fiche parent + _acces=parent. Protocole confirmé de
2. Passwords sent in plaintext form fields over TLS (no hashing client-side); keyToken is a bare 40-hex session key bout en bout. Identifiants internes censurés.
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.
+16 -14
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Log in to a Boti Education school API (Green Wood School variant) and cache the session token. # Connexion à l'API Boti Education d'une école (variante Green Wood School)
# Credentials are read from boti.ini in $XDG_CONFIG_HOME (default ~/.config), never hard-coded. # et mise en cache de la clé de session.
# Les identifiants sont lus dans boti.ini de $XDG_CONFIG_HOME (~/.config par
# défaut), jamais codés en dur.
# #
# boti.ini format: # Format de boti.ini :
# [boti] # [boti]
# login = 0600000000 # login = 0600000000
# password = whatever # password = motdepasse
# #
set -euo pipefail set -euo pipefail
@@ -14,31 +16,31 @@ CONF="${XDG_CONFIG_HOME:-$HOME/.config}/boti.ini"
CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/boti-key" CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/boti-key"
if [[ ! -f "$CONF" ]]; then if [[ ! -f "$CONF" ]]; then
echo "missing $CONF (see header of this script for format)" >&2 echo "fichier $CONF manquant (format : voir l'en-tête de ce script)" >&2
exit 1 exit 1
fi fi
login=$(awk -F= '/^login/{gsub(/[[:space:]]/,"",$2); print $2}' "$CONF") login=$(awk -F= '/^login/{gsub(/[[:space:]]/,"",$2); print $2}' "$CONF")
password=$(awk -F= '/^password/{gsub(/[[:space:]]/,"",$2); print $2}' "$CONF") password=$(awk -F= '/^password/{gsub(/[[:space:]]/,"",$2); print $2}' "$CONF")
resp=$(curl -sS -m 20 "$BASE/login" \ reponse=$(curl -sS -m 20 "$BASE/login" \
-F "login=$login" \ -F "login=$login" \
-F "password=$password" \ -F "password=$password" \
-F "privacy=true" \ -F "privacy=true" \
-F "remembreMe=false") -F "remembreMe=false")
if ! grep -q '"error":false' <<<"$resp"; then if ! grep -q '"error":false' <<<"$reponse"; then
echo "login failed: $(head -c 200 <<<"$resp")" >&2 echo "échec de connexion : $(head -c 200 <<<"$reponse")" >&2
exit 1 exit 1
fi fi
key=$(sed -n 's/.*"keyToken":"\([0-9a-f]*\)".*/\1/p' <<<"$resp") cle=$(sed -n 's/.*"keyToken":"\([0-9a-f]*\)".*/\1/p' <<<"$reponse")
if [[ -z "$key" ]]; then if [[ -z "$cle" ]]; then
echo "no keyToken in response" >&2 echo "keyToken absent de la réponse" >&2
exit 1 exit 1
fi fi
umask 077 umask 077
printf '%s' "$key" > "$CACHE" printf '%s' "$cle" > "$CACHE"
echo "session key cached at $CACHE (40 hex chars)" echo "clé de session enregistrée dans $CACHE (40 caractères hex)"
echo "usage: curl \"$BASE/<endpoint>?user_id=<userId>&key=$(cat "$CACHE")\"" echo "usage : curl \"$BASE/<endpoint>?user_id=<userId>&key=$(cat "$CACHE")\""
+31 -31
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Extract the Boti Education app API endpoints from decompiled Capacitor JS chunks. """Extrait les points d'accès de l'API des apps Boti Education depuis les chunks JS décompilés.
Usage: python3 extract_endpoints.py <dir-with-chunks> [--markdown] Usage : python3 extract_endpoints.py <dossier-avec-chunks> [--markdown]
Scans *.js for the Angular ApiService call signature: Cherche dans les *.js la signature d'appel de l'ApiService Angular :
<http>.post(<payload>, "endpoint") / <http>.get(<payload>, "endpoint") <http>.post(<payload>, "endpoint") / <http>.get(<payload>, "endpoint")
and emits a deduplicated endpoint -> methods table. et produit une table endpoint -> méthodes dédupliquée.
""" """
import re import re
import sys import sys
@@ -13,51 +13,51 @@ import glob
import os import os
import argparse import argparse
CALL = re.compile( APPEL = re.compile(
r'\.(post|get|getBlob)\(\s*' r'\.(post|get|getBlob)\(\s*'
r'(?:"[^"]*"|`[^`]*`|[A-Za-z_$][\w$]*(?:\s*\.\s*[\w$]+)*(?:\([^()]*\))?|\{[^{}]*\})' r'(?:"[^"]*"|`[^`]*`|[A-Za-z_$][\w$]*(?:\s*\.\s*[\w$]+)*(?:\([^()]*\))?|\{[^{}]*\})'
r'\s*,\s*"([^"]*)"' r'\s*,\s*"([^"]*)"'
) )
def scan(directory): def scanner(dossier):
found = {} # endpoint -> {method: [chunks]} trouves = {} # endpoint -> {méthode: [chunks]}
for path in sorted(glob.glob(os.path.join(directory, '*.js'))): for chemin in sorted(glob.glob(os.path.join(dossier, '*.js'))):
chunk = os.path.basename(path) chunk = os.path.basename(chemin)
src = open(path, encoding='utf8', errors='replace').read() src = open(chemin, encoding='utf8', errors='replace').read()
for m in CALL.finditer(src): for m in APPEL.finditer(src):
method, ep = m.group(1), m.group(2) methode, ep = m.group(1), m.group(2)
d = found.setdefault(ep, {}) d = trouves.setdefault(ep, {})
d.setdefault(method, []).append(chunk) d.setdefault(methode, []).append(chunk)
return found return trouves
def main(): def principal():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument('directory') ap.add_argument('dossier')
ap.add_argument('--markdown', action='store_true') ap.add_argument('--markdown', action='store_true')
args = ap.parse_args() args = ap.parse_args()
found = scan(args.directory) trouves = scanner(args.dossier)
if not found: if not trouves:
sys.exit('no endpoints found, wrong directory?') sys.exit('aucun point d\'accès trouvé, mauvais dossier ?')
if args.markdown: if args.markdown:
print(f'# API endpoints extracted from {os.path.basename(args.directory)}') print(f'# Points d\'accès extraits de {os.path.basename(args.dossier)}')
print() print()
print(f'{len(found)} unique endpoints.') print(f'{len(trouves)} points d\'accès uniques (non filtrés, voir docs/endpoints.md pour la liste nettoyée).')
print() print()
print('| endpoint | methods | seen in chunk(s) |') print('| endpoint | méthodes | chunk(s) |')
print('|---|---|---|') print('|---|---|---|')
for ep in sorted(found): for ep in sorted(trouves):
methods = ', '.join(sorted(found[ep])) methodes = ', '.join(sorted(trouves[ep]))
chunks = ', '.join(sorted({c for ms in found[ep].values() for c in ms})[:3]) chunks = ', '.join(sorted({c for ms in trouves[ep].values() for c in ms})[:3])
print(f'| {ep} | {methods} | {chunks} |') print(f'| {ep} | {methodes} | {chunks} |')
else: else:
for ep in sorted(found): for ep in sorted(trouves):
print(ep, sorted(found[ep])) print(ep, sorted(trouves[ep]))
print(f'TOTAL endpoints: {len(found)}', file=sys.stderr) print(f'TOTAL points d\'accès : {len(trouves)}', file=sys.stderr)
if __name__ == '__main__': if __name__ == '__main__':
main() principal()