Exposure of Sensitive Information to an Unauthorized Actor 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
Details
The re-key admin monitor was introduced in Jenkins 1.498 and re-encrypted all secrets in JENKINS_HOME with a new key. It also created a backup directory with all old secrets, and the key used to encrypt them. These backups were world-readable and not removed afterwards. Jenkins now deletes the backup directory, if present. Upgrading from before 1.498 will no longer create a backup directory. Administrators relying on file access permissions in their manually created backups are advised to check them for the directory $JENKINS_HOME/jenkins.security.RekeySecretAdminMonitor/backups, and delete it if present.
The fix
[SECURITY-376] Remove backup directory for
core/src/main/java/hudson/util/SecretRewriter.java+15 −23
@@ -2,7 +2,6 @@import com.trilead.ssh2.crypto.Base64;import hudson.model.TaskListener;-import org.apache.commons.io.FileUtils;import javax.crypto.Cipher;import javax.crypto.SecretKey;@@ -33,21 +32,21 @@ public class SecretRewriter {*/private int count;-/**-* If non-null the original file before rewrite gets in here.-*/-private final File backupDirectory;-/*** Canonical paths of the directories we are recursing to protect* against symlink induced cycles.*/private Set<String> callstack = new HashSet<String>();-public SecretRewriter(File backupDirectory) throws GeneralSecurityException {+public SecretRewriter() throws GeneralSecurityException {cipher = Secret.getCipher("AES");key = Secret.getLegacyKey();-this.backupDirectory = backupDirectory;+}++/** @deprecated SECURITY-376: {@code backupDirectory} is ignored */+@Deprecated+public SecretRewriter(File backupDirectory) throws GeneralSecurityException {+this();}private String tryRewrite(String s) throws IOException, InvalidKeyException {@@ -70,12 +69,14 @@ private String tryRewrite(String s) throws IOException, InvalidKeyException {return s;}-/**-* @param backup-* if non-null, the original file will be copied here before rewriting.-* if the rewrite doesn't happen, no copying.-*/+/** @deprecated SECURITY-376: {@code backup} is ignored */+@Deprecatedpublic boolean rewrite(File f, File backup) throws InvalidKeyException, IOException {+return rewrite(f);+}++public boolean rewrite(File f) throws InvalidKeyException, IOException {+AtomicFileWriter w = new AtomicFileWriter(f, "UTF-8");try {PrintWriter out = new PrintWriter(new BufferedWriter(w));@@ -117,10 +118,6 @@ public boolean rewrite(File f, File backup) throws InvalidKeyException, IOExcept}if (modified) {-if (backup!=null) {-backup.getParentFile().mkdirs();-FileUtils.copyFile(f,backup);-}w.commit();}return modified;@@ -165,11 +162,7 @@ private int rewriteRecursive(File dir, String relative, TaskListener listener) tif ((count++)%100==0)listener.getLogger().println("Scanning "+child);try {-File backup = null;-if (backupDirectory!=null) backup = new File(backupDirectory,relative+'/'+ cn);-if (rewrite(child,backup)) {-if (backup!=null)-listener.getLogger().println("Copied "+child+" to "+backup+" as a backup");+if (rewrite(child)) {listener.getLogger().println("Rewritten "+child);rewritten++;}@@ -199,7 +192,6 @@ protected boolean isIgnoredDir(File dir) {String n = dir.getName();return n.equals("workspace") || n.equals("artifacts")|| n.equals("plugins") // no mutable data here-|| n.equals("jenkins.security.RekeySecretAdminMonitor") // we don't want to rewrite backups|| n.equals(".") || n.equals("..");}
core/src/main/java/jenkins/security/RekeySecretAdminMonitor.java+4 −1
@@ -1,6 +1,7 @@package jenkins.security;import hudson.Extension;+import hudson.Util;import hudson.init.InitMilestone;import hudson.init.Initializer;import hudson.model.TaskListener;@@ -50,6 +51,7 @@ public class RekeySecretAdminMonitor extends AsynchronousAdministrativeMonitor {*/private final FileBoolean scanOnBoot = state("scanOnBoot");+@SuppressWarnings("OverridableMethodCallInConstructor") // should have been finalpublic RekeySecretAdminMonitor() throws IOException {// if JENKINS_HOME existed <1.497, we need to offer rewrite// this computation needs to be done and the value be captured,@@ -59,6 +61,7 @@ public RekeySecretAdminMonitor() throws IOException {if (j.isUpgradedFromBefore(new VersionNumber("1.496.*"))&& new FileBoolean(new File(j.getRootDir(),"secret.key.not-so-secret")).isOff())needed.on();+Util.deleteRecursive(new File(getBaseDir(), "backups")); // SECURITY-376: no longer used}@Override@@ -133,7 +136,7 @@ protected File getLogFile() {protected void fix(TaskListener listener) throws Exception {LOGGER.info("Initiating a re-keying of secrets. See "+getLogFile());-SecretRewriter rewriter = new SecretRewriter(new File(getBaseDir(),"backups"));+SecretRewriter rewriter = new SecretRewriter();try {PrintStream log = listener.getLogger();
core/src/test/groovy/hudson/util/SecretRewriterTest.groovy+1 −3
@@ -70,8 +70,7 @@ class SecretRewriterTest {*/@Testvoid recursionDetection() {-def backup = tmp.newFolder("backup")-def sw = new SecretRewriter(backup);+def sw = new SecretRewriter();def st = StreamTaskListener.fromStdout()def o = encryptOld("Hello world")@@ -101,7 +100,6 @@ class SecretRewriterTest {dirs.each { p->assert new File(t,"$p/foo.xml").text.trim()==answer-assert new File(backup,"$p/foo.xml").text.trim()==payload}// t2 is only reachable by following a symlink. this should be covered, too
[SECURITY-376] Remove backup directory for
core/src/main/java/hudson/util/SecretRewriter.java+15 −23
@@ -2,7 +2,6 @@import com.trilead.ssh2.crypto.Base64;import hudson.model.TaskListener;-import org.apache.commons.io.FileUtils;import javax.crypto.Cipher;import javax.crypto.SecretKey;@@ -33,21 +32,21 @@ public class SecretRewriter {*/private int count;-/**-* If non-null the original file before rewrite gets in here.-*/-private final File backupDirectory;-/*** Canonical paths of the directories we are recursing to protect* against symlink induced cycles.*/private Set<String> callstack = new HashSet<String>();-public SecretRewriter(File backupDirectory) throws GeneralSecurityException {+public SecretRewriter() throws GeneralSecurityException {cipher = Secret.getCipher("AES");key = Secret.getLegacyKey();-this.backupDirectory = backupDirectory;+}++/** @deprecated SECURITY-376: {@code backupDirectory} is ignored */+@Deprecated+public SecretRewriter(File backupDirectory) throws GeneralSecurityException {+this();}private String tryRewrite(String s) throws IOException, InvalidKeyException {@@ -70,12 +69,14 @@ private String tryRewrite(String s) throws IOException, InvalidKeyException {return s;}-/**-* @param backup-* if non-null, the original file will be copied here before rewriting.-* if the rewrite doesn't happen, no copying.-*/+/** @deprecated SECURITY-376: {@code backup} is ignored */+@Deprecatedpublic boolean rewrite(File f, File backup) throws InvalidKeyException, IOException {+return rewrite(f);+}++public boolean rewrite(File f) throws InvalidKeyException, IOException {+AtomicFileWriter w = new AtomicFileWriter(f, "UTF-8");try {PrintWriter out = new PrintWriter(new BufferedWriter(w));@@ -117,10 +118,6 @@ public boolean rewrite(File f, File backup) throws InvalidKeyException, IOExcept}if (modified) {-if (backup!=null) {-backup.getParentFile().mkdirs();-FileUtils.copyFile(f,backup);-}w.commit();}return modified;@@ -165,11 +162,7 @@ private int rewriteRecursive(File dir, String relative, TaskListener listener) tif ((count++)%100==0)listener.getLogger().println("Scanning "+child);try {-File backup = null;-if (backupDirectory!=null) backup = new File(backupDirectory,relative+'/'+ cn);-if (rewrite(child,backup)) {-if (backup!=null)-listener.getLogger().println("Copied "+child+" to "+backup+" as a backup");+if (rewrite(child)) {listener.getLogger().println("Rewritten "+child);rewritten++;}@@ -199,7 +192,6 @@ protected boolean isIgnoredDir(File dir) {String n = dir.getName();return n.equals("workspace") || n.equals("artifacts")|| n.equals("plugins") // no mutable data here-|| n.equals("jenkins.security.RekeySecretAdminMonitor") // we don't want to rewrite backups|| n.equals(".") || n.equals("..");}
core/src/main/java/jenkins/security/RekeySecretAdminMonitor.java+4 −1
@@ -1,6 +1,7 @@package jenkins.security;import hudson.Extension;+import hudson.Util;import hudson.init.InitMilestone;import hudson.init.Initializer;import hudson.model.TaskListener;@@ -50,6 +51,7 @@ public class RekeySecretAdminMonitor extends AsynchronousAdministrativeMonitor {*/private final FileBoolean scanOnBoot = state("scanOnBoot");+@SuppressWarnings("OverridableMethodCallInConstructor") // should have been finalpublic RekeySecretAdminMonitor() throws IOException {// if JENKINS_HOME existed <1.497, we need to offer rewrite// this computation needs to be done and the value be captured,@@ -59,6 +61,7 @@ public RekeySecretAdminMonitor() throws IOException {if (j.isUpgradedFromBefore(new VersionNumber("1.496.*"))&& new FileBoolean(new File(j.getRootDir(),"secret.key.not-so-secret")).isOff())needed.on();+Util.deleteRecursive(new File(getBaseDir(), "backups")); // SECURITY-376: no longer used}@Override@@ -133,7 +136,7 @@ protected File getLogFile() {protected void fix(TaskListener listener) throws Exception {LOGGER.info("Initiating a re-keying of secrets. See "+getLogFile());-SecretRewriter rewriter = new SecretRewriter(new File(getBaseDir(),"backups"));+SecretRewriter rewriter = new SecretRewriter();try {PrintStream log = listener.getLogger();
core/src/test/groovy/hudson/util/SecretRewriterTest.groovy+1 −3
@@ -70,8 +70,7 @@ class SecretRewriterTest {*/@Testvoid recursionDetection() {-def backup = tmp.newFolder("backup")-def sw = new SecretRewriter(backup);+def sw = new SecretRewriter();def st = StreamTaskListener.fromStdout()def o = encryptOld("Hello world")@@ -101,7 +100,6 @@ class SecretRewriterTest {dirs.each { p->assert new File(t,"$p/foo.xml").text.trim()==answer-assert new File(backup,"$p/foo.xml").text.trim()==payload}// t2 is only reachable by following a symlink. this should be covered, too
References
- ADVISORYhttps://nvd.nist.gov/vuln/detail/CVE-2017-1000362
- WEBhttps://github.com/jenkinsci/jenkins/commit/0be33cf7328fad6a7596ce9505a74561a8b1eb85
- WEBhttps://github.com/jenkinsci/jenkins/commit/a572450f039fdb99410fcf6eb0ba307bd69ea458
- PACKAGEhttps://github.com/jenkinsci/jenkins
- WEBhttps://jenkins.io/security/advisory/2017-02-01