Short answer: the auth bypass was not successful. A few notes on why this was not possible and what was tested.
Summary
- Resources
- What is Portainer
- Set up the environment
- Register admin account
- JWT implementation
- Authorization and authentication
- Directory listing
- Debug and recompile
- Bolt database
- Chisel service
- Conclusion
1. Resources
- https://app.swaggerhub.com/apis/deviantony/Portainer/1.23.2/
- https://www.portainer.io/2019/07/how-does-the-edge-agent-work/
- https://github.com/portainer/portainer
2. What is Portainer
Portainer is a docker management interface that allows you to manage all your Docker resources (containers, images, volumes, networks and more). It is compatible with the standalone Docker engine and with Docker Swarm mode.
The application is written in Go/JavaScript, has around 14k stars on Github, 12k results on Shodan, making it an interesting target. Main objective was to bypass the authentication/ authorization an gain access to the managed dockers. For this test I used the bleeding edge version 1.24.0-dev.

3. Set up the environment
Starting up with Portainer is straight forward, as it can run in its own docker container
x@ubuntu:/tmp$ sudo docker volume create portainer_data
portainer_data
x@ubuntu:/tmp$ sudo docker run -d -p 9000:9000 -p 8000:8000 --name portainer --restart always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer
Unable to find image 'portainer/portainer:latest' locally
latest: Pulling from portainer/portainer
d1e017099d17: Pull complete
a7dca5b5a9e8: Pull complete
Digest: sha256:4ae7f14330b56ffc8728e63d355bc4bc7381417fa45ba0597e5dd32682901080
Status: Downloaded newer image for portainer/portainer:latest
87e5f6ccc9d6a098d6f67a1457988fcddb0e82b002da924f5eec96ddc2bd10c6
Once done, access http://localhost:9000/ and you should be prompted to register an administrative account.
4. Register admin account
Register an admin account
What caught my attention in first place to check more about Portainer is the “register an admin account if one doesn’t exist” when you first set it up. Scanning the internet for exposed Portainers that did not set up an account sounds like a good idea, except they also thought about this and are shutting down the service after 5 minutes if the account is not created.
2020/04/13 05:21:43 No administrator account was created after 5 min. Shutting down the Portainer instance for security reasons.

Bruteforcing
AFAIK in previous versions the username was always “admin”, requiring an attacker to only guess the password, but in newer versions it is also possible to change the username (even tho is less likely that people will do it given that it is pre-filled). Brute-forcing is neither a solution because of the rate-limiting implemented that returns 403 forbidden after too many attempts.
API “check” and “init” accont
The “register account” has 3 main calls to the backend
- GET /api/users/admin/check that checks if an administrator account is already registered in the database. If not, it returns {“message”:”No administrator account found inside the database”,”details”:”Object not found inside the database”} and we get asked to register one
- POST /api/users/admin/init is the request sending the username/password to be registered. If all good, we get a confirmation along with our password’s hash (huh?) and the id/role/permissions. A bit too much info imo:
{
"Id": 1,
"Username": "admin",
"Password": "$2a$10$vh4kgtvZ8zcMWB4CT3aoj.Dmd8YLO1h5XisdzN.9rr58UjudIlhFK",
"Role": 1,
"PortainerAuthorizations": {
"PortainerDockerHubInspect": true,
"PortainerEndpointExtensionAdd": true,
"PortainerEndpointExtensionRemove": true,
"PortainerEndpointGroupList": true,
"PortainerEndpointInspect": true,
"PortainerEndpointList": true,
"PortainerExtensionList": true,
"PortainerMOTD": true,
"PortainerRegistryInspect": true,
"PortainerRegistryList": true,
"PortainerTeamList": true,
"PortainerTemplateInspect": true,
"PortainerTemplateList": true,
"PortainerUserInspect": true,
"PortainerUserList": true,
"PortainerUserMemberships": true
},
"EndpointAuthorizations": null
}
- POST /api/auth is sending again the creds {“username”:”admin”,”password”:”123123123″} and gets a JWT which from now on is used for authentication and authorization.
The admin/check and admin/init endpoints are the interesting ones. The user/check checks if the number of existing users in the DB is zero, and if there are no users it allows us to register one:
func (handler *Handler) adminCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
users, err := handler.UserService.UsersByRole(portainer.AdministratorRole)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err}
}
if len(users) == 0 {
return &httperror.HandlerError{http.StatusNotFound, "No administrator account found inside the database", portainer.ErrObjectNotFound}
}
return response.Empty(w)
}
Delete all accounts
Since we don’t provide any input to this request, one option would be to use another endpoint (i.e.: DELETE /api/users/:id) to delete all the users, and then get asked to register an admin account. However, the delete user enpoint is restricted to administrator account only:
h.Handle("/users/{id}", bouncer.AdminAccess(httperror.LoggerHandler(h.userDelete))).Methods(http.MethodDelete)
Fuzzing
Moving further to the next endpoint, trying to directly call the admin/init endpoint returns a conflict response code. Fuzzing the JSON input did not reveal too much either.
HTTP/1.1 409 Conflict
Content-Type: application/json
X-Content-Type-Options: nosniff
X-Xss-Protection: 1; mode=block
Date: Mon, 13 Apr 2020 12:51:49 GMT
Content-Length: 99
Connection: close
{"message":"Unable to create administrator user","details":"An administrator user already exists"}
Checking the source code, we can see that the same condition that we saw in the admin/check endpoint is also enforced here. Found it a bit odd that they don’t call the admin/check function itself, as this is some code duplication making it harder to ensure the same conditions are checked in both places.
// POST request on /api/users/admin/init
func (handler *Handler) adminInit(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
[some code]
if len(users) != 0 {
return &httperror.HandlerError{http.StatusConflict, "Unable to create administrator user", portainer.ErrAdminAlreadyInitialized}
}
[other code]
5. JWT implementation
3rd party library
From now on, almost all requests required a JWT authorization header to ensure that we have the right privileges to perform the action. The JWT implementation uses a libary “github.com/dgrijalva/jwt-go” known to be vulnerable to the none algorithm vulnerability and issue in the cryotp/elliptic in versions prior to 3.0. However, checking the Portainer (go.mod) dependency list, we can see that version 3.2.0 is required, so unless new vulns are found in this library we can look at the Portainer usage.
module github.com/portainer/portainer/api
go 1.13
require (
github.com/Microsoft/go-winio v0.4.14
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a
github.com/boltdb/bolt v1.3.1
github.com/containerd/containerd v1.3.1 // indirect
github.com/coreos/go-semver v0.3.0
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/docker/cli v0.0.0-20191126203649-54d085b857e9
github.com/docker/docker v0.0.0-00010101000000-000000000000
...
Portainer library usage
There are 3 main functions in the api/jwt/jwt.go class of portainer:
- NewService – initializes a new JWT service and set the secret key that is used to sign the tokens.
- GenerateToken
- ParseAndVerifyToken
Secret key handling
The NewService and the secret key handling is the one that caught my attention as it is a common issue that weak/hardcoded secrets are provided together with the solution and people forget to change these. Gaining access to the secret key could allow an attacker the sign his own JWT token and use it to bypass the login. The function below is called when the Portainer starts – it can be seen that the secret is a 32 character randomly generated. No hardcoded value and no config files.
// NewService initializes a new service. It will generate a random key that will be used to sign JWT tokens.
func NewService() (*Service, error) {
secret := securecookie.GenerateRandomKey(32)
if secret == nil {
return nil, portainer.ErrSecretGeneration
}
service := &Service{
secret,
}
return service, nil
}
Key randomness
Taking this one step further, I wrote a script that dumps the secret to a file and restarts Portainer for 50 times just to confirm the random value is indeed random. Bellow is the dump (spoiler alert: all good, the random is random)
[175 230 209 142 223 19 120 114 98 207 53 177 138 65 154 255 211 191 174 179 77 186 178 191 227 32 176 233 215 132 187 27]
[86 81 50 38 78 252 102 93 80 93 211 73 170 109 9 228 140 248 134 223 189 215 31 24 18 15 8 39 186 9 132 227]
[222 158 139 7 221 36 206 162 80 245 227 198 144 202 247 124 126 5 17 217 81 204 133 178 26 243 211 242 141 43 99 130]
[175 147 68 12 3 114 250 162 141 250 174 173 62 216 232 183 8 34 123 170 44 221 98 145 105 25 30 121 236 162 96 34]
[98 126 252 24 54 63 124 129 48 227 102 34 249 239 198 193 232 200 229 40 222 32 117 57 130 92 245 161 228 31 138 146]
[191 42 95 251 180 131 77 230 135 28 7 19 138 105 233 122 120 82 27 84 147 83 81 93 213 169 81 63 170 168 70 181]
[205 151 163 142 135 105 40 179 87 62 21 88 65 54 23 22 60 249 41 212 104 199 255 31 218 46 254 119 78 233 35 12]
[255 56 183 65 125 5 3 155 61 95 142 251 108 110 72 16 163 171 247 114 125 225 65 37 20 95 117 148 185 60 4 175]
[153 4 71 159 175 190 197 147 156 40 247 86 40 69 210 47 166 92 175 57 37 17 204 13 242 24 234 94 211 162 62 58]
[14 62 149 208 166 131 30 229 181 80 169 221 169 163 107 76 177 35 218 102 248 122 107 75 207 115 25 55 56 253 164 210]
[15 42 254 70 113 234 208 219 194 85 235 184 192 236 97 220 2 165 255 68 185 8 54 182 99 10 5 111 112 181 249 84]
[28 15 166 210 13 223 39 157 200 119 156 106 109 162 223 206 248 95 8 17 229 220 162 233 99 233 172 111 65 80 205 98]
[193 32 76 223 147 190 240 20 55 67 51 96 187 206 249 182 159 104 96 196 201 106 124 148 21 188 18 88 52 5 153 82]
[231 52 61 226 157 179 128 172 222 126 175 98 52 135 114 30 206 1 249 152 128 15 95 85 54 92 1 212 127 107 196 221]
[216 23 17 127 222 149 250 57 208 143 141 133 8 220 91 148 159 196 214 118 233 179 19 151 73 229 107 21 32 58 229 34]
[62 78 202 195 249 130 101 224 175 225 199 89 61 222 232 25 120 185 254 140 134 246 163 37 168 32 107 197 61 50 27 200]
[41 166 214 194 159 63 64 147 171 26 108 223 79 132 121 41 206 200 130 240 219 173 110 16 83 249 254 200 85 109 249 48]
[207 50 210 199 198 238 92 3 65 119 17 60 78 192 126 164 32 43 154 43 244 106 207 137 213 214 187 64 141 51 202 144]
[93 179 195 2 210 50 102 206 186 56 253 95 94 85 100 192 50 127 67 0 204 243 56 71 153 229 169 165 45 32 54 58]
[211 185 69 164 157 88 95 81 142 211 94 73 225 98 148 61 10 23 230 68 55 20 94 40 93 185 82 57 172 39 178 239]
[225 199 194 5 41 140 67 215 28 178 198 201 100 162 119 39 252 236 15 225 191 127 84 215 139 221 174 182 175 94 32 189]
[151 150 129 190 252 126 220 26 138 175 117 88 26 11 22 142 17 10 185 135 101 182 162 234 61 141 241 133 73 58 127 209]
[182 104 126 25 62 210 207 73 63 47 168 76 74 120 211 91 231 106 75 241 225 171 28 236 181 188 118 146 115 161 42 150]
[68 130 185 62 234 128 194 181 61 131 112 254 165 95 60 138 194 82 247 112 105 45 9 12 149 117 156 208 20 37 191 144]
[209 106 41 241 171 147 233 254 87 23 225 122 164 217 64 180 64 182 15 150 5 81 76 49 19 27 44 194 2 125 12 235]
[47 221 143 80 121 28 26 100 22 189 148 211 240 161 222 107 44 10 146 224 193 193 216 94 49 221 92 210 80 28 183 122]
[113 173 55 115 216 135 3 34 239 141 164 248 178 6 98 148 64 92 252 188 173 96 241 164 44 162 226 209 246 222 192 33]
[182 44 139 52 254 255 186 87 223 83 151 211 180 44 184 226 111 159 245 120 170 27 113 141 84 114 236 131 241 113 22 37]
[135 226 244 116 227 167 138 159 196 135 182 20 132 199 125 187 25 177 200 57 46 107 114 249 73 227 185 158 32 139 87 178]
[154 202 248 149 37 190 223 186 221 236 155 211 203 58 116 200 240 219 16 68 233 253 192 39 134 153 79 93 143 97 176 136]
[34 122 201 231 149 208 67 102 129 0 155 136 202 231 102 240 75 125 108 111 213 187 245 134 229 81 221 142 249 32 102 191]
[153 95 177 6 189 16 41 239 65 215 142 19 131 23 23 36 246 103 74 12 233 101 25 240 144 198 22 73 6 171 170 124]
[41 103 60 175 176 75 66 33 29 76 205 4 223 247 231 190 83 29 47 183 31 138 34 83 136 56 35 98 69 7 33 93]
[92 47 88 198 241 78 193 226 119 133 32 58 174 67 149 227 86 44 103 74 15 200 99 145 3 180 129 67 161 79 114 22]
[37 31 187 40 152 97 144 194 225 16 97 181 234 70 51 51 84 168 180 68 75 120 217 194 12 247 247 227 155 211 0 251]
[22 201 79 219 239 54 91 153 5 255 102 187 145 32 255 198 32 109 23 209 114 205 128 35 96 85 75 93 180 122 21 59]
[252 249 26 143 136 122 75 53 13 168 102 6 38 11 68 71 150 107 116 72 189 237 116 17 58 98 233 185 243 247 109 79]
[203 8 93 38 147 97 112 237 115 157 48 243 62 45 197 121 233 24 46 79 58 189 231 75 138 13 61 247 95 40 164 36]
[181 166 196 113 167 104 43 218 205 121 120 249 99 31 102 46 244 145 232 227 135 68 134 57 209 171 153 144 226 216 203 226]
[142 94 129 213 69 215 145 192 165 120 51 188 11 156 217 13 167 125 244 94 176 77 191 215 54 148 155 41 193 66 197 197]
[52 105 246 155 70 194 29 194 39 5 54 173 126 80 79 73 114 169 22 151 87 206 54 120 217 17 112 38 18 181 237 153]
[49 94 93 237 63 237 29 61 71 229 41 167 50 31 142 28 80 14 224 149 64 252 55 81 226 61 33 54 16 173 8 26]
[168 175 117 188 64 135 173 39 179 246 233 194 152 227 28 177 60 238 173 235 31 9 208 128 12 191 152 191 4 185 59 196]
[53 14 242 27 206 102 110 249 15 178 238 34 19 211 154 90 20 151 242 129 225 28 44 157 136 39 50 209 38 77 178 15]
[117 181 168 236 137 125 244 210 223 3 7 148 10 252 98 218 151 104 150 117 232 21 247 222 229 59 236 50 210 208 221 242]
[5 41 4 199 173 12 231 89 182 152 183 185 32 225 104 152 126 236 15 174 186 249 113 174 172 97 240 106 144 44 55 250]
[193 223 114 79 99 117 249 207 230 17 151 151 35 199 145 78 56 242 77 238 139 76 96 246 101 94 143 0 205 89 26 59]
[184 153 57 227 0 68 35 68 54 99 117 139 47 71 53 96 24 70 30 174 11 123 203 246 210 184 138 251 40 59 127 244]
[45 190 74 209 15 24 131 153 30 52 76 199 232 45 26 3 211 60 216 104 235 213 219 10 59 121 50 164 171 182 64 128]
[28 3 231 178 197 4 128 29 87 33 47 104 73 198 63 12 107 232 151 51 229 137 203 57 241 1 54 163 129 167 250 95]
Token algorithm and data
Moving further to the GenerateToken function, we can see that it uses HS256 a symmetric algorithm, with only one (secret) key that is shared between the parties: one that signs the token and one that consumes it. Because Portainer does both of them, a symmetric algorithm makes sense. We can also see that the secret key used to sign the token is indeed the one randomly generated when Portainer started. There is no sensitive information stored in the JWT, such as the password/hash. The other fields are needed by the application when decoding the JWT to check the user’s identity. One problem is the expiration time of the token, 8 hours, therefore anyone gaining access to it has a relative large time window to use the token and log in to the application.
// GenerateToken generates a new JWT token.
func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) {
expireToken := time.Now().Add(time.Hour * 8).Unix()
cl := claims{
UserID: int(data.ID),
Username: data.Username,
Role: int(data.Role),
StandardClaims: jwt.StandardClaims{
ExpiresAt: expireToken,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, cl)
signedToken, err := token.SignedString(service.secret)
if err != nil {
return "", err
}
return signedToken, nil
}
Decoding the JWT that the application issued to us when we logged in, we can confirm that there is no other data stored inside it:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTU4NjgwOTYyOX0.2peYLFrwbfkqWNU8MhnmgViFNWs3sfkdFlKuuEmavBY
Headers = {
"alg" : "HS256",
"typ" : "JWT"
}
Payload = {
"id" : 1,
"username" : "admin",
"role" : 1,
"exp" : 1586809629
}
Signature = "2peYLFrwbfkqWNU8MhnmgViFNWs3sfkdFlKuuEmavBY"
Finally the ParseAndVerifyToken function checks if the algorithm mentioned in the JWT header is valid, and verifies the signature, which makes the application return {“message”:”Invalid JWT token”,”details”:”Invalid JWT token”} if we cannot provide a valid one.
Issues: leakage, expiration, XSS
So far, so good – as long as the JWT is not leaked. Except that there is at least one API endpoint that leaks that JWT through the URL by passing it in a GET request. This for example will make the browser save it in the local history, but also leak it to any proxies that are between the user and the application. Not a big deal, but the token is valid for 8 hours and it cannot be revoked (?). The logout button issues a new JWT, but it does not invalidate the old one. Having a short expiration time (5 minutes) + refresh option is an option. Otherwise, the JWT could be saved in the database with an additional “valid” flag that is flipped once the logout button is pressed.
An interesting attack would be XSS through docker log pollution, and read the localStorage to get the JWT (window.localStorage.getItem(‘portainer.JWT’);). However, the log HTTP response a few of the necessary headers to mitigate this attack
X-Content-Type-Options: nosniff
X-Xss-Protection: 1; mode=block
Content-Type: text/plain; charset=utf-8
6. Authorization and authentication
HTTP API handlers
Besides the JWT, there are specific classes and functions to ensure the right permissions for each user role, and to check the authorization and authentication for each API call to ensure that no unauthorized actions can be performed. The HTTP API handler has a switch case that whitelists all accessible paths such as:
switch {
case strings.HasPrefix(r.URL.Path, "/api/auth"):
http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/dockerhub"):
http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"):
http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/endpoints"):
switch {
case strings.Contains(r.URL.Path, "/docker/"):
http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r)
case strings.Contains(r.URL.Path, "/storidge/"):
http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r)
case strings.Contains(r.URL.Path, "/azure/"):
http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r)
default:
http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r)
}
case strings.HasPrefix(r.URL.Path, "/api/extensions"):
http.StripPrefix("/api", h.ExtensionHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/motd"):
http.StripPrefix("/api", h.MOTDHandler).ServeHTTP(w, r)
[...]
case strings.HasPrefix(r.URL.Path, "/api/webhooks"):
http.StripPrefix("/api", h.WebhookHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/"):
h.FileHandler.ServeHTTP(w, r)
}
}
Each of these cases redirects the logic to the specific handler of that path. For example, the users handler that is also used to register an account and to log in looks like this
// NewHandler creates a handler to manage user operations.
func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter) *Handler {
h := &Handler{
Router: mux.NewRouter(),
}
h.Handle("/users",
bouncer.AdminAccess(httperror.LoggerHandler(h.userCreate))).Methods(http.MethodPost)
h.Handle("/users",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.userList))).Methods(http.MethodGet)
h.Handle("/users/{id}",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.userInspect))).Methods(http.MethodGet)
h.Handle("/users/{id}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userUpdate))).Methods(http.MethodPut)
h.Handle("/users/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.userDelete))).Methods(http.MethodDelete)
h.Handle("/users/{id}/memberships",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.userMemberships))).Methods(http.MethodGet)
h.Handle("/users/{id}/passwd",
rateLimiter.LimitAccess(bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userUpdatePassword)))).Methods(http.MethodPut)
h.Handle("/users/admin/check",
bouncer.PublicAccess(httperror.LoggerHandler(h.adminCheck))).Methods(http.MethodGet)
h.Handle("/users/admin/init",
bouncer.PublicAccess(httperror.LoggerHandler(h.adminInit))).Methods(http.MethodPost)
return h
}
Access types
The handler specifies what is the user role that can access the endpoint, what is the HTTP action, and whether or not there should be a rate limiter. This make the API very robust, limiting the attack surface to logic bugs. There are currently 4 access types:
- bouncer.PublicAccess -> Nothing
- bouncer.RestrictedAccess -> Authorization + Authentication
- bouncer.AdminAccess -> Authorization + Authentication
- bouncer.AuthenticatedAccess -> Authentication
Source code of this implementation can be found in the security/bouncer class, and reflects the previous list with self-explanatory comments. Overall, as a non-authenticated user we will access the application using the PublicAccess type so my focus was based on this one.
// PublicAccess defines a security check for public API endpoints.
// No authentication is required to access these endpoints.
func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler {
h = mwSecureHeaders(h)
return h
}
// AdminAccess defines a security check for API endpoints that require an authorization check.
// Authentication is required to access these endpoints.
// If the RBAC extension is enabled, authorizations are required to use these endpoints.
// If the RBAC extension is not enabled, the administrator role is required to use these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to inside the API operation for extra authorization validation
// and resource filtering.
func (bouncer *RequestBouncer) AdminAccess(h http.Handler) http.Handler {
h = bouncer.mwUpgradeToRestrictedRequest(h)
h = bouncer.mwCheckPortainerAuthorizations(h, true)
h = bouncer.mwAuthenticatedUser(h)
return h
}
// RestrictedAccess defines a security check for restricted API endpoints.
// Authentication is required to access these endpoints.
// If the RBAC extension is enabled, authorizations are required to use these endpoints.
// If the RBAC extension is not enabled, access is granted to any authenticated user.
// The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to inside the API operation for extra authorization validation
// and resource filtering.
func (bouncer *RequestBouncer) RestrictedAccess(h http.Handler) http.Handler {
h = bouncer.mwUpgradeToRestrictedRequest(h)
h = bouncer.mwCheckPortainerAuthorizations(h, false)
h = bouncer.mwAuthenticatedUser(h)
return h
}
// AuthenticatedAccess defines a security check for restricted API endpoints.
// Authentication is required to access these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to inside the API operation for extra authorization validation
// and resource filtering.
func (bouncer *RequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler {
h = bouncer.mwUpgradeToRestrictedRequest(h)
h = bouncer.mwAuthenticatedUser(h)
return h
}
Public API endpoints
Greping through the source code for all PublicAccess endpoints I found the following ones:
- GET /api/users/admin/check
- GET /api/settings/public
- GET /api/endpoints/{id}/status
- GET /api/status
- POST /api/users/admin/init
- POST /api/auth/oauth/validate
- POST /api/auth
- POST /api/webhooks/{token}
GET /api/users/admin/check
- nothing pretty much
HTTP/1.1 204 No Content
GET /api/settings/public
{
"LogoURL": "",
"AuthenticationMethod": 1,
"AllowBindMountsForRegularUsers": true,
"AllowPrivilegedModeForRegularUsers": true,
"AllowVolumeBrowserForRegularUsers": false,
"EnableHostManagementFeatures": false,
"ExternalTemplates": false,
"OAuthLoginURI": "?response_type=code&client_id=&redirect_uri=&scope=&prompt=login"
}
GET /api/endpoints/{id}/status
- While this is public, it requires that you know the endpoint id, the endpoint to be of type edge, and also to know the edge identifier (looks like this aHR0cDovLzE5Mi4xNjguMTM2LjE2Njo4NzY1fDE5Mi4xNjguMTM2LjE2Njo4NzY2fDJlOmU3OmJhOjU1OjEwOmYwOjI1OjA3OmQyOjczOmMwOjk2OjEyOjZkOmM2OmY2fDI) that is displayed once you create an endpoint. So not that much of a “public” to read the status of an endpoint
{
"status": "IDLE",
"port": 0,
"schedules": [],
"checkin": 5,
"credentials": ""
}
GET /api/status
- good for fingerprinting the exact version
{
"Authentication": true,
"EndpointManagement": true,
"Snapshot": true,
"Analytics": true,
"Version": "1.24.0-dev"
}
POST /api/users/admin/init
- register an account if no account exists, but the service is shutting down if an account is not registered within 5 minutes.
POST /api/auth/oauth/validate
- it requires and OAuth authorization code
POST /api/auth
- requires valid credentials and returns JWT
POST /api/webhooks/{token}
The webhooks are part of the services functionality which is not shown if Portainer is set up to use local, but the page can still be access by directly navigating to /#/services/ as admin, so not big deal, but there is another similar issue and that one is more interesting – however i’m not looking into authenticated attacks. I couldn’t create a webhook, but checking this article it can be seen that the token is an UUID used to identify the service, confirmed as well by the source code:
// Acts on a passed in token UUID to restart the docker service
func (handler *Handler) webhookExecute(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
webhookToken, err := request.RetrieveRouteVariableValue(r, "token")
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Invalid service id parameter", err}
}
webhook, err := handler.WebhookService.WebhookByToken(webhookToken)
if err == portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a webhook with this token", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve webhook from the database", err}
}
[..]
Overall the public API endpoints do no expose that much attack surface.
7. Directory listing
The directory listing is enabled if there is no index file in that directory. By default, the installation of the Portainer comes with an index file and no additional folders, but creating a folder in the path that is served by the webserver and navigating to it will expose the files inside it. Note that the files in image are copied by me in the newly created “data” folder and they are not there by default.

By default, the exposed folder is the current directory where Portainer starts, as shown in the default configuration file, therefore it may be worth running a directory enumeration against a Portainer instances based on how they are configured
0const (
defaultBindAddress = ":9000"
defaultTunnelServerAddress = "0.0.0.0"
defaultTunnelServerPort = "8000"
defaultDataDirectory = "/data"
defaultAssetsDirectory = "./"
defaultNoAuth = "false"
defaultNoAnalytics = "false"
defaultTLS = "false"
defaultTLSSkipVerify = "false"
defaultTLSCACertPath = "/certs/ca.pem"
defaultTLSCertPath = "/certs/cert.pem"
defaultTLSKeyPath = "/certs/key.pem"
defaultSSL = "false"
defaultSSLCertPath = "/certs/portainer.crt"
defaultSSLKeyPath = "/certs/portainer.key"
defaultSyncInterval = "60s"
defaultSnapshot = "true"
defaultSnapshotInterval = "5m"
defaultTemplateFile = "/templates.json"
)
Another interesting http handler that does not have the bouncer authorization checks is the file one, but since we don’t control the FileServer parameter the directory traversal is not possible:
func NewHandler(assetPublicPath string) *Handler {
h := &Handler{
Handler: http.FileServer(http.Dir(assetPublicPath)),
}
return h
}
8. Debug and recompile
The easy way to get started with Portainer is either the docker container or the downloading a pre-compiled binary. Both of them however don’t provide too much option to debug, dump variables, follow the logic flow etc. To be able to easier debug the application there are 2 options, either get a shell in the docker container where Portainer is running or change the source code and recompile the binary.
Docker shell
By default, there is no shell access into the Portainer container. We can however copy a static compile busybox binary and than access it through sh. Now we can access the /data and /public folder, get access to more configuration, keys and database but we are still limited to actual debugging as there is only a Portainer binary – still black box.
sudo apt-get install busybox-static
sudo docker cp /bin/busybox portainer:/
sudo docker exec -it portainer /busybox sh
BusyBox v1.27.2 (Ubuntu 1:1.27.2-2ubuntu3.2) built-in shell (ash)
Enter 'help' for a list of built-in commands.
/ # ls -la
drwxr-xr-x 1 0 0 4096 Apr 4 19:21 .
drwxr-xr-x 1 0 0 4096 Apr 4 19:21 ..
-rwxr-xr-x 1 0 0 0 Apr 3 14:37 .dockerenv
-rwxr-xr-x 1 0 0 2062296 Mar 6 2019 busybox
drwxr-xr-x 5 0 0 4096 Apr 3 14:37 data
drwxr-xr-x 5 0 0 340 Apr 13 12:10 dev
-rwxr-xr-x 1 0 0 50683148 Feb 28 2019 docker
drwxr-xr-x 1 0 0 4096 Apr 4 13:29 etc
-rw-r--r-- 1 0 0 2723 Mar 19 22:44 extensions.json
-rwxr-xr-x 1 0 0 21344256 Mar 19 22:45 portainer
dr-xr-xr-x 361 0 0 0 Apr 13 12:10 proc
drwxr-xr-x 1 0 0 4096 Apr 4 19:21 public
dr-xr-xr-x 13 0 0 0 Apr 13 12:10 sys
-rw-r--r-- 1 0 0 25716 Mar 19 22:44 templates.json
drwxr-xr-x 3 0 0 4096 Apr 3 14:37 var
Compile locally
A better alternative is to clone the repository locally, add the debug code that we need and recompile the binary. This is how I was able to dump the JWT secret key and follow more complex flows in the application by adding print messages. To compile Portainer locally we need Golang 1.13 and the additional dependencies that come with it.
Install Go
sudo apt-get update
sudo apt-get -y upgrade
cd /tmp
wget https://dl.google.com/go/go1.13.3.linux-amd64.tar.gz
sudo tar -xvf go1.13.3.linux-amd64.tar.gz
sudo mv go /usr/local
Clone the repo and build the binary
go get github.com/portainer/portainer
cd /home/x/go/src/github.com/portainer/api
export GO111MODULE="on"
go mod download
go clean -modcache
go mod tidy
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -a --installsuffix cgo --ldflags '-s
Set up additional required paths
mkdir /tmp/data
wget https://raw.githubusercontent.com/portainer/portainer/develop/templates.json
<copy and extract /public in current directory>
$ ls -la
-rw-rw-r-- 1 x x 21262 Apr 4 10:58 main.go
-rwxrwxr-x 1 x x 20447232 Apr 4 11:40 portainer
drwxr-xr-x 3 x x 4096 Apr 4 12:22 public
-rw-rw-r-- 1 x x 25716 Apr 4 12:15 templates.json
Start the service
./portainer --bind=":8765" --tunnel-port="8766" --data /tmp/data --template-file "${PWD}/templates.json"
2020/04/04 12:25:51 Templates already registered inside the database. Skipping template import.
2020/04/04 12:25:51 server: Reverse tunnelling enabled
2020/04/04 12:25:51 server: Fingerprint 46:79:a7:5a:45:69:85:c4:a9:f6:31:83:62:ed:67:34
2020/04/04 12:25:51 server: Listening on 0.0.0.0:8766...
2020/04/04 12:25:51 Starting Portainer 1.24.0-dev on :8765
2020/04/04 12:25:51 [DEBUG] [chisel, monitoring] [check_interval_seconds: 10.000000] [message: starting tunnel management process]
Because the binary has to be re-compiled after each change I made to the code, and the service restarted, I automated the process with a simple script
export GOPATH=/home/hayden/go
echo "run from /home/x/go/src/github.com/portainer/api/cmd/portainer"
echo "[+] clear old data.."
rm /home/x/go/src/github.com/portainer/api/cmd/portainer/portainer
rm -rf /tmp/data
mkdir /tmp/data
echo "[+] recompile the binary.."
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 /usr/local/go/bin/go build -a --installsuffix cgo --ldflags '-s'
echo "[+] starting the server.."
./portainer --bind=":8765" --tunnel-port="8766" --data /tmp/data --template-file "${PWD}/templates.json"
The debug messages will be displayed in the console
import "fmt"
fmt.Println("inside file x.go")
fmt.Printf("%+v", variable)
byte[] to string
fmt.Println(string(secret))
9. Bolt database
The backend database used is Bolt, a project that is no longer maintained, but that has no public vulns. The whole database is store unencrypted in the /data folder, named portainer.db
There are several tools available to open such a file. I used boltbrowser and boltdbweb – both are written in go and need to be compiled locally. Probably the most interesting table/bucket is the users one, where the accounts details can be found. The portainer.db has read and write permissions only for the owner of the file.
boltbrowser

boltdbweb

10. Chisel server
Additionally to the web server running on port 9000, there is an additional service running by default on port 8000. I didn’t get to look too much into it, but it is an interesting attack point. It seems that when the service is started in StartTunnelServer function, a new pair of username/password creds are generated. These tunnel credentials are encrypted by Portainer using the edge_UUID as the encryption.
More about this: https://www.portainer.io/2019/07/how-does-the-edge-agent-work/
func (service *Service) StartTunnelServer(addr, port string, snapshotter portainer.Snapshotter) error {
[..]
// TODO: work-around Chisel default behavior.
// By default, Chisel will allow anyone to connect if no user exists.
username, password := generateRandomCredentials()
err = service.chiselServer.AddUser(username, password, "127.0.0.1")
if err != nil {
return err
}
[..]
}
11. Conclusion
Overall I tried to gain access to the Portainer Web interface through different paths: registration, API endpoints, JWT, improper authorization/ authentication mechanism, directory traversal, Bolt DB and Chisel service but without success. Looks pretty solid so far, small improvements could be made.