The $5,000 Hytale Bounty

TL;DR: I reported this Hytale server bug less than a week after launch, then waited about a month for a response while their team got buried in incoming reports. The bug itself was a clean path traversal in the server file browser, and it ended with a $5,000 payout plus an invite to a private Bugcrowd program.

1775459636566-png.66


I found this one seven days after Hytale launched.

Which was a pretty good time to be looking at it, because brand new game launches tend to produce two things very reliably: a lot of traffic, and a lot of weird trust decisions that nobody has really stress-tested yet. Especially Hytale had a long history of changing development studious and they themslves announced it's extremely early alpa.

In this case, the bug was in the server file browser. More specifically, in how it handled a client-controlled root path.

That is already the kind of sentence that usually ends badly.

The report took about a month to get a response, which I can hardly blame them for. The game had just launched and they were apparently flooded with reports. Still, the outcome was solid. They paid a $5,000 bounty for this bug and invited me to a private Bugcrowd, so I am not going to complain too much about the wait.

The bug

The vulnerable component was ServerFileBrowser, which handled CustomPageEvent packets for builder tooling.

The issue was simple. The server accepted a root path from the client and, in some cases, turned it directly into a filesystem path without properly checking whether that path should ever be allowed.

Here is the part that mattered:

Java:
if (data.getRoot() != null) {
    Path newRoot = this.findConfigRoot(data.getRoot());
    if (newRoot == null) {
        newRoot = Path.of(data.getRoot(), new String[0]);
    }
    this.setRoot(newRoot);
    this.currentDir = this.root.getFileSystem().getPath("", new String[0]);
    this.searchQuery = "";
    return true;
}

And then the setter:

Java:
public void setRoot(@Nonnull Path root) {
    if (Files.isDirectory(root, new LinkOption[0])) {
        this.root = root;
    }
}

That check is the entire problem.

It only verifies that the path exists and is a directory. It does not verify that the path is inside an allowed root. It does not normalize it against a whitelist. It does not reject absolute paths. It just asks, "is this a directory?" and if the answer is yes, it accepts it.

That is not validation. That is optimism. Quiet optimistic optimism lolz.

What made it interesting

The funny part is that other logic in the same class was clearly trying to be careful. There were methods using normalization and boundary checks to stop traversal when navigating inside the browser.

But the initial root assignment bypassed all of that.

So even if later operations were "secure" relative to the chosen root, that did not matter much when I could first move the root somewhere it should never have been.

Once I saw that, the bug was basically already dead.

How I reproduced it

The attack path was through a CustomPageEvent packet carrying file browser event data.

If I supplied a root value that matched a configured root, the code used it normally.

If it did not match, the code just fell back to this:

Java:
newRoot = Path.of(data.getRoot(), new String[0]);

That meant I could send absolute paths directly.

On Linux, something like this was enough:

Java:
CustomPageEvent {
    type: Data
    data: {
        "@Root": "/etc"
    }
}

That path gets turned into /etc, and if the server process can access it and Files.isDirectory() returns true, the browser root becomes /etc.

That is obviously not what a game server file browser should be doing.

Relative traversal also worked in the right environment. Something like this:

Java:
CustomPageEvent {
    type: Data
    data: {
        "@Root": "../../etc"
    }
}

That one depended on where the server was running from. If the working directory made the traversal land on a real directory, it worked. If not, it failed. So the reliable version was the absolute path case, which was already enough.

What I could actually get

This is the part where people tend to overstate things, so I want to keep it precise.

This bug did not give arbitrary file reads.

It gave directory and file name enumeration outside the intended scope. The browser could list directories and file names because it used filesystem operations like Files.newDirectoryStream() and file tree walking, but it was not reading raw file contents through this path.

So I could do things like:
  • see that sensitive files existed
  • discover config and log locations
  • map the filesystem structure
  • enumerate directories the server process could access
That is still useful. A lot more useful than people like to pretend. Recon matters, especially on self-hosted game infrastructure where permissions and deployment quality are all over the place.

But I am not going to call a file listing bug full filesystem compromise. It was not that.

Why it was still worth reporting

Because this kind of bug becomes much more valuable in messy real-world setups.

The file browser was used in builder tooling, so exploitation depended on access to those features. That means editor permissions, Creative-related access, or some custom server setup exposing the functionality too broadly.

And that is exactly why these bugs matter.

Security bugs do not exist in a vacuum where every admin has perfect permissions, perfect plugins, and perfect judgment. Real servers get misconfigured. Features get exposed to people they should not be exposed to. Temporary permissions become permanent. Somebody decides convenience matters more than separation.

Then a bug like this stops being a niche issue and starts being a problem.

The fix was not complicated

The server should never have accepted arbitrary paths as browser roots in the first place.

If the supplied root is not found in the configured allowlist, reject it. Full stop.

Something along these lines would have been enough:

Java:
if (data.getRoot() != null) {
    Path newRoot = this.findConfigRoot(data.getRoot());

    if (newRoot == null) {
        return false;
    }


    Path normalized = newRoot.normalize().toAbsolutePath();

    boolean allowed = this.config.roots().stream()

        .anyMatch(root - > normalized.startsWith(

            root.path().normalize().toAbsolutePath()));


    if (!allowed) {
        return false;
    }

    this.setRoot(newRoot);
}

And setRoot() should have enforced the same rule instead of only checking whether the directory exists.

This is one of those cases where the remediation is less interesting than the original mistake. The bug existed because untrusted input was allowed to define a security boundary. That usually ends in nonsense.

About the other bug

I also reported a username impersonation issue in the authentication flow around the same time, but that one ended up having a lot of duplicates because it was pretty obvious once you looked at the handshake logic. So I am not going to pretend that was the star of the show here.

This path traversal was the one that actually got paid out.

Timeline
  • I submitted the report within seven days of Hytale launching.
  • The response took about a month, which made sense given how new the release was and how many reports they were apparently dealing with.
  • The final outcome was a $5,000 bounty and an invite to a private Bugcrowd.
  • That is a pretty decent return for spotting a server-side trust failure early.

Final thoughts

I like bugs like this because they are not glamorous. No absurd exploit chain, no crypto trick, no fake movie-hacker nonsense. Just a file browser that trusted user input too much and forgot that filesystem roots are not something clients should be choosing.

Those bugs are still everywhere, or were, the hytale team did an incredible job in working with security researches to iron out alot of those. I was unable to find anymore critical to high vulenrabilities, which is a great sign!

So cheers to the hytale security & development team!
 

Attachments

  • 1775459636566.png
    128.6 KB · Views: 16
Back
Top