Redis-injection ft. ioredis.zadd()
This article covers the Redis-injection in a challenge of corCTF 2024 where in-validated input is combined with the usage of a critical database specific redis-client method.
If you are only interested in the vulnerability Click Here
It is always a good idea to start working on a challenge by taking a high-level overview of the application. We should go ahead and interact with the application, capture various requests and responses for our web-proxy and essentially understand how it works from a user’s point of view.
Overview of the application
The following events we have observed ::
- When the app is first loaded in a new session, it asks us for a
username
. It then sends aPOST
request to/new
endpoint with our username. After that, the request returns response thatset-cookie
oursession
token.
- It also saves our
username
intolocal-storage
of the browser and then starts the game. Once we choose our “move”, it sends arequest
to/play
endpoint which returns theresponse
about whether we “won” or we lost and game has “ended”.
- There is also a
scoreboard.html
page that shows us the “scoreboard”. In this page, we can observe a player withusername
FizzBuzz101
that has scored1336
.
Once we have the basic overview of application and we also understand it’s flow. It’s time to analyze the provided source-code
.
Digging into the source
One should start by taking a look at package.json
or something similar to figure out the version
of various libraries
that is being used by an application to see if any vulnerability
is publicly-known or is being discussed upon. As of writing this article, I am not aware of any such publicly-known vulnerability
.
In our case, package.json
also gives a nice overview of the tech-stack
that is being utilized for our target application. Here, they are using fastify
which is a web-framework
for building Node.js
applications and ioredis
which is a redis-client
for Node.js
applications.
{
"dependencies": {
"@fastify/cookie": "^9.3.1",
"@fastify/jwt": "^8.0.1",
"@fastify/static": "^7.0.4",
"fastify": "^4.28.1",
"ioredis": "^5.4.1"
},
"type": "module"
}
Let’s move ahead and take a look at this application’s server
code file named index.js
::
import Redis from "ioredis";
import fastify from "fastify";
import fastifyStatic from "@fastify/static";
import fastifyJwt from "@fastify/jwt";
import fastifyCookie from "@fastify/cookie";
import { join } from "node:path";
import { randomBytes, randomInt } from "node:crypto";
As can be seen above, first few lines of the index.js
is importing necessary libraries and methods to use.
Redis
will be used to setup aredis-client
instance that will interact withredis-server
in theback-end
.fastify
will be used to set-up theserver
thatfront-end client
application will interact with.fastifyStatic
is a plugin used to serve multiple static directories under a single prefix.fastifyJwt
is a utility library that providesJWT
authentication and will be used to maintain and validate user’s gamesessions
.fastifyCookie
is another utility library that helps with maintaining browsercookies
for the application.join
is aNode.js
path
function that joins all givenpath
segments.randomBytes
andrandomInt
fromnode::crypto
generates cryptographically strong pseudorandom data or integer values.
const winning = new Map([
["🪨", "📃"],
["📃", "✂️"],
["✂️", "🪨"],
]);
app.register(fastifyStatic, {
root: join(import.meta.dirname, "static"),
prefix: "/",
});
app.register(fastifyJwt, {
secret: process.env.SECRET_KEY || randomBytes(32),
cookie: { cookieName: "session" },
});
app.register(fastifyCookie);
await redis.zadd("scoreboard", 1336, "FizzBuzz101");
winning
storesassociative
key:value
pair in aMap
data-structure. This is used to check for winning “move”. For example, if game chose “🪨” (rock) thenwinning
may be used to get “📃” (paper) and then compare it againstuser's
choice to decide “win” or “end”.app.register(...)
sets up additional plugins or related libraries.redis.zadd(...)
is used to setkey
that isscoreboard
to1336, "FizzBuzz101"
in theredis
database. It seems like this is where we are getting auser
namedFizzBuzz101
who has scored1336
in ourscoreboard.html
page.
app.post("/new", async (req, res) => {
const { username } = req.body;
const game = randomBytes(8).toString("hex");
await redis.set(game, 0);
return res
.setCookie("session", await res.jwtSign({ username, game }))
.send("OK");
});
app.post(...)
triggers when aPOST
request to sent to/new
endpoint.- It extracts
username
fromreq.body
i.e ourrequest's body payload
. - It sets
game
string constant equal to a random8-bytes
hex
value. - It then uses
redis.set(...)
method to setkey
calledgame
equal to0
. - Finally, it returns
response
that setscookie
in our browser that is ajwt
signed
token
which contains ourusername
and currentgame
id
.
app.post("/play", async (req, res) => {
try {
await req.jwtVerify();
} catch (e) {
return res.status(400).send({ error: "invalid token" });
}
const { game, username } = req.user;
const { position } = req.body;
const system = ["🪨", "📃", "✂️"][randomInt(3)];
if (winning.get(system) === position) {
const score = await redis.incr(game);
return res.send({ system, score, state: "win" });
} else {
const score = await redis.getdel(game);
if (score === null) {
return res.status(404).send({ error: "game not found" });
}
await redis.zadd("scoreboard", score, username);
return res.send({ system, score, state: "end" });
}
});
app.post(...)
triggers when aPOST
request is sent to/play
endpoint.- First, it tries to verify our
jwt
token
usingreq.jwtVerify()
. If any error then it returnsStatus Code : 400
otherwise it continues. - It then extracts our
game
id
andusername
fromreq.user
i.e basically fromsession
cookie that hasjwt
token
which it received from our side. - It then extracts
position
fromreq.body
that is our “move”. system
constant contains choice made by the game that is randomly selected using index of valuerandomInt(3)
.- Next, it checks for
winning
condition
and if itmatches
then itincrements
thevalue
ofkey
called whatevergame
variable contains by1
usingredis.incr(game)
method. After that, it sends back ourresponse
withstate:"win"
. - Otherwise, if it doesn’t match then it stores the value from
key
calledgame
intoscore
constant after that deletes the key:value pair from redis database usingredis.getdel(game)
method. - If
score == null
then it means there was nogame
session. - Next
res.zadd(...)
is again used to setscoreboard
key with valuescore, username
as provided. - Finally, it returns the
response
withstate:"end"
.
app.get("/flag", async (req, res) => {
try {
await req.jwtVerify();
} catch (e) {
return res.status(400).send({ error: "invalid token" });
}
const score = await redis.zscore("scoreboard", req.user.username);
if (score && score > 1336) {
return res.send(process.env.FLAG || "corctf{test_flag}");
}
return res.send("You gotta beat Fizz!");
});
app.get(...)
triggers when aGET
request is to sent to/flag
endpoint.- First, it tries to verify our
jwt
token
usingreq.jwtVerify()
. If any error then it returnsStatus Code : 400
otherwise it continues. - It retrieves score from
scoreboard
key usingredis.zscore(...)
and stores it intoscore
constant. - It then checks if retrieved
score
exists and if it does then is itgreater than
“1336”. If it is then it returns theflag
and if it is not then it continues and returns the message “You gotta beat Fizz!”.
Pitfalls
- One might be tempted to set their
username
toFizzBuzz101
and then try toincrement
their score. - It does not work because of the application’s way of handling
game
session and settingscore
. - The following line initially sets up our
game
session when we send ourusername
to/new
endpoint which is later stored in asession
cookie that containsjwt
withgame
andusername
as payloads.
const game = randomBytes(8).toString("hex");
await redis.set(game, 0);
return res
.setCookie("session", await res.jwtSign({ username, game }))
.send("OK");
- Then if we “win ” then the following lines
increment
our score
const { game, username } = req.user;
const { position } = req.body;
const system = ["🪨", "📃", "✂️"][randomInt(3)];
if (winning.get(system) === position) {
const score = await redis.incr(game);
...
}
- If we “lose” then the following lines gets our
score
from thekey
calledgame
and then deletes it. Only after that, it adds it to thescoreboard
.
const score = await redis.getdel(game);
await redis.zadd("scoreboard", score, username);
- We can set ourselves to become
FizzBuzz101
user and have ourselves the score of1336
but we need ascore
that isgreater than 1336
and toincrement
it we need to incrementFizzBuzz101
‘sgame
session score which is only temporary and in thescoreboard
, only the final score is set once we “lose” and that score is derived fromgame
key’s value. Score
set inscoreboard
is the final state of a user’sgame
session and therefore it cannot beincremented
once agame
session is over and any attempt to do so will onlyoverwrite
their previousscoreboard
value.
Research and Vulnerability
A very common mantra that is recited during web
pentesting
is ::
“What do we control?” and “Where are they being used?”
- We control
username
that we firstinput
and is persisted withjwt
token
insession
cookie that is consumed by the application. We also controlposition
that is ourchoice
or “move” in the game. position
is used at the following occasion ::
if (winning.get(system) === position) {...}
username
is used at the following occasions ::
// 1. inside app.post("/new", ...)
return res
.setCookie("session", await res.jwtSign({ username, game }))
.send("OK");
// 2. inside app.post("/play", ...)
await redis.zadd("scoreboard", score, username);
// 3. inside app.get("/flag", ...)
const score = await redis.zscore("scoreboard", req.user.username);
- Since, there is a strict
===
type-check, we can let go of anytype-juggling
ideas forposition
variable to bypass thechecking condition
. Learn more about this on freecodecamp.org/loose-vs-strict-equality-in-javascript. First
use ofusername
input tells us that our input is being stored insidesession
cookie in the payload ofjwt
token
. There is nosanitization
in place meaning whatever we enter for theusername
persist throughout our session.Second
use ofusername
input should point us towards learning more aboutredis.zadd(...)
method. Let’s dig a bit deeper into that.- An example use of
redis.zadd(...)
can be found in theReadme
forioredis
library at https://github.com/redis/ioredis?tab=readme-ov-file#basic-usage.
redis.zadd("sortedSet", 1, "one", 2, "dos", 4, "quatro", 3, "three");
redis.zrange("sortedSet", 0, 2, "WITHSCORES").then((elements) => {
// ["one", "1", "dos", "2", "three", "3"]
// as if the command was `redis> ZRANGE sortedSet 0 2 WITHSCORES`
console.log(elements);
});
- The above tells us that
redis.zadd(...)
method can be used to add one or “more than one” member for thekey
. - To verify this, let’s take a look at https://github.com/redis/ioredis/blob/main/lib/utils/RedisCommander.ts#L10362
- The same can also be found more easily on https://redis.github.io/ioredis/classes/Redis.html#zadd.
- So, we know that
redis.zadd(...)
is used to add ourscore
,username
as members forkey
calledscoreboard
in ourtarget
application. Thus, having the ability of adding “more than one” member i.e “multiple members” can potentially allow us to “add our own values” given that ourinput
goes properly into the method’sargument
without any validation. Third
use ofusername
input tells us that theusername
from ourjwt
token
is being used to fetch forscore
.
Exploitation
- Now, our goal becomes to add “extra members”. From what we have learnt so far, the flow of using
redis.zadd(...)
goes likeredis.zadd("scoreboard", score, username)
and we controlusername
, so if we put more than onevalue
inusername
then we will be able to add our own “members”. - It will be like
[score1, username1, score2, username2]
. Thus, providing anarray
as ourusername
seems sensible in this case. Let’s debug and take a look at how our various inputs behave ::
- First, we will try to modify our
username
input point to be anarray
::
{"username":["1337", "SuperDuper1"]}
- Then we will copy the
session
token in the response and use it to send a request at/play
endpoint ::
- Everything seems okay so far. Let’s check our debug console.
- We can see that we are able to
inject
our desirable values into thearguments
forredis.zadd(...)
function but a
ReplyError: RR syntax error
occurred. - In here, we made a mistake with the way we entered our values. Recall that
args
should contain[score1, username1, score2, username2]
but instead it currently has[score1, score2, username2]
.
Note :: The above was a mistake which I made in a hurry and spend stupidly long minutes to figure out, I decided to keep it in to show you that
“it is what it is”.
- Let’s fix our mistake with the following payload ::
{"username":["TrialGuy1", "1337", "SuperDuper1"]}
- Chipped it in. It worked 😆. Now, let’s get our flag in the deployed-instance.
- Also, to get the
flag
remember theThird
use of ourusername
input. Ourusername
is coming from ourJWT
Token
and thus, make sure to generate atoken
with theinjected
username
to get theflag
.
corctf{lizard_spock!_a8cd3ad8ee2cde42}
Remediation
- Validate the input you are expecting.
- Here, one can use https://www.npmjs.com/package/yup for Object-Schema validation.