diff --git a/meta/lib/oeqa/utils/qemurunner.py b/meta/lib/oeqa/utils/qemurunner.py
index 5d7374446e..81434dc117 100644
--- a/meta/lib/oeqa/utils/qemurunner.py
+++ b/meta/lib/oeqa/utils/qemurunner.py
@@ -404,6 +404,11 @@ class QemuRunner:
                 # The running qemu is the authoritative source for the
                 # effective hostfwd rules: the host port a rule ends up on is
                 # not necessarily the one that was wished for.
+                # runqemu adds its forwards via QMP shortly after qemu starts.
+                endtime = time.time() + 10
+                while (('tcp', 22) not in self.slirp_port_mappings
+                       and self.is_alive() and time.time() < endtime):
+                    time.sleep(0.1)
                 try:
                     host, port = self._get_slirp_host_endpoint(22)
                     self.ip = "%s:%s" % (host, port)
diff --git a/scripts/runqemu b/scripts/runqemu
index ca1f74e9d7..c569fc7c84 100755
--- a/scripts/runqemu
+++ b/scripts/runqemu
@@ -19,6 +19,8 @@ import glob
 import configparser
 import signal
 import time
+import json
+import tempfile
 import shlex
 import socket
 import traceback
@@ -221,6 +223,8 @@ class BaseConfig(object):
         # slirp qemus are running.
         self.mac_tap = "52:54:00:12:34:"
         self.mac_slirp = "52:54:00:12:35:"
+        self.slirp_hostfwd_allowlist = []
+        self.internal_qmp_path = None
         # pid of the actual qemu process
         self.qemu_environ = os.environ.copy()
         self.qemuprocess = None
@@ -1156,30 +1160,103 @@ to your build configuration.
         hostfwd = ",hostfwd=tcp:127.0.0.1:2222-:22,hostfwd=tcp:127.0.0.1:2323-:23"
         qb_slirp_opt_default = "-netdev user,id=net0%s,tftp=%s" % (hostfwd, self.get('DEPLOY_DIR_IMAGE'))
         qb_slirp_opt = self.get('QB_SLIRP_OPT') or qb_slirp_opt_default
-        # Figure out the port
-        ports = re.findall('hostfwd=[^-]*:([0-9]+)-[^,-]*', qb_slirp_opt)
-        ports = [int(i) for i in ports]
 
-        lockdir = "/tmp/qemu-port-locks"
-        self.make_lock_dir(lockdir)
+        # Strip hostfwd rules from argv and add them via QMP after startup,
+        # making QEMU's host port binding authoritative.
+        self.slirp_hostfwd_allowlist = [
+            (proto, hostip, int(hostport), guestip, int(targetport)) for
+            proto, hostip, hostport, guestip, targetport in
+            re.findall(r'hostfwd=([a-z]+):([^:]*):([0-9]+)-([^:,]*):([0-9]+)', qb_slirp_opt)
+        ]
+        qb_slirp_opt = re.sub(r',hostfwd=[a-z]+:[^:]*:[0-9]+-[^:,]*:[0-9]+', '', qb_slirp_opt)
 
-        # Find a free port to avoid conflicts
-        for p in ports[:]:
-            p_new = p
-            while not self.check_free_port('localhost', p_new, lockdir):
-                p_new += 1
-                while p_new in ports:
-                        p_new += 1
-            if p != p_new:
-                ports.append(p_new)
-                qb_slirp_opt = re.sub(':%s-' % p, ':%s-' % p_new, qb_slirp_opt)
-                logger.info("Port forward changed: %s -> %s" % (p, p_new))
         mac = "%s%02x" % (self.mac_slirp, self.alloc_mac_slot("/tmp/qemu-mac-locks"))
         self.set('NETWORK_CMD', '%s %s' % (self.network_device.replace('@MAC@', mac), qb_slirp_opt))
-        # Print out port foward
-        hostfwd = re.findall('(hostfwd=[^,]*)', qb_slirp_opt)
-        if hostfwd:
-            logger.info('Port forward: %s' % ' '.join(hostfwd))
+
+    SLIRP_QMP_TIMEOUT = 10
+
+    def add_slirp_hostfwds(self):
+        """Add the slirp hostfwd allowlist to the running qemu via QMP"""
+        # qemu creates/binds this socket itself soon after it starts, so it
+        # may not be ready to accept a connection yet right after Popen().
+        sock = None
+        for _ in range(100):
+            if self.qemuprocess and self.qemuprocess.poll() is not None:
+                raise RunQemuError("Failed to run qemu: %s" % self.qemuprocess.stderr.read().decode())
+            try:
+                sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+                sock.settimeout(self.SLIRP_QMP_TIMEOUT)
+                sock.connect(self.internal_qmp_path)
+                break
+            except (FileNotFoundError, ConnectionRefusedError):
+                sock.close()
+                sock = None
+                time.sleep(0.1)
+            except socket.timeout as error:
+                sock.close()
+                raise RunQemuError("Timed out connecting to internal QMP socket %s" % self.internal_qmp_path) from error
+            except OSError:
+                sock.close()
+                raise
+        else:
+            raise RunQemuError("Timed out waiting for internal QMP socket %s" % self.internal_qmp_path)
+        sockf = sock.makefile('rw')
+
+        def read_response():
+            # QMP may interleave asynchronous event messages with command
+            # responses; skip those and return the first actual reply.
+            while True:
+                line = sockf.readline()
+                if not line:
+                    raise RunQemuError("Internal QMP connection closed unexpectedly")
+                msg = json.loads(line)
+                if 'event' not in msg:
+                    return msg
+
+        try:
+            greeting = read_response()
+            if 'QMP' not in greeting:
+                raise RunQemuError("Invalid internal QMP greeting: %s" % greeting)
+            sockf.write(json.dumps({"execute": "qmp_capabilities"}) + '\n')
+            sockf.flush()
+            response = read_response()
+            if 'error' in response or response.get('return') != {}:
+                raise RunQemuError("Internal QMP negotiation failed: %s" % response)
+
+            used_ports = set()
+            hostfwd_added = []
+            for proto, hostip, hostport, guestip, targetport in self.slirp_hostfwd_allowlist:
+                candidate = hostport
+                for _ in range(100):
+                    while (proto, candidate) in used_ports:
+                        candidate += 1
+                    cmdline = 'hostfwd_add %s:%s:%d-%s:%d' % (proto, hostip, candidate, guestip, targetport)
+                    sockf.write(json.dumps({"execute": "human-monitor-command",
+                                            "arguments": {"command-line": cmdline}}) + '\n')
+                    sockf.flush()
+                    response = read_response()
+                    # hostfwd_add prints nothing on success; on failure it
+                    # prints a message like "Could not set up host forwarding
+                    # rule ..." which does NOT contain the word "error", so
+                    # check for empty output rather than absence of that word.
+                    result = response.get('return')
+                    if 'error' not in response and isinstance(result, str) and result.strip() == '':
+                        used_ports.add((proto, candidate))
+                        hostfwd_added.append('hostfwd=%s:%s:%d-%s:%d' % (proto, hostip, candidate, guestip, targetport))
+                        if candidate != hostport:
+                            logger.info("Port forward changed: %s -> %s" % (hostport, candidate))
+                        break
+                    candidate += 1
+                else:
+                    raise RunQemuError("Could not find a free host port for hostfwd target %d after 100 attempts" % targetport)
+        except socket.timeout as error:
+            raise RunQemuError("Timed out communicating with internal QMP socket %s" % self.internal_qmp_path) from error
+        finally:
+            sockf.close()
+            sock.close()
+
+        if hostfwd_added:
+            logger.info('Port forward: %s' % ' '.join(hostfwd_added))
 
     def setup_tap(self):
         """Setup tap"""
@@ -1460,6 +1537,14 @@ to your build configuration.
     def setup_qmp(self):
         if self.qmp:
             self.qemu_opt += " -qmp %s,server,nowait" % self.qmp
+        if self.slirp_hostfwd_allowlist:
+            # A private monitor, in addition to any user-requested self.qmp,
+            # used internally to add the hostfwd allowlist once qemu is up
+            # (see add_slirp_hostfwds()).
+            qmp_dir = tempfile.mkdtemp(prefix='qemu-slirp-qmp-')
+            self.cleanup_files.append(qmp_dir)
+            self.internal_qmp_path = os.path.join(qmp_dir, 'monitor.sock')
+            self.qemu_opt += " -qmp unix:%s,server,nowait" % self.internal_qmp_path
 
     def setup_vga(self):
         if self.nographic == True:
@@ -1645,6 +1730,8 @@ to your build configuration.
                 pass_fds.append(descriptor.fileno())
         process = subprocess.Popen(cmds, stderr=subprocess.PIPE, pass_fds=pass_fds, env=self.qemu_environ)
         self.qemuprocess = process
+        if self.slirp_hostfwd_allowlist:
+            self.add_slirp_hostfwds()
         retcode = process.wait()
         if retcode:
             if retcode == -signal.SIGTERM:
