04 Dec 2022

Stack the Flags 2022

Oops my last CTF was 2020???



Team Name: JSons
Category: University, Polytechnics
Position: 23
Score: 3850

Scoreboard with JSons in 23 place

Hardware (IoT)

Jaga’s Lovely Bike

Points: 575

In this challenge, we are given a PCAP file and this beautiful poster with the description.

JAGA Poster

The challenge asks us to find:

  1. The name of the attacker
  2. 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

Wireshark Packet with Jaga's Ugly Bike

Wireshark Packet with 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.

Wireshark Packet with JAG0NGhp+L

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.

Quantum Schematics


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.

Example terminal output when netcat into server

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
_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 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 -_-)

Simplified circuit diagram of quantum setup

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\)


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("", 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
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()
    V == Ib * (Ru + Rp),
    V == Ia * (Rx + (C * Ia)),
    Vd == (Rc * Ia) - (Rp * Ib),
    Rc == C * Ia,
    Ia > 0,
m = s.model()

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()
    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:
    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"))

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.

Terminal output of solution

Flag: STF22{L0ng_Qu8ntum_3hift}


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 (Only incoming ICMP packets from that IP) gives us a list of large ICMP replies.

List of ICMP replies in Wireshark

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.

Wireshark packet with base64 contents

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

Base64 packet contents converted to image of a Blackpink poster

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.

exiftool output on poster

Lo and behold, we find the flag!

Flag: STF22{Bl@ckPin9_V3n0m}



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.

Keyboard website homepage

Clicking /build brings us to a menu to create our keyboard, after which, we are given a token and instructions to head to /menu.

Website example of token

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?).

Keyboard website menu page

If we click “Generate a heatmap”, we are prompted for a text input

Keyboard website requesting for text input

A blurred/pixelated version of our keyboard with the letters typed shown in red is displayed Pixelated keyboard with letters typed in red

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())
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)
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())
token = ''.join(random.choices('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', k=16))

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!

Succesful login into Jaga's Keyboard using token

Solution Part Two

We click “View your latest heatmap” to download Jaga’s last heatmap.

Jaga's latest 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

This gives us a proper heatmapped keyboard.

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

Anagram solver output: earthlings

So, we enter “earthlings” into the text field on the /text page and our flag appears!

Enter earthlings into text input

Flag output on website

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?

Gameplay screenshot

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.

Cheat engine attempt


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.

Ghidra decompilation

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.

Hex editor search of 69 or 0x45

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.

Game showing "a nice flag has been saved to disk" text

Flag: STF22{iF_n0_s@vep0int_s@d_lIfE}


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.

Finding Nyan.MOV

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.

orange traffic camera post bus stop construction site


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.

data.gov.sg dataset website

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.

Excel sheet with Fixed Speed Camera locations

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.

Excel sheet with possibility narrowed to 6 locations

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.

Pasting lat lng coordinates into Google Maps

The street view of the Bukit Batok Rd looks like the correct one! There is even a construction site!

Bukit Batok street view from Google Maps

Going up the road confirms it with the bus stop!

Street view showing bus stop from video

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.

construction site with some 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.

BCA listing example

Permits to Commence Structural Works Issued > April 2022 yields one such entry:


BCA listing of Tengah

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.

Google Maps output when searching for Tengah Drive