Contents

Decrypting Jenkins secrets and more

Well known tricks ro decrypt Jenkins secrets and some extra.

Retrieve password from hash

Jenkins stores secrets in credentials.xml, they may look like hash {AAAAAAAAAAAANzxft/rDzyt8thxpp3O72dxvVqZksL8vBJ4jNKvAjAA=}

Online via Script console

Jenkins must be online. In order to decrypt hash you need to open /script page (an example http://jenkins-host/script) to run below scripts:

  • Decrypt single secret

    1
    
    println(hudson.util.Secret.decrypt("{AAAAAAAAAAAANzxft/rDzyt8thxpp3O72dxvVqZksL8vBJ4jNKvAjAA=}"))
    
  • One liner to decrypt all

    1
    
    com.cloudbees.plugins.credentials.SystemCredentialsProvider.getInstance().getCredentials().forEach{println it.dump().replace(' ', '\n')} 
    
    • Nos-system provides
       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      
      import com.cloudbees.plugins.credentials.CredentialsProvider
      import com.cloudbees.plugins.credentials.Credentials
      import com.cloudbees.plugins.credentials.domains.Domain
      import jenkins.model.Jenkins
      def indent = { String text, int indentationCount ->
        def replacement = "\t" * indentationCount
        text.replaceAll("(?m)^", replacement)
      }
      
      Jenkins.get().allItems().collectMany{ CredentialsProvider.lookupStores(it).toList()}.unique().forEach { store ->
        Map<Domain, List<Credentials>> domainCreds = [:]
        store.domains.each { domainCreds.put(it, store.getCredentials(it))}
        if (domainCreds.collectMany{ it.value}.empty) {
          return
        }
        def shortenedClassName = store.getClass().name.substring(store.getClass().name.lastIndexOf(".") + 1)
        println "Credentials for store context: ${store.contextDisplayName}, of type $shortenedClassName"
        domainCreds.forEach { domain , creds ->
          println indent("Domain: ${domain.name}", 1)
          creds.each { cred ->
            cred.properties.each { prop, val ->
              println indent("$prop = \"$val\"", 2)
            }
            println indent("-----------------------", 2)
          }
        }
      }
      

Offline via python script

Secrets are encrypted in credentials.xml using AES-128 with hudson.util.Secret as the key, then are base64 encoded. hudson.util.Secret binary file is encrypted with master.key. master.key is stored in plain text.

Get below files from Jenkins instance (do not rename)

  • $JENKINS_HOME/credentials.xml
  • $JENKINS_HOME/secrets/master.key
  • $JENKINS_HOME/secrets/hudson.util.Secret

Put those files and below script to directory

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
#!/usr/bin/env python3

import argparse
import base64
import mimetypes
import os
import xml.etree.ElementTree as ET
from xml.etree.ElementTree import ParseError

from hashlib import md5
from hashlib import sha256
from Crypto.Cipher import AES


class JenkinsDecrypt():
    def __init__(self, master_key, hudson_secret_key):
        # You can find the password format at
        # https://github.com/jenkinsci/jenkins/blob/master/core/src/main/java/hudson/util/Secret.java#L167-L216
        self.MAGIC = b"::::MAGIC::::"
        self.verbose = False
        self.load_master_key(master_key)
        self.load_credentials_confidential_key(hudson_secret_key, "hudson_secret")


    def load_master_key(self, master_key):
        """Read master.key which is used to decrypt instances of ConfidentialKey"""
        master_key = open(master_key, "rb").read()
        self.hashed_master_key = sha256(master_key).digest()[:16]


    def load_credentials_confidential_key(self, path, key_name):
        """Read and decrypt an instance of ConfidentialKey"""
        secret_key_file = open(path, "rb").read()
        o = AES.new(self.hashed_master_key, AES.MODE_ECB)
        secret = o.decrypt(secret_key_file)

        secret = secret[:-16]
        secret = secret[:16]
        setattr(self, key_name, secret)


    def decrypt_secret_bytes(self, data):
        """decrypt() function from credentials-plugin
        https://github.com/jenkinsci/credentials-plugin/blob/master/src/main/java/com/cloudbees/plugins/credentials/SecretBytes.java#L200
        """
        if not data:
            return None
        try:
            if self.credentials_secret is None:
                # The ciphertext doesn't have a newline. So let's at least improve output
                return data + "\n"
        except AttributeError:
            setattr(self, "credentials_secret", None)
            print("\nWARNING - Use --credentials-secret to specify path to Credentials plugin key\n")
            return data + "\n"

        salt_len = 8
        iv_len = 16
        key_len = 16

        data = data.strip("{}")
        p = base64.decodebytes(bytes(data, "utf-8"))

        # decrypt() from SecretBytes.java in credentials-plugin
        totalLen = len(p)
        salt = p[0:salt_len]
        padLen = p[salt_len]
        ct_len = totalLen - salt_len - 1 - (padLen & 0xff)
        encryptedBytes = p[salt_len+1:salt_len+1+ct_len]

        # createCipher() from CredentialsConfidentialKey.java in credentials-plugin
        m = sha256()
        m.update(self.credentials_secret)
        m.update(salt)
        message_digest = m.digest()
        real_key = message_digest[0:key_len]
        real_iv = message_digest[key_len:key_len+iv_len]
        o = AES.new(real_key, AES.MODE_CBC, real_iv)

        pt_bytes = o.decrypt(encryptedBytes)
        return pt_bytes.strip(b"\x0e").decode("utf-8")


    def decrypt_new_password(self, p):
        p = p[1:] #Strip the version

        # Get the length of the IV, almost certainly 16 bytes, but calculating for completeness sake
        iv_length = ((p[0] & 0xff) << 24) | ((p[1] & 0xff) << 16) | ((p[2] & 0xff) << 8) | (p[3] & 0xff)

        if iv_length != 16:
            self.vprint(f"WARN - {p} had invalid IV length of {iv_length}")
            return None

        # Strip the iv length
        p = p[4:]
        # Get the data length, not currently used
        #data_length = ((p[0] & 0xff) << 24) | ((p[1] & 0xff) << 16) | ((p[2] & 0xff) << 8) | (p[3] & 0xff)

        # Strip the data length
        p = p[4:]
        iv = p[:iv_length]
        p = p[iv_length:]
        o = AES.new(self.hudson_secret, AES.MODE_CBC, iv)
        decrypted_p = o.decrypt(p)

        # We may need to strip PKCS7 padding
        fully_decrypted_blocks = decrypted_p[:-16]
        possibly_padded_block = decrypted_p[-16:]
        padding_length = possibly_padded_block[-1]
        if padding_length < 16: # Less than size of one block, so we have padding
            possibly_padded_block = possibly_padded_block[:-padding_length]

        pw = fully_decrypted_blocks + possibly_padded_block
        pw = pw.decode("utf-8")
        return pw


    def decrypt_old_password(self, p):
        # Copying the old code, I have not verified if it works
        # pycrypto requires that values be a multiple of 16 in length
        if len(p) % 16 != 0:
            self.vprint(f"WARN - {base64.b64encode(p)} had invalid length")
            return None
        o = AES.new(self.hudson_secret, AES.MODE_ECB)
        x = o.decrypt(p)
        if self.MAGIC in x:
            pw = x.split(self.MAGIC)[0]
            return pw.decode("utf-8")
        self.vprint(f"WARN - Failed to decrypt {base64.b64encode(p)}")
        return None


    def decrypt(self, password, apiToken=False):
        if not password or not self.hudson_secret:
            return None
        password = password.strip("{}")
        try:
            p = base64.decodebytes(bytes(password, "utf-8"))
        except base64.binascii.Error as e:
            self.vprint(f"WARN - this value doesn't appear to be base64. Maybe it's plaintext?\n{password}")
            return password

        # Get payload version
        payload_version = p[0]
        if payload_version == 1:
            decrypted_value = self.decrypt_new_password(p)
        else:
            # Assuming we don't have a V2 payload, seeing as current crypto isn't
            # horrible that's a fair assumption
            decrypted_value = self.decrypt_old_password(p)
        if apiToken:
            if not decrypted_value:
                return decrypted_value
            # Return the md5 hex digest of the secret for the real apiToken
            if len(decrypted_value) != 48:
                self.vprint("WARN - apiToken has incorrect length")
                return decrypted_value

            # The secret value seems to consistently be padded with 16 extra bytes
            decrypted_bytes = bytes(decrypted_value, "utf-8")[:32]
            md5_obj = md5()
            md5_obj.update(decrypted_bytes)
            decrypted_value = md5_obj.hexdigest()
        return decrypted_value


    def get_tokens_from_node(self, node_name, root_element_tree):
        """Create key-value pairs from children of node_name. Return as dict."""
        nodes = root_element_tree.findall(f".//{node_name}")

        for node in nodes:
            creds = {}
            for child in node:
                creds[child.tag] = child.text
                # Only a couple plugins have one more level of nested children
                for subchild in child:
                    creds[subchild.tag] = subchild.text
            yield creds


    def add_attributes(self, base_str, plugin_tree, **kwargs):
        """Add values of other XML tags if they exist

        kwargs should be in the form of:
        xml_tag="printed description of tag"
        """
        for key in kwargs:
            attribute = plugin_tree.get(key, None)
            if attribute:
                base_str = base_str + f"\n{kwargs[key]}: {attribute}"
        return base_str


    def print_creds_from_plugins(self, file_tree):
        """Search a file for all plugins and associated values we're interested in."""
        # username / password
        output_fmt = "{} / {}"
        plugins = [
            "com.cloudbees.plugins.credentials.impl.CertificateCredentialsImpl",
            "com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl",
            "com.cloudbees.jenkins.plugins.awscredentials.AWSCredentialsImpl",
            "com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey",

            "hudson.plugins.ec2.EC2Cloud",
            "hudson.security.HudsonPrivateSecurityRealm_-Details",
            "hudson.scm.CVSSCM.xml",
            "hudson.tools.JDKInstaller.xml",

            "jenkins.security.ApiTokenProperty",
            "jenkins.security.plugins.ldap.LDAPConfiguration",

            "org.jenkinsci.main.modules.cli.auth.ssh.UserPropertyImpl",
            "org.jenkinsci.plugins.docker.commons.credentials.DockerServerCredentials",
            "org.jenkinsci.plugins.github__branch__source.GitHubAppCredentials",
            "org.jenkinsci.plugins.kubernetes.credentials.OpenShiftBearerTokenCredentialImpl",
            "org.jenkinsci.plugins.p4.credentials.P4PasswordImpl",
            "org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl",
            "org.jenkinsci.plugins.plaincredentials.impl.StringCredentialsImpl"
        ]

        # Find, decrypt, and print credentials for each plugin
        finding = False
        for plugin in plugins:
            creds = self.get_tokens_from_node(plugin, file_tree)

            if not creds:
                continue
            for cred in creds:
                try:
                    output = None
                    ## com.cloudbees ##
                    if plugin == "com.cloudbees.plugins.credentials.impl.CertificateCredentialsImpl":
                        output = output_fmt.format(
                            "Cert ID: " + cred.get("id", None),
                            self.decrypt_secret_bytes(cred.get("uploadedKeystoreBytes", None)))
                        output = self.add_attributes(output, cred, description="Description")
                    elif plugin == "com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl":
                        output = output_fmt.format(
                            cred.get("username", None),
                            self.decrypt(cred.get("password", None)))
                        output = self.add_attributes(output, cred, description="Description")
                    elif plugin == "com.cloudbees.jenkins.plugins.awscredentials.AWSCredentialsImpl":
                        output = output_fmt.format(
                            cred.get("accessKey", None),
                            self.decrypt(cred.get("secretKey", None)))
                        output = self.add_attributes(output, cred, description="Description", iamRoleArn="IAM role")
                    elif plugin == "com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey":
                        output = output_fmt.format(
                            cred.get("username", None),
                            self.decrypt(cred.get("privateKey", None)))
                        output = self.add_attributes(output, cred, description="Description")

                    ## hudson ##
                    elif plugin == "hudson.plugins.ec2.EC2Cloud":
                        output = "EC2 Cloud key:\n{}".format(
                            self.decrypt(cred.get("privateKey", None)))
                    elif plugin == "hudson.security.HudsonPrivateSecurityRealm_-Details":
                        output = "Password hash: {}".format(cred.get("passwordHash", None))
                    elif plugin == "hudson.scm.CVSSCM.xml":
                        output = output_fmt.format(
                            cred.get("privateKeyLocation", None),
                            self.decrypt(cred.get("privateKeyPassword", None)))
                    elif plugin == "hudson.tools.JDKInstaller.xml":
                        output = output_fmt.format(
                            cred.get("username", None),
                            self.decrypt(cred.get("password", None)))

                    ## jenkins.security ##
                    elif plugin == "jenkins.security.ApiTokenProperty":
                        # Starting in Jenkins 2.129 they changed the way API tokens are generated
                        # They now store a SHA256 hash on disk which means we can't recover the actual token
                        # https://www.jenkins.io/blog/2018/07/02/new-api-token-system/
                        output = "apiToken: {}".format(self.decrypt(cred.get("apiToken", None), apiToken=True))
                    elif plugin == "jenkins.security.plugins.ldap.LDAPConfiguration":
                        output = output_fmt.format(
                            cred.get("server", None),
                            self.decrypt(cred.get("managerPasswordSecret", None)))

                    ## org.jenkinsci ##
                    elif plugin == "org.jenkinsci.main.modules.cli.auth.ssh.UserPropertyImpl":
                        output = "Authorized keys: {}".format(cred.get("authorizedKeys", None))
                    elif plugin == "org.jenkinsci.plugins.docker.commons.credentials.DockerServerCredentials":
                        output = output_fmt.format(
                            "Docker Server ID: " + cred.get("id", None),
                            self.decrypt(cred.get("clientKey", None)))
                        output = self.add_attributes(output, cred, description="Description")
                    elif plugin == "org.jenkinsci.plugins.github__branch__source.GitHubAppCredentials":
                        output = output_fmt.format(
                            "GitHub App ID: " + cred.get("id", None),
                            self.decrypt(cred.get("privateKey", None)))
                    elif plugin == "org.jenkinsci.plugins.kubernetes.credentials.OpenShiftBearerTokenCredentialImpl":
                        output = output_fmt.format(
                            cred.get("username", None),
                            self.decrypt(cred.get("password", None)))
                        output = self.add_attributes(output, cred, description="Description")
                    elif plugin == "org.jenkinsci.plugins.p4.credentials.P4PasswordImpl":
                        output = output_fmt.format(
                            cred.get("username", None),
                            self.decrypt(cred.get("password", None)))
                    elif plugin == "org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl":
                        output = output_fmt.format(
                            cred.get("fileName", None),
                            self.decrypt_secret_bytes(cred.get("secretBytes", None)))
                        output = self.add_attributes(output, cred, description="Description")
                    elif plugin == "org.jenkinsci.plugins.plaincredentials.impl.StringCredentialsImpl":
                        output = "Secret string: {}".format(
                            self.decrypt(cred.get("secret", None)))
                        output = self.add_attributes(output, cred, description="Description")

                    # Only print plugin info if we find results
                    if output:
                        finding = True
                        section_label = "\n=== {} ===".format(plugin)
                        print(section_label)
                        print(output)
                except KeyError as e:
                    self.vprint(f"WARN - {cred} didn't have an attribute we need\n{e}")
        if finding:
            # If we have found something in this file, print a newline to
            # improve output with -v
            self.vprint("")


    def find_xml_files(self, directory):
        """Return all xml files from directory and subdirectories"""
        for dirpath, dirnames, filenames in os.walk(directory, topdown=True):
            # Modify dirnames in place to exclude plugins directory
            dirnames[:] = [d for d in dirnames if d != "plugins"]
            for filename in filenames:
                if mimetypes.guess_type(filename)[0] == "application/xml":
                    yield dirpath + "/" + filename


    def parse_xml_file(self, xml_file):
        try:
            return ET.parse(xml_file).getroot()
        except ParseError:
            self.vprint(f"WARN - {xml_file} contains improperly formatted XML")


    def vprint(self, print_string):
        """Verbose print. Only prints if -v is passed"""
        if self.verbose:
            print(print_string)


def main():
    parser = argparse.ArgumentParser()
    group = parser.add_mutually_exclusive_group(required=True)
    parser.add_argument("master_key", help="Path to master.key")
    parser.add_argument("hudson_util_secret", help="Path to hudson.util.Secret")
    parser.add_argument("-cs", "--credentials-secret", help="Path to credentials-plugin key (com.cloudbees.plugins.credentials.SecretBytes.KEY)")
    group.add_argument("-f", "--file", help="File with credentials")
    group.add_argument("-d", "--dir", help="Directory to search")
    parser.add_argument("-v", "--verbose", action="store_true", help="Include errors and warnings")
    args = parser.parse_args()

    jd = JenkinsDecrypt(args.master_key, args.hudson_util_secret)
    if args.verbose:
        jd.verbose = True
    if args.credentials_secret:
        jd.load_credentials_confidential_key(args.credentials_secret, "credentials_secret")

    if args.dir:
        all_xml_files = jd.find_xml_files(os.path.realpath(args.dir))
        for xml_file in all_xml_files:
            jd.vprint(f"{xml_file}...")
            credentials_file_tree = jd.parse_xml_file(xml_file)
            if not credentials_file_tree:
                continue
            jd.print_creds_from_plugins(credentials_file_tree)
    else:
        credentials_file_tree = jd.parse_xml_file(args.file)
        if credentials_file_tree:
            jd.print_creds_from_plugins(credentials_file_tree)


if __name__ == "__main__":
    main()

Run python container in the directory

1
docker run -it --rm -v "$(pwd)":/app:ro python:alpine  sh -c "pip install setuptools wheel pycryptodome && sh"

Using previously downloaded files you can decrypt secrets:

1
2
cd /app
python decrypt.py -f credentials.xml -v master.key hudson.util.Secret

Reveal hidden secrets hash in credentials

Secrets hash also available on manage > credentials page (http://jenkins-host/manage/credentials/), but it is shown with asterisks or dots.
In order to get secret hash you need to open credential record and click on update.The field ‘Secret Access Key’ contains Concealed placeholder, even after pressed ‘Change password’ it shows asterisks or dots. The trick here is to use page HTML code to find the password hash. Chrome has developer tools > inspect on right mouse button click. The hash is visible in HTML code.

Set user-defined token in GitLab plugin

Similar technique can be used to set user defined Secret token when using Gitlab Plugin.

Go to job config, find section Build Triggers, below should me selected:

[x] Build when a change is pushed to GitLab. GitLab webhook URL: http://jenkins-host/project/my-project

Then expand Advanced section to see below

Secret token:

From the very first look the value of Secret token can be only generated using Generate button. However, with developer tools > inspect you can edit HTML and put any value.

Misc

List of installed plugins

1
curl http://jenkins-host/pluginManager/api/xml?depth=1&xpath=/*/*/shortName%7C/*/*/version&wrapper=plugins

Get jenkins process uptime, run in http://jenkins-host/script

1
println "Jenkins has been started " + (ExtensionList.lookupSingleton(Uptime.class).uptime / 1000 / 60 / 60 /24 ) + " days ago"