Bypassing Factory Login of GoodTop GT-ST024M

The password hash for the factory configuration page at /Hengrui_mp_cfg is hard-coded (and encoded for obfuscation presumably) in the firmware. The encoding method encodes the password hash by XORing each byte with 0x5a and then with the index of each character (0x00, 0x01, 0x02 etc), effectively obscuring the hash in the firmware.
Username: hengrui^e
Password hash: 91edf91c0b3c9b9e99de2268962bc0fc
Although the password was not uncovered after attempts to run the hash through hashcat with common password lists, only the password hash is necessary to login to the factory configuration page; password hashing occurs client-side upon login, so the login request can be intercepted (with Burpsuite, for instance) and modified to include the expected hard-coded password hash. It does not check the plaintext password on the backend during login, only the username and the Response/admin cookie (which is what must be modified to be the hard-coded password hash for login to be successful).
Finding the Hengrui Login Page Password Hash
Finding the strings
Once banks are mapped in Ghidra, The "hengrui" string appears:
BANK_8::bb6d "/Hengrui_mp_cfg"
BANK_9::a37a "/Hengrui_mp_cfg"
BANK_4::b5be "..\\src\\web_hengrui\\web_image.c"
BANK_21::407d "..\\src\\web_hengrui\\web_file.c"
Strings loading by bank appears to be using the following pattern:
MOV R3,#0x8a // this is the bank number (0x8a is Bank 9)
MOV R2,#0xa3 // 1st byte of address in bank
MOV R1,#0x7a // 2nd byte of address in bank
Finding where the string is loaded
Using that byte sequence to search, found an occurrence at BANK_9::9da0. The code loads the string "/Hengrui_mp_cfg" using this pattern:
BANK_9::9da0 7b 8a MOV R3,#0x8a
BANK_9::9da2 7a a3 MOV R2,#0xa3
BANK_9::9da4 79 7a MOV R1,#0x7a
BANK_9::9da6 90 19 9b MOV DPTR,#0x199b
BANK_9::9da9 12 1b 29 LCALL FUN_CODE_1b29
Near where the string is loaded, notice a few other string comparisons such as "language" being checked ("CN" or "EN") at BANK_9::9d75
Username/password check might be at BANK_9::9d0e / BANK_9::9d9d
Username / Password (hash) check
First Credential Check (Username?):
BANK_9::9d02 78 60 MOV R0,#0x60 ; source pointer
BANK_9::9d04 7c 18 MOV R4,#0x18
BANK_9::9d06 7d 01 MOV R5,#0x1 ; R5:R4 = 0x0118
BANK_9::9d08 7b 01 MOV R3,#0x1
BANK_9::9d0a 7a 18 MOV R2,#0x18
BANK_9::9d0c 79 b6 MOV R1,#0xb6 ; R3:R2:R1 = 0x0118b6
BANK_9::9d0e 12 1e 0e LCALL FUN_CODE_1e0e ; Compare!
BANK_9::9d11 ef MOV A,R7
BANK_9::9d12 70 1d JNZ LAB_BANK_9__9d31 ; Jump if not equal
This compares user input at 0x1860 with something at 0x118b6 ? (see below)
Second Credential Check (Password hash?):
BANK_9::9d14 78 57 MOV R0,#0x57 ; different offset
BANK_9::9d16 7c 19 MOV R4,#0x19
BANK_9::9d18 7d 01 MOV R5,#0x1 ; R5:R4 = 0x0119
BANK_9::9d1a 7b 01 MOV R3,#0x1
BANK_9::9d1c 7a 18 MOV R2,#0x18
BANK_9::9d1e 79 cb MOV R1,#0xcb ; R3:R2:R1 = 0x0118cb
BANK_9::9d20 fe MOV R6,A
BANK_9::9d21 7f 20 MOV R7,#0x20 ; Compare 32 bytes
BANK_9::9d23 12 20 ef LCALL FUN_CODE_20ef ; Another compare function
This compares 32 bytes at 0x1957 with something at 0x118cb ? (see below)
Finding the data the check uses
Not sure about this, but maybe when R3 is 0x01 , this is in external RAM (XDATA), not in code banks? Need to find out where that RAM data gets initialized, so search for the bytes 7b 01 7a 18 79 cb (to find the RAM address we saw above: 0x0118cb). There are four results:
BANK_9::9ac5
BANK_9::9af0
BANK_9::9ce5
BANK_9::9d1a // this is where it is used
Looking closer, these just seem to be pointers to where the data will be placed. It looks like FUN_CODE_28f1 might be where the data is loaded. See here:
BANK_9::9ce5 7b 01 MOV R3,#0x1
BANK_9::9ce7 7a 18 MOV R2,#0x18
BANK_9::9ce9 79 cb MOV R1,#0xcb ; Address 0x0118cb
BANK_9::9ceb 90 19 9b MOV DPTR,#0x199b ; Store this address at 0x199b?
BANK_9::9cee 12 1b 29 LCALL FUN_CODE_1b29 ; Write the address?
BANK_9::9cf1 7a 19 MOV R2,#0x19
BANK_9::9cf3 79 0c MOV R1,#0xc ; Address 0x0190c
BANK_9::9cf5 90 19 9e MOV DPTR,#0x199e ; Store this address at 0x199e?
BANK_9::9cf8 12 1b 29 LCALL FUN_CODE_1b29 ; Write the address?
BANK_9::9cfb 7a 18 MOV R2,#0x18
BANK_9::9cfd 79 b6 MOV R1,#0xb6 ; Address 0x0118b6
BANK_9::9cff 12 28 f1 LCALL FUN_CODE_28f1 ; likely loads the credentials?
FUN_CODE_28f1 (not really a function) is loading DPTR with 0xa016 and jumping to FUN_CODE_2318:
CODE:28f1 90 a0 16 MOV DPTR,#0xa016
CODE:28f4 02 23 18 LJMP FUN_CODE_2318
Here is FUN_CODE_2318 :
CODE:2318 85 96 bb MOV DAT_SFR_bb,DAT_SFR_96
CODE:231b 75 96 05 MOV DAT_SFR_96,#0x5
CODE:231e 91 5c ACALL FUN_CODE_245c
CODE:2320 85 bb 96 MOV DAT_SFR_96,DAT_SFR_bb
CODE:2323 22 RET
Looks like a bank switching function? Since it's using 0x5 as the bank ID, check 0xa016 in bank 5 and see:
BANK_5::a022 7b 95 MOV R3,#0x95
BANK_5::a024 7a 40 MOV R2,#0x40
BANK_5::a026 79 06 MOV R1,#0x6 ; Address: 0x954006
BANK_5::a028 7e 00 MOV R6,#0x0
BANK_5::a02a 7f 20 MOV R7,#0x20 ; Copy 32 bytes
BANK_5::a02c 12 16 31 LCALL FUN_CODE_1631 ; Copy function
...
BANK_5::a035 7b 95 MOV R3,#0x95
BANK_5::a037 7a 40 MOV R2,#0x40
BANK_5::a039 79 26 MOV R1,#0x26 ; Address: 0x954026
BANK_5::a03b 7e 00 MOV R6,#0x0
BANK_5::a03d 7f 20 MOV R7,#0x20 ; Copy 32 bytes
BANK_5::a03f 12 16 31 LCALL FUN_CODE_1631 ; Copy function
This looks like it’s loading data from Bank 20 (0x95 in R#). It's probably accessing BANK_20::4006?
There is a long string that appears to be encoded, which starts at BANK_20::4004:
#ycj==8fm>b1c2o5m0sr,,|}zu{ur#%w\"&?nj;jgo;4k3244gm}.x}~}xx$p&v
XOR decoding (using 0x5a and position value)
Going back to where this string gets used, two XOR operations are uncovered:
BANK_9::9a99 e0 MOVX A,@DPTR
BANK_9::9a9a 64 5a XRL A,#0x5a ; XOR with 0x5A
BANK_9::9a9c 6d XRL A,R5 ; XOR with index
BANK_9::9a9d ff MOV R7,A
This shows the data is XOR encoded with:
- The constant
0x5A - The index/position value
After decoding the first 32 characters (#ycj==8fm>b1c2o5m0sr,,|}zu{ur#%w\"&) of the encoded string using the same method as the assembly, it spits out a hash that looks like MD5:
91edf91c0b3c9b9e99de2268962bc0fc
The characters at BANK_20::4026 should be the encoded username (?nj;jgo;4k3244gm}.x}~}xx$p&v), ~~but the output I get is 28 characters that form 14 hex characters… strange~~ : Ah, I had an error in the encoded string I was trying to decode because of how Ghidra displays it. Using the brute-force XOR script lower on this page, found it is in fact a hash:
e52b483ff8ccbc387e040245f3f7961f
UPDATE: found the username.
The username is located at BANK_8::b222
hengrui^e
Decoding script (from above password hash decoding):
#!/usr/bin/env python3
def decode_string(encoded_str):
"""
Decodes a string by:
1. XORing each character with 0x5a
2. Then XORing the result with its position (0x00, 0x01, 0x02, etc.)
"""
result = []
for position, char in enumerate(encoded_str):
# Get ASCII value of the character
char_value = ord(char)
# First XOR with 0x5a
first_xor = char_value ^ 0x5a
# Then XOR with position
final_value = first_xor ^ position
# Convert back to character and append
result.append(chr(final_value))
return ''.join(result)
def main():
# The encoded string from the CTF challenge
#encoded_string = 'cj==8fm>b1c2o5m0sr,,|}zu{ur#%w\"&?nj;jgo;4k3244gm}.x}~}xx$p&v'
# password hash encoded
encoded_string = 'cj==8fm>b1c2o5m0sr,,|}zu{ur#%w"&'
# prints: 91edf91c0b3c9b9e99de2268962bc0fc
# next 32 chars, probably username?
#encoded_string = '?nj;jgo;4k3244gm}.x}~}xx$p&v'
# prints: e52b483ff8ccbc387e040245f3f7 (only 28 chars?)
print("Encoded string:")
print(f"'{encoded_string}'")
print(f"Length: {len(encoded_string)}")
print()
# Decode the string
decoded_string = decode_string(encoded_string)
print("Decoded string:")
print(f"'{decoded_string}'")
print()
if __name__ == "__main__":
main()
Brute forcing XOR operation to find other hashes
Here is a script to brute-force the above XOR operation to discover other hashes. Running it results in
Processing file: .\W25Q16JV_GT-ST024M_aliexpress_1.BIN
File size: 2097152 bytes
Window size: 32 bytes
------------------------------------------------------------
HEX STRING FOUND
0x000e8006: 91edf91c0b3c9b9e99de2268962bc0fc
HEX STRING FOUND
0x000e8026: e52b483ff8ccbc387e040245f3f7961f
HEX STRING FOUND
0x000e8046: f6fdffe48c908deb0f4c3bd36c032e72
HEX STRING FOUND
0x001fd0cc: f6fdffe48c908deb0f4c3bd36c032e72
HEX STRING FOUND
0x001fe02c: f6fdffe48c908deb0f4c3bd36c032e72
script:
#!/usr/bin/env python3
import sys
import string
def decode_bytes(data):
"""
Decodes a byte sequence by:
1. XORing each byte with 0x5a
2. Then XORing the result with its position (0x00, 0x01, 0x02, etc.)
Returns None if any non-printable ASCII character is encountered.
"""
result = []
for position, byte in enumerate(data):
# First XOR with 0x5a
first_xor = byte ^ 0x5a
# Then XOR with position
final_value = first_xor ^ position
# Check if it's printable ASCII (0x20-0x7E)
if 0x20 <= final_value <= 0x7E:
result.append(chr(final_value))
else:
# Non-printable character found, break
return None
return ''.join(result)
def is_hex_string(s):
"""Check if a string contains only hexadecimal characters (0-9, a-f, A-F)"""
hex_chars = set('0123456789abcdefABCDEF')
return all(c in hex_chars for c in s)
def process_file(filename):
"""Process a binary file with 32-byte sliding windows"""
try:
with open(filename, 'rb') as f:
# Read entire file into memory
data = f.read()
# Process with sliding window
file_size = len(data)
window_size = 32
print(f"Processing file: {filename}")
print(f"File size: {file_size} bytes")
print(f"Window size: {window_size} bytes")
print("-" * 60)
print()
# Slide through the file
for offset in range(file_size - window_size + 1):
# Extract 32-byte window
window = data[offset:offset + window_size]
# Try to decode the window
decoded = decode_bytes(window)
if decoded is not None:
# Printable ASCII found
if is_hex_string(decoded):
print("HEX STRING FOUND")
print(f"0x{offset:08x}: {decoded}")
print()
except FileNotFoundError:
print(f"Error: File '{filename}' not found")
sys.exit(1)
except Exception as e:
print(f"Error reading file: {e}")
sys.exit(1)
def main():
if len(sys.argv) != 2:
print("Usage: python script.py <binary_file>")
sys.exit(1)
filename = sys.argv[1]
process_file(filename)
if __name__ == "__main__":
main()