Cross-Site Request Forgery in Jenkins
Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.
Affected versions
0 → fixed in 2.176.22.177 → fixed in 2.186
Details
CSRF tokens in Jenkins 2.185 and earlier, LTS 2.176.1 and earlier did not expire, thereby allowing attackers able to obtain them to bypass CSRF protection.
The fix
[SECURITY-626]
core/src/main/java/hudson/security/csrf/DefaultCrumbIssuer.java+21 −3
@@ -17,14 +17,19 @@import jenkins.model.Jenkins;import hudson.model.ModelObject;+import javax.annotation.Nonnull;import javax.servlet.ServletRequest;import javax.servlet.http.HttpServletRequest;+import javax.servlet.http.HttpSession;+import jenkins.security.HexStringConfidentialKey;import net.sf.json.JSONObject;import org.acegisecurity.Authentication;import org.jenkinsci.Symbol;+import org.kohsuke.accmod.Restricted;+import org.kohsuke.accmod.restrictions.NoExternalUse;import org.kohsuke.stapler.DataBoundConstructor;import org.kohsuke.stapler.StaplerRequest;@@ -38,6 +43,9 @@ public class DefaultCrumbIssuer extends CrumbIssuer {private transient MessageDigest md;private boolean excludeClientIPFromCrumb;+@Restricted(NoExternalUse.class)+public static /* non-final: Groovy Console */ boolean EXCLUDE_SESSION_ID = SystemProperties.getBoolean(DefaultCrumbIssuer.class.getName() + ".EXCLUDE_SESSION_ID");+@DataBoundConstructorpublic DefaultCrumbIssuer(boolean excludeClientIPFromCrumb) {try {@@ -75,13 +83,15 @@ protected synchronized String issueCrumb(ServletRequest request, String salt) {HttpServletRequest req = (HttpServletRequest) request;StringBuilder buffer = new StringBuilder();Authentication a = Jenkins.getAuthentication();-if (a != null) {-buffer.append(a.getName());-}+buffer.append(a.getName());buffer.append(';');if (!isExcludeClientIPFromCrumb()) {buffer.append(getClientIP(req));}+if (!EXCLUDE_SESSION_ID) {+buffer.append(';');+buffer.append(getSessionId(req));+}md.update(buffer.toString().getBytes());return Util.toHexString(md.digest(salt.getBytes()));@@ -90,6 +100,14 @@ protected synchronized String issueCrumb(ServletRequest request, String salt) {return null;}+private String getSessionId(@Nonnull HttpServletRequest request) {+HttpSession session = request.getSession(false);+if (session == null) {+return "NO_SESSION";+}+return session.getId();+}+/*** {@inheritDoc}*/
test/src/test/java/hudson/security/csrf/DefaultCrumbIssuerSEC626Test.java+89 −0
@@ -0,0 +1,89 @@+/**+* Copyright (c) 2008-2010 Yahoo! Inc.+* All rights reserved.+* The copyrights to the contents of this file are licensed under the MIT License (http://www.opensource.org/licenses/mit-license.php)+*/++package hudson.security.csrf;++import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException;+import com.gargoylesoftware.htmlunit.html.DomElement;+import com.gargoylesoftware.htmlunit.html.HtmlPage;+import hudson.model.User;+import org.junit.Before;+import org.junit.Rule;+import org.junit.Test;+import org.jvnet.hudson.test.Issue;+import org.jvnet.hudson.test.JenkinsRule;+import org.jvnet.hudson.test.JenkinsRule.WebClient;++import static org.junit.Assert.assertEquals;+import static org.junit.Assert.assertTrue;+import static org.junit.Assert.fail;++/**+*+* @author dty+*/+//TODO merge back to DefaultCrumbIssuerTest+public class DefaultCrumbIssuerSEC626Test {++@Rule public JenkinsRule r = new JenkinsRule();++@Before public void setIssuer() {+r.jenkins.setCrumbIssuer(new DefaultCrumbIssuer(false));+}++@Test+@Issue("SECURITY-626")+public void crumbOnlyValidForOneSession() throws Exception {+r.jenkins.setSecurityRealm(r.createDummySecurityRealm());+DefaultCrumbIssuer issuer = new DefaultCrumbIssuer(false);+r.jenkins.setCrumbIssuer(issuer);++User.getById("foo", true);++DefaultCrumbIssuer.EXCLUDE_SESSION_ID = true;+compareDifferentSessions_tokenAreEqual(true);++DefaultCrumbIssuer.EXCLUDE_SESSION_ID = false;+compareDifferentSessions_tokenAreEqual(false);+}++private void compareDifferentSessions_tokenAreEqual(boolean areEqual) throws Exception {+WebClient wc = r.createWebClient();+wc.login("foo");++HtmlPage p = wc.goTo("configure");+String crumb1 = p.getElementByName("Jenkins-Crumb").getAttribute("value");+r.submit(p.getFormByName("config"));++wc.goTo("logout");+wc.login("foo");++p = wc.goTo("configure");+String crumb2 = p.getElementByName("Jenkins-Crumb").getAttribute("value");+r.submit(p.getFormByName("config"));++assertEquals(crumb1.equals(crumb2), areEqual);++if (areEqual) {+r.submit(p.getFormByName("config"));+} else {+replaceAllCrumbInPageBy(p, crumb1);+try {+// submit the form with previous session crumb+r.submit(p.getFormByName("config"));+fail();+} catch (FailingHttpStatusCodeException e) {+assertTrue(e.getMessage().contains("No valid crumb"));+}+}+}++private void replaceAllCrumbInPageBy(HtmlPage page, String newCrumb) {+for (DomElement el : page.getElementsByName("Jenkins-Crumb")) {+el.setAttribute("value", newCrumb);+}+}+}
References
- ADVISORYhttps://nvd.nist.gov/vuln/detail/CVE-2019-10353
- WEBhttps://github.com/jenkinsci/jenkins/commit/772152315aa0a9ba27b812a4ba0f3f9b64af78d9
- WEBhttps://access.redhat.com/errata/RHSA-2019:2503
- WEBhttps://access.redhat.com/errata/RHSA-2019:2548
- WEBhttps://jenkins.io/security/advisory/2019-07-17/#SECURITY-626
- WEBhttp://www.openwall.com/lists/oss-security/2019/07/17/2