作者:Janes

漏洞概要

Jenkins 未授權遠程代碼執行漏洞, 允許攻擊者將序列化的Java SignedObject對象傳輸給Jenkins CLI處理,反序列化ObjectInputStream作為Command對象,這將繞過基于黑名單的保護機制, 導致代碼執行。

漏洞觸發執行流程

SSD的報告披露了完整的漏洞細節,作為才學JAVA的我來說,看完這份報告,依舊不清楚具體的執行流程,因此有了下文,梳理漏洞觸發的具體執行流程。

觸發jenkins反序列化導致代碼執行的漏洞發生在使用HTTP協議實現雙向通信通道的代碼中,Jenkins利用此通道來接收命令。大致流程如下圖:

如何建立雙向Channel

基于HTTP建立雙向Channel的入口函數位于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);
            }
        }
    }
}

從上述代碼可知,建立一對雙向通道(download/upload), 需要發送兩次POST請求,根據請求頭Session字段的值uuid識別不同的雙向通道,Side字段的值識別download或upload通道,請求發送的順序是先發送download請求再發送upload請求,跟進download函數(/Users/js/IdeaProjects/vulnhub/jenkins-2.46.1/core/src/main/java/hudson/model/FullDuplexHttpChannel.java), 當服務器收到download請求時會阻塞請求,等待upload請求,收到upload請求后,新建Channel對象處理upload請求和返回響應,代碼如下:

 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();
    }

以上就是建立雙向通道的基本過程。

Channel對象啟動ReaderThread

upload請求作為輸入流實例化Channel對象(~/.m2/repository/org/jenkins-ci/main/remoting/3.7/remoting-3.7-sources.jar!/hudson/remoting/Channel.java), 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);

negotiate()會檢查輸入(upload請求)的前導碼, 所有發往Jenkins CLI的命令中都包含某種格式的前導碼(preamble),前導碼格式通常為:<===[JENKINS REMOTING CAPACITY]===>rO0ABXNyABpodWRzb24ucmVtb3RpbmcuQ2FwYWJpbGl0eQAAAAAAAAABAgABSgAEbWFza3hwAAAAAAAAAH4=, 該前導碼包含一個經過base64編碼的序列化對象。“Capability”類型的序列化對象的功能是告訴服務器客戶端具備哪些具體功能(比如HTTP分塊編碼功能)。

最后調用makeTransport()方法返回CommandTransport對象, 根據cap是否支持Chunking返回不同的對象ChunkedCommandTransportClassicCommandTransport

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);
    }
}

利用SSD的PoC腳本發送的upload請求返回的是ClassicCommandTransport對象,其繼承關系如下圖所示。

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對象

通過上面的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命令,反序列化導致的執行代碼觸發的相關異常如下:

類型轉換異常ClassCastException: org.apache.commons.collections.map.ReferenceMap cannot be cast to hudson.remoting.Command.

正常執行Command

雖說反序列化時就執行了cmd代碼,這里也順帶了解下正常的執行cmd的過程。SynchronousCommandTransport類的run()方法中,獲得返回的Command對象(cmd),然后調用receiver.handle(cmd);處理命令,其實質是回調Channel類構造方法里面的handle方法,而傳入handle方法的cmd參數就是反序列化得到的Command對象。

transport.setup(this, new CommandReceiver() {
            public void handle(Command cmd) {
                ......
                try {
                    cmd.execute(Channel.this);

繞過黑名單保護機制

上面過程主要講述的是漏洞觸發的流程,而該漏洞的核心是反序列化Java SignedObject對象會繞過黑名單保護機制,從而導致的代碼執行漏洞。

ClassFilter類定義的默認的黑名單如下:

private static final String[] DEFAULT_PATTERNS = {
        "^bsh[.].*",
        "^com[.]google[.]inject[.].*",
        "^com[.]mchange[.]v2[.]c3p0[.].*",
        "^com[.]sun[.]jndi[.].*",
        "^com[.]sun[.]corba[.].*",
        "^com[.]sun[.]javafx[.].*",
        "^com[.]sun[.]org[.]apache[.]regex[.]internal[.].*",
        "^java[.]awt[.].*",
        "^java[.]rmi[.].*",
        "^javax[.]management[.].*",
        "^javax[.]naming[.].*",
        "^javax[.]script[.].*",
        "^javax[.]swing[.].*",
        "^org[.]apache[.]commons[.]beanutils[.].*",
        "^org[.]apache[.]commons[.]collections[.]functors[.].*",
        "^org[.]apache[.]myfaces[.].*",
        "^org[.]apache[.]wicket[.].*",
        ".*org[.]apache[.]xalan.*",
        "^org[.]codehaus[.]groovy[.]runtime[.].*",
        "^org[.]hibernate[.].*",
        "^org[.]python[.].*",
        "^org[.]springframework[.](?!(\\p{Alnum}+[.])*\\p{Alnum}*Exception$).*",
        "^sun[.]rmi[.].*",
        "^javax[.]imageio[.].*",
        "^java[.]util[.]ServiceLoader$",
        "^java[.]net[.]URLClassLoader$"
    };

黑名單機制繞過可以通過分析補丁得到印證。

參考


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/295/