|
| 1 | +/* |
| 2 | + * Licensed to the Apache Software Foundation (ASF) under one |
| 3 | + * or more contributor license agreements. See the NOTICE file |
| 4 | + * distributed with this work for additional information |
| 5 | + * regarding copyright ownership. The ASF licenses this file |
| 6 | + * to you under the Apache License, Version 2.0 (the |
| 7 | + * "License"); you may not use this file except in compliance |
| 8 | + * with the License. You may obtain a copy of the License at |
| 9 | + * |
| 10 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 11 | + * |
| 12 | + * Unless required by applicable law or agreed to in writing, |
| 13 | + * software distributed under the License is distributed on an |
| 14 | + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| 15 | + * KIND, either express or implied. See the License for the |
| 16 | + * specific language governing permissions and limitations |
| 17 | + * under the License. |
| 18 | + */ |
| 19 | + |
| 20 | +package org.apache.sshd.common.kex.extension; |
| 21 | + |
| 22 | +import java.io.PipedInputStream; |
| 23 | +import java.io.PipedOutputStream; |
| 24 | +import java.nio.charset.StandardCharsets; |
| 25 | + |
| 26 | +import org.apache.sshd.client.ClientFactoryManager; |
| 27 | +import org.apache.sshd.client.SshClient; |
| 28 | +import org.apache.sshd.client.channel.ChannelShell; |
| 29 | +import org.apache.sshd.client.session.ClientSession; |
| 30 | +import org.apache.sshd.client.session.ClientSessionImpl; |
| 31 | +import org.apache.sshd.client.session.SessionFactory; |
| 32 | +import org.apache.sshd.common.channel.StreamingChannel; |
| 33 | +import org.apache.sshd.common.io.IoSession; |
| 34 | +import org.apache.sshd.common.keyprovider.FileKeyPairProvider; |
| 35 | +import org.apache.sshd.util.test.BaseTestSupport; |
| 36 | +import org.apache.sshd.util.test.CommonTestSupportUtils; |
| 37 | +import org.apache.sshd.util.test.ContainerTestCase; |
| 38 | +import org.junit.After; |
| 39 | +import org.junit.Before; |
| 40 | +import org.junit.Test; |
| 41 | +import org.junit.experimental.categories.Category; |
| 42 | +import org.slf4j.Logger; |
| 43 | +import org.slf4j.LoggerFactory; |
| 44 | +import org.testcontainers.containers.GenericContainer; |
| 45 | +import org.testcontainers.containers.output.Slf4jLogConsumer; |
| 46 | +import org.testcontainers.containers.wait.strategy.Wait; |
| 47 | +import org.testcontainers.images.builder.ImageFromDockerfile; |
| 48 | +import org.testcontainers.images.builder.dockerfile.DockerfileBuilder; |
| 49 | +import org.testcontainers.utility.MountableFile; |
| 50 | + |
| 51 | +/** |
| 52 | + * Tests to ensure that an Apache MINA sshd client can talk to OpenSSH servers with or without "strict KEX". This |
| 53 | + * implicitly tests the message sequence number handling; if sequence numbers get out of sync or are reset wrongly, |
| 54 | + * subsequent messages cannot be decrypted correctly and there will be exceptions. |
| 55 | + * |
| 56 | + * @author <a href="mailto:[email protected]">Apache MINA SSHD Project</a> |
| 57 | + * @see <A HREF="https://github.com/apache/mina-sshd/issues/445">Terrapin Mitigation: "strict-kex"</A> |
| 58 | + */ |
| 59 | +@Category(ContainerTestCase.class) |
| 60 | +public class StrictKexInteroperabilityTest extends BaseTestSupport { |
| 61 | + |
| 62 | + private static final Logger LOG = LoggerFactory.getLogger(StrictKexInteroperabilityTest.class); |
| 63 | + |
| 64 | + private static final String TEST_RESOURCES = "org/apache/sshd/common/kex/extensions/client"; |
| 65 | + |
| 66 | + private SshClient client; |
| 67 | + |
| 68 | + public StrictKexInteroperabilityTest() { |
| 69 | + super(); |
| 70 | + } |
| 71 | + |
| 72 | + @Before |
| 73 | + public void setUp() throws Exception { |
| 74 | + client = setupTestClient(); |
| 75 | + SessionFactory factory = new TestSessionFactory(client); |
| 76 | + client.setSessionFactory(factory); |
| 77 | + } |
| 78 | + |
| 79 | + @After |
| 80 | + public void tearDown() throws Exception { |
| 81 | + if (client != null) { |
| 82 | + client.stop(); |
| 83 | + } |
| 84 | + } |
| 85 | + |
| 86 | + private DockerfileBuilder strictKexImage(DockerfileBuilder builder, boolean withStrictKex) { |
| 87 | + if (!withStrictKex) { |
| 88 | + return builder |
| 89 | + // CentOS 7 is EOL and thus unlikely to get the security update for strict KEX. |
| 90 | + .from("centos:7.9.2009") // |
| 91 | + .run("yum install -y openssh-server") // Installs OpenSSH 7.4 |
| 92 | + .run("/usr/sbin/sshd-keygen") // Generate multiple host keys |
| 93 | + .run("adduser bob"); // Add a user |
| 94 | + } else { |
| 95 | + return builder |
| 96 | + .from("alpine:20231219") // |
| 97 | + .run("apk --update add openssh-server") // Installs OpenSSH 9.6 |
| 98 | + .run("ssh-keygen -A") // Generate multiple host keys |
| 99 | + .run("adduser -D bob") // Add a user |
| 100 | + .run("echo 'bob:passwordBob' | chpasswd"); // Give it a password to unlock the user |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + @Test |
| 105 | + public void testStrictKexOff() throws Exception { |
| 106 | + testStrictKex(false); |
| 107 | + } |
| 108 | + |
| 109 | + @Test |
| 110 | + public void testStrictKexOn() throws Exception { |
| 111 | + testStrictKex(true); |
| 112 | + } |
| 113 | + |
| 114 | + private void testStrictKex(boolean withStrictKex) throws Exception { |
| 115 | + // This tests that the message sequence numbers are handled correctly. Strict KEX resets them to zero on any |
| 116 | + // KEX, without strict KEX, they're not reset. If sequence numbers get out of sync, received messages are |
| 117 | + // decrypted wrongly and there will be exceptions. |
| 118 | + @SuppressWarnings("resource") |
| 119 | + GenericContainer<?> sshdContainer = new GenericContainer<>(new ImageFromDockerfile() |
| 120 | + .withDockerfileFromBuilder(builder -> strictKexImage(builder, withStrictKex) // |
| 121 | + .run("mkdir -p /home/bob/.ssh") // Create the SSH config directory |
| 122 | + .entryPoint("/entrypoint.sh") // |
| 123 | + .build())) // |
| 124 | + .withCopyFileToContainer(MountableFile.forClasspathResource(TEST_RESOURCES + "/bob_key.pub"), |
| 125 | + "/home/bob/.ssh/authorized_keys") |
| 126 | + // entrypoint must be executable. Spotbugs doesn't like 0777, so use hex |
| 127 | + .withCopyFileToContainer( |
| 128 | + MountableFile.forClasspathResource(TEST_RESOURCES + "/entrypoint.sh", 0x1ff), |
| 129 | + "/entrypoint.sh") |
| 130 | + .waitingFor(Wait.forLogMessage(".*Server listening on :: port 22.*\\n", 1)) // |
| 131 | + .withExposedPorts(22) // |
| 132 | + .withLogConsumer(new Slf4jLogConsumer(LOG)); |
| 133 | + sshdContainer.start(); |
| 134 | + try { |
| 135 | + FileKeyPairProvider keyPairProvider = CommonTestSupportUtils.createTestKeyPairProvider(TEST_RESOURCES + "/bob_key"); |
| 136 | + client.setKeyIdentityProvider(keyPairProvider); |
| 137 | + client.start(); |
| 138 | + try (ClientSession session = client.connect("bob", sshdContainer.getHost(), sshdContainer.getMappedPort(22)) |
| 139 | + .verify(CONNECT_TIMEOUT).getSession()) { |
| 140 | + session.auth().verify(AUTH_TIMEOUT); |
| 141 | + assertTrue("Should authenticate", session.isAuthenticated()); |
| 142 | + assertTrue("Unexpected session type " + session.getClass().getName(), session instanceof TestSession); |
| 143 | + assertEquals("Unexpected strict KEX usage", withStrictKex, ((TestSession) session).usesStrictKex()); |
| 144 | + try (ChannelShell channel = session.createShellChannel()) { |
| 145 | + channel.setOut(System.out); |
| 146 | + channel.setErr(System.err); |
| 147 | + channel.setStreaming(StreamingChannel.Streaming.Sync); |
| 148 | + PipedOutputStream pos = new PipedOutputStream(); |
| 149 | + PipedInputStream pis = new PipedInputStream(pos); |
| 150 | + channel.setIn(pis); |
| 151 | + assertTrue("Could not open session", channel.open().await(DEFAULT_TIMEOUT)); |
| 152 | + LOG.info("writing some data..."); |
| 153 | + pos.write("\n\n".getBytes(StandardCharsets.UTF_8)); |
| 154 | + assertTrue("Channel should be open", channel.isOpen()); |
| 155 | + assertTrue(session.reExchangeKeys().verify(CONNECT_TIMEOUT).isDone()); |
| 156 | + assertTrue("Channel should be open", channel.isOpen()); |
| 157 | + LOG.info("writing some data..."); |
| 158 | + pos.write("\n\n".getBytes(StandardCharsets.UTF_8)); |
| 159 | + assertTrue("Channel should be open", channel.isOpen()); |
| 160 | + channel.close(true); |
| 161 | + } |
| 162 | + } |
| 163 | + } finally { |
| 164 | + sshdContainer.stop(); |
| 165 | + } |
| 166 | + } |
| 167 | + |
| 168 | + // Subclass ClientSessionImpl to get access to the strictKex flag. |
| 169 | + |
| 170 | + private static class TestSessionFactory extends SessionFactory { |
| 171 | + |
| 172 | + TestSessionFactory(ClientFactoryManager client) { |
| 173 | + super(client); |
| 174 | + } |
| 175 | + |
| 176 | + @Override |
| 177 | + protected ClientSessionImpl doCreateSession(IoSession ioSession) throws Exception { |
| 178 | + return new TestSession(getClient(), ioSession); |
| 179 | + } |
| 180 | + } |
| 181 | + |
| 182 | + private static class TestSession extends ClientSessionImpl { |
| 183 | + |
| 184 | + TestSession(ClientFactoryManager client, IoSession ioSession) throws Exception { |
| 185 | + super(client, ioSession); |
| 186 | + } |
| 187 | + |
| 188 | + boolean usesStrictKex() { |
| 189 | + return strictKex; |
| 190 | + } |
| 191 | + } |
| 192 | +} |
0 commit comments