Skip to content

Bypassing Factory Login of GoodTop GT-ST024M

sdr

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:

  1. The constant 0x5A
  2. 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()