InfoSec University Hackathon 2024 Write-Up
A detailed exploration of the challenges and solutions from the InfoSec University Hackathon 2024, highlighting techniques used in mobile security, web exploitation, reverse engineering, and forensics.
MOBILE
SECURE BANK (300 Points)
SecureBank Pvt Ltd is releasing their beta version of the banking application for bug bounty hunters. The test credentials for the test account are as follows:
- Account Number:
667614145
- PIN:
1260585352
We’ve got an APK and a server instance to mess with. Let’s get cracking—literally!
1. Static Analysis
Decompiled the APK using jadx-gui
and an online APK decompiler. My first stop? AndroidManifest.xml
—the cheat sheet of app permissions and activities. Found 7 activities. Let’s snoop around.
1.1 Activity Gossip
1.1.1 Messages Activity
1
2
3
4
5
6
7
8
9
10
11
public class Messages extends AppCompatActivity {
TextView msg;
protected void onCreate(Bundle bundle) {
super.onCreate(bundle);
setContentView(R.layout.activity_messages);
TextView textView = (TextView) findViewById(R.id.messageText);
this.msg = textView;
textView.setText("Stay tuned for upcoming features!!");
}
}
Spoiler alert: Nothing juicy here. Just a motivational message: “Stay tuned for upcoming features!!”. Thanks, devs. 🙄
1.1.2 ViewProfile Activity
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class ViewProfile extends AppCompatActivity {
TextView accNo;
TextView name;
protected void onCreate(Bundle bundle) {
super.onCreate(bundle);
setContentView(R.layout.activity_view_profile);
this.name = (TextView) findViewById(R.id.name);
this.accNo = (TextView) findViewById(R.id.accNo);
Volley.newRequestQueue(this).add(new StringRequest(0, getIntent().getStringExtra("depl_URL") + "/user/" + getIntent().getStringExtra("id") + "?secret=" + getIntent().getStringExtra("pin"), new Response.Listener() {
public final void onResponse(Object obj) {
ViewProfile.this.m6lambda$onCreate$0$comsecurebankingViewProfile((String) obj);
}
}, new Response.ErrorListener() {
public void onErrorResponse(VolleyError volleyError) {
Toast.makeText(ViewProfile.this.getApplicationContext(), volleyError.toString(), 0).show();
Log.d("Error is: ", volleyError.toString());
}
}));
}
/* synthetic */ void m6lambda$onCreate$0$comsecurebankingViewProfile(String str) {
try {
JSONObject jSONObject = new JSONObject(str);
this.name.setText(jSONObject.getString("username"));
this.accNo.setText(jSONObject.getString("accountNumber"));
} catch (JSONException e) {
throw new RuntimeException(e);
}
}
}
The app sends a GET request to fetch user details with id
and pin
using volley. Response displays your username and account number. Nothing screams “security!” here 😂.
1.1.3 Customer Login Page Activity
This is where things get spicy! The login logic:
- UI Setup: Initializes fields for account number, PIN, deployment URL.
- Validation Checks: Fields can’t be empty, and URL must be valid.
- Authentication:
- Calls
dBHelper.authenticateUser()
with account number and hashed PIN. - The “hashing”? Wait for it…
- Calls
Here’s the PIN hashing magic:
1
2
3
4
5
6
7
public static Long hash(Long l) {
Long valueOf = Long.valueOf(((l.longValue() & 2863311530L) >>> 1) | ((l.longValue() & 1431655765) << 1));
Long valueOf2 = Long.valueOf(((valueOf.longValue() & 3435973836L) >>> 2) | ((valueOf.longValue() & 858993459) << 2));
Long valueOf3 = Long.valueOf(((valueOf2.longValue() & 4042322160L) >>> 4) | ((valueOf2.longValue() & 252645135) << 4));
Long valueOf4 = Long.valueOf(((valueOf3.longValue() & 4278255360L) >>> 8) | ((valueOf3.longValue() & 16711935) << 8));
return Long.valueOf((valueOf4.longValue() >>> 16) | (valueOf4.longValue() << 16));
}
This obfuscates the PIN using bitwise operations:
- Swaps even and odd bits.
- Swaps adjacent pairs, groups of 4, and groups of 8 bits.
- Finally, swaps 16-bit halves.
TL;DR: This swaps bits around like a Rubik’s cube. Obfuscation? Sure. Real security? Not really.
1.1.4 DBHelper
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class DBHelper extends SQLiteOpenHelper {
private static String name = "bankDB.db";
private final Context context;
private String path;
public DBHelper(Context context) {
super(context, name, (SQLiteDatabase.CursorFactory) null, 1);
this.context = context;
this.path = context.getDatabasePath(name).getPath();
}
private boolean checkDB() {
return new File(this.path).exists();
}
}
Spotted! A local SQLite database named bankDB.db
. Let’s grab it 👀.
1.2 Database Extraction
Exported the database. Here’s what we found:
1
2
3
4
5
6
7
8
9
sqlite> .tables
android_metadata customerDetails
sqlite> SELECT * FROM customerDetails;
uid | username | acct_number | hashed_PIN | balance
1 | user1 | 667614145 | 216406515827922 | 1000.0
0 | admin | 000000000 | 43431626549120 | 2000.0
3 | user3 | 407142357 | 13348376939555 | 3000.0
2 | user2 | 111111111 | 240658437888736 | 5000.0
2. Reverse the Hashed PIN
Let’s undo their “fancy” hashing:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.println("Enter the hashed PIN to decrypt:");
Long hashedPin = scanner.nextLong();
Long valueOf4 = Long.valueOf((hashedPin << 16) | (hashedPin >>> 16));
Long valueOf3 = Long.valueOf(((valueOf4 & 16711935L) << 8) | ((valueOf4 & 4278255360L) >>> 8));
Long valueOf2 = Long.valueOf(((valueOf3 & 252645135L) << 4) | ((valueOf3 & 4042322160L) >>> 4));
Long valueOf = Long.valueOf(((valueOf2 & 858993459L) << 2) | ((valueOf2 & 3435973836L) >>> 2));
Long originalPin = Long.valueOf(((valueOf & 1431655765L) << 1) | ((valueOf & 2863311530L) >>> 1));
System.out.println("Hashed PIN: " + hashedPin + " => Original PIN: " + originalPin);
scanner.close();
}
}
Boom! PIN reversed. Let’s take these for a spin.
3. Dynamic Analysis
3.1 ADB Logging
Installed the APK, connected via adb
, and logged requests:
1
2
adb logcat | grep eng.run
https://ch2016112962.challenges.eng.run/user/0?secret=31733100
Logged in with the provided creds. Observed API interactions. Tried swapping user IDs and PINs.
3.2 Admin Privileges
Used the admin credentials from the database. And voilà! The system handed me the flag. Secure Bank? More like “Surrender Bank.”
Pro Tip: If this bank actually launched, I’d keep my money in a mattress instead. At least my mattress won’t leak my account details. 🤣
WEB
SNAP FROM URL (100 points)
Two Flask scripts are in play: main.py
, boldly exposed to the internet, and admin.py
, quietly minding its business on 127.0.0.1
. The goal? Sneak past URL validation, access the hidden admin.py
, and retrieve the flag. Here’s how it went down:
Main.py: Functionality
Routes
/
Route:- Loads
index.html
, keeping things simple and uneventful.
- Loads
1
2
3
@app.route("/")
def home():
return render_template('index.html')
/images
Route:- Accepts a POST request with a
url
parameter. - Validates the URL and attempts to parse images. Sounds secure, but…
- Accepts a POST request with a
Key Steps in /images
- Extract URL:
1
2
3
4
try:
url = request.form.get('url')
if not url:
return render_template("error.html",error="Missing url :("), 400
- Checks if a
url
is provided. If not, it responds with a400
error. Straightforward enough.
- Blacklist Check:
1
2
if blacklisted(url):
return render_template("error.html",error="URL is blacklisted (unsafe or restricted)"), 403
- Runs a blacklist check on the URL. Anything suspicious gets blocked. Or so it thinks…
- DNS Resolution:
1
2
3
4
ip = socket.gethostbyname(urlparse(url).hostname)
print(ip)
if ip in ["localhost", "0.0.0.0"]:
return render_template("error.html",error="Blocked !! "), 403
- Resolves the hostname to an IP and blocks certain values like
localhost
. Unfortunately, it forgets about relatives like127.0.0.1
and127.0.0.0
,However, it isn’t entirely foolproof—DNS rebinding can still bypass it.
- Image Parsing:
1
2
3
response = requests.get(url,allow_redirects=False)
res_text=response.text
img_urls = image_parser(res_text,url)
- Fetches HTML content from the given URL and parses it for
<img>
tags. Functional, but exploitable.
Blacklist Validation (blacklisted
Function)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
blacklist = [
"127.0.0.1",
"0.0.0.0",
"169.254.169.254",
""
]
def blacklisted(url):
try:
parsed_url = urlparse(url)
if parsed_url.scheme not in ["http", "https"]:
return render_template("error.html", error="Invalid URL scheme"), 400
host = parsed_url.hostname
print("host",host)
except:
return True
if host in blacklist:
return True
private_ip_patterns = [
r"^127\..*",
r"\b(0|o|0o|q)177\b"
r"^2130*",
r"^10\..*",
r"^172\.(1[6-9]|2[0-9]|3[0-1])\..*",
r"^192\.168\..*",
r"^169\.254\..*",
]
for pattern in private_ip_patterns:
if re.match(pattern, host):
print("blocked")
return True
return False
- Blocks known suspicious hosts like
127.0.0.1
and169.254.169.254
and uses regex to catch private IPs. a little drama here—a missing comma afterr"\b(0|o|0o|q)177\b"
. This little oopsie merges it with the next pattern (r"^2130*"
)
Admin.py Functionality
On the other side, admin.py
serves admin.html
, which contains the flag. It operates locally and doesn’t anticipate any interference.
1
2
3
4
5
6
7
8
9
10
from flask import Flask, render_template, make_response
import os
app = Flask(__name__)
@app.route('/')
def home():
response = make_response(render_template('admin.html', flag=os.environ["flag"]))
response.headers['X-Frame-Options'] = 'DENY'
return response
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80, debug=False)
- The flag is sourced from an environment variable and rendered in the
alt
text of an image inadmin.html
.
1
<img src="" alt="">
The Plan to Capture the Flag
- The blacklist had a blind spot—it didn’t account for all
127.0.0.1
variations. - Using the tool Rebinder, a domain was set up to alternately resolve to
127.0.0.1
and127.0.0.0
. - Payload used:
http://7f000001.7f000101.rbndr.us
. - The bypass worked flawlessly, and the flag was retrieved from
admin.py
. Victory never tasted so sweet!
The Comma Issue :
a snippet of regex that caused an interesting issue:
1
2
3
4
5
6
7
8
9
private_ip_patterns = [
r"^127\..*",
r"\b(0|o|0o|q)177\b"
r"^2130*",
r"^10\..*",
r"^172\.(1[6-9]|2[0-9]|3[0-1])\..*",
r"^192\.168\..*",
r"^169\.254\..*",
]
Did you spot it? There’s a missing comma after r"\b(0|o|0o|q)177\b"
. This tiny oversight merges it with the next pattern, creating an unintended regex sequence:
1
r"\b(0|o|0o|q)177\b^2130*"
As a result, octal representations of 127.0.0.1
like 0177.0000.0000.0000
sneak past the blacklist. and gave us the flag
REVERSE ENGINEERING
REWIND(100 points)
Attachments:
- chal
- Readme.txt
- It’s all a matter of seconds.
Solution:
- chal is elf file which requires flag.txt(fake one) to work with.
- Used ghidra to analyze the chal. All the code does is bitwise/arthmetic operations.
- The below C code is part of the main function.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
local_10 = *(long *)(in_FS_OFFSET + 0x28); local_e8[0] = FUN_00101349; local_e8[1] = FUN_0010136a; local_e8[2] = FUN_0010137f; local_e8[3] = FUN_001013a0; local_c8 = FUN_001013c1; local_c0 = FUN_001013d7; local_b8 = FUN_001013f8; local_b0 = FUN_00101419; local_a8 = FUN_0010142f; __stream = fopen("flag.txt","r"); if (__stream == (FILE *)0x0) { perror("Error opening file"); uVar3 = 1; } else { __n = fread(local_98,1,0x22,__stream); local_98[__n] = 0; fclose(__stream); memcpy(local_68,local_98,__n); tVar4 = time((time_t *)0x0); srand((uint)tVar4); for (local_108 = 0; local_108 < __n; local_108 = local_108 + 1) { iVar2 = rand(); snprintf(local_38,4,"%d",(ulong)(uint)(iVar2 % 9)); iVar2 = FUN_00101448(local_38); bVar1 = (*local_e8[iVar2])(local_68[local_108]); local_68[local_108] = bVar1; } for (local_100 = 0; local_100 < __n; local_100 = local_100 + 1) { printf("%02X ",(ulong)local_68[local_100]); }
local_e8
is pointing to the functions that does the operations.- The order of operations performed in the flag is randomized using rand() with a seed.
- Seed value is nothing but timestamp (
tVar4
) which is then scaled down between[0-8]
. - This seed value is then passed to function
FUN_00101448
which does some operation and return a value in[0-8]
. After the operations are performed and encrypted flag is printed out as hex.
- To solve this problem we need to find the seed which is nothing but timestamp. With the same Seed value you can regenerate the same random values.
- The below C code will get the timestamp.
1
2
3
4
5
6
7
8
#include <stdio.h>
#include <time.h>
int main(){
int tt = time(NULL);
printf("%d",tt);
return 0;
}
- The below C code will generate the random generated values based on timestamp. This code will give randint array for 15 timestamp. One of them will be as same as the chal.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
#include <stdlib.h>
int main() {
int seed = 1736595259; // Your known seed value
for (int i=0;i<15;i++){
srand(seed+i);
printf("[");
for (int j=0;j<31;j++){
int random_number = rand();
printf("%d, ",random_number % 9);
}
printf("]\n");
}
return 0;
}
- The below python3 code will do the randomization and perform operation in the characters until it receives the correct encrypted flag character(each one).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#!/usr/bin/env python3
import string
s = string.printable
rand = []
obflag = ""
obflag = [int(x, 16) for x in obflag.split()]
def encode(val,c):
if val == 0:
return (c >> 4 | c << 4) & 0xff
elif val == 1:
return ~c & 0xff
elif val == 2:
return (c << 5 | c >> 3) & 0xff
elif val == 3:
return (c >> 2 | c << 6) & 0xff
elif val == 4:
return (c ^ 0xaa)
elif val == 5:
return (c + ord('\a')) & 0xff
elif val == 6:
return (c << 6 | c >> 2) & 0xff
elif val == 7:
return (c + 0x55) & 0xff
elif val == 8:
return (c * 2 + c) & 0xff
def randomize(input_str):
local_10 = 1
local_c = 0
for char in input_str:
local_10 = (local_10 + ord(char)) % 0xFFF1
local_c = (local_10 + local_c) % 0xFFF1
return ((local_c << 16 | local_10) % 9)
def running(rand)
for i in range(len(obflag)):
value = obflag[i]
for j in range(len(s)):
if encode(randomize(str(rand[i])),ord(s[j])) == value:
print(s[j],end="")
break
print()
for i in range(len(rand)):
running(rand[i])
- Stored the encrypted flag from the chal in
obflag
and rand lists inrand
. - Run timestamp C code first. Note the timstamp and then execute the chal. Store the timestamp value in
seed
variable in the next C code to calculate the rand. - This will spit the flag
MORE THAN MEETS THE EYE (300 points )
Attachments:
- Chall
- Readme.txt
- It’s time to show that we are ‘more than meets the eye’.
Solution:
- Chall program’s main function just perform xor operation on input string with
0x20
and compares with xored string stored in the program. - Once we get pass that part, we have to go through another function that does a lot of if else condition checking on the input and if the string passes all the condition, only then we get the flag.
- To reverse this condition checking, I’ve used
z3 solver
in python3.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
#!/usr/bin/env python3
from z3 import *
encrypted = [0x54, 0x4F, 0x7F, 0x42, 0x45, 0x7F, 0x4F, 0x52,
0x7F, 0x4E, 0x4F, 0x54, 0x7F, 0x54, 0x4F, 0x7F,
0x42, 0x45]
original = ''.join(chr(b ^ 0x20) for b in encrypted)
print("Password 1:",original)
length = 41
f = [BitVec(f'flag_{i}', 32) for i in range(length)]
sol = Solver()
sol.add(f[1] + f[0] == 0x9b)
sol.add(f[0] - f[1] == 0xb)
sol.add(f[3] + f[2] == 0x60)
sol.add(f[2] == f[3])
sol.add(f[5]+f[4] == 0xca)
sol.add(f[4] - f[5] == 0xc)
sol.add(f[7] + f[6] == 0x9f)
sol.add(f[6] - f[7] == -0x31)
sol.add(f[9] + f[8] == 0xa8)
sol.add(f[8] - f[9] == -0x40)
sol.add(f[11]+f[10] == 0xb2)
sol.add(f[10] - f[11] == 0xc)
sol.add(f[13] + f[12] == 0xd8)
sol.add(f[12] - f[13] == 8)
sol.add(f[15] + f[14] == 0xa5)
sol.add(f[14] - f[15] == -0x3f)
sol.add(f[17] + f[16] == 0xc4)
sol.add(f[16] - f[17] == 6)
sol.add(f[19] + f[18] == 0xbf)
sol.add(f[18] - f[19] == -0x11)
sol.add(f[21] + f[20] == 0xdd)
sol.add(f[20] - f[21] == -0xb)
sol.add(f[23] + f[22] == 0x93)
sol.add(f[22] - f[23] == 0x2b)
sol.add(f[25] + f[24] == 0xd8)
sol.add(f[24] == f[25])
sol.add(f[27] + f[26] == 0xc3)
sol.add(f[26] - f[27] == -5)
sol.add(f[29] + f[28] == 0x6b)
sol.add(f[28] - f[29] == -3)
sol.add(f[31] + f[30] == 0xcb)
sol.add(f[30] - f[31] == -0xd)
sol.add(f[33] + f[32] == 0xdd)
sol.add(f[32] - f[33] == -0xb)
sol.add(f[35] + f[34] == 0xd7)
sol.add(f[34] - f[35] == -0xd)
sol.add(f[37] + f[36] == 0xd5)
sol.add(f[36] - f[37] == -0x13)
sol.add(f[39] + f[38] == 0xe7)
sol.add(f[38] - f[39] == 3)
sol.add(f[40] == ord('e'))
if sol.check()==sat:
m = sol.model()
flag = [chr(int(str(m[f[i]]))) for i in range(41)]
print("Password 2:",''.join(flag))
else:
print('unsat')
- Script output:
Password 1: to_be_or_not_to_be Password 2: SH00k_7h4t_Sph3re_Whit_4ll_d47_literature
BLINDNESS (500 points)
Attachments:
- Readme.txt
- Do you think you know reversing because you can analyse the files? Well, let’s see how good you are when there are no files!
Solution:
- We don’t get the
elf
file for this. We’ll have to connect the netcat server straight away. - The challenges gives you a
base64-encoded
string ofgzip
file. We have to decode and store it as.gz
file and unzip the file. - The
gzip
file gives us a elf file which has the password in it. We have to extract the password and give it back to the challenge server. - We have to do this for 60 files in 60 seconds. And there are special elf files between in which the password is xored with a key.
- Wrote a python3 script that remotely connects the chall server using
pwntool
and does all the work.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#!/usr/bin/env python3
from pwn import *
import base64
import subprocess
conn = remote('13.234.240.113',31813)
conn.recvline()
conn.recvline()
conn.recvline()
conn.recvline()
for i in range(0,59):
output = conn.recvline().decode()
encoded = output[15:-2]
decoded = base64.b64decode(encoded)
f = open('file_'+str(i)+'.gz','wb')
f.write(decoded)
f.close()
subprocess.run(['gzip', '-d', 'file_'+str(i)+'.gz'])
elf = ELF('file_'+str(i),checksec=False)
data_section = elf.get_section_by_name('.data')
if data_section:
data = data_section.data()
if len(data.hex()) == 42:
password = data[:-1]
elif len(data.hex()) == 44:
password = b""
key = data[-1:][0]
for j in range(len(data[:-2])):
password += chr(data[j] ^ key).encode()
# result = subprocess.run(['strings', 'file_'+str(i)], capture_output=True, text=True)
# output = result.stdout
# password = output.split('\n')[0]
conn.recvuntil(b"what's the password: ")
conn.sendline(password)
conn.recvline()
print("Cracking...",i+1)
print(conn.recvline().decode())
NETWORK
PING OF SECRETS (100 points)
We got our hands on a traffic.pcap
file, stuffed with ICMP, TCP, SSH, and even some NTP packets. The mission? Find the flag hidden in this mix. Let’s see how i captured.
The ICMP secrets
First stop: Wireshark. I zoomed in on the ICMP packets and noticed something curious. Each packet carried just 1 byte of data. Suspicious much? 🤔
Time to extract this mysterious data with Scapy:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
from scapy.all import rdpcap, ICMP
pcap_file_path = 'traffic.pcap'
packets = rdpcap(pcap_file_path)
payload_data = []
for packet in packets:
if ICMP in packet and hasattr(packet[ICMP], 'load'):
payload_data.append(packet[ICMP].load.hex())
try:
flag = ''.join([bytes.fromhex(byte).decode('ascii') for byte in payload_data])
print("Flag:", flag)
except Exception as e:
print("Error decoding payload:", e)
The result? A scrambled mess:
1
Flag: SBoizX=ja5lvW{1n=}whUAbafpCgGh
It had all the right characters, but they were jumbled up like a word scramble. Time to figure this out.
Sorting It Out
Here’s where the light bulb moment happened. What if the ICMP packets were sent letter by letter, but out of order? Sorting them by timestamp might just reveal the flag in the right sequence.
And guess what? It worked. Here’s the updated script:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from scapy.all import rdpcap, ICMP
pcap_file_path = 'traffic.pcap'
packets = rdpcap(pcap_file_path)
payload_data = []
sorted_packets = sorted(packets, key=lambda x: x.time) # Sort packets by timestamp
for packet in sorted_packets:
if ICMP in packet and hasattr(packet[ICMP], 'load'):
payload_data.append(packet[ICMP].load.hex())
try:
flag = ''.join([bytes.fromhex(byte).decode('ascii') for byte in payload_data])
print("Flag:", flag)
except Exception as e:
print("Error decoding payload:", e)
This time, the output was crystal clear:
1
Flag: flag{1aUoAbGhWCXzSpBjihnv5w==}
FORENSICS
FIX ME (100 points)
The challenge was straightforward: repair the broken PNG file and uncover the hidden flag. Here’s how i done:
Fixing the PNG Header
We started with a file named chall.png
. Opening it in a hex editor, the PNG header was scrambled. A valid PNG header should begin with these 8 bytes:
1
89 50 4E 47 0D 0A 1A 0A
After replacing the jumbled header with the correct values, the file’s foundation was restored.
Repairing the IHDR Chunk
Next, the IHDR chunk, identified by these bytes:
1
49 48 44 52
This chunk holds key information about the image, like its width, height, and bit depth. Fixing this restored the core image structure.
Fixing the IEND Chunk
Finally, the IEND chunk, marked by:
1
49 45 4E 44
This chunk, which signifies the end of a PNG file, was also corrected. With this step complete, the file was fully repaired and ready to export.
Flag
Once the header, IHDR, and IEND chunks were fixed, the flag appeared in plain sight within the image. Mission accomplished!