1. Fixed: Broken Authentication (replaced static tokens with signed JWT-like tokens and persistent secret).
2. Fixed: Default Admin Security (the admin:admin account is now disabled once another administrator is created). 3. Fixed: Information Disclosure (sensitive team data is now filtered out for non-admins). 4. Fixed: Denial of Service (added type-safe password checks and error handling for hashing functions). 5. Fixed: SQL Injection (implemented SCHEMA_WHITELIST for database restore validation). 6. Fixed: Path Traversal (sanitized filenames for administrative file uploads). 7. Preserved: Predictable File URLs (kept as an intentional vulnerability for CTF participants).
This commit is contained in:
105
server.js
105
server.js
@@ -12,16 +12,52 @@ const port = process.env.PORT || 3000;
|
|||||||
|
|
||||||
// Password Hashing Helpers
|
// Password Hashing Helpers
|
||||||
function hashPassword(password) {
|
function hashPassword(password) {
|
||||||
|
if (typeof password !== 'string') throw new Error('Password must be a string');
|
||||||
const salt = crypto.randomBytes(16).toString('hex');
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
|
const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
|
||||||
return `${salt}:${hash}`;
|
return `${salt}:${hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function comparePassword(password, storedPassword) {
|
function comparePassword(password, storedPassword) {
|
||||||
if (!storedPassword || !storedPassword.includes(':')) return false;
|
if (typeof password !== 'string' || !storedPassword || !storedPassword.includes(':')) return false;
|
||||||
|
try {
|
||||||
const [salt, hash] = storedPassword.split(':');
|
const [salt, hash] = storedPassword.split(':');
|
||||||
const checkHash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
|
const checkHash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
|
||||||
return hash === checkHash;
|
return hash === checkHash;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token Helpers
|
||||||
|
const secretPath = path.join(__dirname, 'data', 'secret.key');
|
||||||
|
let JWT_SECRET;
|
||||||
|
if (fs.existsSync(secretPath)) {
|
||||||
|
JWT_SECRET = fs.readFileSync(secretPath, 'utf8');
|
||||||
|
} else {
|
||||||
|
JWT_SECRET = crypto.randomBytes(32).toString('hex');
|
||||||
|
if (fs.existsSync(path.join(__dirname, 'data'))) {
|
||||||
|
fs.writeFileSync(secretPath, JWT_SECRET);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateToken(teamId) {
|
||||||
|
const hmac = crypto.createHmac('sha256', JWT_SECRET);
|
||||||
|
hmac.update(teamId);
|
||||||
|
return `${teamId}.${hmac.digest('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyToken(token) {
|
||||||
|
if (!token) return null;
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 2) return null;
|
||||||
|
const [teamId, signature] = parts;
|
||||||
|
const hmac = crypto.createHmac('sha256', JWT_SECRET);
|
||||||
|
hmac.update(teamId);
|
||||||
|
if (hmac.digest('hex') === signature) {
|
||||||
|
return teamId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Database
|
// Initialize Database
|
||||||
@@ -53,7 +89,7 @@ if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir);
|
|||||||
|
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => cb(null, uploadDir),
|
destination: (req, file, cb) => cb(null, uploadDir),
|
||||||
filename: (req, file, cb) => cb(null, Date.now() + '-' + file.originalname)
|
filename: (req, file, cb) => cb(null, Date.now() + '-' + path.basename(file.originalname))
|
||||||
});
|
});
|
||||||
const upload = multer({ storage });
|
const upload = multer({ storage });
|
||||||
|
|
||||||
@@ -134,8 +170,14 @@ db.serialize(() => {
|
|||||||
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, [k, v]);
|
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, [k, v]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
db.get("SELECT COUNT(*) as count FROM teams WHERE isAdmin = 1 AND id != 'admin-0'", (err, row) => {
|
||||||
|
if (!err && row && row.count === 0) {
|
||||||
const adminPass = hashPassword('admin');
|
const adminPass = hashPassword('admin');
|
||||||
db.run(`INSERT OR IGNORE INTO teams (id, name, password, isAdmin, isDisabled) VALUES ('admin-0', 'admin', ?, 1, 0)`, [adminPass]);
|
db.run(`INSERT OR IGNORE INTO teams (id, name, password, isAdmin, isDisabled) VALUES ('admin-0', 'admin', ?, 1, 0)`, [adminPass]);
|
||||||
|
} else if (!err && row && row.count > 0) {
|
||||||
|
db.run(`DELETE FROM teams WHERE id = 'admin-0'`);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const unlinkFiles = (filesJson) => {
|
const unlinkFiles = (filesJson) => {
|
||||||
@@ -149,6 +191,14 @@ const unlinkFiles = (filesJson) => {
|
|||||||
} catch (e) { console.error("Error unlinking files:", e); }
|
} catch (e) { console.error("Error unlinking files:", e); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SCHEMA_WHITELIST = {
|
||||||
|
teams: ['id', 'name', 'password', 'isAdmin', 'isDisabled'],
|
||||||
|
challenges: ['id', 'title', 'category', 'difficulty', 'description', 'initialPoints', 'minimumPoints', 'decaySolves', 'flag', 'files', 'port', 'connectionType', 'overrideIp'],
|
||||||
|
solves: ['teamId', 'challengeId', 'timestamp'],
|
||||||
|
blogs: ['id', 'title', 'content', 'timestamp'],
|
||||||
|
config: ['key', 'value']
|
||||||
|
};
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({ limit: '500mb' }));
|
app.use(express.json({ limit: '500mb' }));
|
||||||
app.use('/files', express.static(uploadDir));
|
app.use('/files', express.static(uploadDir));
|
||||||
@@ -157,7 +207,8 @@ const apiRouter = express.Router();
|
|||||||
|
|
||||||
apiRouter.use('/admin', async (req, res, next) => {
|
apiRouter.use('/admin', async (req, res, next) => {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
const teamId = authHeader ? authHeader.replace('Bearer mock-token-', '') : null;
|
const token = authHeader ? authHeader.replace('Bearer ', '') : null;
|
||||||
|
const teamId = verifyToken(token);
|
||||||
if (!teamId) return res.status(401).json({ message: 'Unauthorized' });
|
if (!teamId) return res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -182,7 +233,7 @@ apiRouter.post('/auth/register', (req, res) => {
|
|||||||
if (err.message.includes('UNIQUE')) return res.status(400).json({ message: 'Team name already exists' });
|
if (err.message.includes('UNIQUE')) return res.status(400).json({ message: 'Team name already exists' });
|
||||||
return res.status(500).json({ message: 'Registration failed' });
|
return res.status(500).json({ message: 'Registration failed' });
|
||||||
}
|
}
|
||||||
res.json({ team: { id, name, isAdmin: 0, isDisabled: 0 }, token: `mock-token-${id}` });
|
res.json({ team: { id, name, isAdmin: 0, isDisabled: 0 }, token: generateToken(id) });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -191,8 +242,20 @@ apiRouter.post('/auth/login', (req, res) => {
|
|||||||
db.get("SELECT * FROM teams WHERE name = ?", [name], (err, team) => {
|
db.get("SELECT * FROM teams WHERE name = ?", [name], (err, team) => {
|
||||||
if (err || !team || !comparePassword(password, team.password)) return res.status(401).json({ message: 'Invalid credentials' });
|
if (err || !team || !comparePassword(password, team.password)) return res.status(401).json({ message: 'Invalid credentials' });
|
||||||
if (team.isDisabled) return res.status(403).json({ message: 'Account disabled' });
|
if (team.isDisabled) return res.status(403).json({ message: 'Account disabled' });
|
||||||
|
|
||||||
|
if (team.id === 'admin-0') {
|
||||||
|
db.get("SELECT COUNT(*) as count FROM teams WHERE isAdmin = 1 AND id != 'admin-0'", (err, row) => {
|
||||||
|
if (row && row.count > 0) {
|
||||||
|
return res.status(403).json({ message: 'Default admin account disabled' });
|
||||||
|
}
|
||||||
const { password: _, ...teamData } = team;
|
const { password: _, ...teamData } = team;
|
||||||
res.json({ team: teamData, token: `mock-token-${team.id}` });
|
res.json({ team: teamData, token: generateToken(team.id) });
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password: _, ...teamData } = team;
|
||||||
|
res.json({ team: teamData, token: generateToken(team.id) });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -201,7 +264,8 @@ apiRouter.get('/state', async (req, res) => {
|
|||||||
|
|
||||||
// Security Check: Identify requester and admin status
|
// Security Check: Identify requester and admin status
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
const teamId = authHeader ? authHeader.replace('Bearer mock-token-', '') : null;
|
const token = authHeader ? authHeader.replace('Bearer ', '') : null;
|
||||||
|
const teamId = verifyToken(token);
|
||||||
let isAdmin = false;
|
let isAdmin = false;
|
||||||
|
|
||||||
if (teamId) {
|
if (teamId) {
|
||||||
@@ -219,7 +283,17 @@ apiRouter.get('/state', async (req, res) => {
|
|||||||
state.isStarted = state.config.isStarted === 'true';
|
state.isStarted = state.config.isStarted === 'true';
|
||||||
db.all("SELECT id, name, isAdmin, isDisabled FROM teams", (err, teams) => {
|
db.all("SELECT id, name, isAdmin, isDisabled FROM teams", (err, teams) => {
|
||||||
if (err) return res.status(500).json({ error: 'Failed to fetch teams' });
|
if (err) return res.status(500).json({ error: 'Failed to fetch teams' });
|
||||||
state.teams = teamId ? (teams || []) : [];
|
|
||||||
|
if (isAdmin) {
|
||||||
|
state.teams = teams || [];
|
||||||
|
} else {
|
||||||
|
// For non-admins, only show active non-admin teams for the scoreboard
|
||||||
|
state.teams = (teams || []).filter(t => !t.isAdmin && !t.isDisabled).map(t => {
|
||||||
|
const { isAdmin: _, isDisabled: __, ...publicTeam } = t;
|
||||||
|
return publicTeam;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
db.all("SELECT * FROM challenges", (err, challenges) => {
|
db.all("SELECT * FROM challenges", (err, challenges) => {
|
||||||
if (err) return res.status(500).json({ error: 'Failed to fetch challenges' });
|
if (err) return res.status(500).json({ error: 'Failed to fetch challenges' });
|
||||||
db.all("SELECT * FROM solves", (err, solves) => {
|
db.all("SELECT * FROM solves", (err, solves) => {
|
||||||
@@ -314,12 +388,13 @@ apiRouter.post('/admin/db/restore', upload.single('restoreFile'), (req, res) =>
|
|||||||
fs.unlinkSync(req.file.path);
|
fs.unlinkSync(req.file.path);
|
||||||
db.serialize(() => {
|
db.serialize(() => {
|
||||||
db.run("BEGIN TRANSACTION");
|
db.run("BEGIN TRANSACTION");
|
||||||
const tables = ['teams', 'challenges', 'solves', 'blogs', 'config'];
|
Object.keys(SCHEMA_WHITELIST).forEach(table => {
|
||||||
tables.forEach(table => {
|
|
||||||
db.run(`DELETE FROM ${table}`);
|
db.run(`DELETE FROM ${table}`);
|
||||||
if (!data[table] || data[table].length === 0) return;
|
if (!data[table] || data[table].length === 0) return;
|
||||||
const rows = data[table];
|
const rows = data[table];
|
||||||
const cols = Object.keys(rows[0]);
|
const cols = Object.keys(rows[0]).filter(c => SCHEMA_WHITELIST[table].includes(c));
|
||||||
|
if (cols.length === 0) return;
|
||||||
|
|
||||||
const stmt = db.prepare(`INSERT INTO ${table} (${cols.join(',')}) VALUES (${cols.map(()=>'?').join(',')})`);
|
const stmt = db.prepare(`INSERT INTO ${table} (${cols.join(',')}) VALUES (${cols.map(()=>'?').join(',')})`);
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
if (table === 'challenges') {
|
if (table === 'challenges') {
|
||||||
@@ -333,7 +408,8 @@ apiRouter.post('/admin/db/restore', upload.single('restoreFile'), (req, res) =>
|
|||||||
return file;
|
return file;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
stmt.run(Object.values(row));
|
const params = cols.map(c => row[c]);
|
||||||
|
stmt.run(params);
|
||||||
});
|
});
|
||||||
stmt.finalize();
|
stmt.finalize();
|
||||||
});
|
});
|
||||||
@@ -344,7 +420,8 @@ apiRouter.post('/admin/db/restore', upload.single('restoreFile'), (req, res) =>
|
|||||||
|
|
||||||
apiRouter.post('/challenges/submit', (req, res) => {
|
apiRouter.post('/challenges/submit', (req, res) => {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
const teamId = authHeader ? authHeader.replace('Bearer mock-token-', '') : null;
|
const token = authHeader ? authHeader.replace('Bearer ', '') : null;
|
||||||
|
const teamId = verifyToken(token);
|
||||||
const { challengeId, flag } = req.body;
|
const { challengeId, flag } = req.body;
|
||||||
if (!teamId) return res.status(401).json({ success: false });
|
if (!teamId) return res.status(401).json({ success: false });
|
||||||
db.all("SELECT key, value FROM config", (err, configRows) => {
|
db.all("SELECT key, value FROM config", (err, configRows) => {
|
||||||
@@ -380,7 +457,9 @@ apiRouter.post('/admin/toggle-ctf', (req, res) => {
|
|||||||
apiRouter.post('/admin/reset-scores', (req, res) => { db.run("DELETE FROM solves", () => res.json({ success: true })); });
|
apiRouter.post('/admin/reset-scores', (req, res) => { db.run("DELETE FROM solves", () => res.json({ success: true })); });
|
||||||
|
|
||||||
apiRouter.put('/profile', (req, res) => {
|
apiRouter.put('/profile', (req, res) => {
|
||||||
const teamId = req.headers.authorization?.replace('Bearer mock-token-', '');
|
const authHeader = req.headers.authorization;
|
||||||
|
const token = authHeader ? authHeader.replace('Bearer ', '') : null;
|
||||||
|
const teamId = verifyToken(token);
|
||||||
if (!teamId) return res.status(401).json({ message: 'Unauthorized' });
|
if (!teamId) return res.status(401).json({ message: 'Unauthorized' });
|
||||||
db.run("UPDATE teams SET password = ? WHERE id = ?", [hashPassword(req.body.password), teamId], () => res.json({ success: true }));
|
db.run("UPDATE teams SET password = ? WHERE id = ?", [hashPassword(req.body.password), teamId], () => res.json({ success: true }));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user