首先是Jenkins环境的部署(都是烂大街的东西,就不写的太多了)

wget https://repo.huaweicloud.com/jenkins/redhat-stable/jenkins-2.190.3-1.1.noarch.rpm

rpm -ivh jenkins-2.190.3-1.1.noarch.rpm

# 启动jenkins服务
systemctl start jenkins

# 查看jenkins状态
systemctl status jenkins

记得关掉防火墙

查看密码

CVE-2017-1000353 (Jenkins 远程代码执行漏洞)

CVE-2017-1000353 是一个与 Jenkins CI(持续集成工具)相关的漏洞,该漏洞可能导致远程代码执行。

由于我之前自己配制的jenkins环境有问题,所以后期我选择了vulhub靶场进行复现

具体攻击手法我就不再多赘述了,网上的复现都烂大街了,我就从它的源代码层面来分析一下这个漏洞的形成原因

代码层面的漏洞成因

Jenkins有一个专门进行命令执行的模块

而该反序列化漏洞就是出现在jenkins利用http协议进行双向通信的过程中,在该快代码中发生的

大致历程就是这样的:

创建双向channel->启动Reader Thread->读取command对象->反序列化漏洞执行cmd

双向通道构成

双向通道入口函数位于

jenkins-2.46.1/core/src/main/java/hudson/cli/CLIAction.java

@Extension @Symbol("cli")
@Restricted(NoExternalUse.class)
public class CLIAction implements UnprotectedRootAction, StaplerProxy {

    private transient final Map<UUID,FullDuplexHttpChannel> duplexChannels = new HashMap<UUID, FullDuplexHttpChannel>();

     ......

    @Override
    public Object getTarget() {
        StaplerRequest req = Stapler.getCurrentRequest();
        if (req.getRestOfPath().length()==0 && "POST".equals(req.getMethod())) {
            // CLI connection request
            throw new CliEndpointResponse();
        } else {
            return this;
        }
    }

    private class CliEndpointResponse extends HttpResponseException {
        @Override
        public void generateResponse(StaplerRequest req, StaplerResponse rsp, Object node) throws IOException, ServletException {
            try {
                // do not require any permission to establish a CLI connection
                // the actual authentication for the connecting Channel is done by CLICommand

                UUID uuid = UUID.fromString(req.getHeader("Session"));
                rsp.setHeader("Hudson-Duplex",""); // set the header so that the client would know

                FullDuplexHttpChannel server;
                if(req.getHeader("Side").equals("download")) {
                    duplexChannels.put(uuid,server=new FullDuplexHttpChannel(uuid, !Jenkins.getActiveInstance().hasPermission(Jenkins.ADMINISTER)) {
                        @Override
                        protected void main(Channel channel) throws IOException, InterruptedException {
                            // capture the identity given by the transport, since this can be useful for SecurityRealm.createCliAuthenticator()
                            channel.setProperty(CLICommand.TRANSPORT_AUTHENTICATION, Jenkins.getAuthentication());
                            channel.setProperty(CliEntryPoint.class.getName(),new CliManagerImpl(channel));
                        }
                    });
                    try {
                        server.download(req,rsp);
                    } finally {
                        duplexChannels.remove(uuid);
                    }
                } else {
                    duplexChannels.get(uuid).upload(req,rsp);
                }
            } catch (InterruptedException e) {
                throw new IOException(e);
            }
        }
    }

分析这段代码可以看出:

CliEndpointResponse是一个内部类,继承自HttpResponseException。它重写了generateResponse()方法,用于处理CLI连接的建立和数据传输。

在generateResponse()方法中,通过请求头中的Session标识符和Side标识符来处理下载和上传两种不同的请求。

如果是下载请求(Side为"download"),则根据Session标识符创建一个FullDuplexHttpChannel实例,并进行下载操作。下载完成后,从duplexChannels中移除对应的通道。

如果是上传请求,则根据Session标识符获取对应的FullDuplexHttpChannel实例,并进行上传操作。

跟进download函数

jenkins-2.46.1/core/src/main/java/hudson/model/FullDuplexHttpChannel.java

public synchronized void download(StaplerRequest req, StaplerResponse rsp) throws InterruptedException, IOException {
            ......

        {// wait until we have the other channel
            long end = System.currentTimeMillis() + CONNECTION_TIMEOUT;
            while (upload == null && System.currentTimeMillis()<end)
                wait(1000);

            if (upload==null)
                throw new IOException("HTTP full-duplex channel timeout: "+uuid);
        }

        try {
            channel = new Channel("HTTP full-duplex channel " + uuid,
                    Computer.threadPoolForRemoting, Mode.BINARY, upload, out, null, restricted);
             ......
        } finally {
            // publish that we are done
            completed=true;
            notify();
        }
    }

  public synchronized void upload(StaplerRequest req, StaplerResponse rsp) throws InterruptedException, IOException {
        rsp.setStatus(HttpServletResponse.SC_OK);
        InputStream in = req.getInputStream();
        if(DIY_CHUNKING)    in = new ChunkedInputStream(in);

        // publish the upload channel
        upload = in;
        notify();

        // wait until we are done
        while (!completed)
            wait();
    }

在 download() 方法中,通过循环等待来获取上传通道。它会不断检查 upload 是否为 null,直到超过设定的连接超时时间(CONNECTION_TIMEOUT)或者成功获取到上传通道。如果超时仍未获取到上传通道,则抛出 IOException 异常。

然后,通过创建一个新的 Channel 对象来建立全双工通道。这个通道使用上传通道和输出流作为输入参数,并指定了线程池和模式。

最后,在 finally 块中,设置 completed 为 true,并调用 notify() 方法通知其他等待线程,表示下载操作已完成。

在 upload() 方法中,首先设置响应状态为 HttpServletResponse.SC_OK,表示上传操作已经准备就绪。

然后,通过 req.getInputStream() 获取请求中的输入流,并进行可选的分块处理。

接下来,将上传通道设置为获取到的输入流,并调用 notify() 方法通知其他等待线程。

最后,在循环中等待直到 completed 为 true,表示下载操作已完成。在等待过程中,其他线程可以通过调用 notify() 方法来通知上传操作已完成。

这是双向通道建立的基本过程

启动ReaderThread

ReaderThread是由Channel对象来启动。在原代码中,channel对象被upload请求作为输入流来实例化

channel类的构造链:

最终调用的构造方法为Channel(ChannelBuilder settings, CommandTransport transport),

该构造方法的transport参数,由ChannelBuilder类的negotiate()方法获得。

protected CommandTransport negotiate(final InputStream is, final OutputStream os) throws IOException {
          ......
        {// read the input until we hit preamble
            Mode[] modes={Mode.BINARY,Mode.TEXT};
            byte[][] preambles = new byte[][]{Mode.BINARY.preamble, Mode.TEXT.preamble, Capability.PREAMBLE};
            int[] ptr=new int[3];
            Capability cap = new Capability(0); // remote capacity that we obtained. If we don't hear from remote, assume no capability

            while(true) {
                int ch = is.read();
                ......
                for(int i=0;i<preambles.length;i++) {
                    byte[] preamble = preambles[i];
                    if(preamble[ptr[i]]==ch) {
                        if(++ptr[i]==preamble.length) {
                            switch (i) {
                            case 0:
                            case 1:
                                ......
                                return makeTransport(is, os, mode, cap);
                            case 2:
                                cap = Capability.read(is);

在这个方法中,首先定义了两个数组 modes 和 preambles。modes 数组包含了两种通信模式,分别是 Mode.BINARY 和 Mode.TEXT。preambles 数组包含了对应通信模式的标志序列,以及一个表示能力的标志序列。

然后,通过循环不断读取输入流 is 的字节,直到遇到标志序列的起始字节。

在每次读取字节后,会遍历 preambles 数组,检查当前字节是否与某个标志序列的下一个字节匹配。如果匹配成功,则将指针 ptr[i] 自增,并检查是否已经匹配到了整个标志序列。

如果匹配到了 Mode.BINARY 或 Mode.TEXT 的标志序列,则根据匹配的模式创建相应的传输对象,并返回该对象。

如果匹配到了能力的标志序列,则通过 Capability.read(is) 方法从输入流中读取远程能力信息,并将其存储在 cap 对象中。

protected CommandTransport makeTransport(InputStream is, OutputStream os, Mode mode, Capability cap) throws IOException {
    FlightRecorderInputStream fis = new FlightRecorderInputStream(is);

    if (cap.supportsChunking())
        return new ChunkedCommandTransport(cap, mode.wrap(fis), mode.wrap(os), os);
    else {
        ObjectOutputStream oos = new ObjectOutputStream(mode.wrap(os));
        oos.flush();    // make sure that stream preamble is sent to the other end. avoids dead-lock

        return new ClassicCommandTransport(
                new ObjectInputStreamEx(mode.wrap(fis),getBaseLoader(),getClassFilter()),
                oos,fis,os,cap);
    }
}

在该方法中,首先使用 FlightRecorderInputStream 对输入流 is 进行包装,以记录输入流的操作。

然后,根据远程能力 cap 是否支持分块传输进行判断。如果支持分块传输,则创建一个 ChunkedCommandTransport 对象,该对象使用支持分块传输的模式包装输入输出流。

如果不支持分块传输,则创建一个 ClassicCommandTransport 对象。在创建该对象之前,先使用 ObjectOutputStream 对输出流 os 进行包装,并调用 flush() 方法确保流的前导信息被发送到对端,以避免死锁。然后,分别使用 ObjectInputStreamEx 对输入流 fis 和 ObjectOutputStream 对输出流 oos 进行包装,创建 ClassicCommandTransport 对象。

最终,根据远程能力的支持情况,选择合适的传输方式并返回相应的 CommandTransport 对象。

negotiate()会检查输入(upload请求)的前导码, 所有发往Jenkins CLI的命令中都包含某种格式的前导码(preamble),前导码格式通常为:<===[JENKINS REMOTING CAPACITY]===>rO0ABXNyABpodWRzb24ucmVtb3RpbmcuQ2FwYWJpbGl0eQAAAAAAAAABAgABSgAEbWFza3hwAAAAAAAAAH4=, 该前导码包含一个经过base64编码的序列化对象。“Capability”类型的序列化对象的功能是告诉服务器客户端具备哪些具体功能(比如HTTP分块编码功能)。

现在github上流行的POC发送数据包之后返回的是ClassicCommandTransport对象

继承关系如下所示

ClassicCommandTransport -> SynchronousCommandTransport -> CommandTransport

Channel构造函数Channel(ChannelBuilder settings, CommandTransport transport)中, transport.setup()调用SynchronousCommandTransport类的setup()方法来启动一个ReaderThread线程。

public void setup(Channel channel, CommandReceiver receiver) {
        this.channel = channel;
        new ReaderThread(receiver).start();
    }

读取Command对象

在该类的构造函数中,接收一个 CommandReceiver 对象作为参数,并通过调用父类 Thread 的构造函数来设置线程的名称。

在 run() 方法中,使用一个循环来读取通道中的命令,直到通道被关闭为止。在每次循环迭代中,通过调用 read() 方法来读取命令。

//通过上面的ReaderThread.start()方法启动一个线程,ReaderThread为SynchronousCommandTransport类的内部类,在run()方法中,调用ClassicCommandTransport类的read()方法读取输入,read()方法实际是调用Command类的readFrom()方法读取,通过反序列化输入返回一个Command对象。
private final class ReaderThread extends Thread {
        ......
        public ReaderThread(CommandReceiver receiver) {
            super("Channel reader thread: "+channel.getName());
            this.receiver = receiver;
        }

        @Override
        public void run() {
            final String name =channel.getName();
            try {
                while(!channel.isInClosed()) {
                    Command cmd = null;
                    try {
                        cmd = read();
  public final Command read() throws IOException, ClassNotFoundException {
        try {
            Command cmd = Command.readFrom(channel, ois);

在反序列化输入返回一个Command对象时就执行了cmd命令,

而不是通过正常的回调handle()方法执行cmd命令