-
Notifications
You must be signed in to change notification settings - Fork 2k
Golang : Add query to detect JWT signing vulnerabilities #9378
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
bd1ddc1
Golang : Add query to detect JWT signing vulnerabilities
e0f74a5
Include suggested changes from review.
ae2bc1b
Include suggested changes from review.
1ef42a1
Include suggested changes from review.
361b703
Include suggested changes from review.
bfbc1d4
Simplify redundant sanitizer
smowton 3155771
Rename empty-string sanitizer to reflect what it actually does.
smowton b48a07e
Tighten up CompareExprSanitizer
smowton 602495d
Replace cases accidentally handled by CompareExprSanitizer with Retur…
smowton e54b29a
Autoformat
smowton d5ac719
Remove duplicate function
smowton File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| <!DOCTYPE qhelp PUBLIC "-//Semmle//qhelp//EN" "qhelp.dtd"> | ||
| <qhelp> | ||
| <overview> | ||
| <p> | ||
| A JSON Web Token (JWT) is used for authenticating and managing users in an application. | ||
| </p> | ||
| <p> | ||
| Using a hard-coded secret key for signing JWT tokens in open source projects | ||
| can leave the application using the token vulnerable to authentication bypasses. | ||
| </p> | ||
|
|
||
| <p> | ||
| A JWT token is safe for enforcing authentication and access control as long as it can't be forged by a malicious actor. However, when a project exposes this secret publicly, these seemingly unforgeable tokens can now be easily forged. | ||
| Since the authentication as well as access control is typically enforced through these JWT tokens, an attacker armed with the secret can create a valid authentication token for any user and may even gain access to other privileged parts of the application. | ||
| </p> | ||
|
|
||
| </overview> | ||
| <recommendation> | ||
|
|
||
| <p> | ||
| Generating a cryptograhically secure secret key during application initialization and using this generated key for future JWT signing requests can prevent this vulnerability. | ||
| </p> | ||
|
|
||
| </recommendation> | ||
| <example> | ||
|
|
||
| <p> | ||
| The following code uses a hard-coded string as a secret for signing the tokens. In this case, an attacker can very easily forge a token by using the hard-coded secret. | ||
| </p> | ||
|
|
||
| <sample src="HardcodedKeysBad.go" /> | ||
|
|
||
| </example> | ||
| <example> | ||
|
|
||
| <p> | ||
| In the following case, the application uses a programatically generated string as a secret for signing the tokens. In this case, since the secret can't be predicted, the code is secure. A function like `GenerateCryptoString` can be run to generate a secure secret key at the time of application installation/initialization. This generated key can then be used for all future signing requests. | ||
| </p> | ||
|
|
||
| <sample src="HardcodedKeysGood.go" /> | ||
|
|
||
| </example> | ||
| <references> | ||
| <li> | ||
| CVE-2022-0664: | ||
| <a href="https://nvd.nist.gov/vuln/detail/CVE-2022-0664">Use of Hard-coded Cryptographic Key in Go github.com/gravitl/netmaker prior to 0.8.5,0.9.4,0.10.0,0.10.1. </a> | ||
| </li> | ||
| </references> | ||
|
|
||
| </qhelp> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| /** | ||
| * @name Use of a hardcoded key for signing JWT | ||
| * @description Using a fixed hardcoded key for signing JWT's can allow an attacker to compromise security. | ||
| * @kind path-problem | ||
| * @problem.severity error | ||
| * @id go/hardcoded-key | ||
| * @tags security | ||
| * external/cwe/cwe-321 | ||
| */ | ||
|
|
||
| import go | ||
| import HardcodedKeysLib | ||
| import DataFlow::PathGraph | ||
|
|
||
| from HardcodedKeys::Configuration cfg, DataFlow::PathNode source, DataFlow::PathNode sink | ||
| where cfg.hasFlowPath(source, sink) | ||
| select sink.getNode(), source, sink, "$@ is used to sign a JWT token.", source.getNode(), | ||
| "Hardcoded String" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| mySigningKey := []byte("AllYourBase") | ||
|
|
||
| claims := &jwt.RegisteredClaims{ | ||
| ExpiresAt: jwt.NewNumericDate(time.Unix(1516239022, 0)), | ||
| Issuer: "test", | ||
| } | ||
|
|
||
| token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) | ||
| ss, err := token.SignedString(mySigningKey) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| func GenerateCryptoString(n int) (string, error) { | ||
| const chars = "123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-" | ||
| ret := make([]byte, n) | ||
| for i := range ret { | ||
| num, err := crand.Int(crand.Reader, big.NewInt(int64(len(chars)))) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| ret[i] = chars[num.Int64()] | ||
| } | ||
| return string(ret), nil | ||
| } | ||
|
|
||
| mySigningKey := GenerateCryptoString(64) | ||
|
|
||
|
|
||
| claims := &jwt.RegisteredClaims{ | ||
| ExpiresAt: jwt.NewNumericDate(time.Unix(1516239022, 0)), | ||
| Issuer: "test", | ||
| } | ||
|
|
||
| token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) | ||
| ss, err := token.SignedString(mySigningKey) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,291 @@ | ||
| /** | ||
| * Provides default sources, sinks and sanitizers for reasoning about | ||
| * JWT token signing vulnerabilities as well as extension points | ||
| * for adding your own. | ||
| */ | ||
|
|
||
| import go | ||
| import StringOps | ||
| import DataFlow::PathGraph | ||
|
|
||
| /** | ||
| * Provides default sources, sinks and sanitizers for reasoning about | ||
| * JWT token signing vulnerabilities as well as extension points | ||
| * for adding your own. | ||
| */ | ||
| module HardcodedKeys { | ||
| /** | ||
| * A data flow source for JWT token signing vulnerabilities. | ||
| */ | ||
| abstract class Source extends DataFlow::Node { } | ||
|
|
||
| /** | ||
| * A data flow sink for JWT token signing vulnerabilities. | ||
| */ | ||
| abstract class Sink extends DataFlow::Node { } | ||
|
|
||
| /** | ||
| * A sanitizer for JWT token signing vulnerabilities. | ||
| */ | ||
| abstract class Sanitizer extends DataFlow::Node { } | ||
|
|
||
| /** | ||
| * A sanitizer guard for JWT token signing vulnerabilities. | ||
| */ | ||
| abstract class SanitizerGuard extends DataFlow::BarrierGuard { } | ||
|
|
||
| private predicate isTestCode(Expr e) { | ||
| e.getFile().getAbsolutePath().toLowerCase().matches("%test%") and | ||
| not e.getFile().getAbsolutePath().toLowerCase().matches("%ql/test%") | ||
| } | ||
|
|
||
| private predicate isDemoCode(Expr e) { | ||
| e.getFile().getAbsolutePath().toLowerCase().matches(["%mock%", "%demo%", "%example%"]) | ||
| } | ||
|
|
||
| /** | ||
| * A hardcoded string literal as a source for JWT token signing vulnerabilities. | ||
| */ | ||
| private class HardcodedStringSource extends Source { | ||
| HardcodedStringSource() { | ||
| this.asExpr() instanceof StringLit and | ||
| not (isTestCode(this.asExpr()) or isDemoCode(this.asExpr())) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * An expression used to sign JWT tokens as a sink for JWT token signing vulnerabilities. | ||
| */ | ||
| private class GolangJwtSign extends Sink { | ||
| GolangJwtSign() { | ||
| exists(string pkg | | ||
| pkg = | ||
| [ | ||
| "github.com/golang-jwt/jwt/v4", "github.com/dgrijalva/jwt-go", | ||
| "github.com/form3tech-oss/jwt-go", "github.com/ory/fosite/token/jwt" | ||
| ] | ||
| | | ||
| exists(DataFlow::MethodCallNode m | | ||
| // Models the `SignedString` method | ||
| // `func (t *Token) SignedString(key interface{}) (string, error)` | ||
| m.getTarget().hasQualifiedName(pkg, "Token", "SignedString") and | ||
| this = m.getArgument(0) | ||
| or | ||
| // Model the `Sign` method of the `SigningMethod` interface | ||
| // type SigningMethod interface { | ||
| // Verify(signingString, signature string, key interface{}) error | ||
| // Sign(signingString string, key interface{}) (string, error) | ||
| // Alg() string | ||
| // } | ||
| m.getTarget().hasQualifiedName(pkg, "SigningMethod", "Sign") and | ||
| this = m.getArgument(1) | ||
| ) | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private class GinJwtSign extends Sink { | ||
| GinJwtSign() { | ||
| exists(Field f | | ||
| // https://pkg.go.dev/github.com/appleboy/gin-jwt/v2#GinJWTMiddleware | ||
| f.hasQualifiedName("github.com/appleboy/gin-jwt/v2", "GinJWTMiddleware", "Key") and | ||
| f.getAWrite().getRhs() = this | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private class SquareJoseKey extends Sink { | ||
| SquareJoseKey() { | ||
| exists(Field f, string pkg | | ||
| // type Recipient struct { | ||
| // Algorithm KeyAlgorithm | ||
| // Key interface{} | ||
| // KeyID string | ||
| // PBES2Count int | ||
| // PBES2Salt []byte | ||
| // } | ||
| // type SigningKey struct { | ||
| // Algorithm SignatureAlgorithm | ||
| // Key interface{} | ||
| // } | ||
| f.hasQualifiedName(pkg, ["Recipient", "SigningKey"], "Key") and | ||
| f.getAWrite().getRhs() = this | ||
| | | ||
| pkg = ["github.com/square/go-jose/v3", "gopkg.in/square/go-jose.v2"] | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private class CrystalHqJwtSigner extends Sink { | ||
| CrystalHqJwtSigner() { | ||
| exists(DataFlow::CallNode m | | ||
| // `func NewSignerHS(alg Algorithm, key []byte) (Signer, error)` | ||
| m.getTarget().hasQualifiedName("github.com/cristalhq/jwt/v3", "NewSignerHS") | ||
| | | ||
| this = m.getArgument(1) | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private class GoKitJwt extends Sink { | ||
| GoKitJwt() { | ||
| exists(DataFlow::CallNode m | | ||
| // `func NewSigner(kid string, key []byte, method jwt.SigningMethod, claims jwt.Claims) endpoint.Middleware` | ||
| m.getTarget().hasQualifiedName("github.com/go-kit/kit/auth/jwt", "NewSigner") | ||
| | | ||
| this = m.getArgument(1) | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private class LestrratJwk extends Sink { | ||
| LestrratJwk() { | ||
| exists(DataFlow::CallNode m, string pkg | | ||
| pkg.matches([ | ||
| "github.com/lestrrat-go/jwx", "github.com/lestrrat/go-jwx/jwk", | ||
| "github.com/lestrrat-go/jwx%/jwk" | ||
| ]) and | ||
| // `func New(key interface{}) (Key, error)` | ||
| m.getTarget().hasQualifiedName(pkg, "New") | ||
| | | ||
| this = m.getArgument(0) | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Mark any comparision expression where any operand is tainted as a | ||
| * sanitizer for all instances of the taint | ||
| */ | ||
| private class CompareExprSanitizer extends Sanitizer { | ||
| CompareExprSanitizer() { | ||
| exists(BinaryExpr c | | ||
| c.getAnOperand().getGlobalValueNumber() = this.asExpr().getGlobalValueNumber() | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| /** Mark an empty string returned with an error as a sanitizer */ | ||
| private class EmptyErrorSanitizer extends Sanitizer { | ||
| EmptyErrorSanitizer() { | ||
| exists(ReturnStmt r, DataFlow::CallNode c | | ||
| c.getTarget().hasQualifiedName("errors", "New") and | ||
| r.getNumChild() > 1 and | ||
| r.getAChild() = c.getAResult().getASuccessor*().asExpr() and | ||
| r.getAChild() = this.asExpr() | ||
| ) | ||
| } | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removing this has no effect on the test results, so it either doesn't work or isn't tested
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have added a better test-case now. |
||
|
|
||
| /** Mark any formatting string call as a sanitizer */ | ||
| private class FormattingSanitizer extends Sanitizer { | ||
| FormattingSanitizer() { exists(Formatting::StringFormatCall s | s.getAResult() = this) } | ||
| } | ||
|
|
||
| /** | ||
| * Mark any taint arising from a read on a tainted slice with a random index as a | ||
| * sanitizer for all instances of the taint | ||
| */ | ||
| private class RandSliceSanitizer extends Sanitizer { | ||
| RandSliceSanitizer() { | ||
| // Sanitize flows like this: | ||
| // func GenerateCryptoString(n int) (string, error) { | ||
| // const chars = "123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-" | ||
| // ret := make([]byte, n) | ||
| // for i := range ret { | ||
| // num, err := crand.Int(crand.Reader, big.NewInt(int64(len(chars)))) | ||
| // if err != nil { | ||
| // return "", err | ||
| // } | ||
| // ret[i] = chars[num.Int64()] | ||
| // } | ||
| // return string(ret), nil | ||
| // } | ||
| exists( | ||
|
This conversation was marked as resolved.
Outdated
|
||
| DataFlow::CallNode randint, string name, DataFlow::ElementReadNode r, DataFlow::Node index | ||
| | | ||
| ( | ||
| randint.getTarget().hasQualifiedName("math/rand", name) or | ||
| randint.getTarget().(Method).hasQualifiedName("math/rand", "Rand", name) or | ||
| randint.getTarget().hasQualifiedName("crypto/rand", "Int") | ||
|
This conversation was marked as resolved.
Outdated
|
||
| ) and | ||
| name = | ||
| [ | ||
| "ExpFloat64", "Float32", "Float64", "Int", "Int31", "Int31n", "Int63", "Int63n", "Intn", | ||
| "NormFloat64", "Uint32", "Uint64" | ||
| ] and | ||
| TaintTracking::localTaint(randint.getAResult(), index) and | ||
| r.reads(this, index) | ||
| ) | ||
| or | ||
| // Sanitize flows like : | ||
| // func GenerateRandomString(size int) string { | ||
| // var bytes = make([]byte, size) | ||
| // rand.Read(bytes) | ||
| // for i, x := range bytes { | ||
| // bytes[i] = characters[x%byte(len(characters))] | ||
| // } | ||
| // return string(bytes) | ||
| // } | ||
| exists(DataFlow::CallNode randread, DataFlow::Node rand | | ||
| randread.getTarget().hasQualifiedName("crypto/rand", "Read") and | ||
| TaintTracking::localTaint(any(DataFlow::PostUpdateNode pun | | ||
| pun.getPreUpdateNode() = randread.getArgument(0) | ||
| ), rand) and | ||
| this.(DataFlow::ElementReadNode).reads(_, rand) | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Models flow from a call to `Int64` if the receiver is tainted | ||
| */ | ||
| private class BigIntFlow extends TaintTracking::FunctionModel { | ||
| BigIntFlow() { this.(Method).hasQualifiedName("math/big", "Int", "Int64") } | ||
|
|
||
| override predicate hasTaintFlow(DataFlow::FunctionInput inp, DataFlow::FunctionOutput outp) { | ||
| inp.isReceiver() and | ||
| outp.isResult(0) | ||
| } | ||
| } | ||
|
|
||
| /* | ||
| * This is code is used to model taint flow through a binary operation such as a | ||
| * modulo `%` operation or an addition `+` operation | ||
| */ | ||
|
|
||
|
This conversation was marked as resolved.
|
||
| private class BinExpAdditionalTaintStep extends TaintTracking::AdditionalTaintStep { | ||
| // This is required to model the sanitizers for the `HardcodedKeys` query. | ||
| // This is required to correctly detect a sanitizer such as the one shown below. | ||
| // func GenerateRandomString(size int) string { | ||
| // var bytes = make([]byte, size) | ||
| // rand.Read(bytes) | ||
| // for i, x := range bytes { | ||
| // bytes[i] = characters[x%byte(len(characters))] | ||
| // } | ||
| // return string(bytes) | ||
| // } | ||
| override predicate step(DataFlow::Node prev, DataFlow::Node succ) { | ||
| exists(BinaryExpr b | b.getAnOperand() = prev.asExpr() | succ.asExpr() = b) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * A configuration depicting taint flow for studying JWT token signing vulnerabilities. | ||
| */ | ||
| class Configuration extends TaintTracking::Configuration { | ||
| Configuration() { this = "Hard-coded JWT Signing Key" } | ||
|
|
||
| override predicate isSource(DataFlow::Node source) { source instanceof Source } | ||
|
|
||
| override predicate isSink(DataFlow::Node sink) { sink instanceof Sink } | ||
|
|
||
| override predicate isSanitizer(DataFlow::Node sanitizer) { sanitizer instanceof Sanitizer } | ||
|
|
||
| // override predicate isAdditionalTaintStep(DataFlow::Node prev, DataFlow::Node succ) { | ||
| // } | ||
|
This conversation was marked as resolved.
Outdated
|
||
| override predicate isSanitizerGuard(DataFlow::BarrierGuard guard) { | ||
| guard instanceof SanitizerGuard | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried removing this sanitizer, and it doesn't seem to be useful:
""guarded by a testif x != nil, wherexis of error type.x := "hello"; if x != "hello" { ... }, which doesn't seem like a useful test (but perhaps you could change the test to better represent a real-world situation?)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@smowton
This may not be ideal. There could be multiple instances where a error code may be returned without a test to nil, say for ex:
To avoid this, I just check for cases where there the taint is compared with anything and mark that as sanitized. This handles the
if x!=nilandif y !=""cases very well.I have added a better test case now. The test case for this sanitizer was earlier in
main.go. I have moved that tosanitizer.go. Also, I have removed the test-case you mention.