Stack the Flags 2022
Oops my last CTF was 2020???
Contents
Overview
Team Name: JSons
Category: University, Polytechnics
Position: 23
Score: 3850
Hardware (IoT)
Jaga’s Lovely Bike
Points: 575
In this challenge, we are given a PCAP file and this beautiful poster with the description.
The challenge asks us to find:
- The name of the attacker
- A hidden message
Basic Communication
The challenge tells us that Ubertooth was used to log the PCAP file. Searching up the docs tells us it is a BLE (Bluetooth Low Energy) logger. Most networking packet structures start with some header, some identification address, a data payload and a CRC or checksum at the end. The first few packets sent are usually used to establish certain fields such as mapping user information to a digital address.
Part 1 Solution
We know this is a “hardware” challenge and we are expected to scan all the packets in the PCAP file, but I believe in simplicity. Although most network packets (be it hardware or network) have some sort of format/packet structure, the data payload is still stored as plaintext or encoded/encrypted in some form.
Running the strings
command directly to find any plaintext strings yields JagaBike
and JAG0NGhp+L
.
The first one looks like the name of the device, while the latter could be a potential name. Let’s find this name in the PCAP file in Wireshark.
Since packet structures typically involve a fixed header and footer length, packets with the targeted strings should have a larger payload and thus larger packet size. So let’s scan through the larger packets first by sorting the packets by size in Wireshark. We can see multiple packets with the intended display text Jaga's Ugly Bike
and Jaga's Lovely Bike
Note that the last 3 bytes do not contain relevant data and is most likely the checksum/CRC/packet footer.
Scanning up to packet no. 208 shows our JAG0NGhp+L
string.
The last 3 characters “hp+” are most likely part of the packet structure in the form of the packet footer or CRC. Let’s remove those to get the first part of our flag JAG0NG
- sounds like a coined name from JAGA Gong (Blur JAGA in Singlish/Hokkein)
Part 2 Solution
From our preliminary findings, we know that Jaga logged BLE data from JagaBike using an Ubertooth. We should probably use this information to our advantage. Since we can’t find any plaintext data, let’s try to decrypt the packets using crackle
- A BLE encryption cracker (Install crackle).
$ crackle -i JagaBike.pcapng -o out.pcap
Found 2 connections
Analyzing connection 0:
4e:d2:09:63:09:ae (random) -> da:5a:10:cf:2b:5b (random)
Found 20 encrypted packets
Cracking with strategy 0, 20 bits of entropy
!!!
TK found: 000000
ding ding ding, using a TK of 0! Just Cracks(tm)
!!!
Decrypted 20 packets
Analyzing connection 1:
4e:d2:09:63:09:ae (random) -> da:5a:10:cf:2b:5b (random)
Found 0 encrypted packets
Unable to crack due to the following errors:
Missing both Mrand and Srand
Missing LL_ENC_REQ
Missing LL_ENC_RSP
Decrypted 20 packets, dumping to PCAP
Done, processed 2931 total packets, decrypted 20
Wow! 20 packets decrypted. We can either strings
the output pcap again, or sieve through the pcap
file. To make our life easier with the above plaintext assumptions again, we run strings
and get a suspicious flag-like string InV!Si_BLE_A+T@cK3R
which is the message the attacker hid.
Flag: STF22{JAG0NG_the_InV!Si_BLE_A+T@cK3R}
Quantum Watch
Points: 425
Jaga was tasked to investigate a disturbance in a local village and, after some sleuthing, found an abandoned research facility on the outskirts of the village. An initial search of the place led them to the central research room, where it looked like a large disturbance had occurred: everything was wildly scattered across the lab, with upturned tables, sparking equipment, and various bits of research documents scattered everywhere.
In the center of the mess was a watch that looked nothing like Jaga had seen before. It was perfectly symmetrical, and the shadows seemed to fade directly into darkness without a hint of an edge.
Jaga, curious, put the watch on, and it instantly locked onto their wrist! An error message began to print out on the screen, and a heartbeat later, the research lab seemed to warp around Jaga. When Jaga tried to escape from the lab to seek help, they were reset back to the moment when the watch started to lock.
Help Jaga to fix the watch and escape the time loop!
We are also given an image and a docker web service to connect to.
Analysis
Connecting to the service gives us the parameters: _COEFFICIENT
, _PRECISION_RESISTOR
and _VOLTAGE
. Entering the _MODIFIABLE_RESISTANCE
, gives us Voltage across points
and the chance to input another _MODIFIABLE_RESISTANCE
. Entering a second input that does not cause Voltage across points
to be 0
resets the entire system with new constants.
For simplicity, let’s rename a few things (We assume everything that’s not cancelled in the schematic image is correct):
Initial Name | New Name |
---|---|
_COEFFICIENT |
\(C\) |
_CURRENT_RESISTOR |
\(R_{current}\) or \(R_c\) |
_PRECISION_RESISTOR |
\(R_{precision}\) or \(R_p\) |
_UNKNOWN_RESISTOR |
\(R_{unknown}\) or \(R_u\) |
_MODIFIABLE_RESISTOR (Target Input) |
\(R_x\) |
_VOLTAGE |
\(V\) |
Voltage across Quantum Core (\(V_A - V_B\)) - Output; Must be \(0\) | \(V_D\) |
Current along \(V_A\) | \(I_A\) |
Current along \(V_B\) | \(V_B\) |
So we are given a Wheatstone Bridge (You don’t need to know the name) and expected to find a couple of unknown values - \(R_c, R_u, R_x, I_A, I_B\). More importantly, we need to solve for the value of \(R_x\) where \(V_D = 0\). Since we have 2 tries, we can input an arbitrary \(R_x\), use the output \(V_d\) to derive \(R_u\), then find the target \(R_x\) to make \(V_D = 0\), but I’ll cover how to do this later.
I drew a simplified diagram for those who can’t read positive to ground diagrams (like me -_-)
Physics Work
Sorry for the non-physics gang, but I’m going to use a couple of secondary school physics formulas. Most importantly \(V = IR\) and parallel/serires voltage and currents.
The first most basic formula we use is given in the schematic diagram for \(R_c\)
\[R_c = C*I_A\]Since parallel voltages should be the same, we can get 2 more formulas by calculating the voltages.
\[\begin{eqnarray} V &=& I_A(R_x+R_c) \nonumber \\ &=& I_B(R_u+R_p) \end{eqnarray}\]To get the potential difference \(V_D\), we calculate sum the potential difference across \(R_p\) and \(R_c\) (Resistors on the negative/ground side). To confirm this, we can use a online electronic schematic simulator such as https://www.circuit-diagram.org/editor/. The circuit I used can be found on crcit.net.
\[\begin{eqnarray} V_D &=& V_A - V_B \nonumber \\ &=& R_c*I_A - R_p*I_B \end{eqnarray}\]This gives us a total of 4 equations to solve for 4 unknowns - \(I_A\),\(I_B\),\(R_c\) and either \(R_x\) or \(R_u\) depending on which stage we are at.
Math Work
However, we need to add an extra condition. If we try to use math to solve for \(I_A\), we get a quadratic which we can solve using the quadratic formula.
\[\begin{eqnarray} V &=& I_A(R_x+R_c) \nonumber \\ &=& I_A(R_x+C*I_A) \nonumber \\ &=& I_A*R_x+C*I_A^2 \nonumber \\ 0 &=& C(I_A^2) + R_x(I_A) -V \nonumber \\ \\ I_A &=& \frac{-R_x\pm\sqrt{R_x^2 - 4C(-V)}}{2C} \end{eqnarray}\]Since current cannot be negative in this context due to conventional current flow direction, the 5th equation is \(I_A > 0\)
Solution
Although we can solve this math by hand, let’s leverage technology. We will connect to the webservice using pwntools and evaluate the equations using z3-solver.
Part One
First we put in an arbitrary value of \(R_x\) to backcalculate \(R_u\) using the outputted \(V_D\). I used RegEx to extract the constants though multiple recvuntil
works too. For the value of \(R_x\), I chose \(R_x = R_p\). This is because we don’t want a value too far from the other values such that the resistance becomes too high or insignificant.
conn = remote("157.230.242.192", 30329)
# Read constants in
inp = str(
conn.recvuntil(b"Please Enter _MODIFIABLE_RESISTOR resistance:", timeout=5), "utf-8"
)
print(inp, end="")
C = float(re.search("_COEFFICIENT: ([\d\.]*)", inp).group(1))
Rp = float(re.search("_PRECISION_RESISTOR: ([\d\.]*)", inp).group(1))
V = float(re.search("_VOLTAGE: ([\d\.]*)", inp).group(1))
Rx = Rp #Set Rx to Rp
print(str(Rx))
conn.sendline(bytes(str(Rx), "utf8"))
# Note second input typo???
inp = str(
conn.recvuntil(b"Please Enter _MODIFIABLE_REISTOR resistance:", timeout=5), "utf-8"
)
Vd = float(re.search("Voltage across points: ([\-\d\.]*)", inp).group(1))
#Print constants parsed
print("C:", C)
print("Rp:", Rp)
print("V:", V)
print("Temp Rx:", Rx)
print("Vd:", Vd)
#Use z3 to solve for Ru
Ia = Real("Ia")
Ib = Real("Ib")
Rc = Real("Rc")
Ru = Real("Ru")
s = Solver()
s.add(
V == Ib * (Ru + Rp),
V == Ia * (Rx + (C * Ia)),
Vd == (Rc * Ia) - (Rp * Ib),
Rc == C * Ia,
Ia > 0,
)
s.check()
m = s.model()
print(m)
def getAsFloat(model, key):
r = model[key].approx(20)
return float(r.numerator_as_long()) / float(r.denominator_as_long())
Ru = getAsFloat(m, Ru)
Note: I used the getAsFloat
function to convert the z3
output to a Python float. Although this loses precision (z3
precision is higher than Python floats
), the values given are only up to 4 decimal places so this precision should suffice.
Part Two
Now that we have \(R_u\), we can calculate for the value of \(R_x\) where \(V_D = 0\).
Rx = Real("Rx") #Use Rx as variable now instead of constant
Vd = 0 #Vd is now a constant
t = Solver()
t.add(
V == Ib * (Ru + Rp),
V == Ia * (Rx + (C * Ia)),
Vd == (Rc * Ia) - (Rp * Ib),
Rc == C * Ia,
Ia > 0,
)
x = 0
while t.check() == sat:
print(t.model())
x = getAsFloat(t.model(), Rx)
t.add(Or(Rx != t.model()[Rx], Rc != t.model()[Rc]))
# Send immediately if not it will fail
conn.sendline(bytes(str(x), "utf-8"))
conn.interactive()
Note: For the web service used, a timeout was probably implemented. More specifically, the “constants” that the pointers referenced were modified on some cron timer. Therefore, we have to use pwntools
to send our desired \(R_x\) input. This is preferable rather than waiting for the terminal to become interactive, manually evaluating if our answer is correct, then submitting our answer.
Flag: STF22{L0ng_Qu8ntum_3hift}
Forensics
Hit You With That
Points: 350
An adversary was observed to be exfiltrating a file from a music entertainment company that contains a to-be-released embargoed poster of a new song for one of their bands. Find the leaked file, which will contain the flag.
We are given a PCAP file
PCAP Analysis
When analyzing the PCAP we notice that ICMP (ping) packets are exceptionally huge at 298 Bytes! The data payload looks like base64
. Filtering the data for icmp.type eq 0
(ICMP replies) and ip.src eq 192.168.96.11
(Only incoming ICMP packets from that IP) gives us a list of large ICMP replies.
The last entry ends with an =
character, which is used to pad base64 strings to be of a specific length. This confirms that the packets form a long base64 string and that we need to combine all the packet data together to generate this string.
To quickly extract this data, we use tshark
- the command line version of Wireshark. -T fields -e data
extracts only the “data” portion of each packet, while -Y
specifies the filter to use (the same one we used earlier). We dump the HEX output to output.txt
.
tshark -r ./STF22.pcapng -T fields -e data -Y "icmp.type eq 0 && ip.src eq 192.168.96.11" > output.txt
Decoding PCAP Data
We can convert the outputted HEX back to ASCII using some online tools. Since the challenge description suggests an image is exfiltrated, we attempt to convert the ASCII base64 string to an image using the Code Beautify Base64 to Image Converter. This gives us the following image.
We are on the right track! This is a Blackpink poster. It matches the title which is named after some Blackpink song lyrics. So this is definitely the way to go!
Final Steps
We perform typical image forensics to find more info about this image. We start with a simple exiftool
to find image Metadata.
Lo and behold, we find the flag!
Flag: STF22{Bl@ckPin9_V3n0m}
Misc
HeatKeeb
Points: 350
Jaga had gained interest in custom keyboards and has created a platform to create your own keebs! We know we created his custom keeb on the 22nd of September 2022, at 09:41:17 SGT. Oddly specific but we know it's true.
We are given a web service (with source code).
Challenge Analysis
The website allows us to enter a token, or build a keyboard.
Clicking /build
brings us to a menu to create our keyboard, after which, we are given a token and instructions to head to /menu
.
In the menu, we can view the keyboard (Sorry I did not save an example image), create a heatmap, view the last heatmap and test some text (who knows what this last option is for?).
If we click “Generate a heatmap”, we are prompted for a text input
A blurred/pixelated version of our keyboard with the letters typed shown in red is displayed
We can guess that the challenge wants us to find what Jaga typed in on his keyboard based on the heatmap. Since the challenge description hints at the timing, let’s poke around at the source code for a bit.
Source Code Analysis
Token Creation
In app.py
under /build
, we can see how the token is generated. This is important for finding Jaga’s keyboard later.
t = datetime.datetime.now(pytz.timezone('Asia/Singapore'))
seed = int(t.timestamp())
random.seed(seed)
token = ''.join(random.choices('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', k=16))
In pseudo-randomizing, a seed is used to determine the first value generated. Thus, setting the seed to the same number will always yield the same token - We will exploit this later since the time Jaga created his keyboard is given.
Heatmap Generation
In app.py
under /heatmap/generate
, we see that draw_heatmap()
is called and the image is resized down using Bilinear Interpolation
(Average colors along the “line”), then resized up using the nearest value function. This effectively blurs the image,
img = draw_heatmap(name, frameColor, keyColor, textColor, specialColor, text.upper())
imgSmall = img.resize((int(img.width / 100), int(img.height / 100)), Image.BILINEAR)
result = imgSmall.resize(img.size, Image.NEAREST)
result.save(f'keebs/heatmap-{token}.png')
db[token] = {
'name': name,
'frameColor': frameColor,
'keyColor': keyColor,
'textColor': textColor,
'specialColor': specialColor,
'text': text.upper()
}
return FileResponse(f"keebs/heatmap-{token}.png")
Analyzing draw_heatmap()
in keebcreator.py
only shows that a keyboard image is created. However, we can see a specific line that sets the key color to (255, 84, 84)
when text in htext
(ie set the key color to red-ish if the character is included in the typed string)
text = key['t']
if text == '':
color = specialColor
elif text in htext:
color = (255, 84, 84)
image[y:int(y+h), x:int(x+w)] = color
Therefore, we conclude that the heatmap can be simplified as “A blurred version of the keyboard with the keys typed in red”
An Addition Point
Remember how we didn’t know what the “Test your text” does? If we study /text
in app.py
, we see that it prints the FLAG if the user is ADMIN_USER (Jaga) and the text entered is what Jaga typed.
if token == ADMIN_TOKEN and text.upper() == KEY:
return templates.TemplateResponse("flag.html", {"request": request, "word": text, "flag": FLAG})
elif text.upper() == db[token]['text']:
return templates.TemplateResponse("flag.html", {"request": request, "word": text})
This will be our final target point.
Solution Part One
We first need Jaga’s token. This is simple based on the pseudo-random analysis above. We copy the token creation method, but replace the datetime with a static datetime described in the challenge description: “22nd of September 2022, at 09:41:17 SGT”
#Just a comparison to ensure we feed in the correct input
a = datetime.datetime.now(pytz.timezone('Asia/Singapore'))
b = datetime.datetime(2022,12,3,hour=9,minute=0,second=0) #Manually input current time
print(int(a.timestamp()),int(b.timestamp())) #Both numbers should be close to each other
#Actual token cracking
t = datetime.datetime(2022,9,22,hour=9,minute=41,second=17)
seed = int(t.timestamp())
random.seed(seed)
token = ''.join(random.choices('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', k=16))
print(token)
This gives us the token: rMwwbpMkzAwyRoWs
. We return back to the home page to enter in this token which brings us to the menu - success! We have logged into Jaga’s keyboard!
Solution Part Two
We click “View your latest heatmap” to download Jaga’s last heatmap.
To find the text entered, we can try to create a deblurred keyboard with the keys colored. We copy the draw_heatmap()
function with all relevant methods (inherently removing the blur), and color each key based on the corresponding pixel color of the heatmap.
# Load Jaga's latest heatmap image
heatmap = Image.open("view.png")
# Start of heatmap code
def hex_to_rgb(hex):
h = hex.lstrip('#')
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
adminFrame = hex_to_rgb('#000000')
adminKeys = hex_to_rgb('#dcdedb')
adminText = hex_to_rgb('#000000')
adminKeys = hex_to_rgb('#98b5bb') #typo?
def key(text, w=1):
return {'t': text, 'w': w}
keeb = [
[key(''), key('1'), key('2'), key('3'), key('4'), key('5'), key('6'), key('7'), key('8'), key('9'), key('0'), key('-'), key('='), key('', 2), key('')],
[key('', 1.5), key('Q'), key('W'), key('E'), key('R'), key('T'), key('Y'), key('U'), key('I'), key('O'), key('P'), key('['), key(']'), key('\\', 1.5), key('')],
[key('', 1.75), key('A'), key('S'), key('D'), key('F'), key('G'), key('H'), key('J'), key('K'), key('L'), key(';'), key("'"), key('', 2.25), key('')],
[key('', 2.25), key('Z'), key('X'), key('C'), key('V'), key('B'), key('N'), key('M'), key(','), key('.'), key('/'), key('', 1.75), key(''), key('')],
[key('', 1.25), key('', 1.25), key('', 1.25), key('', 6.25), key(''), key(''), key(''), key(''), key(''), key('')]
]
def draw_heatmap(name, frameColor, keyColor, textColor, specialColor, htext, keeb=keeb, height=580, width=1720, unit=100):
def draw_key(image, key, x, y, w, h, color):
x = int(x)
y = int(y)
text = key['t']
# Change key color based on heatmap and remove old coloring
color = heatmap.getpixel((int(x+w/2),int(y+h/2)))
# if text == '':
# color = specialColor
# elif text in htext:
# color = (255, 84, 84)
image[y:int(y+h), x:int(x+w)] = color
img_pil = Image.fromarray(image)
draw = ImageDraw.Draw(img_pil)
font = ImageFont.truetype('./app/Poppins-Regular.ttf', 20)
textsize = font.getsize(text)
textX = (w - textsize[0]) / 2
textY = (h + textsize[1]) / 2
draw.text((x+textX, y+textY), text, textColor, font=font, align='center')
return np.array(img_pil)
image = np.zeros((height, width, 3), np.uint8)
image[:] = frameColor
img_pil = Image.fromarray(image)
draw = ImageDraw.Draw(img_pil)
font = ImageFont.truetype('./app/Poppins-Regular.ttf', 20)
textsize = font.getsize(name)
textX = (width - textsize[0]) / 2
textY = height - 30
draw.text((textX, textY), name, (255,255,255,255), font=font, align='center')
image = np.array(img_pil)
x = 30
y = 30
for row in keeb:
gap = (width - x * 2 - unit * sum([key['w'] for key in row])) / (len(row) - 1)
for key in row:
w = unit * key['w']
h = unit
image = draw_key(image, key, x, y, w, h, keyColor)
x += w + gap
# draw gap
x = 30
y += unit + 5
image = Image.fromarray(image)
return image
# Call draw_heatmap with empty htext as it is now irrelevant - we color the keys using our own method
draw_heatmap("cracked",adminFrame,adminKeys,adminText,adminKeys,"").save("output.png")
This gives us a proper heatmapped keyboard.
Flag Time
Now to figure out which letters specifically were “typed”, we can eyeball it and see which keys are more red than blue. Technically because bilinear interpolation was used, we could mathematically determine exactly where the border between a red and blue key is depending on the hex values, but human intuition seems to be the easier option here.
The letters that I presumed seemed to be colored red enough in my eyes were ASERTGHNIL
.
Now we want to figure out the word that Jaga entered. We can put it into an online anagram solver which gives us - “earthlings”.
So, we enter “earthlings” into the text field on the /text
page and our flag appears!
Flag: STF22{h34t_k3yb04rD}
Jaga Almighty’s Grand Adventure
Points: 425
Jaga was returning from a nearby convenience store when it was suddenly summoned to a fantasy-like world of supernatural abilities and magic. Not long after arriving, Jaga discovered that it could fly! And it must fly to the boss castle to save the princess, as is typical of adventure games. However, the road ahead is paved with danger and pipes, and each time it hits a pipe, it is magically teleported to where he first spawned in the world! Could you help Jaga Almighty to navigate past at least 69 pipes?
We are given a Flappy Bird clone executable (I no longer have it on my machine as Windows Defender automatically deletes it - GovTech what have you done???) with Jaga as the character. We are told we need to hit a score of 69.
Initial Thoughts
My first idea was to use Cheat Engine being inspired by Live Overflow’s YouTube Video. However, some playing around with it made me realize the game was not so easily hacked and we would have to patch the binary.
Solution
Normally, I would use a debugger but trial and error by searching the disassembly and changing the binary was a simpler option. So I resorted to our trusty NSA tool - Ghidra. Playing around with the code and searching for the value 69
(45
in hex) lands us in a specific compare. We choose 69 since that is our target score so there must be some jump or comparison made whether directly with the integer constant or referenced memory address.
Changing the binary directly using HxD (because it’s just easier than using Ghidra), we can modify the value from 69 (45 in hex) to 0 (shown in red in HxD). To find which hex value to change from Ghidra to HxD, simply type the entire instruction line’s hex code (with preceding and subsequent lines if necessary) into the search box.
We can run the program again, get a score of 1, and the top left of the screen shows the text A nice flag has been saved to disk.
Opening the outputted .bin
file in a text editor shows the flag.
Flag: STF22{iF_n0_s@vep0int_s@d_lIfE}
OSINT
Finding Nyan
Points: 325
Jaga is on a road trip to find Nyan. Do you know which road this is in the metaverse?
Note that for this challenge, the road name should be CAPITAL LETTERS. For example - STF22{JLN_BINCHANG_RD}. The road name can be seen on Google Maps.
Information Gathering
We are given a video with a cute Jaga soft toy riding in a car along some road. We see a couple of things - an orange traffic camera post, a bus stop and a huge construction site.
Solution
The Singapore government has so kindly given us a lot of data to work with using the data.gov.sg Datasets. We chose the Location of Speed Enforcement Cameras Dataset.
Filtering Data
This orange traffic camera can either be a speeding camera or red light camera. Since we did not pass a traffic junction we can assume it is a “Fixed Speed Camera” and sort the dataset accordingly leaving us with 33 results.
Considering the following:
- The road is 2-3 lanes wide
- Cyclists riding on pavements
- Presence of bus stops
We can safely assume this is NOT an expressway and filter our results to 12. Since speed cameras come in pairs (1 for each direction), this realistically narrows down our search to 6 roads.
Now we can simply search each of these locations on Google Maps.
Finding the Location
To find the desired road among these 6 roads, we can directly copy paste the latitude and longitude into Google Maps.
The street view of the Bukit Batok Rd looks like the correct one! There is even a construction site!
Going up the road confirms it with the bus stop!
Alternate Solution
Our teammate solved this challenge differently at about the same time. In the video, we notice the construction site has a poster with a construction company logo.
A bit of research would tell us that this is Chiu Teng Construction
Searching the BCA permit listing gives us an entry by Chew Sang Fatt , CHIU TENG CONSTRUCTION CO PTE LTD
.
Permits to Commence Structural Works Issued
> April 2022
yields one such entry:
TENGAH GARDEN DISTRICT C4 & COMMON GREEN - PROPOSED PUBLIC HOUSING DEVELOPMENT COMPRISING 7 BLOCKS OF 15-STOREY RESIDENTIAL BUILDING (TOTAL 782 UNITS) WITH MULTI-STOREY CAR PARK, ESS, PRECINCT PAVILIONS, SOCIAL COMMUNAL FACILITIES & COMMON GREEN AT TENGAH CENTRAL, TENGAH DRIVE & TENGAH GARDEN WALK ON LOT 05244N PT , 05242A , 05240P PT , 05238T PT , 05229L PT , 05196P PT , 05195V PT & 00160X PT MK10 AT
Searching for Tengah Drive on Google Maps gives us a huge green plot of land (a strong indicator of a large construction site) next to Bukit Batok Rd. Once again, we use Google Map’s street view to confirm that it is indeed the location.
Flag: STF22{BUKIT_BATOK_RD}