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
+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()